/** * F2 — the web session: pairing handshake (success marks the flag; a failure * rejects with a clear message), and the 401 → unauthorized transition that * clears the flag and notifies subscribers. * * #77 extends the handshake to `{code, name}` and freezes the two exposed * failure codes. The "no oracle" cases below are a **security** contract, not a * wording preference: if they start distinguishing wrong from expired from * already-used, the API is leaking to an attacker. */ 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 {code, name} 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("AB12CD34", "iPhone"); 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: "AB12CD34", name: "iPhone", }); }); it("maps invalid_or_expired to the single frozen message", async () => { const store = memStore(); const { fetchImpl } = fetchReturning(401, { code: "invalid_or_expired", message: "whatever the server says", }); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store }); await expect(session.pair("nope", "iPhone")).rejects.toMatchObject({ code: "invalidOrExpired", message: "Code invalide ou expiré.", }); expect(session.isPaired()).toBe(false); }); it("maps invalid_name to its own actionable message", async () => { // B3 validates the name *before* consuming the code, so this failure leaves // the code alive — the message must not send the user regenerating one. const { fetchImpl } = fetchReturning(400, { code: "invalid_name", message: "device name must be 40 characters or fewer", }); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); await expect(session.pair("AB12CD34", "x".repeat(60))).rejects.toMatchObject({ code: "invalidName", message: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.", }); }); it("does not blame the name for an unrelated 400", async () => { // Only the wire code may point at the name; a bare bad request must not send // the user editing a field that was fine. const { fetchImpl } = fetchReturning(400, undefined); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({ code: "pairingFailed", }); }); it("maps rate_limited to its own message", async () => { const { fetchImpl } = fetchReturning(429, { code: "rate_limited", message: "slow down" }); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({ code: "rateLimited", message: "Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.", }); }); it("never forwards the server's own wording, invalid_name included", async () => { const { fetchImpl } = fetchReturning(400, { code: "invalid_name", message: "device name is required", }); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); await expect(session.pair("AB12CD34", " ")).rejects.toMatchObject({ message: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.", }); }); it("never forwards a server message that could distinguish failure causes", async () => { // Even if a future backend leaks the distinction in its message, the UI must // not repeat it: an attacker learns nothing about *why* a code was refused. const { fetchImpl } = fetchReturning(401, { code: "invalid_or_expired", message: "code already used at 14:02 by device iPhone", }); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({ message: "Code invalide ou expiré.", }); }); it("falls back to the invalid/expired 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", "iPhone")).rejects.toMatchObject({ code: "invalidOrExpired", message: "Code invalide ou expiré.", }); }); }); 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("AB12CD34", "iPhone"); } 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); }); });