Expose la gestion des appareils appairés introduite en B1-B4, sur une surface unique partagée par le web et le desktop. - Écran Appareils : liste, renommage, révocation unitaire ou globale, activité formatée, panneau de code éphémère. - Le parcours d'appairage demande un nom d'appareil, pour qu'une révocation porte sur quelque chose d'identifiable par l'utilisateur. - Gateways DeviceGateway en trois adapters (Tauri, HTTP, Mock), le port restant le seul contrat connu de la feature. Les erreurs sont mappées localement et le message du serveur n'est jamais affiché tel quel : un échec d'appairage ne doit pas devenir un oracle pour qui teste des codes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
236 lines
8.7 KiB
TypeScript
236 lines
8.7 KiB
TypeScript
/**
|
|
* 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<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 {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<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("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);
|
|
});
|
|
});
|