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>
This commit is contained in:
2026-10-12 13:05:12 +02:00
co-authored by Claude Opus 5.5
parent 2dad8e5631
commit a47cae4082
16 changed files with 1608 additions and 16 deletions
+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");
});
});