Files
sshkeyvault/tests/e2e/zuweisungen.spec.ts
T
florian.wildeandClaude Opus 5.5 a47cae4082 feat(zuweisungen): Matrix Schluessel x Host mit CSV-Export (#025)
Bisher liess sich nur je Schluessel oder je Host nachsehen, wo ein Key
verwaltet ist. Die Matrix zeigt das auf einen Blick, loest
Hostgruppen-Zuweisungen je Mitglied auf (P vs. P·G) und macht nicht
synchrone Zustaende ohne Farbe erkennbar.

Die Matrix plant nur: Zuweisen und Entfernen laufen ueber die
vorhandenen Aktionen, ausgerollt wird weiter per Dry-Run auf der
Schluesselseite. Der Aufbau ist eine reine Funktion in src/lib, damit
Seite und CSV-Export garantiert dasselbe zeigen und die
Gruppenaufloesung ohne Datenbank testbar ist. Der CSV-Export entschaerft
Formelbeginne, weil Schluesselnamen frei waehlbar sind und Excel sie
sonst ausfuehren wuerde.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:05:12 +02:00

148 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
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";""`);
});
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);
});