import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { createHttpClient } from "../../src/adapters/http/client.ts"; import { defineRestOperation } from "../../src/contracts/api-operations.ts"; import { createRestProviderProfile } from "../../src/contracts/rest-profiles.ts"; import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.ts"; /** * LEG-01 / LEG-02. The V2 client is not the default path any more, but a * rollback re-activates it, so its credential and recovery authority must match * V3 rather than diverge quietly. */ const BEARER_LIST = defineRestOperation({ method: "GET", path: "/api/entities", operationId: "LIST_ENTITIES", auth: "external-session", timeoutMs: null, idempotency: "safe", retry: "runtime", requestSource: "search", requestSchema: "EntityListQuery", responseSchema: "EntityListPayload", owner: "test-fixture", contractVersion: 2, protocol: "REST", semantics: "QUERY", replayPolicy: "SAFE", idempotencyKeyPolicy: "NONE", mapperId: "EntityListMapper", successStatuses: [200], responseMediaTypes: ["application/json"], maxResponseBytes: 32_768, providerId: "PRIMARY_API", authProfileId: "REFERENCE_EXTERNAL_BEARER", csrfProfileId: "NO_CSRF_BEARER", pathSchema: "NoRequest", pathParameterNames: [], maxEncodedSearchBytes: 1_024, }); let observedAuthorization: string | null = null; let requestCount = 0; let nextStatus = 200; const server = setupServer( http.get("https://api.test/api/entities", ({ request }) => { requestCount += 1; observedAuthorization = request.headers.get("authorization"); if (nextStatus === 401) { return HttpResponse.json( { success: false, error: { code: "UNAUTHENTICATED" }, meta: { requestId: "request-1", traceId: "trace-1" }, }, { status: 401 }, ); } return HttpResponse.json({ success: true, data: [{ id: "resource-1", name: "Example" }], meta: { requestId: "request-2", traceId: "trace-1" }, }); }), ); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => { observedAuthorization = null; requestCount = 0; nextStatus = 200; server.resetHandlers(); }); afterAll(() => server.close()); const clock = { now: () => 0, sleep: async () => {} }; type HttpDependencies = Parameters[0]; function bearerClient(options: Partial) { return createHttpClient({ ...TEST_HTTP_CONTRACT, getOperation: (operationId: string) => { if (operationId !== "LIST_ENTITIES") { throw new Error(`Unknown test operation: ${operationId}`); } return BEARER_LIST; }, baseUrl: "https://api.test", providerProfile: createRestProviderProfile("PRIMARY_API", "https://api.test", [ "omit", ]), // The V2 fixture declares `NoRequest` for its path codec, which the shared // schema registry does not carry. validatePath: () => ({ success: true as const, data: {} }), clock, ...options, } as HttpDependencies); } function sessionStub( overrides: Partial<{ getState: () => "authenticated" | "unauthenticated" | "recovery-pending" | "integration-failed"; credentialPatch: (...args: never[]) => Promise<{ headers: Record }>; recover: (...args: never[]) => Promise<"restored" | "no-session">; onUnauthenticated: () => void; }> = {}, ) { return { getState: () => "authenticated" as const, subscribe: () => () => {}, beginSignIn: async () => {}, signOut: async () => {}, credentialPatch: async () => ({ headers: { authorization: "Bearer t" } }), recover: async () => "restored" as const, onUnauthenticated: () => {}, ...overrides, }; } describe("LEG-02 the bearer profile's required header is enforced", () => { it("refuses to dispatch when a READY patch omits authorization", async () => { const client = bearerClient({ authSession: sessionStub({ credentialPatch: async () => ({ headers: {} }), }) as never, }); await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({ ok: false, error: { kind: "AUTH_INTEGRATION_FAILURE" }, }); expect(requestCount).toBe(0); }); it("rejects a malformed or duplicated authorization value", async () => { const hostilePatches: readonly Record[] = [ { authorization: "" }, { authorization: "Bearer bad\nvalue" }, { Authorization: "Bearer a", authorization: "Bearer b" }, ]; for (const headers of hostilePatches) { const client = bearerClient({ authSession: sessionStub({ credentialPatch: async () => ({ headers }), }) as never, }); await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({ ok: false, error: { kind: "AUTH_INTEGRATION_FAILURE" }, }); } expect(requestCount).toBe(0); }); it("dispatches with the admitted authorization header", async () => { const client = bearerClient({ authSession: sessionStub() as never, }); await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({ ok: true, }); expect(observedAuthorization).toBe("Bearer t"); }); }); describe("LEG-01 recovery notification follows the adopted result", () => { it("does not sign the user out when recovery answers after the deadline", async () => { nextStatus = 401; const onUnauthenticated = vi.fn(); let elapsed = 0; const client = bearerClient({ clock: { now: () => elapsed, sleep: async () => {}, }, timeoutMs: 20, authSession: sessionStub({ onUnauthenticated, recover: async () => { // The request's own deadline expires while recovery is still out. elapsed = 10_000; await new Promise((resolve) => setTimeout(resolve, 30)); return "no-session" as const; }, }) as never, }); const outcome = await client.execute("LIST_ENTITIES"); expect(outcome.ok).toBe(false); await new Promise((resolve) => setTimeout(resolve, 60)); expect(onUnauthenticated).toHaveBeenCalledTimes(0); }); it("signs the user out exactly once for an adopted no-session result", async () => { nextStatus = 401; const onUnauthenticated = vi.fn(); const client = bearerClient({ authSession: sessionStub({ onUnauthenticated, recover: async () => "no-session" as const, }) as never, }); await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({ ok: false, error: { kind: "AUTH_REQUIRED" }, }); expect(onUnauthenticated).toHaveBeenCalledTimes(1); }); it("stays bounded when recovery never settles", async () => { nextStatus = 401; const onUnauthenticated = vi.fn(); let elapsed = 0; const client = bearerClient({ clock: { now: () => { elapsed += 5; return elapsed; }, sleep: async () => {}, }, timeoutMs: 20, authSession: sessionStub({ onUnauthenticated, recover: () => new Promise<"restored">(() => {}), }) as never, }); const outcome = await client.execute("LIST_ENTITIES"); expect(outcome.ok).toBe(false); expect(onUnauthenticated).toHaveBeenCalledTimes(0); }); });