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/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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
7
ts/CI.ts
7
ts/CI.ts
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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>();
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
//
|
//
|
||||||
|
|
|
@ -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…
Add table
Add a link
Reference in a new issue