Merge branch 'phase/025-zuweisungsmatrix'

This commit is contained in:
2026-10-12 13:06:33 +02:00
16 changed files with 1608 additions and 16 deletions
+147
View File
@@ -0,0 +1,147 @@
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);
});
+150
View File
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@/lib/db";
import { zellenSchluessel } from "@/lib/zuweisungsmatrix";
import { zuweisungsDaten, zuweisungsMatrix } from "@/server/queries/zuweisungen";
import { datenbankLeeren } from "./hilfen/db";
/**
* Zuweisungsmatrix (#025) gegen die Testdatenbank: Query und CSV-Route. Die Sitzung wird fuer
* die Route nachgebildet – geprueft wird, dass der Handler das Recht selbst verlangt.
*/
const recht = vi.hoisted(() => ({ erlaubt: true }));
vi.mock("@/server/auth/sitzungspflicht", async (original) => {
const echt = await original<typeof import("@/server/auth/sitzungspflicht")>();
return {
...echt,
rechtErforderlich: vi.fn(async () => {
if (!recht.erlaubt) throw new echt.RechteFehler("Nicht angemeldet.");
return { benutzer: { id: "b", rolle: "LESER" } };
}),
};
});
let zaehler = 0;
async function schluesselAnlegen(daten: {
name: string;
status?: "AKTIV" | "WIDERRUFEN";
mitPrivat?: boolean;
}) {
zaehler += 1;
return prisma.schluessel.create({
data: {
name: daten.name,
slug: `test-${zaehler}`,
typ: "ED25519",
kategorie: "DIENST",
status: daten.status ?? "AKTIV",
publicKey: "ssh-ed25519 AAAA test",
fingerprint: `SHA256:test${zaehler}`,
kommentar: "test",
...(daten.mitPrivat
? { privatVerschluesselt: Buffer.from("x"), dekVerschluesselt: Buffer.from("y"), masterKeyVersion: 1 }
: {}),
},
});
}
let ids: Record<string, string>;
beforeEach(async () => {
await datenbankLeeren();
recht.erlaubt = true;
zaehler = 0;
const gruppe = await prisma.hostgruppe.create({ data: { name: "pve" } });
const [pve1, pve2, alt] = await Promise.all([
prisma.host.create({ data: { name: "srv-pve01", adresse: "10.0.0.1" } }),
prisma.host.create({ data: { name: "srv-pve02", adresse: "10.0.0.2" } }),
prisma.host.create({ data: { name: "srv-alt", adresse: "10.0.0.3", archiviertAm: new Date() } }),
]);
await prisma.hostgruppeMitglied.createMany({
data: [pve1, pve2, alt].map((h) => ({ hostId: h.id, hostgruppeId: gruppe.id })),
});
const backup = await schluesselAnlegen({ name: "Backup", mitPrivat: true });
const formel = await schluesselAnlegen({ name: "=1+1" });
const widerrufen = await schluesselAnlegen({ name: "Alt", status: "WIDERRUFEN" });
await prisma.publicVerwendung.createMany({
data: [
{ schluesselId: backup.id, hostgruppeId: gruppe.id, zielbenutzer: "root", optionen: "restrict" },
{ schluesselId: formel.id, hostId: pve1.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
{ schluesselId: formel.id, hostId: pve2.id, zielbenutzer: "root", zustand: "ENTFERNT" },
{ schluesselId: widerrufen.id, hostId: pve1.id, zielbenutzer: "root" },
],
});
await prisma.privateVerwendung.create({
data: { schluesselId: backup.id, hostId: pve2.id, zielbenutzer: "backup", pfad: "~/.ssh/id_backup" },
});
ids = {
gruppe: gruppe.id,
pve1: pve1.id,
pve2: pve2.id,
alt: alt.id,
backup: backup.id,
formel: formel.id,
};
});
describe("zuweisungsDaten", () => {
it("liest nur aktive Schluessel, nicht archivierte Hosts und keine entfernten Verwendungen", async () => {
const daten = await zuweisungsDaten();
expect(daten.schluessel.map((s) => s.name)).toEqual(["=1+1", "Backup"]);
expect(daten.hosts.map((h) => h.name)).toEqual(["srv-pve01", "srv-pve02"]);
expect(daten.hosts[0]!.gruppenIds).toEqual([ids["gruppe"]]);
expect(daten.oeffentlich).toHaveLength(2);
expect(daten.privat).toHaveLength(1);
});
it("liefert nie Chiffrat-Felder", async () => {
const text = JSON.stringify(await zuweisungsDaten());
for (const feld of ["privatVerschluesselt", "dekVerschluesselt", "masterKeyVersion", "publicKey"]) {
expect(text).not.toContain(feld);
}
});
});
describe("zuweisungsMatrix", () => {
it("loest die Gruppe auf die nicht archivierten Mitglieder auf", async () => {
const { matrix, gruppen } = await zuweisungsMatrix();
expect(gruppen.map((g) => g.name)).toEqual(["pve"]);
expect(matrix.zellen.get(zellenSchluessel(ids["backup"]!, ids["pve1"]!))?.kennungen).toEqual(["P·G"]);
expect(matrix.zellen.get(zellenSchluessel(ids["backup"]!, ids["pve2"]!))?.kennungen).toEqual([
"P·G",
"K",
]);
expect(matrix.zellen.has(zellenSchluessel(ids["backup"]!, ids["alt"]!))).toBe(false);
expect(matrix.zaehler).toEqual({ schluessel: 2, hosts: 2, zuweisungen: 4, nichtSynchron: 3 });
});
});
describe("GET /api/zuweisungen.csv", () => {
async function abrufen(query = "") {
const { GET } = await import("@/app/api/zuweisungen.csv/route");
return GET(new Request(`http://localhost/api/zuweisungen.csv${query}`));
}
it("verweigert ohne Recht", async () => {
recht.erlaubt = false;
const antwort = await abrufen();
expect(antwort.status).toBe(403);
});
it("liefert die Matrix als Excel-sichere CSV", async () => {
const antwort = await abrufen();
expect(antwort.status).toBe(200);
expect(antwort.headers.get("content-type")).toContain("text/csv");
expect(antwort.headers.get("content-disposition")).toContain("sshkv-zuweisungen.csv");
const text = await antwort.text();
// Response.text() entfernt die BOM nicht immer – beides zulassen.
const zeilen = text.replace(/^/, "").trimEnd().split("\r\n");
expect(zeilen).toHaveLength(1 + 4);
expect(zeilen).toContain(
'"Backup";"SHA256:test1";"srv-pve01";"root";"Public Key";"pve";"restrict";"noch nicht ausgerollt"',
);
expect(zeilen).toContain(`"'=1+1";"SHA256:test2";"srv-pve01";"deploy";"Public Key";"";"";"synchron"`);
});
it("wendet die Filter der Seite an", async () => {
const text = await (await abrufen("?q=srv-pve02")).text();
expect(text).toContain("srv-pve02");
expect(text).not.toContain("srv-pve01");
});
});
+210
View File
@@ -0,0 +1,210 @@
import { describe, expect, it } from "vitest";
import { csvEntschaerfen, csvZeile } from "@/lib/csv";
import {
matrixAufbauen,
matrixCsv,
matrixFilterAus,
matrixFilterQuery,
zellenSchluessel,
type MatrixDaten,
type PublicZeile,
} from "@/lib/zuweisungsmatrix";
/** Zuweisungsmatrix (#025): reine Funktion, Gruppenaufloesung, Filter, CSV. */
const backup = {
id: "s-backup",
name: "Backup pve",
fingerprint: "SHA256:backup",
kategorie: "DIENST" as const,
person: null,
};
const anna = {
id: "s-anna",
name: "Anna Laptop",
fingerprint: "SHA256:anna",
kategorie: "PERSON" as const,
person: { id: "p-anna", name: "Anna" },
};
const leer = {
id: "s-leer",
name: "Unbenutzt",
fingerprint: "SHA256:leer",
kategorie: "DIENST" as const,
person: null,
};
const public_ = (
abweichung: Partial<PublicZeile> & Pick<PublicZeile, "id" | "schluesselId">,
): PublicZeile => ({
hostId: null,
hostgruppeId: null,
zielbenutzer: "root",
optionen: null,
zustand: "AUSGEROLLT",
...abweichung,
});
const DATEN: MatrixDaten = {
schluessel: [anna, backup, leer],
hosts: [
{ id: "h-pve1", name: "srv-pve01", gruppenIds: ["g-pve"] },
{ id: "h-pve2", name: "srv-pve02", gruppenIds: ["g-pve"] },
{ id: "h-web", name: "srv-web01", gruppenIds: [] },
],
gruppen: [{ id: "g-pve", name: "pve" }],
oeffentlich: [
public_({ id: "v-gruppe", schluesselId: "s-backup", hostgruppeId: "g-pve", optionen: "restrict" }),
public_({
id: "v-direkt",
schluesselId: "s-anna",
hostId: "h-pve1",
zielbenutzer: "anna",
zustand: "GEPLANT",
}),
public_({
id: "v-direkt2",
schluesselId: "s-anna",
hostId: "h-pve1",
zielbenutzer: "root",
zustand: "ABWEICHUNG",
}),
public_({ id: "v-weg", schluesselId: "s-anna", hostId: "h-web", zustand: "ENTFERNT" }),
],
privat: [
{
id: "k-1",
schluesselId: "s-backup",
hostId: "h-web",
zielbenutzer: "backup",
pfad: "~/.ssh/id_backup",
zustand: "FEHLGESCHLAGEN",
},
],
};
describe("matrixAufbauen", () => {
it("loest Gruppen-Zuweisungen je Mitglied auf und kennzeichnet sie", () => {
const m = matrixAufbauen(DATEN);
for (const host of ["h-pve1", "h-pve2"]) {
const zelle = m.zellen.get(zellenSchluessel("s-backup", host))!;
expect(zelle.kennungen).toEqual(["P·G"]);
expect(zelle.eintraege[0]).toMatchObject({
verwendungId: "v-gruppe",
gruppe: { name: "pve" },
optionen: "restrict",
});
}
});
it("unterscheidet direkt, Gruppe und Private Key", () => {
const m = matrixAufbauen(DATEN);
expect(m.zellen.get(zellenSchluessel("s-anna", "h-pve1"))!.kennungen).toEqual(["P"]);
const privat = m.zellen.get(zellenSchluessel("s-backup", "h-web"))!;
expect(privat.kennungen).toEqual(["K"]);
expect(privat.eintraege[0]).toMatchObject({ art: "privat", pfad: "~/.ssh/id_backup" });
});
it("zeigt den dringendsten Zustand und alle Zielbenutzer", () => {
const zelle = matrixAufbauen(DATEN).zellen.get(zellenSchluessel("s-anna", "h-pve1"))!;
expect(zelle.zustand).toBe("ABWEICHUNG");
expect(zelle.zielbenutzer).toEqual(["anna", "root"]);
});
it("laesst entfernte Verwendungen weg", () => {
expect(matrixAufbauen(DATEN).zellen.has(zellenSchluessel("s-anna", "h-web"))).toBe(false);
});
it("zaehlt Zuweisungen je Host und die nicht synchronen", () => {
expect(matrixAufbauen(DATEN).zaehler).toEqual({
schluessel: 3,
hosts: 3,
zuweisungen: 5,
nichtSynchron: 3,
});
});
it("filtert Spalten nach Hostgruppe und Zeilen nach Kategorie und Person", () => {
expect(matrixAufbauen(DATEN, { gruppeId: "g-pve" }).spalten.map((h) => h.id)).toEqual([
"h-pve1",
"h-pve2",
]);
expect(matrixAufbauen(DATEN, { kategorie: "PERSON" }).zeilen.map((s) => s.id)).toEqual(["s-anna"]);
expect(matrixAufbauen(DATEN, { personId: "p-anna" }).zeilen.map((s) => s.id)).toEqual(["s-anna"]);
});
it("sucht in Schluessel- und Hostnamen", () => {
const nachSchluessel = matrixAufbauen(DATEN, { q: "backup" });
expect(nachSchluessel.zeilen.map((s) => s.id)).toEqual(["s-backup"]);
expect(nachSchluessel.spalten).toHaveLength(3);
const nachHost = matrixAufbauen(DATEN, { q: "WEB01" });
expect(nachHost.zeilen).toHaveLength(3);
expect(nachHost.spalten.map((h) => h.id)).toEqual(["h-web"]);
const nichts = matrixAufbauen(DATEN, { q: "gibtsnicht" });
expect(nichts.zeilen).toHaveLength(0);
expect(nichts.spalten).toHaveLength(0);
});
it("blendet mit „nur belegt“ leere Zeilen und Spalten aus", () => {
const m = matrixAufbauen(DATEN, { nurBelegt: true, gruppeId: "g-pve" });
expect(m.zeilen.map((s) => s.id)).toEqual(["s-anna", "s-backup"]);
expect(m.spalten.map((h) => h.id)).toEqual(["h-pve1", "h-pve2"]);
expect(matrixAufbauen(DATEN, { nurBelegt: true, q: "unbenutzt" }).zeilen).toHaveLength(0);
});
it("zaehlt nur, was in der gefilterten Ansicht steht", () => {
expect(matrixAufbauen(DATEN, { gruppeId: "g-pve" }).zaehler.zuweisungen).toBe(4);
});
});
describe("Filter aus GET-Parametern", () => {
it("liest bekannte Werte und verwirft unbekannte Kategorien", () => {
const filter = matrixFilterAus({
gruppe: "g1",
kategorie: "QUATSCH",
person: ["p1", "p2"],
q: " pve ",
belegt: "1",
});
expect(filter).toEqual({ gruppeId: "g1", kategorie: "", personId: "p1", q: "pve", nurBelegt: true });
expect(matrixFilterQuery(filter)).toBe("?gruppe=g1&person=p1&q=pve&belegt=1");
expect(matrixFilterQuery(matrixFilterAus({}))).toBe("");
});
});
describe("CSV", () => {
it("entschaerft Formelbeginne fuer Excel", () => {
for (const wert of ["=1+1", "+49", "-x", "@SUMME(A1)", "\tx"])
expect(csvEntschaerfen(wert)).toBe(`'${wert}`);
expect(csvEntschaerfen("root")).toBe("root");
expect(csvZeile(['restrict,from="10.0.0.1"', null])).toBe('"restrict,from=""10.0.0.1""";""');
});
it("schreibt eine Zeile je Zelleneintrag mit Kopf und BOM", () => {
const csv = matrixCsv(matrixAufbauen(DATEN));
expect(csv.startsWith("")).toBe(true);
const zeilen = csv.slice(1).trimEnd().split("\r\n");
expect(zeilen[0]).toBe(
'"Schlüssel";"Fingerprint";"Host";"Zielbenutzer";"Art";"Über Gruppe";"Optionen";"Zustand"',
);
expect(zeilen).toHaveLength(1 + 5);
expect(zeilen).toContain(
'"Backup pve";"SHA256:backup";"srv-pve02";"root";"Public Key";"pve";"restrict";"synchron"',
);
expect(zeilen).toContain(
'"Backup pve";"SHA256:backup";"srv-web01";"backup";"Private Key";"";"Pfad: ~/.ssh/id_backup";"fehlgeschlagen"',
);
});
it("entschaerft boesartige Schluesselnamen im Export", () => {
const daten: MatrixDaten = {
...DATEN,
schluessel: [{ ...backup, name: '=HYPERLINK("http://x","klick")' }],
};
const csv = matrixCsv(matrixAufbauen(daten));
expect(csv).toContain(`"'=HYPERLINK(""http://x"",""klick"")"`);
expect(csv).not.toMatch(/(^|;|\n)"=/);
});
});