feat(auth): add remembered local login to the frontend
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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[];
|
||||
}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
setAuthState({
|
||||
issuer: "local", ...user, csrfToken: null, session: null,
|
||||
});
|
||||
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>);
|
||||
}
|
||||
|
||||
function KeyedAuthenticatedShell() {
|
||||
const user = useAuthUser();
|
||||
const generation = useAuthGeneration();
|
||||
return user ? <AppShell key={`${user.issuer}:${user.subject}:${generation}`} /> : 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("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 /></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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user