185 lines
6.7 KiB
TypeScript
185 lines
6.7 KiB
TypeScript
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
|
||
});
|
||
});
|