diff --git a/components/compliance/AccessControlEditor.tsx b/components/compliance/AccessControlEditor.tsx index 1b17d05..5353ed6 100644 --- a/components/compliance/AccessControlEditor.tsx +++ b/components/compliance/AccessControlEditor.tsx @@ -43,7 +43,7 @@ export function AccessControlEditor({ disabled, guidance, initialData }: { disab onValueChange={(v) => draft && setDraft({ ...draft, model: v as "rbac" | "abac" | "hybrid" })} disabled={fieldsDisabled} > - + {tc("rbac")} {tc("abac")} @@ -60,6 +60,7 @@ export function AccessControlEditor({ disabled, guidance, initialData }: { disab {tc("standardReview")} draft && setDraft({ @@ -74,6 +75,7 @@ export function AccessControlEditor({ disabled, guidance, initialData }: { disab {tc("privilegedReview")} draft && setDraft({ @@ -111,7 +113,7 @@ export function AccessControlEditor({ disabled, guidance, initialData }: { disab onValueChange={(v) => draft && setDraft({ ...draft, sharedAccountPolicy: v as "prohibited" | "documented_exceptions" })} disabled={fieldsDisabled} > - + {tc("sharedProhibited")} {tc("sharedDocumentedExceptions")} diff --git a/components/compliance/CryptoPolicyEditor.tsx b/components/compliance/CryptoPolicyEditor.tsx index 538241d..0fdd29b 100644 --- a/components/compliance/CryptoPolicyEditor.tsx +++ b/components/compliance/CryptoPolicyEditor.tsx @@ -197,6 +197,7 @@ export function CryptoPolicyEditor({ disabled, guidance, initialData }: { disabl {tc("keyRotation")} {tc("reviewCycle")} - + {t("cancel")} - + {isPending ? ( ) : ( @@ -71,7 +71,7 @@ export function PolicyEditorShell({ ) : ( - + {t("edit")} diff --git a/components/compliance/ProcurementEditor.tsx b/components/compliance/ProcurementEditor.tsx index bb7e2d9..232b894 100644 --- a/components/compliance/ProcurementEditor.tsx +++ b/components/compliance/ProcurementEditor.tsx @@ -47,6 +47,7 @@ export function ProcurementEditor({ disabled, guidance, initialData }: { disable draft && setDraft({ ...draft, thresholdEur: Number(e.target.value) || 0 })} @@ -63,6 +64,7 @@ export function ProcurementEditor({ disabled, guidance, initialData }: { disable {CLAUSE_KEYS.map((key) => ( draft && setDraft({ diff --git a/components/compliance/RiskMethodologyEditor.tsx b/components/compliance/RiskMethodologyEditor.tsx index 204815b..f9d07c8 100644 --- a/components/compliance/RiskMethodologyEditor.tsx +++ b/components/compliance/RiskMethodologyEditor.tsx @@ -315,6 +315,7 @@ export function RiskMethodologyEditor({ @@ -327,7 +328,7 @@ export function RiskMethodologyEditor({ ) : ( - + {t("edit")} @@ -340,6 +341,7 @@ export function RiskMethodologyEditor({ {t("methodologyName")} setName(e.target.value)} disabled={fieldsDisabled} @@ -390,7 +392,7 @@ export function RiskMethodologyEditor({ onValueChange={(v) => setAcceptanceThreshold(Number(v))} disabled={fieldsDisabled} > - + diff --git a/components/compliance/SecureDevEditor.tsx b/components/compliance/SecureDevEditor.tsx index 59d8dd7..81a1267 100644 --- a/components/compliance/SecureDevEditor.tsx +++ b/components/compliance/SecureDevEditor.tsx @@ -51,7 +51,7 @@ export function SecureDevEditor({ disabled, guidance, initialData }: { disabled? } disabled={fieldsDisabled} > - + {tc("owaspSamm")} {tc("bsimm")} @@ -70,7 +70,7 @@ export function SecureDevEditor({ disabled, guidance, initialData }: { disabled? } disabled={fieldsDisabled} > - + {tc("cis")} {tc("bsi")} @@ -108,6 +108,7 @@ export function SecureDevEditor({ disabled, guidance, initialData }: { disabled? {/* Environment Segregation — CIR 6(2) */} draft && setDraft({ ...draft, environmentSegregation: !!v }) diff --git a/components/risks/AssetRiskRegister.tsx b/components/risks/AssetRiskRegister.tsx index a7c061d..3e1fc59 100644 --- a/components/risks/AssetRiskRegister.tsx +++ b/components/risks/AssetRiskRegister.tsx @@ -168,6 +168,7 @@ export function AssetRiskRegister({ initialData, disabled }: AssetRiskRegisterPr { setAddingToAsset(asset.id); setExpandedAssets(prev => new Set(prev).add(asset.id)); }} > @@ -402,7 +403,7 @@ function InlineRiskForm({ {/* Title (editable after threat selection or manual) */} {t("fields.title")} - setTitle(e.target.value)} disabled={disabled} /> + setTitle(e.target.value)} disabled={disabled} /> @@ -446,6 +447,7 @@ function InlineRiskForm({ {t("cancel")} diff --git a/components/risks/RiskMatrixPicker.tsx b/components/risks/RiskMatrixPicker.tsx index 592867b..9040a1c 100644 --- a/components/risks/RiskMatrixPicker.tsx +++ b/components/risks/RiskMatrixPicker.tsx @@ -71,6 +71,7 @@ export function RiskMatrixPicker({ onChange(lik.value, imp.value)} className={cn( diff --git a/components/risks/RiskTreatmentView.tsx b/components/risks/RiskTreatmentView.tsx index 3fb5441..4cbab7c 100644 --- a/components/risks/RiskTreatmentView.tsx +++ b/components/risks/RiskTreatmentView.tsx @@ -186,7 +186,7 @@ export function RiskTreatmentView({ initialData, disabled }: RiskTreatmentViewPr disabled={disabled} /> ) : !disabled && ( - setAddingTreatment(risk.id)}> + setAddingTreatment(risk.id)}> {t("addMeasure")} @@ -213,7 +213,7 @@ export function RiskTreatmentView({ initialData, disabled }: RiskTreatmentViewPr setSettingResidual(null)}>{t("cancel")} ) : ( - setSettingResidual(risk.id)} disabled={disabled}> + setSettingResidual(risk.id)} disabled={disabled}> {hasResidual ? t("updateResidual") : t("setResidual")} )} @@ -223,6 +223,7 @@ export function RiskTreatmentView({ initialData, disabled }: RiskTreatmentViewPr {residualBelow && !risk.acceptedAt && !disabled && ( updateRiskMut.mutate({ id: risk.id, acceptedAt: new Date() })} disabled={updateRiskMut.isPending} > @@ -292,7 +293,7 @@ function AddTreatmentForm({ {t("measureAction")} - setAction(e.target.value)} disabled={disabled} placeholder={t("measureActionPlaceholder")} /> + setAction(e.target.value)} disabled={disabled} placeholder={t("measureActionPlaceholder")} /> {t("fields.description")} @@ -300,7 +301,7 @@ function AddTreatmentForm({ {t("cancel")} - onSave({ riskId, action, description })} disabled={!action || isPending || disabled}> + onSave({ riskId, action, description })} disabled={!action || isPending || disabled}> {isPending ? : t("addMeasure")} diff --git a/components/suppliers/SupplierRiskRegister.tsx b/components/suppliers/SupplierRiskRegister.tsx index 0dc78da..4fbc37f 100644 --- a/components/suppliers/SupplierRiskRegister.tsx +++ b/components/suppliers/SupplierRiskRegister.tsx @@ -169,6 +169,7 @@ export function SupplierRiskRegister({ initialData, disabled }: SupplierRiskRegi { setAddingToSupplier(sup.id); setExpandedSuppliers(prev => new Set(prev).add(sup.id)); }} > @@ -399,7 +400,7 @@ function InlineRiskForm({ {/* Title */} {t("fields.title")} - setTitle(e.target.value)} disabled={disabled} /> + setTitle(e.target.value)} disabled={disabled} /> @@ -443,6 +444,7 @@ function InlineRiskForm({ {t("cancel")} diff --git a/e2e/coverage-report.ts b/e2e/coverage-report.ts index 4eba6c0..390bbd9 100644 --- a/e2e/coverage-report.ts +++ b/e2e/coverage-report.ts @@ -41,9 +41,14 @@ const MODULES_TESTED = new Set([ "internal_audit", "management_review", ]); -const EDITORS_TESTED = new Set(); // none yet +// l2/editors.spec.ts drives every custom editor, so the set IS the custom- +// editor registry — derive it rather than restate the nine codes a third time. +// NOTE: "tested" here means the editor's page was driven and persisted, not +// that every structured sub-table (algorithm/criteria rows) was exercised; +// those inner surfaces remain a partial gap. +const EDITORS_TESTED = UI_CUSTOM_EDITORS; -const rows: Row[] = nis2Categories +const rows: Row[] = [...nis2Categories] .sort((a, b) => a.sortOrder - b.sortOrder) .flatMap((cat) => getNis2RequirementsForCategory(cat.slug).map((r) => ({ diff --git a/e2e/l2/editors.spec.ts b/e2e/l2/editors.spec.ts new file mode 100644 index 0000000..691491f --- /dev/null +++ b/e2e/l2/editors.spec.ts @@ -0,0 +1,226 @@ +/** + * L2 custom editors: the nine bespoke config surfaces that bypass + * SchemaForm entirely — five PolicyEditorShell editors, the risk + * methodology, and the three register/treatment views. Every spec drives + * the real UI, waits on the actual mutation, asserts the JSONB/row in + * Postgres, and re-renders after reload. + */ +import { test, expect, type Page } from "@playwright/test"; +import { e2eQuery } from "../lib/db"; + +const RISK_TITLE = "E2E Risiko Datenbankausfall Kernsystem"; +const SUPPLIER_RISK_TITLE = "E2E Lieferantenausfall Rechenzentrum"; + +async function trpcOk(page: Page, procedure: string, action: () => Promise): Promise { + const [resp] = await Promise.all([ + page.waitForResponse( + (r) => r.request().method() === "POST" && r.url().includes(procedure), + { timeout: 20_000 }, + ), + action(), + ]); + expect(resp.ok(), `${procedure} returned HTTP ${resp.status()}`).toBe(true); +} + +async function policyConfig(policyType: string): Promise> { + const rows = await e2eQuery<{ config: Record }>( + `SELECT config FROM company_policy_config WHERE policy_type = $1`, + [policyType], + ); + expect(rows.length, `config row for ${policyType}`).toBe(1); + return rows[0].config; +} + +test("6.4 patch policy: SLA and review cycle round-trip", async ({ page }) => { + await page.goto("/de/compliance/procurement/6.4"); + await page.getByTestId("policy-editor-edit").click(); + await page.getByTestId("patch-sla-critical").fill("12"); + await page.getByTestId("patch-review-cycle").fill("3"); + await trpcOk(page, "policyConfig.update", () => page.getByTestId("policy-editor-save").click()); + + const config = await policyConfig("patch_mgmt"); + expect((config.patchSlaHours as Record).critical).toBe(12); + expect(config.reviewCycleYears).toBe(3); + + await page.reload(); + await expect(page.getByTestId("patch-sla-critical")).toHaveValue("12"); + await expect(page.getByTestId("patch-sla-critical")).toBeDisabled(); +}); + +test("9.1 crypto policy: key rotation round-trip", async ({ page }) => { + await page.goto("/de/compliance/cryptography/9.1"); + await page.getByTestId("policy-editor-edit").click(); + await page.getByTestId("crypto-key-rotation").fill("2"); + await trpcOk(page, "policyConfig.update", () => page.getByTestId("policy-editor-save").click()); + + const config = await policyConfig("crypto"); + expect(config.keyRotationFrequencyYears).toBe(2); + expect(Array.isArray(config.algorithms)).toBe(true); + expect((config.algorithms as unknown[]).length).toBeGreaterThan(0); + + await page.reload(); + await expect(page.getByTestId("crypto-key-rotation")).toHaveValue("2"); +}); + +test("10.1 access control: model, review frequencies, SLA round-trip", async ({ page }) => { + await page.goto("/de/compliance/access-control/10.1"); + await page.getByTestId("policy-editor-edit").click(); + + await page.getByTestId("access-model-select").click(); + await page.getByRole("option", { name: /Hybrid/ }).click(); + await page.getByTestId("access-review-standard").fill("halbjaehrlich"); + await page.getByTestId("access-review-privileged").fill("monatlich"); + await trpcOk(page, "policyConfig.update", () => page.getByTestId("policy-editor-save").click()); + + const config = await policyConfig("access_control"); + expect(config.model).toBe("hybrid"); + expect((config.reviewFrequency as Record).standard).toBe("halbjaehrlich"); + expect((config.reviewFrequency as Record).privileged).toBe("monatlich"); + + await page.reload(); + await expect(page.getByTestId("access-review-standard")).toHaveValue("halbjaehrlich"); +}); + +test("6.1 procurement: threshold and clause toggle round-trip", async ({ page }) => { + await page.goto("/de/compliance/procurement/6.1"); + await page.getByTestId("policy-editor-edit").click(); + await page.getByTestId("procurement-threshold").fill("25000"); + await page.getByTestId("procurement-clause-auditRights").click(); // default true -> false + await trpcOk(page, "policyConfig.update", () => page.getByTestId("policy-editor-save").click()); + + const config = await policyConfig("procurement"); + expect(config.thresholdEur).toBe(25000); + expect((config.requiredClauses as Record).auditRights).toBe(false); + + await page.reload(); + await expect(page.getByTestId("procurement-threshold")).toHaveValue("25000"); + await expect(page.getByTestId("procurement-clause-auditRights")).toHaveAttribute("aria-checked", "false"); +}); + +test("6.2 secure development: frameworks and segregation round-trip", async ({ page }) => { + await page.goto("/de/compliance/procurement/6.2"); + await page.getByTestId("policy-editor-edit").click(); + + await page.getByTestId("sdlc-framework-select").click(); + await page.getByRole("option", { name: /BSIMM/ }).click(); + await page.getByTestId("hardening-baseline-select").click(); + await page.getByRole("option", { name: /Grundschutz|BSI/ }).first().click(); + const segregation = page.getByTestId("environment-segregation"); + const before = await segregation.getAttribute("aria-checked"); + await segregation.click(); + await trpcOk(page, "policyConfig.update", () => page.getByTestId("policy-editor-save").click()); + + const config = await policyConfig("secure_dev"); + expect(config.sdlcFramework).toBe("bsimm"); + expect(config.hardeningBaseline).toBe("bsi"); + expect(config.environmentSegregation).toBe(before !== "true"); + + await page.reload(); + await expect(page.getByTestId("sdlc-framework-select")).toContainText(/BSIMM/); +}); + +test("2.1 risk methodology: name and acceptance threshold round-trip", async ({ page }) => { + await page.goto("/de/compliance/risk-management/2.1"); + await page.getByTestId("methodology-edit").click(); + await page.getByTestId("methodology-name").fill("BSI 200-3 Stadtwerk-Anpassung E2E"); + await page.getByTestId("methodology-threshold").click(); + await page.getByRole("option", { name: "6", exact: true }).click(); + await trpcOk(page, "risk.updateMethodology", () => page.getByTestId("methodology-save").click()); + + const rows = await e2eQuery<{ name: string; acceptance_threshold: number }>( + `SELECT name, acceptance_threshold FROM company_risk_methodology`, + ); + expect(rows.length).toBe(1); + expect(rows[0].name).toBe("BSI 200-3 Stadtwerk-Anpassung E2E"); + expect(Number(rows[0].acceptance_threshold)).toBe(6); + + await page.reload(); + await expect(page.getByTestId("methodology-name")).toHaveValue("BSI 200-3 Stadtwerk-Anpassung E2E"); +}); + +test("2.3 asset risk register: create and link a risk to an asset", async ({ page }) => { + await page.goto("/de/compliance/risk-management/2.3"); + + // The first add button following the asset's name in document order is + // that asset's own row button. + await page + .getByText("Customer Database (PostgreSQL)", { exact: false }) + .first() + .locator('xpath=following::*[@data-testid="risk-register-add"][1]') + .click(); + await page.getByTestId("risk-form-title").fill(RISK_TITLE); + await page.getByTestId("matrix-cell-4-4").click(); + await trpcOk(page, "risk.linkAsset", () => page.getByTestId("risk-form-submit").click()); + + const rows = await e2eQuery<{ title: string; risk_score: number }>( + `SELECT r.title, r.risk_score FROM risk r + JOIN risk_asset ra ON ra.risk_id = r.id + JOIN asset a ON a.id = ra.asset_id + WHERE r.title = $1 AND a.name LIKE 'Customer Database%'`, + [RISK_TITLE], + ); + expect(rows.length).toBe(1); + expect(Number(rows[0].risk_score)).toBe(16); + + await page.reload(); + await expect(page.getByText(RISK_TITLE).first()).toBeVisible({ timeout: 20_000 }); +}); + +test("2.4 risk treatment: measure, residual score, acceptance", async ({ page }) => { + await page.goto("/de/compliance/risk-management/2.4"); + + // Expand the card of the risk created in 2.3 (score 16 > threshold 6). + await page.getByRole("button").filter({ hasText: RISK_TITLE }).first().click(); + await page.getByTestId("treatment-add").first().click(); + await page.getByTestId("treatment-action").fill("Notfallwiederherstellung dokumentieren und testen"); + await page.getByTestId("treatment-submit").click(); + await expect(page.getByText("Notfallwiederherstellung dokumentieren und testen").first()).toBeVisible({ + timeout: 20_000, + }); + + await page.getByTestId("residual-open").first().click(); + await trpcOk(page, "risk.update", () => page.getByTestId("matrix-cell-1-1").first().click()); + + const accept = page.getByTestId("risk-accept"); + await expect(accept.first()).toBeVisible({ timeout: 20_000 }); + await trpcOk(page, "risk.update", () => accept.first().click()); + + const rows = await e2eQuery<{ accepted_at: string | null; residual_likelihood: number }>( + `SELECT accepted_at, residual_likelihood FROM risk WHERE title = $1`, + [RISK_TITLE], + ); + expect(rows.length).toBe(1); + expect(rows[0].accepted_at).not.toBeNull(); + expect(Number(rows[0].residual_likelihood)).toBe(1); + + const treatments = await e2eQuery<{ action: string }>( + `SELECT t.action FROM risk_treatment t JOIN risk r ON r.id = t.risk_id WHERE r.title = $1`, + [RISK_TITLE], + ); + expect(treatments.length).toBe(1); +}); + +test("5.3 supplier risk register: create and link a risk to a supplier", async ({ page }) => { + await page.goto("/de/compliance/supply-chain/5.3"); + + await page + .getByText("Hetzner Online GmbH", { exact: false }) + .first() + .locator('xpath=following::*[@data-testid="risk-register-add"][1]') + .click(); + await page.getByTestId("risk-form-title").fill(SUPPLIER_RISK_TITLE); + await page.getByTestId("matrix-cell-4-4").click(); + await trpcOk(page, "risk.linkSupplier", () => page.getByTestId("risk-form-submit").click()); + + const rows = await e2eQuery<{ title: string }>( + `SELECT r.title FROM risk r + JOIN risk_supplier rs ON rs.risk_id = r.id + JOIN supplier s ON s.id = rs.supplier_id + WHERE r.title = $1 AND s.name LIKE 'Hetzner%'`, + [SUPPLIER_RISK_TITLE], + ); + expect(rows.length).toBe(1); + + await page.reload(); + await expect(page.getByText(SUPPLIER_RISK_TITLE).first()).toBeVisible({ timeout: 20_000 }); +}); diff --git a/e2e/l3/signoff.spec.ts b/e2e/l3/signoff.spec.ts index a9db569..320b394 100644 --- a/e2e/l3/signoff.spec.ts +++ b/e2e/l3/signoff.spec.ts @@ -8,23 +8,13 @@ * flips completed requirements back to in_progress by design). */ import { test, expect, type Page } from "@playwright/test"; -import { - nis2Categories, - getNis2RequirementsForCategory, -} from "@nisd2/grc-data-model/frameworks"; import { e2eQuery } from "../lib/db"; import { E2E_STORAGE_STATE_MANAGER, E2E_MANAGER_EMAIL, E2E_USER_EMAIL } from "../lib/env"; +import { makeSignable, signOffViaUi } from "../lib/journey"; const SINGLE_TARGET = "3.1"; // incident lead — no required role, admin signs alone const NOFM_TARGET = "1.4"; // personal liability — CEO-role, two assigned signers -const slugByCode = new Map(); -for (const cat of nis2Categories) { - for (const r of getNis2RequirementsForCategory(cat.slug)) { - slugByCode.set(r.code, cat.slug); - } -} - async function statusRow(code: string): Promise<{ id: string; status: string; signed_off_by: string | null }> { const rows = await e2eQuery<{ id: string; status: string; signed_off_by: string | null }>( `SELECT s.id, s.status, s.signed_off_by @@ -39,39 +29,6 @@ async function statusRow(code: string): Promise<{ id: string; status: string; si return rows[0]; } -/** - * Ensure the requirement is signable, via the product's own invalidation - * path: saving answers flips a completed requirement back to in_progress. - * Makes the spec self-sufficient instead of depending on l1 side effects. - */ -async function makeSignable(page: Page, code: string): Promise { - await page.goto(`/de/compliance/${slugByCode.get(code)}/${code}`); - if (await page.getByTestId("sign-off-button").isVisible().catch(() => false)) { - return; - } - const edit = page.getByTestId("requirement-edit"); - const save = page.getByTestId("requirement-save"); - await expect(edit.or(save).first()).toBeVisible({ timeout: 20_000 }); - if (await edit.isVisible()) await edit.click(); - await save.click(); - await expect(edit).toBeVisible({ timeout: 20_000 }); - await page.reload(); -} - -async function signOffViaUi(page: Page, code: string): Promise { - await makeSignable(page, code); - const button = page.getByTestId("sign-off-button"); - await expect(button).toBeVisible({ timeout: 20_000 }); - const [resp] = await Promise.all([ - page.waitForResponse( - (r) => r.url().includes("assessment.signOff") && r.request().method() === "POST", - { timeout: 20_000 }, - ), - button.click(), - ]); - expect(resp.ok(), `signOff mutation HTTP ${resp.status()}`).toBe(true); -} - test("single signer: admin sign-off completes the requirement", async ({ page }) => { await makeSignable(page, SINGLE_TARGET); const before = await statusRow(SINGLE_TARGET); @@ -166,6 +123,8 @@ test("cross-framework carry-over credits the linked ISO requirement", async () = `SELECT s.status FROM requirement_satisfaction rs JOIN requirement src ON src.id IN (rs.requirement_a_id, rs.requirement_b_id) AND src.code = $1 + JOIN requirement_category rc ON rc.id = src.category_id + JOIN compliance_framework f ON f.id = rc.framework_id AND f.code = 'nis2' JOIN requirement dst ON dst.id IN (rs.requirement_a_id, rs.requirement_b_id) AND dst.id <> src.id JOIN company_requirement_status s ON s.requirement_id = dst.id`, [SINGLE_TARGET], diff --git a/e2e/l4/grand-tour.spec.ts b/e2e/l4/grand-tour.spec.ts new file mode 100644 index 0000000..867653a --- /dev/null +++ b/e2e/l4/grand-tour.spec.ts @@ -0,0 +1,68 @@ +/** + * L4 grand tour: the capstone. By the time this runs, the serial suite has + * already populated the company's intake answers (l1), module records, + * editor configs and evidence (l2), and exercised multi-party sign-off + * (l3). This spec finishes the implementation: every requirement not yet + * done is signed off through the real UI, and the run ends with the + * journey at 49/49 and every status row completed in Postgres. + */ +import { test, expect } from "@playwright/test"; +import { e2eQuery } from "../lib/db"; +import { journeyCodes, slugByCode } from "../lib/journey"; + +test("grand tour: the company reaches a fully signed-off NIS2 implementation", async ({ page }) => { + test.setTimeout(360_000); + + const signed: string[] = []; + const alreadyDone: string[] = []; + + for (const code of journeyCodes) { + // networkidle after goto is the page-ready signal that works for every + // requirement page type (intake, custom editor, module). Deciding + // signable via a fixed timeout on the sign-off button alone both burned + // the full wait on every already-done page and risked misclassifying a + // slow-rendering signable page as done — a silent flake surfacing only + // at the final DB assertion. + await page.goto(`/de/compliance/${slugByCode.get(code)}/${code}`, { + waitUntil: "networkidle", + }); + const button = page.getByTestId("sign-off-button"); + if (!(await button.isVisible())) { + alreadyDone.push(code); + continue; + } + const [resp] = await Promise.all([ + page.waitForResponse( + (r) => r.url().includes("assessment.signOff") && r.request().method() === "POST", + { timeout: 20_000 }, + ), + button.click(), + ]); + expect(resp.ok(), `signOff for ${code} returned HTTP ${resp.status()}`).toBe(true); + signed.push(code); + } + + console.log( + `[grand-tour] signed ${signed.length}, already done ${alreadyDone.length} of ${journeyCodes.length}`, + ); + + // Authoritative: every NIS2 status row is terminal-done in the database. + const open = await e2eQuery<{ code: string; status: string }>( + `SELECT r.code, s.status + FROM company_requirement_status s + JOIN requirement r ON r.id = s.requirement_id + JOIN company_assessment a ON a.id = s.assessment_id + JOIN compliance_framework f ON f.id = a.framework_id + WHERE f.code = 'nis2' + AND s.status NOT IN ('completed', 'approved', 'not_applicable') + ORDER BY r.code`, + ); + expect( + open.map((o) => `${o.code}:${o.status}`).join(", "), + "requirements still open after the tour", + ).toBe(""); + + // And the product agrees: the sidebar shows the full count. + await page.goto("/de/journey"); + await expect(page.getByText("49/49").first()).toBeVisible({ timeout: 20_000 }); +}); diff --git a/e2e/lib/env.ts b/e2e/lib/env.ts index 1ade9ef..7c81400 100644 --- a/e2e/lib/env.ts +++ b/e2e/lib/env.ts @@ -14,7 +14,7 @@ export const E2E_DATABASE_URL = process.env.E2E_DATABASE_URL ?? "postgres://e2e:e2e@localhost:5434/openisms_e2e"; -export const E2E_BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3026"; +export const E2E_BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3410"; /** Seeded by drizzle/seed.ts (Dev GmbH admin). The harness only adds the * password hash — see e2e/auth.setup.ts. */ diff --git a/e2e/lib/form-driver.ts b/e2e/lib/form-driver.ts index 0b37ee4..733b795 100644 --- a/e2e/lib/form-driver.ts +++ b/e2e/lib/form-driver.ts @@ -17,6 +17,25 @@ function normalize(s: string): string { return s.toLowerCase().replace(/[^a-z0-9]/g, ""); } +/** + * Resolve the option whose normalized label corresponds to `value`. + * Exact match wins; otherwise a single non-empty prefix match (either + * direction) is accepted, and ambiguity or no match throws. This avoids two + * silent wrong-picks the naive bidirectional-`includes` had: an empty/icon + * label ("") matched every value, and "3levels" was a substring of + * "13levels". Returns the winning index into `norm`, or -1. + */ +function matchOption(want: string, norm: string[]): number { + const exact = norm.indexOf(want); + if (exact !== -1) return exact; + const candidates = norm + .map((t, i) => ({ t, i })) + .filter(({ t }) => t !== "" && (t.startsWith(want) || want.startsWith(t))); + if (candidates.length === 1) return candidates[0].i; + if (candidates.length > 1) return -2; // ambiguous + return -1; +} + function isoDate(value: unknown): string { if (value instanceof Date) return value.toISOString().slice(0, 10); return String(value).slice(0, 10); @@ -46,12 +65,10 @@ export async function fillFields( await expect(options.first()).toBeVisible(); const want = normalize(String(value)); const texts = await options.allTextContents(); - const idx = texts.findIndex( - (t) => normalize(t) === want || normalize(t).includes(want) || want.includes(normalize(t)), - ); - if (idx === -1) { + const idx = matchOption(want, texts.map(normalize)); + if (idx < 0) { throw new Error( - `field "${meta.key}": no option matching "${value}" among [${texts.join(", ")}]`, + `field "${meta.key}": ${idx === -2 ? "ambiguous" : "no"} option match for "${value}" among [${texts.join(", ")}]`, ); } await options.nth(idx).click(); @@ -97,7 +114,7 @@ export async function verifyFields( const text = normalize((await select.textContent()) ?? ""); const want = normalize(String(value)); expect( - text.includes(want) || want.includes(text), + text !== "" && (text === want || text.startsWith(want) || want.startsWith(text)), `field "${meta.key}": select shows "${text}", expected "${want}"`, ).toBe(true); } else if ((await checkbox.count()) > 0) { diff --git a/e2e/lib/journey.ts b/e2e/lib/journey.ts new file mode 100644 index 0000000..0ae0366 --- /dev/null +++ b/e2e/lib/journey.ts @@ -0,0 +1,51 @@ +/** + * Shared journey helpers: code -> category slug, and the sign-off drive + * used by the l3 sign-off specs and the l4 grand tour. + */ +import { expect, type Page } from "@playwright/test"; +import { + nis2Categories, + getNis2RequirementsForCategory, +} from "@nisd2/grc-data-model/frameworks"; + +export const slugByCode = new Map(); +export const journeyCodes: string[] = []; +for (const cat of [...nis2Categories].sort((a, b) => a.sortOrder - b.sortOrder)) { + for (const r of getNis2RequirementsForCategory(cat.slug)) { + slugByCode.set(r.code, cat.slug); + journeyCodes.push(r.code); + } +} + +/** + * Ensure the requirement is signable, via the product's own invalidation + * path: saving answers flips a completed requirement back to in_progress. + */ +export async function makeSignable(page: Page, code: string): Promise { + await page.goto(`/de/compliance/${slugByCode.get(code)}/${code}`); + if (await page.getByTestId("sign-off-button").isVisible().catch(() => false)) { + return; + } + const edit = page.getByTestId("requirement-edit"); + const save = page.getByTestId("requirement-save"); + await expect(edit.or(save).first()).toBeVisible({ timeout: 20_000 }); + if (await edit.isVisible()) await edit.click(); + await save.click(); + await expect(edit).toBeVisible({ timeout: 20_000 }); + await page.reload(); +} + +/** Sign the requirement via the real button, asserting the mutation. */ +export async function signOffViaUi(page: Page, code: string): Promise { + await makeSignable(page, code); + const button = page.getByTestId("sign-off-button"); + await expect(button).toBeVisible({ timeout: 20_000 }); + const [resp] = await Promise.all([ + page.waitForResponse( + (r) => r.url().includes("assessment.signOff") && r.request().method() === "POST", + { timeout: 20_000 }, + ), + button.click(), + ]); + expect(resp.ok(), `signOff mutation HTTP ${resp.status()}`).toBe(true); +} diff --git a/e2e/lib/walker-classification.ts b/e2e/lib/walker-classification.ts index a7c7164..94f9618 100644 --- a/e2e/lib/walker-classification.ts +++ b/e2e/lib/walker-classification.ts @@ -25,10 +25,14 @@ export const UI_CUSTOM_EDITORS = new Set( CUSTOM_EDITOR_KEYS.map((k) => k.split(":")[1]), ); -/** Codes with no custom editor, no moduleRef, and no intake fields. */ +/** + * Codes the walker treats as module-backed that grc-data-model does not + * mark with a moduleRef. Only genuinely module-backed codes belong here: + * 2.3 and 5.3 render custom editors, so the custom-editor branch below wins + * before this set is consulted — listing them here would be dead. If the + * product later adds one of these to NIS2_MODULE_REF, drop it from here. + */ export const EXTRA_MODULE_BACKED: Record = { - "2.3": { route: "/risks", note: "risk register module (no moduleRef in grc-data-model)" }, - "5.3": { route: "/risks", note: "supplier risk register (no moduleRef in grc-data-model)" }, "9.2": { route: "/assets", note: "per-asset encryption fields (no moduleRef in grc-data-model)" }, }; diff --git a/e2e/start-server.sh b/e2e/start-server.sh index ac79a7b..1ace2bf 100755 --- a/e2e/start-server.sh +++ b/e2e/start-server.sh @@ -6,13 +6,17 @@ cd "$(dirname "$0")/.." E2E_DATABASE_URL="${E2E_DATABASE_URL:-postgres://e2e:e2e@localhost:5434/openisms_e2e}" -# Shell-level mirror of e2e/lib/env.ts assertE2eTargets(): localhost + _e2e only. -case "$E2E_DATABASE_URL" in - postgres*://*@localhost*/*_e2e | postgres*://*@127.0.0.1*/*_e2e) ;; - *) - echo "e2e guard: E2E_DATABASE_URL must be a localhost database whose name ends in _e2e — refusing." >&2 - exit 1 - ;; +# Shell-level mirror of e2e/lib/env.ts assertE2eTargets(): parse the host and +# db name and check exactly, so laxer globs cannot accept e.g. +# postgres://u@localhost.evil.example/x_e2e (host "localhost.evil.example"). +_after_at="${E2E_DATABASE_URL#*@}" # host[:port]/db[?query] +_host="${_after_at%%/*}"; _host="${_host%%:*}" +_db="${_after_at#*/}"; _db="${_db%%\?*}" +case "$_host" in localhost|127.0.0.1|::1) ;; *) + echo "e2e guard: DB host '$_host' is not localhost — refusing." >&2; exit 1 ;; +esac +case "$_db" in *_e2e) ;; *) + echo "e2e guard: DB name '$_db' does not end in _e2e — refusing." >&2; exit 1 ;; esac APP_ENV=( @@ -20,9 +24,9 @@ APP_ENV=( AUTH_SECRET="e2e-dummy-auth-secret-0123456789abcdef" # Auth.js v5 in a production build rejects unknown hosts (UntrustedHost) # without these. Localhost-only harness, so trusting the host is safe. - AUTH_URL="http://localhost:3026" + AUTH_URL="http://localhost:3410" AUTH_TRUST_HOST="true" - NEXT_PUBLIC_APP_URL="http://localhost:3026" + NEXT_PUBLIC_APP_URL="http://localhost:3410" AWS_S3_ENDPOINT="http://localhost:9000" AWS_S3_BUCKET="e2e-evidence" AWS_S3_REGION="eu-north-1" @@ -57,5 +61,5 @@ if [ ! -f .next/BUILD_ID ]; then env "${APP_ENV[@]}" SKIP_ENV_VALIDATION=1 bun run build:webpack fi -echo "[e2e] starting next on :3026" -exec env "${APP_ENV[@]}" ./node_modules/.bin/next start -p 3026 +echo "[e2e] starting next on :3410" +exec env "${APP_ENV[@]}" ./node_modules/.bin/next start -p 3410 diff --git a/playwright.config.ts b/playwright.config.ts index 6638716..405bf2c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from "@playwright/test"; -const E2E_BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3026"; +const E2E_BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3410"; export default defineConfig({ testDir: "./e2e",