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

151 lines
5.1 KiB
TypeScript

import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse, delay } from "msw";
import { beforeEach, afterEach, describe, expect, test, vi } from "vitest";
import { StrictMode } from "react";
import { AuthGate } from "./AuthGate";
import { clearAuthState, getAuthGeneration, getAuthState, setAuthState } from "./authState";
import { server } from "../test/msw";
vi.mock("../shell/AppShell", () => ({
AppShell: () => (
<div data-testid="authenticated-shell">
Authenticated shell
<button type="button" onClick={() => { window.dispatchEvent(new Event("test-logout")); }}>Log out</button>
</div>
),
}));
const user = {
issuer: "local",
subject: "user-1",
displayName: "Analyst",
roles: ["user"] as const,
permissions: ["session.use"],
isAdmin: false,
csrfToken: "c".repeat(43),
session: {
method: "local" as const,
remembered: false,
idleExpiresAt: "2026-08-17T10:00:00.000Z",
absoluteExpiresAt: "2026-08-17T20:00:00.000Z",
},
};
const localConfig = { mode: "local", localLogin: true, oidcLogin: false };
beforeEach(() => {
clearAuthState();
server.use(
http.get("/api/auth/config", () => HttpResponse.json(localConfig)),
http.get("/api/me", () => HttpResponse.json(user)),
);
});
afterEach(() => {
cleanup();
clearAuthState();
});
describe("AuthGate", () => {
test("shows a loading state while /me is unresolved", async () => {
server.use(http.get("/api/me", async () => {
await delay(100);
return HttpResponse.json(user);
}));
render(<AuthGate />);
expect(screen.getByRole("status", { name: /checking access/i })).toBeInTheDocument();
expect(screen.queryByTestId("authenticated-shell")).not.toBeInTheDocument();
});
test("renders the authenticated shell from the safe /me DTO", async () => {
render(<AuthGate />);
expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /sign in/i })).not.toBeInTheDocument();
});
test("returns to local login after an expired session 401", async () => {
server.use(http.get("/api/me", () => new HttpResponse(null, { status: 401 })));
render(<AuthGate />);
expect(await screen.findByRole("heading", { name: /sign in to thothii/i })).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
test("presents a forbidden /me response explicitly", async () => {
setAuthState(user);
const generation = getAuthGeneration();
server.use(http.get("/api/me", () => HttpResponse.json(
{ code: "auth_not_authorized", error: "This operation is not permitted" },
{ status: 403 },
)));
render(<AuthGate />);
expect(await screen.findByRole("heading", { name: /access not permitted/i })).toBeInTheDocument();
expect(screen.getByText(/signed in without permission/i)).toBeInTheDocument();
expect(getAuthState()).toMatchObject({ subject: "user-1", csrfToken: "c".repeat(43) });
expect(getAuthGeneration()).toBe(generation);
});
test("offers retry when the authentication provider is unavailable", async () => {
let attempts = 0;
server.use(
http.get("/api/me", () => {
attempts += 1;
return attempts === 1
? HttpResponse.json({ code: "auth_unavailable" }, { status: 503 })
: HttpResponse.json(user);
}),
);
render(<AuthGate />);
expect(await screen.findByRole("heading", { name: /authentication unavailable/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /retry/i }));
expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
});
test("accepts the nullable legacy /me session shape without inventing a token", async () => {
server.use(http.get("/api/me", () => HttpResponse.json({
issuer: "portal",
subject: "legacy-user",
displayName: "Legacy user",
roles: ["user"],
permissions: ["session.use"],
isAdmin: false,
csrfToken: null,
session: null,
})));
render(<AuthGate />);
expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
});
test("does not write credentials or tokens to browser storage", async () => {
const storageWrites = (["setItem", "removeItem", "clear"] as const).map((method) =>
vi.spyOn(Storage.prototype, method));
render(<AuthGate />);
await screen.findByTestId("authenticated-shell");
for (const write of storageWrites) expect(write).not.toHaveBeenCalled();
for (const write of storageWrites) write.mockRestore();
});
test("does not emit act warnings while StrictMode authenticates", async () => {
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
render(<StrictMode><AuthGate /></StrictMode>);
expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
} finally {
errors.mockRestore();
}
});
});