9101aba816
Adds a minimal Svelte 5 i18n primitive (`src/lib/i18n/`) backing the
login form, the layout blocker page, and the lobby placeholder.
SUPPORTED_LOCALES drives both the picker and the runtime lookup;
adding a language is a two-step change inside `src/lib/i18n/`.
Login form gains a globe-icon language dropdown (English / Русский
in their native names), defaulting to navigator.languages with `en`
as the fallback. Switching the locale re-renders the form in place;
on submit, the locale rides in the JSON body of `send-email-code`
because Safari/WebKit silently drops JS-set Accept-Language. Gateway
gains a body `locale` field that takes priority over the request
header for preferred-language resolution.
Email and code inputs disable browser autofill / suggestions
(`autocomplete=off` + `autocorrect=off` + `autocapitalize=off` +
`spellcheck=false`) so Keychain / address-book pickers and
remembered-value dropdowns no longer fire on focus.
Cross-cuts:
- backend & gateway openapi: clarify that body `locale` is honored.
- docs/FUNCTIONAL{,_ru}.md §1.2: document body-vs-header priority.
- gateway tests: body `locale` overrides Accept-Language; blank
body `locale` falls back to header.
- new ui/docs/i18n.md; cross-links from auth-flow.md and ui/README.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
166 lines
5.2 KiB
TypeScript
166 lines
5.2 KiB
TypeScript
// 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("forwards the locale option in the JSON body", async () => {
|
|
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { challenge_id: "ch-1" }));
|
|
await sendEmailCode(BASE_URL, "pilot@example.com", { locale: "ru" });
|
|
const [, init] = fetchSpy.mock.calls[0]!;
|
|
expect(init?.headers).toEqual({ "content-type": "application/json" });
|
|
expect(JSON.parse(init?.body as string)).toEqual({
|
|
email: "pilot@example.com",
|
|
locale: "ru",
|
|
});
|
|
});
|
|
|
|
test("omits the locale field when no locale is provided", async () => {
|
|
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { challenge_id: "ch-1" }));
|
|
await sendEmailCode(BASE_URL, "pilot@example.com");
|
|
const [, init] = fetchSpy.mock.calls[0]!;
|
|
expect(JSON.parse(init?.body as string)).toEqual({
|
|
email: "pilot@example.com",
|
|
});
|
|
});
|
|
|
|
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 });
|
|
});
|
|
});
|