Files
ThothII/frontend/src/shell/AppShell.auth.test.tsx
T
Codex cffa60772e
Publish documentation / publish (push) Successful in 2m12s
feat: complete catalog-driven preprocessing
2026-09-06 17:49:35 +02:00

308 lines
13 KiB
TypeScript

import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AppShell } from "./AppShell";
import { clearAuthState, getAuthGeneration, getAuthState, setAuthState, useAuthGeneration, useAuthUser } from "../auth/authState";
import { server } from "../test/msw";
import { useSessionStore } from "../store/sessionStore";
import { workspacePreferences } from "../workspaces/preferences";
function renderShell(user: {
subject: string;
isAdmin: boolean;
roles: readonly ("user" | "admin")[];
permissions: readonly string[];
}, canLogout = true) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setAuthState({
issuer: "local", ...user, csrfToken: null, session: null,
});
return render(<QueryClientProvider client={client}><AppShell canLogout={canLogout} /></QueryClientProvider>);
}
function KeyedAuthenticatedShell() {
const user = useAuthUser();
const generation = useAuthGeneration();
return user
? <AppShell key={`${user.issuer}:${user.subject}:${generation}`} canLogout />
: null;
}
beforeEach(() => {
clearAuthState();
workspacePreferences.reset();
useSessionStore.getState().resetSession();
server.use(
http.get("/api/sessions", () => HttpResponse.json([])),
http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/workspace-registry/status", () => HttpResponse.json({
branch: "main", ahead: 0, behind: 0, degraded: false,
})),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/health/dwh", () => HttpResponse.json({ ok: true })),
);
});
describe("authenticated shell permissions", () => {
test("does not use the legacy installation default as a preprocessing workspace", async () => {
const requestedWorkspaceIds: string[] = [];
server.use(
http.get("/api/settings", () => HttpResponse.json({
workspace: "local", provider: "test", model: "test", thinking: "low",
})),
http.get("/api/workspaces", () => HttpResponse.json([{
id: "psd-clinical",
name: "psd-clinical",
file: "psd-clinical/workspace.yaml",
displayName: "Policlinico San Donato",
configurationState: "ready",
revision: {
id: "psd-clinical",
commit: "a".repeat(40),
blob: "b".repeat(40),
snapshotPath: "/tmp/psd-clinical.yaml",
},
}])),
http.get("/api/workspaces/:workspaceId/preprocessing", ({ params }) => {
requestedWorkspaceIds.push(String(params.workspaceId));
return HttpResponse.json({
schemaVersion: 1,
workspaceId: String(params.workspaceId),
state: "ready",
actionable: true,
clearable: true,
detail: "Workspace preprocessing is current.",
});
}),
);
const user = userEvent.setup();
renderShell({
subject: "admin-1",
isAdmin: true,
roles: ["admin"],
permissions: ["session.use", "database.manage"],
});
const administration = await screen.findByRole("button", { name: "Administration" });
await user.click(administration);
await waitFor(() => expect(requestedWorkspaceIds).toContain("psd-clinical"));
expect(requestedWorkspaceIds).not.toContain("local");
});
test("hides administrative navigation from a session user", async () => {
renderShell({ subject: "user-1", isAdmin: false, roles: ["user"], permissions: ["session.use"] });
expect(await screen.findByRole("button", { name: "New session" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "All sessions" })).not.toBeInTheDocument();
});
test("shows the ordered administrative accordion only to an admin", async () => {
const user = userEvent.setup();
renderShell({
subject: "admin-1",
isAdmin: true,
roles: ["admin"],
permissions: ["session.use", "session.read_all", "workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage"],
});
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const trigger = await within(sessionNavigation).findByRole("button", { name: "Administration" });
expect(trigger).toHaveClass("font-sans");
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(within(sessionNavigation).queryByRole("region", { name: "Administration" })).not.toBeInTheDocument();
trigger.focus();
await user.keyboard("{Enter}");
expect(trigger).toHaveAttribute("aria-expanded", "true");
const panel = within(sessionNavigation).getByRole("region", { name: "Administration" });
const database = within(panel).getByRole("button", { name: "Database management" });
const separator = within(panel).getByRole("separator");
const workspace = within(panel).getByRole("button", { name: "Workspace management" });
const pi = within(panel).getByRole("button", { name: "Pi management" });
const preprocessing = within(panel).getByRole("region", { name: "Workspace preprocessing" });
expect(panel.children[0]).toBe(database);
expect(panel.children[1]).toBe(separator);
expect(panel.children[2]).toBe(workspace);
expect(panel.children[3]).toBe(pi);
expect(panel.children[4]).toBe(preprocessing);
expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument();
await user.keyboard(" ");
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(within(sessionNavigation).queryByRole("region", { name: "Administration" })).not.toBeInTheDocument();
expect(within(sessionNavigation).queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
});
test("keeps identity but hides logout outside local authentication", async () => {
let logoutCalls = 0;
server.use(http.post("/api/auth/logout", () => {
logoutCalls += 1;
return new HttpResponse(null, { status: 204 });
}));
renderShell({
subject: "portal-user",
isAdmin: false,
roles: ["user"],
permissions: ["session.use"],
}, false);
expect(await screen.findByText("portal-user")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Log out" })).not.toBeInTheDocument();
expect(logoutCalls).toBe(0);
});
test("logout revokes the cookie session and clears in-memory auth", async () => {
const user = {
issuer: "local" as const,
subject: "admin-1",
roles: ["admin"] as const,
permissions: ["session.use", "pi.manage"],
isAdmin: true,
csrfToken: "c".repeat(43),
session: null,
};
setAuthState(user);
let logoutCalls = 0;
server.use(http.post("/api/auth/logout", () => {
logoutCalls += 1;
return new HttpResponse(null, { status: 204 });
}));
renderShell(user);
await userEvent.click(screen.getByRole("button", { name: "Log out" }));
await vi.waitFor(() => expect(logoutCalls).toBe(1));
expect(getAuthState()).toBeNull();
});
test("a stale logout continuation cannot scrub user B after the logout response settles", async () => {
const userA = {
issuer: "local" as const, subject: "user-a", roles: ["user"] as const,
permissions: ["session.use"] as const, isAdmin: false,
csrfToken: "a".repeat(43), session: null,
};
const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
let releaseLogout!: () => void;
let logoutStarted!: () => void;
let logoutSettled!: () => void;
const logoutGate = new Promise<void>((resolve) => { releaseLogout = resolve; });
const started = new Promise<void>((resolve) => { logoutStarted = resolve; });
const settled = new Promise<void>((resolve) => { logoutSettled = resolve; });
server.use(http.post("/api/auth/logout", async () => {
logoutStarted();
try {
await logoutGate;
return new HttpResponse(null, { status: 204 });
} finally {
logoutSettled();
}
}));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setAuthState(userA);
render(<QueryClientProvider client={client}><AppShell canLogout /></QueryClientProvider>);
await userEvent.click(screen.getByRole("button", { name: "Log out" }));
await started;
act(() => setAuthState(userB));
act(() => {
client.setQueryData(["b-only"], { owner: "user-b" });
useSessionStore.getState().applyEvent({ type: "text_delta", text: "B transcript" });
});
releaseLogout();
await act(async () => { await settled; });
expect(getAuthState()).toMatchObject({ subject: "user-b" });
expect(client.getQueryData(["b-only"])).toEqual({ owner: "user-b" });
expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "B transcript" }]);
expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
});
test("handles a failed shell logout without an unhandled rejection", async () => {
const user = {
issuer: "local" as const, subject: "user-a", roles: ["user"] as const,
permissions: ["session.use"] as const, isAdmin: false,
csrfToken: "a".repeat(43), session: null,
};
const rejection = vi.fn();
process.on("unhandledRejection", rejection);
server.use(http.post("/api/auth/logout", () => HttpResponse.json(
{ code: "auth_unavailable" }, { status: 503 },
)));
try {
renderShell(user);
await userEvent.click(screen.getByRole("button", { name: "Log out" }));
await waitFor(() => expect(getAuthState()).toBeNull());
await new Promise((resolve) => setImmediate(resolve));
expect(rejection).not.toHaveBeenCalled();
} finally {
process.off("unhandledRejection", rejection);
}
});
test("permission chrome follows current auth state after a stale admin identity disappears", async () => {
renderShell({
subject: "admin-1", isAdmin: true, roles: ["admin"],
permissions: ["session.use", "session.read_all", "workspace.manage", "pi.manage"],
});
expect(await screen.findByRole("button", { name: "Administration" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument();
act(() => clearAuthState());
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "All sessions" })).not.toBeInTheDocument();
});
test("remounts the real shell so user-A panel and transcript state cannot survive user-B", async () => {
const userA = {
issuer: "local" as const,
subject: "user-a",
roles: ["user"] as const,
permissions: ["session.use"] as const,
isAdmin: false,
};
const userB = { ...userA, subject: "user-b" };
const panelSession = {
id: "panel-a", status: "finalized", question: "User A governed question", summary: null,
created_at: "2026-08-17T10:00:00Z", updated_at: null, author: "user-a", name: null,
group: null, archived: false,
};
server.use(
http.get("/api/sessions", () => HttpResponse.json([panelSession])),
http.get("/api/sessions/panel-a/documents", () => HttpResponse.json([
{ key: "question", title: "Question", phase: "F1", format: "markdown", content: "A-private-document" },
])),
);
setAuthState({ ...userA, csrfToken: null, session: null });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function renderUserShell() {
return render(<QueryClientProvider client={client}><KeyedAuthenticatedShell /></QueryClientProvider>);
}
renderUserShell();
await userEvent.click(await screen.findByTestId("session-item-panel-a"));
expect(await screen.findByText("A-private-document")).toBeInTheDocument();
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "A-private-transcript" }));
expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "A-private-transcript" }]);
act(() => setAuthState({ ...userB, csrfToken: null, session: null }));
await waitFor(() => expect(screen.queryByText("A-private-document")).not.toBeInTheDocument());
expect(screen.queryByRole("heading", { name: "User A governed question" })).not.toBeInTheDocument();
expect(useSessionStore.getState().transcript).toEqual([]);
expect(getAuthGeneration()).toBeGreaterThan(0);
});
});