Add mock test for a 413 response from v1/challenge
This commit is contained in:
parent
2a55bfbef9
commit
9efb046a06
10 changed files with 194 additions and 59 deletions
9
package-lock.json
generated
9
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
7
ts/CI.ts
7
ts/CI.ts
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
//
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue