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/rezip-electron": "1.3.1",
"@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-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
@ -7241,10 +7241,11 @@
}
},
"node_modules/@signalapp/mock-server": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-6.9.0.tgz",
"integrity": "sha512-NXiroPSMvJzfIjrj7+RJgF5v3RH4UTg7pAUCt7cghdITxuZ0SqpcJ5Od3cbuWnbSHUzlMFeaujBrKcQ5P8Fn8g==",
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-6.10.0.tgz",
"integrity": "sha512-StUP0vIKN43T1PDeyIyr7+PBMW/1mxjPOiHFzF7VDKvC53Q2pX84OCd6hsW6m16Tsjrxm5B7gVLIh2e4OYbkdQ==",
"dev": true,
"license": "AGPL-3.0-only",
"dependencies": {
"@signalapp/libsignal-client": "^0.45.0",
"@tus/file-store": "^1.4.0",

View file

@ -209,7 +209,7 @@
"@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "1.3.1",
"@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-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -218,6 +218,15 @@ export class App extends EventEmitter {
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
//

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');
});
});