Add mock test for a 413 response from v1/challenge

This commit is contained in:
Scott Nonnenberg 2024-09-10 06:31:20 +10:00 committed by GitHub
parent 2a55bfbef9
commit 9efb046a06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 194 additions and 59 deletions

9
package-lock.json generated
View file

@ -125,7 +125,7 @@
"@indutny/parallel-prettier": "3.0.0", "@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "1.3.1", "@indutny/rezip-electron": "1.3.1",
"@indutny/symbolicate-mac": "2.3.0", "@indutny/symbolicate-mac": "2.3.0",
"@signalapp/mock-server": "6.9.0", "@signalapp/mock-server": "6.10.0",
"@storybook/addon-a11y": "8.1.11", "@storybook/addon-a11y": "8.1.11",
"@storybook/addon-actions": "8.1.11", "@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11", "@storybook/addon-controls": "8.1.11",
@ -7241,10 +7241,11 @@
} }
}, },
"node_modules/@signalapp/mock-server": { "node_modules/@signalapp/mock-server": {
"version": "6.9.0", "version": "6.10.0",
"resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-6.9.0.tgz", "resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-6.10.0.tgz",
"integrity": "sha512-NXiroPSMvJzfIjrj7+RJgF5v3RH4UTg7pAUCt7cghdITxuZ0SqpcJ5Od3cbuWnbSHUzlMFeaujBrKcQ5P8Fn8g==", "integrity": "sha512-StUP0vIKN43T1PDeyIyr7+PBMW/1mxjPOiHFzF7VDKvC53Q2pX84OCd6hsW6m16Tsjrxm5B7gVLIh2e4OYbkdQ==",
"dev": true, "dev": true,
"license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@signalapp/libsignal-client": "^0.45.0", "@signalapp/libsignal-client": "^0.45.0",
"@tus/file-store": "^1.4.0", "@tus/file-store": "^1.4.0",

View file

@ -209,7 +209,7 @@
"@indutny/parallel-prettier": "3.0.0", "@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "1.3.1", "@indutny/rezip-electron": "1.3.1",
"@indutny/symbolicate-mac": "2.3.0", "@indutny/symbolicate-mac": "2.3.0",
"@signalapp/mock-server": "6.9.0", "@signalapp/mock-server": "6.10.0",
"@storybook/addon-a11y": "8.1.11", "@storybook/addon-a11y": "8.1.11",
"@storybook/addon-actions": "8.1.11", "@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11", "@storybook/addon-controls": "8.1.11",

View file

@ -23,6 +23,7 @@ export type CIType = {
getMessagesBySentAt( getMessagesBySentAt(
sentAt: number sentAt: number
): Promise<ReadonlyArray<MessageAttributesType>>; ): Promise<ReadonlyArray<MessageAttributesType>>;
getPendingEventCount: (event: string) => number;
handleEvent: (event: string, data: unknown) => unknown; handleEvent: (event: string, data: unknown) => unknown;
setProvisioningURL: (url: string) => unknown; setProvisioningURL: (url: string) => unknown;
solveChallenge: (response: ChallengeResponseType) => unknown; solveChallenge: (response: ChallengeResponseType) => unknown;
@ -101,6 +102,11 @@ export function getCI({
return promise; return promise;
} }
function getPendingEventCount(event: string): number {
const completed = completedEvents.get(event) || [];
return completed.length;
}
function setProvisioningURL(url: string): void { function setProvisioningURL(url: string): void {
handleEvent('provisioning-url', url); handleEvent('provisioning-url', url);
} }
@ -197,5 +203,6 @@ export function getCI({
exportBackupToDisk, exportBackupToDisk,
exportPlaintextBackupToDisk, exportPlaintextBackupToDisk,
unlink, unlink,
getPendingEventCount,
}; };
} }

View file

@ -133,6 +133,8 @@ export class ChallengeHandler {
private isOnline = false; private isOnline = false;
private challengeRateLimitRetryAt: undefined | number;
private readonly responseHandlers = new Map<number, Handler>(); private readonly responseHandlers = new Map<number, Handler>();
private readonly registeredConversations = new Map< private readonly registeredConversations = new Map<
@ -194,10 +196,6 @@ export class ChallengeHandler {
log.info(`challenge: online, starting ${pending.length} queues`); log.info(`challenge: online, starting ${pending.length} queues`);
// Start queues for challenges that matured while we were offline // Start queues for challenges that matured while we were offline
await Promise.all(
pending.map(conversationId => this.startQueue(conversationId))
);
await this.startAllQueues(); await this.startAllQueues();
} }
@ -211,11 +209,56 @@ export class ChallengeHandler {
return; return;
} }
if (this.challengeRateLimitRetryAt) {
return;
}
if (challenge.token) { if (challenge.token) {
drop(this.solve({ reason, token: challenge.token })); drop(this.solve({ reason, token: challenge.token }));
} }
} }
public scheduleRetry(
conversationId: string,
retryAt: number,
reason: string
): void {
const waitTime = Math.max(0, retryAt - Date.now());
const oldTimer = this.startTimers.get(conversationId);
if (oldTimer) {
clearTimeoutIfNecessary(oldTimer);
}
this.startTimers.set(
conversationId,
setTimeout(() => {
this.startTimers.delete(conversationId);
this.challengeRateLimitRetryAt = undefined;
drop(this.startQueue(conversationId));
}, waitTime)
);
log.info(
`scheduleRetry(${reason}): tracking ${conversationId} with waitTime=${waitTime}`
);
}
public forceWaitOnAll(retryAt: number): void {
this.challengeRateLimitRetryAt = retryAt;
for (const conversationId of this.registeredConversations.keys()) {
const existing = this.registeredConversations.get(conversationId);
if (!existing) {
continue;
}
this.registeredConversations.set(conversationId, {
...existing,
retryAt,
});
this.scheduleRetry(conversationId, retryAt, 'forceWaitOnAll');
}
}
public async register( public async register(
challenge: RegisteredChallengeType, challenge: RegisteredChallengeType,
data?: SendMessageChallengeData data?: SendMessageChallengeData
@ -238,23 +281,14 @@ export class ChallengeHandler {
return; return;
} }
if (challenge.retryAt) { if (this.challengeRateLimitRetryAt) {
const waitTime = Math.max(0, challenge.retryAt - Date.now()); this.scheduleRetry(
const oldTimer = this.startTimers.get(conversationId);
if (oldTimer) {
clearTimeoutIfNecessary(oldTimer);
}
this.startTimers.set(
conversationId, conversationId,
setTimeout(() => { this.challengeRateLimitRetryAt,
this.startTimers.delete(conversationId); 'register-challengeRateLimit'
drop(this.startQueue(conversationId));
}, waitTime)
);
log.info(
`${logId}: tracking ${conversationId} with waitTime=${waitTime}`
); );
} else if (challenge.retryAt) {
this.scheduleRetry(conversationId, challenge.retryAt, 'register');
} else { } else {
log.info(`${logId}: tracking ${conversationId} with no waitTime`); log.info(`${logId}: tracking ${conversationId} with no waitTime`);
} }
@ -436,6 +470,9 @@ export class ChallengeHandler {
); );
log.info(`challenge: retry after ${retryAfter}ms`); log.info(`challenge: retry after ${retryAfter}ms`);
const retryAt = retryAfter + Date.now();
this.forceWaitOnAll(retryAt);
this.options.onChallengeFailed(retryAfter); this.options.onChallengeFailed(retryAfter);
throw error; throw error;

