phase 7: auth flow UI (email-code login + session resume + revocation)

Implements ui/PLAN.md Phase 7 end-to-end:

- /login two-step form (email -> code) over the gateway public REST
  surface; /lobby placeholder issues the first authenticated
  user.account.get and renders the decoded display name.
- SessionStore (Svelte 5 runes) with loading / unsupported / anonymous /
  authenticated states; layout-level route guard, browser-not-supported
  blocker, and a minimal SubscribeEvents revocation watcher that closes
  the active client within 1s on a clean stream end or
  Unauthenticated.
- VITE_GATEWAY_BASE_URL + VITE_GATEWAY_RESPONSE_PUBLIC_KEY config plus
  AuthError taxonomy in api/auth.ts.
- Vitest (auth-api, session-store, login-page) and Playwright e2e
  (auth-flow.spec.ts) on the four configured projects, with a fixture
  Ed25519 keypair forging Connect-Web JSON responses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-07 15:24:21 +02:00
parent 390ad3196b
commit 22b0710d04
24 changed files with 2125 additions and 48 deletions
+145
View File
@@ -0,0 +1,145 @@
// Verifies the wire shape of `sendEmailCode` / `confirmEmailCode`
// against the gateway public auth REST surface defined in
// `backend/openapi.yaml`. The transport is mocked through
// `globalThis.fetch`; the helpers themselves are exercised in the
// e2e Playwright spec against a Connect-Web mock that adds back the
// real network stack (still not a live gateway).
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AuthError, confirmEmailCode, sendEmailCode } from "../src/api/auth";
const BASE_URL = "https://gateway.test";
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("sendEmailCode", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
});
afterEach(() => {
vi.restoreAllMocks();
});
test("posts a JSON body with the email and returns the challenge id", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse(200, { challenge_id: "ch-123" }),
);
const result = await sendEmailCode(BASE_URL, "pilot@example.com");
expect(result).toEqual({ challengeId: "ch-123" });
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe(`${BASE_URL}/api/v1/public/auth/send-email-code`);
expect(init?.method).toBe("POST");
expect(init?.headers).toEqual({ "content-type": "application/json" });
expect(JSON.parse(init?.body as string)).toEqual({
email: "pilot@example.com",
});
});
test("strips a trailing slash from the base URL", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { challenge_id: "ch-1" }));
await sendEmailCode(`${BASE_URL}/`, "pilot@example.com");
expect(fetchSpy.mock.calls[0]![0]).toBe(
`${BASE_URL}/api/v1/public/auth/send-email-code`,
);
});
test("throws AuthError carrying gateway code and message on 400", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse(400, {
error: { code: "invalid_request", message: "email must be valid" },
}),
);
await expect(sendEmailCode(BASE_URL, "bad")).rejects.toMatchObject({
name: "AuthError",
code: "invalid_request",
message: "email must be valid",
status: 400,
});
});
test("falls back to internal_error on a 5xx without an envelope", async () => {
fetchSpy.mockResolvedValueOnce(
new Response("oops", { status: 503 }),
);
const err = await sendEmailCode(BASE_URL, "pilot@example.com").catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(AuthError);
expect((err as AuthError).code).toBe("internal_error");
expect((err as AuthError).status).toBe(503);
});
test("throws on a malformed success body", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { challenge_id: 42 }));
await expect(sendEmailCode(BASE_URL, "p@x")).rejects.toBeInstanceOf(
AuthError,
);
});
});
describe("confirmEmailCode", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
});
afterEach(() => {
vi.restoreAllMocks();
});
test("base64-encodes the public key and forwards the time zone", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse(200, { device_session_id: "dev-uuid-1" }),
);
const publicKey = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
const result = await confirmEmailCode(BASE_URL, {
challengeId: "ch-1",
code: "123456",
publicKey,
timeZone: "Europe/Berlin",
});
expect(result).toEqual({ deviceSessionId: "dev-uuid-1" });
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe(`${BASE_URL}/api/v1/public/auth/confirm-email-code`);
const body = JSON.parse(init?.body as string) as Record<string, unknown>;
expect(body.challenge_id).toBe("ch-1");
expect(body.code).toBe("123456");
expect(body.time_zone).toBe("Europe/Berlin");
expect(body.client_public_key).toBe(btoa("\xde\xad\xbe\xef"));
});
test("AuthError on 400 carries the gateway error code", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse(400, {
error: { code: "invalid_request", message: "bad code" },
}),
);
await expect(
confirmEmailCode(BASE_URL, {
challengeId: "ch",
code: "wrong",
publicKey: new Uint8Array(32),
timeZone: "UTC",
}),
).rejects.toMatchObject({ code: "invalid_request", status: 400 });
});
});