89 lines
4.6 KiB
TypeScript
89 lines
4.6 KiB
TypeScript
import { expect, loginAs, test } from "./fixtures";
|
|
|
|
test.describe("role-based authorization, driven end to end from the browser", () => {
|
|
test("a viewer never sees the client-create form; an admin does", async ({ page }) => {
|
|
await loginAs(page, "hub-viewer");
|
|
await page.goto("/clients");
|
|
await expect(page.getByRole("button", { name: "Create pending client" })).toHaveCount(0);
|
|
await expect(page.getByText("Client creation requires the admin role.")).toBeVisible();
|
|
|
|
await loginAs(page, "hub-admin");
|
|
await page.goto("/clients");
|
|
await expect(page.getByRole("button", { name: "Create pending client" })).toBeVisible();
|
|
});
|
|
|
|
test("client.create is still server-enforced even if the hidden form were bypassed", async ({ page, request }) => {
|
|
// Proves the security property independent of the UI: the hidden
|
|
// button is a usability affordance, not the authorization boundary.
|
|
await loginAs(page, "hub-viewer");
|
|
const cookies = await page.context().cookies();
|
|
const sessionCookie = cookies.find((cookie) => cookie.name.toLowerCase().includes("session"));
|
|
if (!sessionCookie) throw new Error("expected a session cookie to be set after login");
|
|
const sessionResponse = await request.get("/auth/session", { headers: { Cookie: `${sessionCookie.name}=${sessionCookie.value}` } });
|
|
const { csrf_token: csrfToken } = await sessionResponse.json();
|
|
const response = await request.post("/api/client-management", {
|
|
headers: { Cookie: `${sessionCookie.name}=${sessionCookie.value}`, "X-CSRF-Token": csrfToken },
|
|
data: { name: "e2e-bypass-attempt", project: "ludarium", environment: "prod", kind: "native-2026" },
|
|
});
|
|
expect(response.status()).toBe(403);
|
|
});
|
|
|
|
test("an auditor can read the audit log; a viewer never sees the Audit nav link at all", async ({ page }) => {
|
|
await loginAs(page, "hub-viewer");
|
|
await page.goto("/");
|
|
await expect(page.getByRole("link", { name: "Audit" })).toHaveCount(0);
|
|
|
|
await loginAs(page, "hub-auditor");
|
|
await page.goto("/");
|
|
await expect(page.getByRole("link", { name: "Audit" })).toBeVisible();
|
|
await page.goto("/audit");
|
|
await expect(page.getByRole("alert")).toHaveCount(0);
|
|
await expect(page.getByText(/total · server paginated/)).toBeVisible();
|
|
});
|
|
|
|
test("a viewer cannot select the audit section of a project workspace", async ({ page }) => {
|
|
await loginAs(page, "hub-viewer");
|
|
await page.goto("/projects/ludarium?tab=Audit");
|
|
await expect(page.getByRole("link", { name: "Audit" })).toHaveCount(0);
|
|
await expect(page.getByRole("link", { name: "Summary" })).toHaveAttribute("aria-current", "page");
|
|
});
|
|
|
|
test("an operator sees state-appropriate client actions; a viewer does not", async ({ page }) => {
|
|
await loginAs(page, "hub-admin");
|
|
await page.goto("/clients");
|
|
await page.getByLabel("Name").fill("e2e-operator-target");
|
|
await page.getByRole("button", { name: "Create pending client" }).click();
|
|
await expect(page.getByText(/Client created: e2e-operator-target/)).toBeVisible();
|
|
|
|
await loginAs(page, "hub-viewer");
|
|
await page.goto("/clients");
|
|
const viewerRow = page.getByRole("row", { name: /e2e-operator-target/ });
|
|
await expect(viewerRow).toBeVisible();
|
|
await expect(viewerRow.getByRole("button", { name: "Disable" })).toHaveCount(0);
|
|
|
|
await loginAs(page, "hub-operator");
|
|
await page.goto("/clients");
|
|
const operatorRow = page.getByRole("row", { name: /e2e-operator-target/ });
|
|
await expect(operatorRow.getByRole("button", { name: "Disable" })).toBeDisabled();
|
|
await operatorRow.getByRole("button", { name: "Revoke" }).click();
|
|
// The confirmation modal's title is dynamic ("Revoke e2e-operator-target?"),
|
|
// which makes an exact accessible-name match on the confirm button awkward
|
|
// to pin reliably against the row-scoped locator above. This is the one
|
|
// deliberate exception to this file's role/text-based query convention,
|
|
// justified by the dynamic title: DataTable.tsx's modal confirm button
|
|
// carries data-testid="confirm-action" specifically for this case.
|
|
await page.getByTestId("confirm-action").click();
|
|
await expect(page.getByText(/completed\./)).toBeVisible();
|
|
});
|
|
|
|
test("tool readiness is hidden below operator and visible to an operator", async ({ page }) => {
|
|
await loginAs(page, "hub-viewer");
|
|
await page.goto("/tools");
|
|
await expect(page.getByRole("button", { name: "Readiness" })).toHaveCount(0);
|
|
|
|
await loginAs(page, "hub-operator");
|
|
await page.goto("/tools");
|
|
await expect(page.getByRole("button", { name: "Readiness" }).first()).toBeVisible();
|
|
});
|
|
});
|