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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user