Files
ThothII/frontend/src/shell/AppShell.auth.test.tsx
T
marcopan 610ae8c85a fix(auth): make local verification portable
Keep upstream identity visible while limiting logout to local auth. Inject the restore privilege gate so the deterministic core tests do not depend on the host OS, and confine descriptor-backed projection tests to Linux. Accept the real remaining Pi timeout budget instead of an exact millisecond.
2026-08-25 10:50:30 +02:00

232 lines
9.7 KiB
TypeScript

import { act, render, screen, waitFor } 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";
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();
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("shows only read-safe workspace chrome to a session user", async () => {
renderShell({ subject: "user-1", isAdmin: false, roles: ["user"], permissions: ["session.use"] });
expect(await screen.findByRole("button", { name: "Workspace management" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument();
});
test("shows management and all-session chrome only for exact permissions", async () => {
renderShell({
subject: "admin-1",
isAdmin: true,
roles: ["admin"],
permissions: ["session.use", "session.read_all", "workspace.manage", "workspace.secrets.manage", "pi.manage"],
});
expect(await screen.findByRole("button", { name: "Pi management" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "All sessions" })).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: "Pi management" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument();
act(() => clearAuthState());
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update workspace repository" })).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);
});
});