/** * F2 — the web session: pairing handshake (success marks the flag; wrong code * rejects with a clear message), and the 401 → unauthorized transition that * clears the flag and notifies subscribers. */ import { describe, it, expect, vi } from "vitest"; import type { FetchLike } from "./httpInvoker"; import { WebSession, type FlagStore } from "./webSession"; function memStore(): FlagStore { const map = new Map(); return { getItem: (k) => map.get(k) ?? null, setItem: (k, v) => void map.set(k, v), removeItem: (k) => void map.delete(k), }; } function fetchReturning(status: number, body: unknown): { fetchImpl: FetchLike; calls: { url: string; init: unknown }[]; } { const calls: { url: string; init: unknown }[] = []; const fetchImpl: FetchLike = async (url, init) => { calls.push({ url, init }); return { ok: status >= 200 && status < 300, status, json: async () => body, text: async () => JSON.stringify(body), }; }; return { fetchImpl, calls }; } describe("WebSession pairing", () => { it("POSTs the code to /api/pair and marks the flag on success", async () => { const store = memStore(); const { fetchImpl, calls } = fetchReturning(200, { ok: true }); const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store }); expect(session.isPaired()).toBe(false); await session.pair("4821-93"); expect(session.isPaired()).toBe(true); expect(calls[0].url).toBe("https://host/api/pair"); expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ code: "4821-93" }); }); it("rejects a wrong code with a clear message and stays unpaired", async () => { const store = memStore(); const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" }); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store }); await expect(session.pair("nope")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" }); expect(session.isPaired()).toBe(false); }); it("falls back to a default message when the server sends no body", async () => { const { fetchImpl } = fetchReturning(403, undefined); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); await expect(session.pair("x")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE", message: "Code d'appairage invalide.", }); }); }); describe("WebSession unauthorized handling", () => { it("clears the flag and notifies subscribers on notifyUnauthorized", () => { const store = memStore(); const session = new WebSession({ baseUrl: "https://host", store }); session.markPaired(); const handler = vi.fn(); session.onUnauthorized(handler); session.notifyUnauthorized(); expect(session.isPaired()).toBe(false); expect(handler).toHaveBeenCalledOnce(); }); it("forget() clears the flag (best-effort local sign-out)", () => { const store = memStore(); const session = new WebSession({ baseUrl: "https://host", store }); session.markPaired(); session.forget(); expect(session.isPaired()).toBe(false); }); }); describe("WebSession default fetch receiver", () => { /** * Regression (live Firefox, ticket #13): the default global `fetch` was stored * unbound, so `this.fetchImpl(…)` called it with the WebSession as receiver ⇒ * `TypeError: 'fetch' called on an object that does not implement interface * Window` at pairing. jsdom does not enforce the receiver, so assert it here. */ async function pairWithRecordingGlobalFetch(): Promise { const original = globalThis.fetch; const receivers: unknown[] = []; globalThis.fetch = function (this: unknown) { receivers.push(this); return Promise.resolve({ ok: true, status: 200, json: async () => ({ ok: true }), text: async () => "{}", }); } as unknown as typeof globalThis.fetch; try { // No `fetchImpl` injected ⇒ the global fallback is used. const session = new WebSession({ baseUrl: "https://host", store: memStore() }); await session.pair("4821-93"); } finally { globalThis.fetch = original; } return receivers; } it("pairs using the global fetch bound to globalThis, not the session", async () => { const receivers = await pairWithRecordingGlobalFetch(); expect(receivers).toHaveLength(1); expect(receivers[0]).toBe(globalThis); expect(receivers[0]).not.toBeInstanceOf(WebSession); }); }); describe("WebSession logout", () => { it("POSTs /api/logout same-origin and clears the flag on success", async () => { const store = memStore(); const { fetchImpl, calls } = fetchReturning(200, { revoked: true }); const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store }); session.markPaired(); await session.logout(); expect(session.isPaired()).toBe(false); expect(calls[0].url).toBe("https://host/api/logout"); const init = calls[0].init as { method: string; credentials: string }; expect(init.method).toBe("POST"); expect(init.credentials).toBe("same-origin"); }); it("still clears the flag when the server is unreachable (best-effort)", async () => { const store = memStore(); const fetchImpl: FetchLike = async () => { throw new Error("network down"); }; const session = new WebSession({ baseUrl: "https://host", fetchImpl, store }); session.markPaired(); await expect(session.logout()).resolves.toBeUndefined(); expect(session.isPaired()).toBe(false); }); });