196 lines
9.2 KiB
TypeScript
196 lines
9.2 KiB
TypeScript
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();
|
|
});
|
|
});
|