Files
ThothII/frontend/src/auth/LoginPage.test.tsx
T

275 lines
12 KiB
TypeScript

import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse, delay } from "msw";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { StrictMode } from "react";
import { LoginPage } from "./LoginPage";
import { clearAuthState, setAuthState } from "./authState";
import * as authApi from "../api/auth";
import { server } from "../test/msw";
const localConfig = { mode: "local", localLogin: true, oidcLogin: false } as const;
const oidcConfig = { mode: "oidc", localLogin: false, oidcLogin: true } as const;
const authenticated = {
issuer: "local",
subject: "admin-1",
roles: ["admin"] as const,
permissions: ["session.use", "pi.manage"],
isAdmin: true,
csrfToken: "c".repeat(43),
session: {
method: "local" as const,
remembered: true,
idleExpiresAt: "2026-08-17T10:00:00.000Z",
absoluteExpiresAt: "2026-09-16T10:00:00.000Z",
},
};
beforeEach(() => clearAuthState());
afterEach(() => clearAuthState());
describe("LoginPage", () => {
test("shows an unchecked local Remember me control and clears the password after failure", async () => {
let submittedPassword = "";
server.use(http.post("/api/auth/local/login", async ({ request }) => {
const body = await request.json() as { password: string; remember: boolean };
submittedPassword = body.password;
expect(body.remember).toBe(false);
return HttpResponse.json({ code: "invalid_credentials" }, { status: 401 });
}));
render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
expect(screen.getByRole("checkbox", { name: /remember me/i })).not.toBeChecked();
await userEvent.type(screen.getByLabelText(/username/i), "alice");
await userEvent.type(screen.getByLabelText(/password/i), "not-a-real-password");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i);
expect(submittedPassword).toBe("not-a-real-password");
expect(screen.getByLabelText(/password/i)).toHaveValue("");
});
test("submits a remembered local login once and returns the safe user", async () => {
let requests = 0;
server.use(
http.post("/api/auth/local/login", async ({ request }) => {
requests += 1;
const body = await request.json() as { remember: boolean };
expect(body.remember).toBe(true);
await delay(20);
return HttpResponse.json({});
}),
http.get("/api/me", () => HttpResponse.json(authenticated)),
);
const onAuthenticated = vi.fn();
render(<LoginPage config={localConfig} onAuthenticated={onAuthenticated} />);
await userEvent.click(screen.getByRole("checkbox", { name: /remember me/i }));
await userEvent.type(screen.getByLabelText(/username/i), "admin");
await userEvent.type(screen.getByLabelText(/password/i), "correct-password");
const submit = screen.getByRole("button", { name: /sign in/i });
await Promise.all([userEvent.click(submit), userEvent.click(submit)]);
expect(requests).toBe(1);
await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledWith(expect.objectContaining({ subject: "admin-1" })));
expect(screen.getByLabelText(/password/i)).toHaveValue("");
});
test("shows OIDC only when public configuration enables it", () => {
const { rerender } = render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
expect(screen.queryByRole("link", { name: /single sign-on/i })).not.toBeInTheDocument();
rerender(<LoginPage config={oidcConfig} onAuthenticated={vi.fn()} />);
expect(screen.getByRole("button", { name: /single sign-on/i })).toBeEnabled();
});
test("routes every rendered SSO affordance through the held logout barrier", async () => {
let releaseLogout!: () => void;
let logoutStarted!: () => void;
const logoutGate = new Promise<void>((resolve) => { releaseLogout = resolve; });
const logoutRequest = new Promise<void>((resolve) => { logoutStarted = resolve; });
server.use(http.post("/api/auth/logout", async () => {
logoutStarted();
await logoutGate;
return new HttpResponse(null, { status: 204 });
}));
setAuthState({ ...authenticated, subject: "user-a" });
const logoutPromise = authApi.logout();
await logoutRequest;
const navigate = vi.fn();
const beginOidcLogin = authApi.beginOidcLogin;
const begin = vi.spyOn(authApi, "beginOidcLogin")
.mockImplementation(() => beginOidcLogin(navigate));
try {
render(<LoginPage config={oidcConfig} onAuthenticated={vi.fn()} />);
const buttons = screen.getAllByRole("button", { name: /single sign-on/i });
expect(screen.queryAllByRole("link", { name: /single sign-on/i })).toHaveLength(0);
expect(buttons).toHaveLength(1);
await userEvent.click(buttons[0]);
await Promise.resolve();
expect(begin).toHaveBeenCalledOnce();
expect(navigate).not.toHaveBeenCalled();
releaseLogout();
await Promise.all([logoutPromise, vi.waitFor(() => expect(navigate).toHaveBeenCalledWith("/api/auth/oidc/login"))]);
} finally {
releaseLogout();
begin.mockRestore();
}
});
test("does not dispatch local login until an in-flight logout response settles", async () => {
let releaseLogout!: () => void;
let logoutStarted!: () => void;
let loginDispatched = false;
const logoutGate = new Promise<void>((resolve) => { releaseLogout = resolve; });
const logoutRequest = new Promise<void>((resolve) => { logoutStarted = resolve; });
server.use(
http.post("/api/auth/logout", async () => {
logoutStarted();
await logoutGate;
return new HttpResponse(null, { status: 204 });
}),
http.post("/api/auth/local/login", () => {
loginDispatched = true;
return HttpResponse.json({});
}),
http.get("/api/me", () => HttpResponse.json(authenticated)),
);
setAuthState({ ...authenticated, subject: "user-a" });
const logoutPromise = authApi.logout();
await logoutRequest;
const loginPromise = authApi.loginLocal("admin", "password", false);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(loginDispatched).toBe(false);
releaseLogout();
await expect(logoutPromise).resolves.toBe(true);
await loginPromise;
expect(loginDispatched).toBe(true);
});
test("a failed logout response does not deadlock the next local login", async () => {
let logoutCalls = 0;
let loginDispatched = false;
server.use(
http.post("/api/auth/logout", () => {
logoutCalls += 1;
return HttpResponse.json({ code: "auth_unavailable" }, { status: 503 });
}),
http.post("/api/auth/local/login", () => {
loginDispatched = true;
return HttpResponse.json({});
}),
http.get("/api/me", () => HttpResponse.json(authenticated)),
);
setAuthState({ ...authenticated, subject: "user-a" });
await expect(authApi.logout()).rejects.toMatchObject({ status: 503 });
await authApi.loginLocal("admin", "password", false);
expect(logoutCalls).toBe(1);
expect(loginDispatched).toBe(true);
});
test("uses explicit provider-unavailable copy with retry affordance", async () => {
server.use(http.post("/api/auth/local/login", () => HttpResponse.json(
{ code: "auth_unavailable" }, { status: 503 },
)));
const retry = vi.fn();
render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} onRetry={retry} />);
await userEvent.type(screen.getByLabelText(/username/i), "alice");
await userEvent.type(screen.getByLabelText(/password/i), "correct-password");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/temporarily unavailable/i);
await userEvent.click(screen.getByRole("button", { name: /retry/i }));
expect(retry).toHaveBeenCalledOnce();
});
test("never writes the password, remember choice, or returned token to browser storage", async () => {
server.use(
http.post("/api/auth/local/login", () => HttpResponse.json({})),
http.get("/api/me", () => HttpResponse.json(authenticated)),
);
const storageWrites = (["setItem", "removeItem", "clear"] as const).map((method) =>
vi.spyOn(Storage.prototype, method));
render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
await userEvent.type(screen.getByLabelText(/username/i), "alice");
await userEvent.type(screen.getByLabelText(/password/i), "correct-password");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
await vi.waitFor(() => expect(screen.getByLabelText(/password/i)).toHaveValue(""));
for (const write of storageWrites) expect(write).not.toHaveBeenCalled();
for (const write of storageWrites) write.mockRestore();
});
test("settles a successful login on the current StrictMode mount", async () => {
server.use(
http.post("/api/auth/local/login", () => HttpResponse.json({})),
http.get("/api/me", () => HttpResponse.json(authenticated)),
);
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
const onAuthenticated = vi.fn();
try {
render(<StrictMode><LoginPage config={localConfig} onAuthenticated={onAuthenticated} /></StrictMode>);
await userEvent.type(screen.getByLabelText(/username/i), "admin");
await userEvent.type(screen.getByLabelText(/password/i), "correct-password");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledOnce());
expect(screen.getByLabelText(/password/i)).toHaveValue("");
expect(screen.getByRole("button", { name: /sign in/i })).toBeEnabled();
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
} finally {
errors.mockRestore();
}
});
test("settles a failed login and clears the password on the current StrictMode mount", async () => {
server.use(http.post("/api/auth/local/login", () => HttpResponse.json({ code: "invalid_credentials" }, { status: 401 })));
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
render(<StrictMode><LoginPage config={localConfig} onAuthenticated={vi.fn()} /></StrictMode>);
await userEvent.type(screen.getByLabelText(/username/i), "admin");
await userEvent.type(screen.getByLabelText(/password/i), "wrong-password");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i);
expect(screen.getByLabelText(/password/i)).toHaveValue("");
expect(screen.getByRole("button", { name: /sign in/i })).toBeEnabled();
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
} finally {
errors.mockRestore();
}
});
test("does not update an unmounted StrictMode login attempt", async () => {
let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; });
let settled!: () => void;
const settledAfterUnmount = new Promise<void>((resolve) => { settled = resolve; });
const loginLocal = vi.spyOn(authApi, "loginLocal").mockImplementation(async () => {
try {
await pending;
throw new Error("deferred invalid credentials");
} finally {
settled();
}
});
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const view = render(<StrictMode><LoginPage config={localConfig} onAuthenticated={vi.fn()} /></StrictMode>);
await userEvent.type(screen.getByLabelText(/username/i), "admin");
await userEvent.type(screen.getByLabelText(/password/i), "wrong-password");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
view.unmount();
release();
await act(async () => { await settledAfterUnmount; });
expect(loginLocal).toHaveBeenCalledOnce();
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/can't perform a react state update|not wrapped in act/i);
} finally {
loginLocal.mockRestore();
errors.mockRestore();
}
});
});