151 lines
5.1 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|