Files
IdeA/frontend/src/adapters/http/webSession.test.ts
Blomios de9c90966d fix(frontend): binder le fetch global à globalThis (pairing Firefox) (#13)
Firefox exige que fetch soit appelé sur Window : passer la référence nue
`fetch` en dépendance déclenchait « 'fetch' called on an object that does
not implement interface Window » au pairing. Nouveau helper defaultFetch()
qui binde globalThis.fetch, utilisé par webSession.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:47:00 +02:00

160 lines
5.5 KiB
TypeScript

/**
* 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<string, string>();
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<unknown[]> {
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);
});
});