Files
au_whisper_web/tests/auth-client.test.ts
T

185 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, test, expect } from "bun:test";
import {
fetchAuthStatus,
login,
logout,
register,
validatePassword,
validateUsername,
type FetchLike,
} from "../src/lib/authClient";
// The auth client is framework-free with an injectable fetch, so we can test
// validation, the request contract, and the fail-silent /me behaviour without
// a DOM or a running server (the M4 backend is exercised separately).
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
/** Mock fetch returning a fixed response and recording the call. */
function mockFetch(response: Response | Error): {
fetch: FetchLike;
calls: { input: string; init?: RequestInit }[];
} {
const calls: { input: string; init?: RequestInit }[] = [];
const fetch: FetchLike = (input, init) => {
calls.push({ input, init });
return response instanceof Error
? Promise.reject(response)
: Promise.resolve(response);
};
return { fetch, calls };
}
const USER = { id: 1, username: "alice" };
describe("validateUsername", () => {
test("accepts the allowed charset within 3–32 chars", () => {
expect(validateUsername("abc")).toBeNull(); // lower bound
expect(validateUsername("a".repeat(32))).toBeNull(); // upper bound
expect(validateUsername("User_1.name-x")).toBeNull();
});
test("rejects usernames that are too short or too long", () => {
expect(validateUsername("")).not.toBeNull();
expect(validateUsername("ab")).not.toBeNull();
expect(validateUsername("a".repeat(33))).not.toBeNull();
});
test("rejects characters outside [a-zA-Z0-9_.-]", () => {
expect(validateUsername("has space")).not.toBeNull();
expect(validateUsername("email@host")).not.toBeNull();
expect(validateUsername("émile")).not.toBeNull();
});
});
describe("validatePassword", () => {
test("requires at least 8 characters", () => {
expect(validatePassword("1234567")).not.toBeNull();
expect(validatePassword("12345678")).toBeNull();
});
});
describe("fetchAuthStatus (fail-silent /me)", () => {
test("returns the user on 200", async () => {
const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER }));
expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: USER });
// The cookie is HttpOnly — it must ride along via same-origin credentials.
expect(calls[0]?.input).toBe("/api/auth/me");
expect(calls[0]?.init?.credentials).toBe("same-origin");
});
test("resolves to anonymous (null user) on 401", async () => {
const { fetch } = mockFetch(jsonResponse(401, { error: "unauthorized" }));
expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: null });
});
test("resolves to disabled on 404 (AUTH_ENABLED=false, D-015)", async () => {
const { fetch } = mockFetch(jsonResponse(404, { error: "Not Found" }));
expect(await fetchAuthStatus(fetch)).toEqual({ enabled: false, user: null });
});
test("resolves to disabled on network failure (offline-first)", async () => {
// /api/* is network-only in the service worker, so offline /me rejects.
// That must NOT throw or surface an error — the auth UI just stays
// hidden (signing in is impossible without the server anyway).
const { fetch } = mockFetch(new TypeError("Failed to fetch"));
expect(await fetchAuthStatus(fetch)).toEqual({ enabled: false, user: null });
});
test("resolves to anonymous on a malformed body", async () => {
const { fetch } = mockFetch(
new Response("<html>proxy error</html>", { status: 200 }),
);
expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: null });
});
});
describe("login", () => {
test("POSTs JSON credentials and returns the user on 200", async () => {
const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER }));
expect(await login("alice", "hunter22", fetch)).toEqual(USER);
const call = calls[0];
expect(call?.input).toBe("/api/auth/login");
expect(call?.init?.method).toBe("POST");
expect(call?.init?.credentials).toBe("same-origin");
expect(
(call?.init?.headers as Record<string, string>)["Content-Type"],
).toBe("application/json");
expect(JSON.parse(call?.init?.body as string)).toEqual({
username: "alice",
password: "hunter22",
});
});
test("surfaces the server's error message on 401", async () => {
const { fetch } = mockFetch(
jsonResponse(401, { error: "invalid credentials" }),
);
expect(login("alice", "wrong-pass", fetch)).rejects.toThrow(
"invalid credentials",
);
});
test("throws a UI-safe message on network failure", async () => {
const { fetch } = mockFetch(new TypeError("Failed to fetch"));
expect(login("alice", "hunter22", fetch)).rejects.toThrow(
/could not reach the server/i,
);
});
test("throws on an unexpected success body", async () => {
const { fetch } = mockFetch(jsonResponse(200, { nope: true }));
expect(login("alice", "hunter22", fetch)).rejects.toThrow(
/unexpected response/i,
);
});
});
describe("register", () => {
test("returns the user on 201", async () => {
const { fetch, calls } = mockFetch(jsonResponse(201, { user: USER }));
expect(await register("alice", "hunter22", fetch)).toEqual(USER);
expect(calls[0]?.input).toBe("/api/auth/register");
});
test("surfaces {error} on 409 (username taken)", async () => {
const { fetch } = mockFetch(jsonResponse(409, { error: "username taken" }));
expect(register("alice", "hunter22", fetch)).rejects.toThrow(
"username taken",
);
});
test("surfaces {error} on 400 (server-side validation)", async () => {
const { fetch } = mockFetch(jsonResponse(400, { error: "invalid input" }));
expect(register("a", "short", fetch)).rejects.toThrow("invalid input");
});
test("falls back to a generic message when the error body is not JSON", async () => {
const { fetch } = mockFetch(new Response("boom", { status: 500 }));
expect(register("alice", "hunter22", fetch)).rejects.toThrow(
"Request failed (500).",
);
});
});
describe("logout", () => {
test("POSTs to /api/auth/logout with credentials", async () => {
const { fetch, calls } = mockFetch(new Response(null, { status: 204 }));
await logout(fetch);
expect(calls[0]?.input).toBe("/api/auth/logout");
expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.init?.credentials).toBe("same-origin");
});
test("resolves even when the server is unreachable", async () => {
const { fetch } = mockFetch(new TypeError("Failed to fetch"));
await logout(fetch); // must not throw — local state is cleared regardless
});
});