diff --git a/package-lock.json b/package-lock.json index 686a9d60fe1f..ba8434e43a6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f9810506e4cc..961b9d0301f3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/ts/CI.ts b/ts/CI.ts index d15e2ec72a17..2fa1dd28b7d2 100644 --- a/ts/CI.ts +++ b/ts/CI.ts @@ -23,6 +23,7 @@ export type CIType = { getMessagesBySentAt( sentAt: number ): Promise>; + 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, }; } diff --git a/ts/challenge.ts b/ts/challenge.ts index 1fcfd88408f5..34449d9701cd 100644 --- a/ts/challenge.ts +++ b/ts/challenge.ts @@ -133,6 +133,8 @@ export class ChallengeHandler { private isOnline = false; + private challengeRateLimitRetryAt: undefined | number; + private readonly responseHandlers = new Map(); 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; diff --git a/ts/state/ducks/network.ts b/ts/state/ducks/network.ts index ddd347651f32..1df9b41071d9 100644 --- a/ts/state/ducks/network.ts +++ b/ts/state/ducks/network.ts @@ -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 }, diff --git a/ts/test-both/challenge_test.ts b/ts/test-both/challenge_test.ts index f7502c76ce4a..4338e6de2758 100644 --- a/ts/test-both/challenge_test.ts +++ b/ts/test-both/challenge_test.ts @@ -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'); diff --git a/ts/test-mock/benchmarks/group_send_bench.ts b/ts/test-mock/benchmarks/group_send_bench.ts index b10179628c85..d279c5291d40 100644 --- a/ts/test-mock/benchmarks/group_send_bench.ts +++ b/ts/test-mock/benchmarks/group_send_bench.ts @@ -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 => { const item = window .locator(`.module-message >> text="${LAST_MESSAGE}"`) .first(); - await item.click(); + await item.click({ timeout: MINUTE }); } const deltaList = new Array(); diff --git a/ts/test-mock/messaging/unknown_contact_test.ts b/ts/test-mock/messaging/unknown_contact_test.ts index 42fca2b97d9e..cf12fafe0afe 100644 --- a/ts/test-mock/messaging/unknown_contact_test.ts +++ b/ts/test-mock/messaging/unknown_contact_test.ts @@ -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; diff --git a/ts/test-mock/playwright.ts b/ts/test-mock/playwright.ts index 043dd40ffeab..8442944c4eef 100644 --- a/ts/test-mock/playwright.ts +++ b/ts/test-mock/playwright.ts @@ -218,6 +218,15 @@ export class App extends EventEmitter { return super.emit(type, ...args); } + public async getPendingEventCount(event: string): Promise { + const window = await this.getWindow(); + const result = await window.evaluate( + `window.SignalCI.getPendingEventCount(${JSON.stringify(event)})` + ); + + return Number(result); + } + // // Private // diff --git a/ts/test-mock/rate-limit/viewed_test.ts b/ts/test-mock/rate-limit/viewed_test.ts index a3e9e1b97459..215f905138f6 100644 --- a/ts/test-mock/rate-limit/viewed_test.ts +++ b/ts/test-mock/rate-limit/viewed_test.ts @@ -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'); + }); });