diff --git a/packages/runtime/src/domains/share-links-refusal-order.test.ts b/packages/runtime/src/domains/share-links-refusal-order.test.ts index 895f6a62b5b..7041251ea14 100644 --- a/packages/runtime/src/domains/share-links-refusal-order.test.ts +++ b/packages/runtime/src/domains/share-links-refusal-order.test.ts @@ -57,7 +57,9 @@ * probes recover it the fail-closed way), ONE `ShareLinkService` with the * production password hasher, and the password sent the way the console's * landing page sends it: the `X-Share-Password` header. Refusals are read as - * the ADR-0112 envelope's `status` + `error.code`. + * the ADR-0112 envelope's `status` + `error.code`. Every password verdict is + * the production verifier's own; a pair it already judged is answered from + * a memo of that verdict (see `beforeAll`). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -170,6 +172,38 @@ beforeAll(async () => { await engine.insert(OBJECT, { id: RECORD, title: 'Shared doc' }, { context: { isSystem: true } } as any); const svc = new ShareLinkService({ engine: engine as any }); + // [#24633] One scrypt verification per distinct (password, hash) pair, not + // one per request. The production verifier derives a key at the account + // parameters (scrypt N=16384, r=16) on every call, and `resolveToken` calls + // it for each password presented to a live protected link, once a + // `signed_in` link's caller is signed in, and on an `email` link whatever + // email was sent. Counted at `eadea5720`: 70 calls, plus + // the 4 mints below, and they were the file's wall time. It read 34.84 s in + // CI (run 39298888283, Test Core 6/6, attempt 2), and the loop case with + // 10 calls timed out at vitest's 5000 ms default on attempt 1. The calls + // hold only 6 distinct pairs (3 protected live links x a wrong and the + // correct password), and a verdict is a pure function of its pair, so each + // pair is judged once by the real verifier and its verdict replayed. + // Every request is still made, so every answer is still a real request's, + // and the service is still built with both password members at their + // defaults, as production builds it (so its legacy-hash upgrade stays on; + // no row here is legacy). The memo wraps the instance's own verifier, + // which is not exported. If that member moves, this throws rather than + // leave the file paying for every call again. + type Verify = (plain: string, hash: string) => Promise; + const productionVerify: unknown = (svc as any).verifyPassword; + if (typeof productionVerify !== 'function') { + throw new Error('ShareLinkService no longer keeps its password verifier as `verifyPassword`; re-point the memo below'); + } + const verdicts = new Map>>(); + const memoVerify: Verify = (plain, hash) => { + let byPlain = verdicts.get(hash); + if (!byPlain) verdicts.set(hash, (byPlain = new Map())); + let verdict = byPlain.get(plain); + if (!verdict) byPlain.set(plain, (verdict = (productionVerify as Verify)(plain, hash))); + return verdict; + }; + (svc as any).verifyPassword = memoVerify; const creator = { isSystem: true, userId: 'usr_creator' } as any; const mint = async (audience: 'public' | 'signed_in' | 'email', password?: string) => svc.createLink(