Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance webhook tests and error handling for authentication #930

Merged
merged 1 commit into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/sdk/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,21 @@ export class Client {
.catch(async (err) => {
logger.error(`[BC] c:"${this.getKey()}" err:`, err);
if (await this.handleConnectError(err)) {
if (
err instanceof ConnectError &&
errorCodeOf(err) === Code.ErrUnauthenticated
) {
attachment.doc.publish([
{
type: DocEventType.AuthError,
value: {
reason: errorMetadataOf(err).reason,
method: 'Broadcast',
},
},
]);
}

if (retryCount < maxRetries) {
retryCount++;
setTimeout(() => doLoop(), exponentialBackoff(retryCount - 1));
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/document/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ export interface AuthErrorEvent extends BaseDocEvent {
type: DocEventType.AuthError;
value: {
reason: string;
method: 'PushPull' | 'WatchDocuments';
method: 'PushPull' | 'WatchDocuments' | 'Broadcast';
};
}

Expand Down
213 changes: 203 additions & 10 deletions packages/sdk/test/integration/webhook_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ let webhookServerInstance: any;
let webhookServerAddress: string;
let apiKey: string;
let adminToken: string;
const AllAuthWebhookMethods = [
'ActivateClient',
'DeactivateClient',
'AttachDocument',
'DetachDocument',
'RemoveDocument',
'PushPull',
'WatchDocuments',
'Broadcast',
];

const InvalidTokenErrorMessage = 'invalid token';
const ExpiredTokenErrorMessage = 'expired token';
Expand Down Expand Up @@ -113,6 +123,7 @@ describe('Auth Webhook', () => {
id: projectId,
fields: {
auth_webhook_url: `http://${webhookServerAddress}:${webhookServerPort}/auth-webhook`,
auth_webhook_methods: { methods: AllAuthWebhookMethods },
},
},
{
Expand All @@ -131,24 +142,52 @@ describe('Auth Webhook', () => {
task,
}) => {
// client with token
const client = new yorkie.Client(testRPCAddr, {
const c1 = new yorkie.Client(testRPCAddr, {
apiKey,
authTokenInjector: async () => {
return `token-${Date.now() + 1000 * 60 * 60}`; // expire in 1 hour
},
});
const c2 = new yorkie.Client(testRPCAddr, {
apiKey,
authTokenInjector: async () => {
return `token-${Date.now() + 1000 * 60 * 60}`; // expire in 1 hour
},
});

await client.activate();
const doc = new yorkie.Document<{ k1: string }>(
toDocKey(`${task.name}-${new Date().getTime()}`),
);
await client.attach(doc);
doc.update((root) => {
const docKey = toDocKey(`${task.name}-${new Date().getTime()}`);
await c1.activate();
await c2.activate();
const doc1 = new yorkie.Document<{ k1: string }>(docKey);
const doc2 = new yorkie.Document<{ k1: string }>(docKey);

await c1.attach(doc1);
await c2.attach(doc2);

const eventCollector = new EventCollector();
const topic = 'test';
const payload = 'data';
const unsubscribe = doc2.subscribe('broadcast', (event) => {
if (event.value.topic === topic) {
eventCollector.add(event.value.payload as string);
}
});
doc1.broadcast(topic, payload);
await eventCollector.waitAndVerifyNthEvent(1, payload);

doc1.update((root) => {
root.k1 = 'v1';
});
await client.sync(doc);
await client.detach(doc);
await client.deactivate();
await c1.sync(doc1);
await c2.sync(doc2);
expect(doc2.toSortedJSON()).toBe('{"k1":"v1"}');

await c1.detach(doc1);
await c2.remove(doc2);

unsubscribe();
await c1.deactivate();
await c2.deactivate();
});

it('should return unauthenticated error for client with empty token (401)', async () => {
Expand Down Expand Up @@ -299,6 +338,71 @@ describe('Auth Webhook', () => {
await client.deactivate();
});

it('should refresh token when unauthenticated error occurs (RemoveDocument)', async ({
task,
}) => {
// Create New project
const projectResponse = await axios.post(
`${testRPCAddr}/yorkie.v1.AdminService/CreateProject`,
{ name: `auth-webhook-${new Date().getTime()}` },
{
headers: { Authorization: adminToken },
},
);
const projectId = projectResponse.data.project.id;
apiKey = projectResponse.data.project.publicKey;

// Update project with webhook url and methods
await axios.post(
`${testRPCAddr}/yorkie.v1.AdminService/UpdateProject`,
{
id: projectId,
fields: {
auth_webhook_url: `http://${webhookServerAddress}:${webhookServerPort}/auth-webhook`,
auth_webhook_methods: { methods: ['RemoveDocument'] },
},
},
{
headers: { Authorization: adminToken },
},
);

const TokenExpirationMs = 500;
const authTokenInjector = vi.fn(async (reason) => {
if (reason === ExpiredTokenErrorMessage) {
return `token-${Date.now() + TokenExpirationMs}`;
}
return `token-${Date.now() - TokenExpirationMs}`; // token expired
});
// client with token
const client = new yorkie.Client(testRPCAddr, {
apiKey,
authTokenInjector,
});

await client.activate();
const doc = new yorkie.Document<{ k1: string }>(
toDocKey(`${task.name}-${new Date().getTime()}`),
);
await client.attach(doc, { syncMode: SyncMode.Manual });

await new Promise((res) => setTimeout(res, TokenExpirationMs));
await assertThrowsAsync(
async () => {
await client.remove(doc);
},
ConnectError,
/^\[unauthenticated\]/i,
);
expect(authTokenInjector).toBeCalledTimes(2);
expect(authTokenInjector).nthCalledWith(1);
expect(authTokenInjector).nthCalledWith(2, ExpiredTokenErrorMessage);
// retry remove document
chacha912 marked this conversation as resolved.
Show resolved Hide resolved
await client.remove(doc);

await client.deactivate();
});

it('should refresh token and retry realtime sync', async ({ task }) => {
// Create New project
const projectResponse = await axios.post(
Expand Down Expand Up @@ -471,4 +575,93 @@ describe('Auth Webhook', () => {
await client.detach(doc);
await client.deactivate();
});

it('should refresh token and retry broadcast', async ({ task }) => {
// Create New project
const projectResponse = await axios.post(
`${testRPCAddr}/yorkie.v1.AdminService/CreateProject`,
{ name: `auth-webhook-${new Date().getTime()}` },
{
headers: { Authorization: adminToken },
},
);
const projectId = projectResponse.data.project.id;
apiKey = projectResponse.data.project.publicKey;

// Update project with webhook url and methods
await axios.post(
`${testRPCAddr}/yorkie.v1.AdminService/UpdateProject`,
{
id: projectId,
fields: {
auth_webhook_url: `http://${webhookServerAddress}:${webhookServerPort}/auth-webhook`,
auth_webhook_methods: { methods: ['Broadcast'] },
},
},
{
headers: { Authorization: adminToken },
},
);

const TokenExpirationMs = 1500; // Set higher than DefaultBroadcastOptions.initialRetryInterval (1000ms)
const authTokenInjector = vi.fn(async (reason) => {
if (reason === ExpiredTokenErrorMessage) {
return `token-${Date.now() + TokenExpirationMs}`;
}
return `token-${Date.now()}`;
});
// client with token
const client = new yorkie.Client(testRPCAddr, {
apiKey,
authTokenInjector,
reconnectStreamDelay: 100,
});

await client.activate();
const docKey = toDocKey(`${task.name}-${new Date().getTime()}`);
const doc = new yorkie.Document<{ k1: string }>(docKey);
await client.attach(doc);
const authErrorEventCollector = new EventCollector<{
reason: string;
method: string;
}>();
doc.subscribe('auth-error', (event) => {
authErrorEventCollector.add(event.value);
});

// Another client for verifying if the broadcast is working properly
const client2 = new yorkie.Client(testRPCAddr, {
apiKey,
authTokenInjector: async () => {
return `token-${Date.now() + 1000 * 60 * 60}`; // expire in 1 hour
},
});
await client2.activate();
const doc2 = new yorkie.Document<{ k1: string }>(docKey);
await client2.attach(doc2);
const eventCollector = new EventCollector();
const topic = 'test';
const payload = 'data';
const unsubscribe = doc2.subscribe('broadcast', (event) => {
if (event.value.topic === topic) {
eventCollector.add(event.value.payload as string);
}
});

// retry broadcast
await new Promise((res) => setTimeout(res, TokenExpirationMs));
doc.broadcast(topic, payload);
await eventCollector.waitAndVerifyNthEvent(1, payload);
await authErrorEventCollector.waitFor({
reason: ExpiredTokenErrorMessage,
method: 'Broadcast',
});
expect(authTokenInjector).toBeCalledTimes(2);
expect(authTokenInjector).nthCalledWith(1);
expect(authTokenInjector).nthCalledWith(2, ExpiredTokenErrorMessage);

unsubscribe();
await client.deactivate();
await client2.deactivate();
});
});