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>
160 lines
5.5 KiB
TypeScript
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);
|
|
});
|
|
});
|