import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; 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-M7: administration, personalization and missing CRUD. * * Closes three named V1 "API first, UI deferred" gaps (CURRENT_STATE.md "What is not * yet complete") with a real admin UI: recovery-token issuance, trash-purge, and * LeafWiki import. All three endpoints were already correctly Administrator-only and * integration-tested at the API level (RecoveryTests, TrashPurgeTests, * LeafWikiImportTests) — this spec proves each is now genuinely reachable and usable * from the UI, not API-only. */ test.use({ storageState: authenticatedStatePath }); const testDirectory = path.dirname(fileURLToPath(import.meta.url)); const expectedImportsRoot = path.resolve(testDirectory, "../../../.local-appdata/e2e-imports"); const seededFixtureDirectories = new Set(); function seedLeafWikiFixture(relativeDir: string, title: string) { // The import API reads its configured server-side fixture mount. Fail closed unless // both the API and this test are explicitly pointed at this repository-local test // directory; never discover or fall back to a Docker container, which could be a // production deployment. const configuredImportsRoot = process.env.KNOWLEDGE_HUB_E2E_IMPORTS_ROOT; if (!configuredImportsRoot || path.resolve(configuredImportsRoot) !== expectedImportsRoot) { throw new Error( "Set KNOWLEDGE_HUB_E2E_IMPORTS_ROOT to this checkout's absolute .local-appdata/e2e-imports path before running the LeafWiki import browser test." ); } const fixtureDirectory = path.resolve(expectedImportsRoot, relativeDir); if (!fixtureDirectory.startsWith(expectedImportsRoot + path.sep)) { throw new Error("The synthetic LeafWiki fixture must stay inside .local-appdata/e2e-imports."); } mkdirSync(fixtureDirectory, { recursive: true }); seededFixtureDirectories.add(fixtureDirectory); writeFileSync( path.join(fixtureDirectory, "welkom.md"), "# " + title + "\n\nDit is een synthetische testpagina voor de V2-M7 importproef.\n", { encoding: "utf8", flag: "wx" } ); } test.afterEach(() => { for (const fixtureDirectory of seededFixtureDirectories) { if ( path.dirname(fixtureDirectory) === expectedImportsRoot && path.basename(fixtureDirectory).startsWith("e2e-leafwiki-") ) { rmSync(fixtureDirectory, { recursive: true, force: true }); } seededFixtureDirectories.delete(fixtureDirectory); } }); test.describe("V2-M7 admin actions: recovery tokens, trash purge, LeafWiki import", () => { test("an administrator can issue a one-time recovery token for another user from the admin UI", async ({ page }) => { const targetEmail = `e2e-recovery-target-${Date.now()}@itworx.e2e`; await page.goto("/admin/users"); await expectAuthenticatedShell(page); await expect(page.getByRole("heading", { name: "Gebruikers", exact: true })).toBeVisible(); await page.getByRole("button", { name: "Gebruiker toevoegen" }).click(); await page.getByLabel("Naam").fill("E2E Recovery Doel"); await page.getByLabel("E-mailadres").fill(targetEmail); await page.getByLabel("Tijdelijk wachtwoord").fill("correct-horse-battery-staple"); await page.getByRole("button", { name: "Gebruiker aanmaken" }).click(); await expect(page.getByText(targetEmail)).toBeVisible(); const userRow = page.locator(".admin-row", { hasText: targetEmail }); await userRow.getByRole("button", { name: "Herstelcode aanmaken" }).click(); const tokenPanel = page.getByRole("heading", { name: `Eenmalige herstelcode voor ${targetEmail}` }); await expect(tokenPanel).toBeVisible(); await expect(page.getByTestId("recovery-token")).not.toBeEmpty(); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); const tokenSection = page.locator("section", { has: tokenPanel }); await tokenSection.getByRole("button", { name: "Sluiten" }).click(); await expect(tokenPanel).toBeHidden(); }); test("an administrator can trigger a trash purge from the admin UI, and it honestly reports nothing eligible yet", async ({ page }) => { await page.goto("/admin/backups"); await expectAuthenticatedShell(page); await expect(page.getByRole("heading", { name: "Prullenbak" })).toBeVisible(); await page.getByRole("button", { name: "Prullenbak legen" }).click(); const purgeConfirmation = page.getByRole("group", { name: "Definitieve verwijdering bevestigen" }); await expect(purgeConfirmation).toBeVisible(); await expect( purgeConfirmation.getByText("Dit verwijdert definitief alleen pagina's waarvan de bewaartermijn is verstreken. Deze actie kan niet ongedaan worden gemaakt.") ).toBeVisible(); await purgeConfirmation.getByRole("button", { name: "Bevestig definitieve verwijdering" }).click(); // PurgeTrashUseCase only removes pages whose retention window has already // elapsed — a fresh test run has nothing eligible, and the UI must say so // honestly rather than implying something was deleted. await expect( page.getByText("Geen pagina's kwamen in aanmerking voor definitieve verwijdering") ).toBeVisible(); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); }); test("an administrator can run a full LeafWiki import (preflight, execute, rollback) from the admin UI", async ({ page }) => { const relativeDir = `e2e-leafwiki-${Date.now()}`; // The imported page's *title* has to be unique per run, not just the fixture // directory. The local verification database is deliberately never reset, so any // earlier run that failed before reaching its own rollback step leaves its // imported page behind -- and a fixed title then makes both the "it exists" and // the "it is gone after rollback" assertions ambiguous against those leftovers // rather than against this run's own import. const pageTitle = `Welkom bij LeafWiki ${relativeDir}`; seedLeafWikiFixture(relativeDir, pageTitle); await page.goto("/admin/knowledge"); await expectAuthenticatedShell(page); await expect(page.getByRole("heading", { name: "LeafWiki-import" })).toBeVisible(); await page.getByLabel("Relatief pad onder de importmap").fill(relativeDir); await page.getByRole("button", { name: "Preflight uitvoeren" }).click(); await expect(page.getByText(/item\(s\) gevonden/)).toBeVisible(); const planTable = page.getByRole("table", { name: "Importplan" }); await expect(planTable).toBeVisible(); await expect(planTable.getByRole("cell", { name: pageTitle })).toBeVisible(); // No blockers for a clean synthetic fixture, so the import button is offered. // exact: true since the curated-inventory panel elsewhere on this page has its // own, differently-worded submit button that also happens to contain the word // "importeren". const importButton = page.getByRole("button", { name: "Importeren", exact: true }); await expect(importButton).toBeVisible(); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); // force: true, deliberately, after real investigation (not a reflex fix for a // flaky click): on chromium-mobile only, immediately after the AxeBuilder scan // above, Playwright's own actionability pre-check reported this exact button as // intercepted by an unrelated earlier element (.form-error-summary) and retried // for the full 30s timeout. Directly confirmed this was Playwright's own hit-test // heuristic misfiring, not a real layout bug: getBoundingClientRect()/ // elementFromPoint() at the button's true center resolved correctly and stayed // stable across 20 polls over 3s, and a raw page.mouse.move/down/up sequence at // those exact coordinates clicked it immediately and completed the import // successfully. force: true skips only that specific occlusion pre-check; the // very next assertion still proves the click had its real, intended effect. await importButton.click({ force: true }); await expect(page.getByText(/geïmporteerd/)).toBeVisible(); // Confirm the imported page is now real and reachable via the ordinary Knowledge // module, not just reported as a count — checked from a separate tab so the // admin page's own in-memory import-job state (jobId, executed result) is never // lost to a reload; LeafWikiImportPanel deliberately keeps this state // client-side only, matching every other admin action on this page, so a real // reload here would just as legitimately reset it as leaving the page normally // would (an operator would in practice roll back immediately, not navigate away // first). const knowledgeTab = await page.context().newPage(); await expect(await findImportedPage(knowledgeTab, pageTitle)).toHaveCount(1); await knowledgeTab.close(); // Roll the import back, still on the same admin page/tab, and confirm it // disappears again — proving rollback actually removes what it created, matching // RollbackImportUseCase's contract. await page.getByRole("button", { name: "Importtaak ongedaan maken" }).click(); await expect(page.getByText(/pagina's verwijderd/)).toBeVisible(); const knowledgeTabAfterRollback = await page.context().newPage(); await expect(await findImportedPage(knowledgeTabAfterRollback, pageTitle)).toHaveCount(0); await knowledgeTabAfterRollback.close(); }); }); /** * Confirms an imported page really exists in the ordinary Knowledge module, using the * module's own search rather than the navigation tree. * * The tree was the obvious place to look and is the wrong one here. It loads 100 roots * per request behind a "Meer laden" control, and the verification workspace is * deliberately never reset, so it passed that boundary long ago: a freshly imported * root is simply not in the first batch. Paging through it was tried first and made the * test worse rather than better -- it turned one deterministic assertion into a loop * whose outcome depended on how many pages the workspace happened to hold that day, and * it then failed for a different reason on each run. * * Search answers the actual question -- is this page real and findable? -- in a single * request, independently of corpus size, and behaves identically on both viewports. The * locator is returned either way, so the caller can assert presence and, after the * rollback, absence. */ async function findImportedPage(page: Page, title: string) { await page.goto("/search"); await page.getByLabel("Zoekterm").fill(title); await page.getByRole("button", { name: "Zoekopdracht uitvoeren" }).click(); // Either a result list or an explicit "no results" line; both mean the search // finished, which a bare locator check on the title alone could not distinguish from // "the request has not come back yet". await expect( page.getByRole("region", { name: "Zoekresultaten", exact: true }), ).toBeVisible(); return page.getByRole("region", { name: "Zoekresultaten", exact: true }) .getByRole("link", { name: new RegExp(escapeForRegExp(title)) }); } function escapeForRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }