/** * Single-flight refresh (api.ts `refreshAccessToken`, exposed as * authApi.refresh). One tab must NEVER rotate its httpOnly refresh cookie * twice in parallel: the server rotates the cookie on every refresh, so two * in-flight rotations race โ€” the loser replays the rotated-out token and trips * reuse detection (or the ยง1.2 stale-retry branch). These tests prove the * dedupe: a burst of concurrent callers issues exactly ONE POST /auth/refresh * and shares the outcome (success OR the typed stale/reused error). * * We exercise the REAL module (no vi.mock of @/lib/api) and stub only * `globalThis.fetch` โ€” the refresh primitive is module-scoped, so the * module-level `refreshInFlight` is exactly what the app sees. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError, authApi, isStaleRefreshError } from "@/lib/api"; import { authStore } from "@/lib/auth-store"; /** A promise the test controls โ€” lets two callers interleave before settling. */ function deferred(): { promise: Promise; resolve: (v: T) => void } { let resolve!: (v: T) => void; const promise = new Promise((r) => (resolve = r)); return { promise, resolve }; } function okBody(accessToken = "access-1"): Record { return { accessToken, refreshToken: "rft_new", expiresIn: 3600, session: { id: "s1" } }; } function errBody(reason: string): Record { return { error: { code: "UNAUTHORIZED", message: "x", details: [{ reason }] } }; } describe("single-flight refresh (authApi.refresh)", () => { beforeEach(() => { authStore.clear(); vi.stubGlobal("fetch", vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it("two concurrent bootstraps issue exactly ONE fetch and share the token", async () => { const gate = deferred(); const fetchMock = globalThis.fetch as ReturnType; fetchMock.mockImplementation( () => gate.promise.then(() => new Response(JSON.stringify(okBody("shared-access")), { status: 200 })) ); const p1 = authApi.refresh(); const p2 = authApi.refresh(); // StrictMode double-mount shape expect(fetchMock).toHaveBeenCalledTimes(1); // deduped WHILE in flight gate.resolve(); const [t1, t2] = await Promise.all([p1, p2]); expect(t1.accessToken).toBe("shared-access"); expect(t2.accessToken).toBe("shared-access"); expect(fetchMock).toHaveBeenCalledTimes(1); expect(authStore.accessToken).toBe("shared-access"); // stored exactly once }); it("reuses the in-flight promise for the SAME stale error (no extra fetch)", async () => { const gate = deferred(); const fetchMock = globalThis.fetch as ReturnType; fetchMock.mockImplementation( () => gate.promise.then( () => new Response(JSON.stringify(errBody("refresh_token_stale")), { status: 401, headers: { "Content-Type": "application/json" } }) ) ); const p1 = authApi.refresh(); const p2 = authApi.refresh(); gate.resolve(); await expect(p1).rejects.toBeInstanceOf(ApiError); const err2 = await p2.catch((e: unknown) => e); expect(err2).toBeInstanceOf(ApiError); expect(isStaleRefreshError(err2)).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); // the second caller shared, not re-rotated }); it("propagates a non-stale refresh failure as an ApiError (no null-swallow)", async () => { (globalThis.fetch as ReturnType).mockResolvedValue( new Response(JSON.stringify(errBody("refresh_token_reused")), { status: 401, headers: { "Content-Type": "application/json" } }) ); const err = await authApi.refresh().catch((e: unknown) => e); expect(err).toBeInstanceOf(ApiError); expect(isStaleRefreshError(err)).toBe(false); expect((err as ApiError).status).toBe(401); }); it("resets the in-flight slot after settling (a later call fetches again)", async () => { const fetchMock = globalThis.fetch as ReturnType; fetchMock.mockResolvedValue(new Response(JSON.stringify(okBody("a")), { status: 200 })); await authApi.refresh(); expect(fetchMock).toHaveBeenCalledTimes(1); fetchMock.mockResolvedValue(new Response(JSON.stringify(okBody("b")), { status: 200 })); const second = await authApi.refresh(); expect(second.accessToken).toBe("b"); expect(fetchMock).toHaveBeenCalledTimes(2); }); }); describe("isStaleRefreshError", () => { it("is true only for a 401 whose details carry reason=refresh_token_stale", () => { expect( isStaleRefreshError(new ApiError("UNAUTHORIZED", 401, "x", [{ reason: "refresh_token_stale" }])) ).toBe(true); }); it("is false for a reused-token 401", () => { expect(isStaleRefreshError(new ApiError("UNAUTHORIZED", 401, "x", [{ reason: "refresh_token_reused" }]))).toBe( false ); }); it("is false for a non-ApiError and for a missing details array", () => { expect(isStaleRefreshError(new Error("nope"))).toBe(false); expect(isStaleRefreshError(new ApiError("UNAUTHORIZED", 401, "x"))).toBe(false); expect(isStaleRefreshError(new ApiError("UNAUTHORIZED", 401, "x", "not-an-array"))).toBe(false); }); });