// 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; 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; 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; 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 }); }); });