275 lines
12 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|