import { randomUUID } from "node:crypto"; import { expect, test, type Page } from "@playwright/test"; import pg from "pg"; import "dotenv/config"; /** * Zuweisungsmatrix (#025) im gebauten Stand. Kein Worker und kein SSH-Ziel noetig: die Matrix * zeigt und plant nur. Schluessel, Hosts und Hostgruppe legt der Test direkt in der * Entwicklungsdatenbank an (Praefix zz-e2e) und raeumt sie am Ende wieder weg. */ const name = `zz-e2e-mx${Date.now().toString(36)}`; const ids = { schluessel: randomUUID(), gruppe: randomUUID(), h1: randomUUID(), h2: randomUUID(), h3: randomUUID(), }; async function datenbank(arbeit: (client: pg.Client) => Promise): Promise { const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] }); await client.connect(); try { return await arbeit(client); } finally { await client.end(); } } async function anmelden(page: Page, rolle: "admin" | "reader") { await page.goto("/anmelden"); await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click(); await page.locator(`button[data-rolle="${rolle}"]`).click(); await page.getByRole("button", { name: "Anmelden", exact: true }).click(); await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible(); } test.beforeAll(async () => { await datenbank(async (client) => { for (const [id, nr] of [ [ids.h1, 1], [ids.h2, 2], [ids.h3, 3], ] as const) { await client.query(`INSERT INTO "Host" ("id","name","adresse","geaendertAm") VALUES ($1,$2,$3,now())`, [ id, `${name}-h${nr}`, `10.99.0.${nr}`, ]); } await client.query(`INSERT INTO "Hostgruppe" ("id","name") VALUES ($1,$2)`, [ids.gruppe, `${name}-g`]); await client.query(`INSERT INTO "HostgruppeMitglied" ("hostId","hostgruppeId") VALUES ($1,$3),($2,$3)`, [ ids.h1, ids.h2, ids.gruppe, ]); await client.query( `INSERT INTO "Schluessel" ("id","name","slug","typ","kategorie","publicKey","fingerprint","kommentar","geaendertAm") VALUES ($1,$2,$3,'ED25519','DIENST','ssh-ed25519 AAAA zz-e2e',$4,'zz-e2e',now())`, [ids.schluessel, `${name} Backup`, name, `SHA256:${name}`], ); await client.query( `INSERT INTO "PublicVerwendung" ("id","schluesselId","hostgruppeId","zielbenutzer","optionen") VALUES ($1,$2,$3,'root','restrict')`, [randomUUID(), ids.schluessel, ids.gruppe], ); }); }); test.afterAll(async () => { await datenbank(async (client) => { // Verwendungen und Mitgliedschaften haengen per Cascade daran. await client.query(`DELETE FROM "Schluessel" WHERE "id" = $1`, [ids.schluessel]); await client.query(`DELETE FROM "Hostgruppe" WHERE "id" = $1`, [ids.gruppe]); await client.query(`DELETE FROM "Host" WHERE "id" = ANY($1)`, [[ids.h1, ids.h2, ids.h3]]); }); }); test("Matrix zeigt Gruppen-Zuweisung, filtert, weist zu und exportiert", async ({ page }) => { await anmelden(page, "admin"); // Die Suche trifft Schluessel und Hosts dieses Tests – fremde Daten bleiben draussen. await page.goto(`/zuweisungen?q=${name}`); const matrix = page.locator("table"); await expect(matrix.getByRole("columnheader", { name: `${name}-h3` })).toBeVisible(); // Gruppen-Zuweisung erscheint auf beiden Mitgliedern als „P·G“ mit Zustand. for (const nr of [1, 2]) { const zelle = matrix.getByRole("button", { name: `${name} Backup auf ${name}-h${nr}: Public Key über Hostgruppe ${name}-g, Benutzer root, noch nicht ausgerollt`, }); await expect(zelle).toBeVisible(); await expect(zelle).toContainText("P·G"); } // Filter Hostgruppe: der dritte Host gehoert nicht dazu und verschwindet. await page.getByLabel("Hostgruppe").selectOption({ label: `${name}-g` }); await page.getByRole("button", { name: "Filtern" }).click(); await expect(page).toHaveURL(/gruppe=/); await expect(matrix.getByRole("columnheader", { name: `${name}-h1` })).toBeVisible(); await expect(matrix.getByRole("columnheader", { name: `${name}-h3` })).toHaveCount(0); // Leere Zelle auf h3 → Dialog mit vorausgewaehltem Host → zuweisen. await page.goto(`/zuweisungen?q=${name}`); await matrix.getByRole("button", { name: new RegExp(`auf ${name}-h3: nicht zugewiesen`) }).click(); const dialog = page.getByRole("dialog"); await expect(dialog.getByLabel("Ziel", { exact: true })).toHaveValue(`host:${ids.h3}`); await dialog.getByLabel("Zielbenutzer").fill("deploy"); await dialog.getByRole("button", { name: "Zuweisen" }).click(); await expect(dialog.getByText("Public Key direkt")).toBeVisible(); await expect(dialog.getByRole("link", { name: "Zum Dry-Run" })).toBeVisible(); const angelegt = await datenbank((client) => client.query( `SELECT "zielbenutzer" FROM "PublicVerwendung" WHERE "schluesselId" = $1 AND "hostId" = $2`, [ids.schluessel, ids.h3], ), ); expect(angelegt.rows).toEqual([{ zielbenutzer: "deploy" }]); await dialog.getByRole("button", { name: "Schließen" }).click(); await expect( matrix.getByRole("button", { name: new RegExp(`auf ${name}-h3: Public Key direkt`) }), ).toBeVisible(); // CSV mit derselben Sitzung: Gruppen-Zeile je Mitglied und die neue direkte Zuweisung. const csv = await page.request.get(`/api/zuweisungen.csv?q=${name}`); expect(csv.status()).toBe(200); const text = await csv.text(); expect(text).toContain( `"${name} Backup";"SHA256:${name}";"${name}-h2";"root";"Public Key";"${name}-g";"restrict";"noch nicht ausgerollt"`, ); expect(text).toContain(`"${name}-h3";"deploy";"Public Key";""`); // „Zum Dry-Run“ springt direkt zur Dry-Run-Karte der Schluesselseite (#032). await matrix.getByRole("button", { name: new RegExp(`auf ${name}-h3: Public Key direkt`) }).click(); await dialog.getByRole("link", { name: "Zum Dry-Run" }).click(); await expect(page).toHaveURL(new RegExp(`/schluessel/${ids.schluessel}#dry-run$`)); const karte = page.locator("#dry-run"); await expect(karte).toContainText("Dry-Run-Vorschau"); await expect(karte).toBeInViewport(); }); test("Leser sieht die Matrix, aber keinen Zuweisen-Knopf", async ({ page }) => { await anmelden(page, "reader"); await page.goto(`/zuweisungen?q=${name}`); const matrix = page.locator("table"); await matrix .getByRole("button", { name: new RegExp(`auf ${name}-h1: Public Key über Hostgruppe`) }) .click(); const dialog = page.getByRole("dialog"); await expect(dialog.getByText("Public Key über Hostgruppe")).toBeVisible(); await expect(dialog.getByRole("button", { name: "Zuweisen" })).toHaveCount(0); await expect(dialog.getByRole("button", { name: "Zuweisung entfernen" })).toHaveCount(0); await dialog.getByRole("button", { name: "Schließen" }).click(); // Leere Zellen sind fuer Leser keine Knoepfe. await expect(matrix.getByRole("button", { name: /nicht zugewiesen/ })).toHaveCount(0); });