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.
232 lines
9.7 KiB
TypeScript
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);
|
|
});
|
|
});
|