View file

@ -81,6 +81,9 @@ function relinkDevice(): RelinkDeviceActionType {
function setChallengeStatus( function setChallengeStatus(
challengeStatus: NetworkStateType['challengeStatus'] challengeStatus: NetworkStateType['challengeStatus']
): SetChallengeStatusActionType { ): SetChallengeStatusActionType {
if (challengeStatus === 'required') {
window.SignalCI?.handleEvent('captchaDialog', null);
}
return { return {
type: SET_CHALLENGE_STATUS, type: SET_CHALLENGE_STATUS,
payload: { challengeStatus }, payload: { challengeStatus },

View file

@ -261,6 +261,9 @@ describe('ChallengeHandler', () => {
// Go back online // Go back online
await handler.onOnline(); await handler.onOnline();
// startQueue awaits this.unregister() before calling options.startQueue
await this.clock.nextAsync();
assert.isFalse(isInStorage(one.conversationId)); assert.isFalse(isInStorage(one.conversationId));
assert.deepEqual(queuesStarted, [one.conversationId]); assert.deepEqual(queuesStarted, [one.conversationId]);
assert.equal(challengeStatus, 'idle'); assert.equal(challengeStatus, 'idle');

View file

@ -22,6 +22,7 @@ import {
import { stats } from '../../util/benchmark/stats'; import { stats } from '../../util/benchmark/stats';
import { sleep } from '../../util/sleep'; import { sleep } from '../../util/sleep';
import { typeIntoInput } from '../helpers'; import { typeIntoInput } from '../helpers';
import { MINUTE } from '../../util/durations';
const LAST_MESSAGE = 'start sending messages now'; const LAST_MESSAGE = 'start sending messages now';
@ -164,7 +165,7 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
const item = window const item = window
.locator(`.module-message >> text="${LAST_MESSAGE}"`) .locator(`.module-message >> text="${LAST_MESSAGE}"`)
.first(); .first();
await item.click(); await item.click({ timeout: MINUTE });
} }
const deltaList = new Array<number>(); const deltaList = new Array<number>();

View file

@ -75,40 +75,6 @@ describe('unknown contacts', function (this: Mocha.Suite) {
); );
}); });
it('blocks incoming calls from unknown contacts & shows message request', async () => {
const { desktop } = bootstrap;
debug('sending calling offer message');
await unknownContact.sendRaw(desktop, {
callingMessage: {
offer: {
callId: new Long(Math.floor(Math.random() * 1e10)),
type: Proto.CallingMessage.Offer.Type.OFFER_AUDIO_CALL,
opaque: new Uint8Array(0),
},
},
});
debug('opening conversation');
const leftPane = page.locator('#LeftPane');
const conversationListItem = leftPane.getByRole('button', {
name: 'Chat with Unknown contact',
});
await conversationListItem.getByText('Message Request').click();
const conversationStack = page.locator('.Inbox__conversation-stack');
await conversationStack.getByText('Missed voice call').waitFor();
debug('accepting message request');
await page.getByText('message you and share your name').waitFor();
await page.getByRole('button', { name: 'Accept' }).click();
assert.strictEqual(
await page.getByText('message you and share your name').count(),
0
);
});
it('syncs message request state', async () => { it('syncs message request state', async () => {
const { phone, desktop } = bootstrap; const { phone, desktop } = bootstrap;

View file

@ -218,6 +218,15 @@ export class App extends EventEmitter {
return super.emit(type, ...args); return super.emit(type, ...args);
} }
public async getPendingEventCount(event: string): Promise<number> {
const window = await this.getWindow();
const result = await window.evaluate(
`window.SignalCI.getPendingEventCount(${JSON.stringify(event)})`
);
return Number(result);
}
// //
// Private // Private
// //

View file

@ -258,4 +258,112 @@ describe('challenge/receipts', function (this: Mocha.Suite) {
); );
} }
}); });
it('should show a toast and not another challenge if completion results in 413', async () => {
const { server, desktop } = bootstrap;
debug(
`Rate limiting (desktop: ${desktop.aci}) -> (contact: ${contact.device.aci})`
);
server.rateLimit({ source: desktop.aci, target: contact.device.aci });
server.rateLimit({ source: desktop.aci, target: contactB.device.aci });
const timestamp = bootstrap.getTimestamp();
debug('Sending a message from contact');
await contact.sendText(desktop, 'Hello there!', {
timestamp,
});
const window = await app.getWindow();
const leftPane = window.locator('#LeftPane');
const conversationStack = window.locator('.Inbox__conversation-stack');
debug(`Opening conversation with contact (${contact.toContact().aci})`);
await leftPane
.locator(`[data-testid="${contact.toContact().aci}"]`)
.click();
debug('Accept conversation from contact - does not trigger captcha!');
await conversationStack
.locator('.module-message-request-actions button >> "Accept"')
.click();
debug('Sending a message back to user - will trigger captcha!');
{
const input = await app.waitForEnabledComposer();
await typeIntoInput(input, 'Hi, good to hear from you!');
await input.press('Enter');
}
debug('Waiting for challenge');
const request = await app.waitForChallenge();
server.respondToChallengesWith(413);
debug('Solving challenge');
await app.solveChallenge({
seq: request.seq,
data: { captcha: 'anything' },
});
debug('Waiting for verification failure toast');
await window
.locator('.Toast__content >> "Verification failed. Please retry later."')
.isVisible();
debug('Sending another message - this time it should not trigger captcha!');
{
const input = await app.waitForEnabledComposer();
await typeIntoInput(input, 'How have you been lately?');
await input.press('Enter');
}
debug('Sending a message from Contact B');
await contactB.sendText(desktop, 'Wanna buy a cow?', {
timestamp,
});
debug(`Opening conversation with Contact B (${contactB.toContact().aci})`);
await leftPane
.locator(`[data-testid="${contactB.toContact().aci}"]`)
.click();
debug('Accept conversation from Contact B - does not trigger captcha!');
await conversationStack
.locator('.module-message-request-actions button >> "Accept"')
.click();
debug(
'Sending to Contact B - we should not pop captcha because we are waiting!'
);
{
const input = await app.waitForEnabledComposer();
await typeIntoInput(input, 'You the cow guy from craigslist?');
await input.press('Enter');
}
debug('Checking for no other captcha dialogs');
assert.equal(
await app.getPendingEventCount('captchaDialog'),
1,
'Just one captcha dialog, the first one'
);
const requests = server.stopRateLimiting({
source: desktop.aci,
target: contact.device.aci,
});
debug(`Rate-limited requests: ${requests}`);
assert.strictEqual(requests, 1, 'rate limit requests');
const requestsContactB = server.stopRateLimiting({
source: desktop.aci,
target: contactB.device.aci,
});
debug(`Rate-limited requests to Contact B: ${requests}`);
assert.strictEqual(requestsContactB, 1, 'Contact B rate limit requests');
});
}); });