feat(frontend): écran Appareils et parcours d'appairage nommé (#77 F1/F2)
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>
This commit is contained in:
@ -1,7 +1,12 @@
|
||||
/**
|
||||
* F2 — the web session: pairing handshake (success marks the flag; wrong code
|
||||
* 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";
|
||||
|
||||
@ -35,35 +40,106 @@ function fetchReturning(status: number, body: unknown): {
|
||||
}
|
||||
|
||||
describe("WebSession pairing", () => {
|
||||
it("POSTs the code to /api/pair and marks the flag on success", async () => {
|
||||
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("4821-93");
|
||||
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: "4821-93" });
|
||||
expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({
|
||||
code: "AB12CD34",
|
||||
name: "iPhone",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a wrong code with a clear message and stays unpaired", async () => {
|
||||
it("maps invalid_or_expired to the single frozen message", async () => {
|
||||
const store = memStore();
|
||||
const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" });
|
||||
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")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" });
|
||||
await expect(session.pair("nope", "iPhone")).rejects.toMatchObject({
|
||||
code: "invalidOrExpired",
|
||||
message: "Code invalide ou expiré.",
|
||||
});
|
||||
expect(session.isPaired()).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to a default message when the server sends no body", async () => {
|
||||
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")).rejects.toMatchObject({
|
||||
code: "INVALID_PAIRING_CODE",
|
||||
message: "Code d'appairage invalide.",
|
||||
await expect(session.pair("x", "iPhone")).rejects.toMatchObject({
|
||||
code: "invalidOrExpired",
|
||||
message: "Code invalide ou expiré.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -113,7 +189,7 @@ describe("WebSession default fetch receiver", () => {
|
||||
try {
|
||||
// No `fetchImpl` injected ⇒ the global fallback is used.
|
||||
const session = new WebSession({ baseUrl: "https://host", store: memStore() });
|
||||
await session.pair("4821-93");
|
||||
await session.pair("AB12CD34", "iPhone");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user