diff --git a/clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts b/clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts index be763289..09b02f18 100644 --- a/clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts +++ b/clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts @@ -390,6 +390,12 @@ describe("clearStoredAuthForRelogin", () => { const fetchSpy = vi .spyOn(globalThis, "fetch") .mockResolvedValue(new Response(null, { status: 200 })); + // Frozen, so the remainder at the check is exactly the budget. Left to + // the real clock this is a one-sided detector: a worker preempted for + // more than the budget reaches the check with a NEGATIVE remainder, where + // the unfixed `remainingMs <= 0` bound takes the same branch and prints + // the same message — passing without the floor (Copilot). + const nowSpy = vi.spyOn(performance, "now").mockReturnValue(1_000); try { const outcome = await clearStoredAuthForRelogin("https://example.com", { budgetMs: MIN_REVOCATION_REQUEST_BUDGET_MS - 1, @@ -405,6 +411,7 @@ describe("clearStoredAuthForRelogin", () => { 'budget was exhausted before "', ); } finally { + nowSpy.mockRestore(); fetchSpy.mockRestore(); } }); diff --git a/clients/web/src/test/core/auth/revocation.test.ts b/clients/web/src/test/core/auth/revocation.test.ts index 7e938a7f..e50fa1e9 100644 --- a/clients/web/src/test/core/auth/revocation.test.ts +++ b/clients/web/src/test/core/auth/revocation.test.ts @@ -872,34 +872,44 @@ describe("revokeStoredOAuthTokens (plan + execute)", () => { tokenTypeHint: "refresh_token" as const, }); const timeoutMs = 40; - // Lands the second grant inside the floor but *above* zero — the sliver the - // old bound would have spent. A slow machine only pushes the remainder - // further below the floor, so the assertion cannot flip the other way. - const firstRequestMs = timeoutMs - MIN_REVOCATION_REQUEST_BUDGET_MS + 1; + // The clock is stubbed rather than slept against. A real sleep makes this a + // ONE-SIDED detector: under contention the first request overruns, the + // remainder goes negative, and the unfixed `remainingMs <= 0` bound skips + // the second grant for the wrong reason — so the test passes on an + // implementation that has no floor at all. Advancing a stub inside the + // fetch puts the second grant's remainder at exactly + // `MIN_REVOCATION_REQUEST_BUDGET_MS - 1` on every machine: positive, and + // under the floor, which is the only state that tells the two apart. + let now = 1_000; + const nowSpy = vi.spyOn(performance, "now").mockImplementation(() => now); const fetchFn = vi.fn(async () => { - await new Promise((resolve) => setTimeout(resolve, firstRequestMs)); + now += timeoutMs - MIN_REVOCATION_REQUEST_BUDGET_MS + 1; return new Response(null, { status: 200 }); }); - const outcome = await executeOAuthRevocation( - { - serverUrl: SERVER_URL, - grants: [grant("a"), grant("b")], - failures: [], - endpoint: REVOKE_URL, - supportedAuthMethods: [], - metadataIssuer: "https://as.example.com", - }, - { fetchFn, timeoutMs }, - ); + try { + const outcome = await executeOAuthRevocation( + { + serverUrl: SERVER_URL, + grants: [grant("a"), grant("b")], + failures: [], + endpoint: REVOKE_URL, + supportedAuthMethods: [], + metadataIssuer: "https://as.example.com", + }, + { fetchFn, timeoutMs }, + ); - expect(fetchFn).toHaveBeenCalledTimes(1); - // The unattempted grant outranks the first one's success, so the caller - // hears that a grant may still be live rather than that all was well. - expect(outcome).toMatchObject({ status: "failed" }); - expect(outcome.status === "failed" ? outcome.detail : "").toContain( - "budget was exhausted", - ); + expect(fetchFn).toHaveBeenCalledTimes(1); + // The unattempted grant outranks the first one's success, so the caller + // hears that a grant may still be live rather than that all was well. + expect(outcome).toMatchObject({ status: "failed" }); + expect(outcome.status === "failed" ? outcome.detail : "").toContain( + "budget was exhausted", + ); + } finally { + nowSpy.mockRestore(); + } }); // A grant bound to an issuer the cached metadata does not describe cannot be