import AxeBuilder from "@axe-core/playwright"; import { expect, test, type Page } from "@playwright/test"; import { authenticatedStatePath } from "../global-setup"; import { expectAuthenticatedShell } from "./support/v2"; /** * V2-M4: structured infrastructure facts, editorial profiles and manual overrides are * visibly distinct and safely editable in the UI (ADR-023), and a proposed service has * a real accept/reject review flow reachable from the UI — proven end to end against * the real API, not just the repository-level InfrastructureSynchronizationTests. * Synchronization itself stays API-first by design (ADR-023: an external inventory * tool posts snapshots; there is deliberately no "trigger a sync" UI button, matching * the existing V1 "API first" precedent for LeafWiki import etc.) — this spec seeds a * snapshot directly via the API, matching how a real inventory agent would, and then * exercises only the UI review/override flows. */ test.use({ storageState: authenticatedStatePath }); async function postWithCsrf(page: Page, path: string, data: unknown) { const csrfResponse = await page.request.get("/api/v1/auth/csrf"); const csrf = (await csrfResponse.json()) as { token: string; headerName: string }; return page.request.post(path, { headers: { [csrf.headerName]: csrf.token }, data }); } async function seedProposedService(page: Page, sourceKey: string, detectedName: string) { const response = await postWithCsrf(page, "/api/v1/infrastructure/inventory/snapshots", { sourceKey: "inventory-e2e", sourceHash: `hash-${sourceKey}`, capturedAt: new Date().toISOString(), hosts: [ { sourceKey: "host:e2e", hostname: "E2E-Host", operatingSystem: null, kernelVersion: null, architecture: null, logicalCpuCount: 4, totalMemoryBytes: 1_000_000 } ], services: [ { sourceKey, hostSourceKey: "host:e2e", detectedName, runtimeKind: "container", runtimeStatus: "running", health: "Healthy", imageReference: "example/e2e-service:1", imageDigest: null, runtimeUser: null, readOnlyRootFilesystem: true, noNewPrivileges: true, privileged: false, confidence: "High", components: null, endpoints: null, networks: null, volumes: null, databases: null, dependencies: null, healthObservations: null, deployments: null, backups: null } ] }); expect(response.ok(), await response.text()).toBeTruthy(); } test.describe("V2-M4 infrastructure review, editorial ownership and overrides", () => { test("a proposed service is reviewable (accept) from the admin UI, then its facts and overrides are visibly distinct on its workspace", async ({ page }) => { const sourceKey = `docker:e2e-accept-${Date.now()}`; const detectedName = `e2e-accept-service-${Date.now()}`; await seedProposedService(page, sourceKey, detectedName); await page.goto("/admin/inventory"); await expectAuthenticatedShell(page); await expect(page.getByRole("heading", { name: "Wijzigingen sinds snapshots" })).toBeVisible(); const proposalCard = page.locator(".inventory-proposals article", { hasText: detectedName }); await expect(proposalCard).toBeVisible(); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); await proposalCard.getByRole("button", { name: "Bevestigen" }).click(); await expect(proposalCard).toBeHidden(); // Confirmed via the review action — the summary list now shows it as Present, not // Proposed, matching InfrastructureSynchronizationTests' // An_administrator_can_accept_a_proposed_service_and_the_decision_survives_sync. await page.goto("/infrastructure/services"); await expect(page.getByRole("heading", { name: "Services" })).toBeVisible(); const serviceLink = page.getByRole("link", { name: new RegExp(detectedName) }); await expect(serviceLink).toBeVisible(); await serviceLink.click(); await expect(page.getByRole("heading", { name: detectedName })).toBeVisible(); await page.getByRole("tab", { name: "Live facts" }).click(); const factsTable = page.locator(".data-surface table.data-table"); await expect(factsTable).toBeVisible(); // Every fact row still shows "Inventaris" (source-managed) as its ownership — // ADR-023's "Information ownership" requirement made visible per value, not just // as a generic legend. await expect(factsTable.locator("tbody tr", { hasText: "Inventaris" }).first()).toBeVisible(); await expect(factsTable.getByText("Override", { exact: true })).toHaveCount(0); const factsAccessibility = await new AxeBuilder({ page }).analyze(); expect(factsAccessibility.violations).toEqual([]); // Add a manual override and confirm the same fact's row flips to "Override" // ownership — the whole point of ADR-023's three-layer model made concrete in the // UI, not just provable at the repository level. await page.getByRole("button", { name: "Override toevoegen" }).click(); await page.getByLabel("Fact").selectOption("runtimeKind"); await page.getByLabel("Gecorrigeerde waarde").fill("virtual-machine"); await page.getByLabel("Reden").fill("E2E: automatische detectie was onjuist."); await page.getByRole("button", { name: "Override opslaan" }).click(); // "virtual-machine" legitimately appears twice once saved (the FactsMatrix row // and the ServiceOverrideManager's own override-list entry below it) — assert on // the specific, scoped row rather than an ambiguous page-wide text match. const overrideRow = factsTable.locator("tbody tr", { hasText: "Runtimecategorie" }); await expect(overrideRow.getByText("Override", { exact: true })).toBeVisible(); await expect(overrideRow).toContainText("virtual-machine"); const afterOverrideAccessibility = await new AxeBuilder({ page }).analyze(); expect(afterOverrideAccessibility.violations).toEqual([]); // Removing the override reverts the row to its source-managed value and ownership. await page.getByRole("button", { name: /Override voor runtimeKind verwijderen/ }).click(); await expect(page.getByText("Geen actieve overrides.")).toBeVisible(); await expect(overrideRow.getByText("Inventaris", { exact: true })).toBeVisible(); }); test("a proposed service can be rejected with a reason from the admin UI and is never confirmed", async ({ page }) => { const sourceKey = `docker:e2e-reject-${Date.now()}`; const detectedName = `e2e-reject-service-${Date.now()}`; await seedProposedService(page, sourceKey, detectedName); await page.goto("/admin/inventory"); await expectAuthenticatedShell(page); const proposalCard = page.locator(".inventory-proposals article", { hasText: detectedName }); await expect(proposalCard).toBeVisible(); await proposalCard.getByRole("button", { name: "Afwijzen" }).click(); await proposalCard.getByLabel("Reden voor afwijzing").fill("E2E: geen bekende productiedienst."); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); await proposalCard.getByRole("button", { name: "Afwijzing bevestigen" }).click(); await expect(proposalCard).toBeHidden(); // A rejected proposal must not appear as a confirmed, visible service. await page.goto("/infrastructure/services"); await expect(page.getByRole("heading", { name: "Services" })).toBeVisible(); await expect(page.getByRole("link", { name: new RegExp(detectedName) })).toHaveCount(0); }); test("a Reader sees infrastructure facts read-only: no review, override or editorial controls", async ({ page, baseURL }) => { const readerEmail = `e2e-infra-reader-${Date.now()}@itworx.e2e`; const readerPassword = "correct-horse-battery-staple-reader"; await page.goto("/"); const csrfResponse = await page.request.get("/api/v1/auth/csrf"); const csrf = (await csrfResponse.json()) as { token: string; headerName: string }; const createResponse = await page.request.post("/api/v1/admin/users", { headers: { [csrf.headerName]: csrf.token }, data: { displayName: "E2E Infra Reader", email: readerEmail, password: readerPassword, role: "Reader" } }); expect(createResponse.ok(), await createResponse.text()).toBeTruthy(); const readerContext = await page.context().browser()!.newContext({ baseURL, storageState: { cookies: [], origins: [] } }); const readerPage = await readerContext.newPage(); await readerPage.goto("/"); await readerPage.getByLabel("E-mailadres").fill(readerEmail); await readerPage.getByLabel("Wachtwoord", { exact: true }).fill(readerPassword); await readerPage.getByRole("button", { name: "Aanmelden" }).click(); await expect(readerPage.getByRole("button", { name: "Accountmenu" })).toBeVisible(); // The admin-only review panel must not be reachable at all (server-enforced, // matching v2-routing-and-fidelity.spec.ts's existing /admin role-guard proof). await readerPage.goto("/admin"); await expect(readerPage).toHaveURL(/\/$/); await readerContext.close(); }); });