222 lines
11 KiB
TypeScript
222 lines
11 KiB
TypeScript
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<string>();
|
|
|
|
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, "\\$&");
|
|
}
|