feat(auth): add remembered local login to the frontend
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
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 and uses same-origin navigation", () => {
|
||||
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("link", { name: /single sign-on/i })).toHaveAttribute(
|
||||
"href", "/api/auth/oidc/login",
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user