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
+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)"=/);
});
});