308 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|