diff --git a/docs/anleitung/bilder/zuweisungen-desktop.png b/docs/anleitung/bilder/zuweisungen-desktop.png new file mode 100644 index 0000000..cd469a7 Binary files /dev/null and b/docs/anleitung/bilder/zuweisungen-desktop.png differ diff --git a/docs/anleitung/bilder/zuweisungen-mobil.png b/docs/anleitung/bilder/zuweisungen-mobil.png new file mode 100644 index 0000000..05cf34e Binary files /dev/null and b/docs/anleitung/bilder/zuweisungen-mobil.png differ diff --git a/docs/issues/025-zuweisungsmatrix.md b/docs/issues/025-zuweisungsmatrix.md index ca824e8..22f0864 100644 --- a/docs/issues/025-zuweisungsmatrix.md +++ b/docs/issues/025-zuweisungsmatrix.md @@ -1,19 +1,46 @@ # #025 Zuweisungsmatrix -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.4.0 **Labels:** ui -**Geschätzt:** 2 h **Tatsächlich:** – +**Geschätzt:** 2 h **Tatsächlich:** 0,5 h ## Ziel Matrix Schlüssel × Host. ## Aufgaben -- [ ] wird zu Beginn der Phase verfeinert +- [x] `/zuweisungen` ersetzt die Platzhalterseite: Matrix aktive Schlüssel (Zeilen) × nicht archivierte Hosts (Spalten), jede Rolle darf lesen +- [x] Zelle: „P“ direkt, „P·G“ über Hostgruppe, „K“ Private Key; Zustand als Punkt + Kurztext, voller Text in `title` und für Screenreader; Zielbenutzer mono +- [x] Filter per GET: Hostgruppe, Kategorie, Person, Suche über Schlüssel- und Hostnamen, „nur Zeilen/Spalten mit Zuweisungen“ +- [x] Eigener Scrollbereich mit sticky Kopfzeile und erster Spalte; unter `md` eine Liste je Schlüssel +- [x] Zell-Dialog: Verwendungen mit Zustand, Optionen/Pfad, Links zu Schlüssel und Host; „Public Key zuweisen“ (vorhandenes Formular mit Vorauswahl) und „Entfernen“ für `publicKeyAusrollen`; Link „Zum Dry-Run“ +- [x] Kopf: Zähler (Schlüssel, Hosts, Zuweisungen, nicht synchron) und CSV-Export `/api/zuweisungen.csv` +- [x] Reine Matrix-Funktion in `src/lib/zuweisungsmatrix.ts`, CSV-Helfer in `src/lib/csv.ts`, Query in `src/server/queries/zuweisungen.ts` +- [x] Tests: `tests/zuweisungsmatrix.test.ts` (rein), `tests/zuweisungen.test.ts` (Query + CSV-Route), E2E `tests/e2e/zuweisungen.spec.ts` +- [x] Screenshots `zuweisungen-desktop.png` / `zuweisungen-mobil.png` ## Akzeptanzkriterien -- [ ] wird zu Beginn der Phase verfeinert +- [x] Gruppen-Zuweisungen erscheinen auf jedem Mitglied und sind von direkten unterscheidbar +- [x] Zustand nie nur über Farbe +- [x] Seite scrollt bei 390 px nicht horizontal (Screenshot-Skript prüft das) +- [x] Leser sehen die Matrix, aber weder Zuweisen noch Entfernen +- [x] CSV mit den Spalten Schlüssel, Fingerprint, Host, Zielbenutzer, Art, Über Gruppe, Optionen, Zustand; Formelbeginne `= + - @` mit `'` entschärft +- [x] Route Handler prüft `rechtErforderlich("lesen")` selbst (`tests/zugang.test.ts` grün) ## Abhängigkeiten #016 ## Notizen / Entscheidungen +- Die Matrix plant nur: Zuweisen und Entfernen nutzen die vorhandenen Aktionen, ausgerollt wird + weiter über den Dry-Run der Schlüsselseite (`AusrollenPanel`). +- Suche: trifft der Begriff Schlüssel, werden die Zeilen eingeschränkt; trifft er Hosts, die + Spalten; trifft er beides, beides. So zeigt „srv-pve“ alle Schlüssel auf diesen Hosts statt + einer leeren Matrix. +- Zähler „Zuweisungen“ zählt Zelleneinträge, eine Gruppen-Zuweisung also einmal je Mitglied – + dasselbe, was der CSV-Export als Zeilen liefert. „Nicht synchron“ ist alles außer `AUSGEROLLT`. +- Zellenzustand ist der dringendste der Einträge (Fehler > Drift > entfernen > geplant > synchron). +- CSV: Semikolon, UTF-8-BOM und CRLF für ein deutsches Excel; bei Private Keys steht der Pfad + als „Pfad: …“ in der Spalte Optionen. Der Export nimmt dieselben Filter wie die Seite. +- Deployment-Keys erscheinen als Zeilen, sind aber nicht zuweisbar (verwaltet sshkv selbst). +- Entfernen einer Gruppen-Zuweisung aus einer Zelle gilt für die ganze Gruppe; der Dialog sagt das. +- `PublicZuweisenFormular` hat eine optionale `vorauswahl`; `VerwendungsZustand` exportiert seine + Darstellungstabelle (`ZUSTAND_ANZEIGE`), damit die Zelle dieselben Punkte nutzt. diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index 9028777..d691d4f 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -39,6 +39,9 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri { name: "meine-schluessel", pfad: "/meine-schluessel" }, { name: "hosts", pfad: "/hosts" }, { name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' }, + // Matrix Schluessel × Host (#025): die Seite darf auf 390 px nicht seitwaerts scrollen – + // nur der Matrix-Container. „belegt=1“, damit das Bild Zellen zeigt statt leerer Zeilen. + { name: "zuweisungen", pfad: "/zuweisungen?belegt=1" }, { name: "vorlagen", pfad: "/vorlagen" }, // Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019). { diff --git a/src/app/(app)/zuweisungen/page.tsx b/src/app/(app)/zuweisungen/page.tsx index 4860295..c5425a7 100644 --- a/src/app/(app)/zuweisungen/page.tsx +++ b/src/app/(app)/zuweisungen/page.tsx @@ -1,18 +1,164 @@ +import { Download, Network, Search } from "lucide-react"; import type { Metadata } from "next"; -import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import { Zuweisungsmatrix } from "@/components/zuweisungen/Zuweisungsmatrix"; +import { Button, buttonKlassen } from "@/components/ui/Button"; +import { Ankreuzfeld, Auswahlliste, Feld } from "@/components/ui/Feld"; +import { Kennzahl } from "@/components/ui/Kennzahl"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { KATEGORIE_TEXT } from "@/lib/schluessel"; +import { matrixFilterAus, matrixFilterQuery } from "@/lib/zuweisungsmatrix"; +import { darf } from "@/server/auth/rechte"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { zielAuswahl } from "@/server/queries/ziele"; +import { zuweisungsMatrix } from "@/server/queries/zuweisungen"; export const metadata: Metadata = { title: "Zuweisungen" }; -export default async function Zuweisungen() { +type Suchparameter = Promise>; + +/** + * Zuweisungsmatrix Schluessel × Host (#025). Filter per GET, damit Links und der CSV-Export + * denselben Ausschnitt zeigen. Die Matrix plant nur – ausgerollt wird ueber den Dry-Run. + */ +export default async function Zuweisungen({ searchParams }: { searchParams: Suchparameter }) { // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). - await sitzungErforderlich(); + const { benutzer } = await sitzungErforderlich("lesen", "/zuweisungen"); + const filter = matrixFilterAus(await searchParams); + const darfZuweisen = darf(benutzer.rolle, "publicKeyAusrollen"); + const [{ matrix, gruppen, personen }, auswahl] = await Promise.all([ + zuweisungsMatrix(filter), + darfZuweisen ? zielAuswahl() : Promise.resolve(undefined), + ]); + const gefiltert = Boolean( + filter.gruppeId || filter.kategorie || filter.personId || filter.q || filter.nurBelegt, + ); + const { zaehler } = matrix; + const leer = matrix.zeilen.length === 0 || matrix.spalten.length === 0; + return ( - +
+ + + CSV exportieren + + } + /> + +
+ + + + 0 ? "messing" : "neutral"} + hinweis="geplant, Drift, entfernen, Fehler" + /> +
+ +
+ + + + {gruppen.map((g) => ( + + ))} + + + + {Object.entries(KATEGORIE_TEXT).map(([wert, text]) => ( + + ))} + + + + {personen.map((p) => ( + + ))} + +
+ +
+ + {gefiltert && ( + + )} +
+
+ + + {leer ? ( + gefiltert ? ( + } + titel="Keine Treffer" + text="Kein Schlüssel und kein Host passen zu Suche und Filter." + aktion={} + /> + ) : ( + } + titel="Noch nichts zuzuordnen" + text="Die Matrix braucht mindestens einen aktiven Schlüssel und einen Host." + /> + ) + ) : ( +
+

+ P Public Key direkt ·{" "} + P·G über Hostgruppe ·{" "} + K Private Key · ●{" "} + synchron · ○ geplant oder wird entfernt ·{" "} + ● Drift oder Fehler +

+ +
+ )} +
); } diff --git a/src/app/api/zuweisungen.csv/route.ts b/src/app/api/zuweisungen.csv/route.ts new file mode 100644 index 0000000..2990570 --- /dev/null +++ b/src/app/api/zuweisungen.csv/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { matrixCsv, matrixFilterAus } from "@/lib/zuweisungsmatrix"; +import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { zuweisungsMatrix } from "@/server/queries/zuweisungen"; + +/** + * Zuweisungsmatrix als CSV (#025) mit denselben Filtern wie die Seite. Jede Rolle darf lesen; + * der Handler prueft das selbst, der Proxy laesst ohne Cookie ohnehin niemanden durch. + */ +export async function GET(anfrage: Request) { + try { + await rechtErforderlich("lesen"); + } catch (fehler) { + if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 }); + throw fehler; + } + const filter = matrixFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams)); + const { matrix } = await zuweisungsMatrix(filter); + return new NextResponse(matrixCsv(matrix), { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="sshkv-zuweisungen.csv"', + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/components/ausrollen/PublicZuweisenFormular.tsx b/src/components/ausrollen/PublicZuweisenFormular.tsx index 2ff5080..dbcdc79 100644 --- a/src/components/ausrollen/PublicZuweisenFormular.tsx +++ b/src/components/ausrollen/PublicZuweisenFormular.tsx @@ -14,10 +14,13 @@ export function PublicZuweisenFormular({ schluesselId, auswahl, beiErfolg, + vorauswahl, }: { schluesselId: string; auswahl: ZielAuswahl; beiErfolg?: () => void; + /** Vorausgewaehltes Ziel als „host:“ oder „gruppe:“, z. B. aus der Matrix (#025). */ + vorauswahl?: string; }) { const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => { const ergebnis = await publicZuweisenAktion(vorher, formular); @@ -28,7 +31,7 @@ export function PublicZuweisenFormular({
- + diff --git a/src/components/ausrollen/VerwendungsZustand.tsx b/src/components/ausrollen/VerwendungsZustand.tsx index 16d6b10..2f69ec9 100644 --- a/src/components/ausrollen/VerwendungsZustand.tsx +++ b/src/components/ausrollen/VerwendungsZustand.tsx @@ -2,7 +2,7 @@ import type { VerwendungsZustand as Zustand } from "@/generated/prisma/enums"; import { clsx } from "clsx"; /** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */ -const ANZEIGE: Record = { +export const ZUSTAND_ANZEIGE: Record = { GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" }, AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" }, ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" }, @@ -12,7 +12,7 @@ const ANZEIGE: Record }; export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) { - const anzeige = ANZEIGE[zustand]; + const anzeige = ZUSTAND_ANZEIGE[zustand]; return ( void; +}) { + const dialog = useRef(null); + const router = useRouter(); + // Nach erfolgreichem Zuweisen ein frisches Formular – sonst schickt ein zweiter Klick + // dieselbe Zuweisung noch einmal und erntet „bereits zugewiesen“. + const [formularNr, setFormularNr] = useState(0); + + useEffect(() => { + const element = dialog.current; + if (element && !element.open) element.showModal(); + }, []); + + const titel = host ? `${schluessel.name} auf ${host.name}` : schluessel.name; + const vorauswahl = host && auswahl?.hosts.some((h) => h.id === host.id) ? `host:${host.id}` : undefined; + const eintraege = zelle?.eintraege ?? []; + + return ( + +
+
+

{schluessel.name}

+

+ {host ? ( + <> + auf {host.name} + + ) : ( + "Einem Host oder einer Hostgruppe zuweisen" + )} +

+
+ +
+ +
+ + Zum Schlüssel + + {host && ( + + Zum Host + + )} +
+ + {host && ( +
+ {eintraege.length === 0 ? ( +

+ Auf diesem Host ist der Schlüssel nicht zugewiesen. +

+ ) : ( + eintraege.map((e) => ( + +
+

+ {KENNUNG_TEXT[e.kennung]} + {e.gruppe && <> „{e.gruppe.name}“} +

+

+ Zielbenutzer {e.zielbenutzer} +

+ {e.art === "public" ? ( +

+ Optionen {e.optionen ?? "–"} +

+ ) : ( +

+ Pfad {e.pfad} +

+ )} + + {e.gruppe && darfZuweisen && ( +

+ Entfernen gilt für alle Hosts der Gruppe „{e.gruppe.name}“. +

+ )} +
+ +
+ )) + )} +
+ )} + + {darfZuweisen && auswahl && ( +
+

Public Key zuweisen

+ { + setFormularNr((n) => n + 1); + router.refresh(); + }} + /> +
+ )} + + {darfZuweisen && ( +
+

+ Zuweisen und Entfernen ändern auf dem Host noch nichts – erst der Dry-Run auf der Schlüsselseite + zeigt und schreibt die Änderung. +

+ +
+ )} +
+ ); +} diff --git a/src/components/zuweisungen/Zuweisungsmatrix.tsx b/src/components/zuweisungen/Zuweisungsmatrix.tsx new file mode 100644 index 0000000..842fd4e --- /dev/null +++ b/src/components/zuweisungen/Zuweisungsmatrix.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { clsx } from "clsx"; +import { Plus } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { ZUSTAND_ANZEIGE } from "@/components/ausrollen/VerwendungsZustand"; +import { Button } from "@/components/ui/Button"; +import { + KENNUNG_TEXT, + ZUSTAND_KURZ, + ZUSTAND_TEXT, + zellenSchluessel, + type MatrixHost, + type MatrixSchluessel, + type Zelle, +} from "@/lib/zuweisungsmatrix"; +import type { ZielAuswahl } from "@/server/queries/ziele"; +import { ZellenDialog, type ZellenAuswahl } from "./ZellenDialog"; + +/** + * Matrix Schluessel × Host (#025). Ab md eine Tabelle mit eigenem Scrollbereich – die Seite + * selbst scrollt nie seitwaerts –, darunter eine Liste je Schluessel, weil auf 390 px keine + * zweite Spalte neben den Schluesselnamen passt. + */ +export type MatrixProps = { + zeilen: readonly MatrixSchluessel[]; + spalten: readonly MatrixHost[]; + /** Nur belegte Zellen, Schluessel `${schluesselId}:${hostId}`. */ + zellen: Readonly>; + darfZuweisen: boolean; + darfPrivatEntfernen: boolean; + auswahl?: ZielAuswahl; +}; + +/** Langtext fuer title und Screenreader: Art, Gruppe, Benutzer und Zustand je Eintrag. */ +function zellenText(zelle: Zelle): string { + return zelle.eintraege + .map( + (e) => + `${KENNUNG_TEXT[e.kennung]}${e.gruppe ? ` ${e.gruppe.name}` : ""}, Benutzer ${e.zielbenutzer}, ${ZUSTAND_TEXT[e.zustand]}`, + ) + .join("; "); +} + +function zielbenutzerKurz(zelle: Zelle): string { + const [erster, ...rest] = zelle.zielbenutzer; + return rest.length > 0 ? `${erster} +${rest.length}` : (erster ?? ""); +} + +export function Zuweisungsmatrix({ + zeilen, + spalten, + zellen, + darfZuweisen, + darfPrivatEntfernen, + auswahl, +}: MatrixProps) { + const [offen, setOffen] = useState(); + const zuweisbar = (s: MatrixSchluessel) => darfZuweisen && !!auswahl && s.kategorie !== "DEPLOYMENT"; + + const schluessel = offen ? zeilen.find((s) => s.id === offen.schluesselId) : undefined; + const host = offen?.hostId ? spalten.find((h) => h.id === offen.hostId) : undefined; + const zelle = offen?.hostId ? zellen[zellenSchluessel(offen.schluesselId, offen.hostId)] : undefined; + + return ( + <> + {/* Desktop: Kopfzeile und erste Spalte bleiben beim Scrollen stehen. Der Scrollbereich + braucht eine eigene Hoehe, sonst klebt die Kopfzeile am Container statt am Fenster. */} +
+ + + + + + {spalten.map((h) => ( + + ))} + + + + {zeilen.map((s) => ( + + + {spalten.map((h) => { + const z = zellen[zellenSchluessel(s.id, h.id)]; + return ( + + ); + })} + + ))} + +
+ Zuweisungen: Schlüssel in den Zeilen, Hosts in den Spalten. Eine Zelle öffnet die Details. +
+ Schlüssel + + + {h.name} + +
+ + {s.name} + + {s.person && {s.person.name}} + + {z ? ( + setOffen({ schluesselId: s.id, hostId: h.id })} + /> + ) : zuweisbar(s) ? ( + + ) : ( + + · + nicht zugewiesen + + )} +
+
+ + {/* Telefon: je Schluessel nur die Hosts, auf denen er verwaltet ist. */} +
    + {zeilen.map((s) => { + const belegt = spalten.flatMap((h) => { + const z = zellen[zellenSchluessel(s.id, h.id)]; + return z ? [{ host: h, zelle: z }] : []; + }); + return ( +
  • +
    +
    + + {s.name} + + + {belegt.length === 1 ? "1 Host" : `${belegt.length} Hosts`} + {s.person && <> · {s.person.name}} + +
    + {zuweisbar(s) && ( + + )} +
    + {belegt.length > 0 && ( +
      + {belegt.map(({ host: h, zelle: z }) => { + const anzeige = ZUSTAND_ANZEIGE[z.zustand]; + return ( +
    • + +
    • + ); + })} +
    + )} +
  • + ); + })} +
+ + {offen && schluessel && ( + setOffen(undefined)} + /> + )} + + ); +} + +function BelegteZelle({ + zelle, + schluessel, + host, + oeffnen, +}: { + zelle: Zelle; + schluessel: MatrixSchluessel; + host: MatrixHost; + oeffnen: () => void; +}) { + const anzeige = ZUSTAND_ANZEIGE[zelle.zustand]; + const text = zellenText(zelle); + return ( + + ); +} diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000..4454968 --- /dev/null +++ b/src/lib/csv.ts @@ -0,0 +1,37 @@ +/** + * CSV fuer Excel (#025). Framework-frei, damit spaetere Exporte (Protokoll, Phase 5) dieselben + * Regeln nutzen. + * + * - Semikolon als Trenner und UTF-8-BOM: ein deutsches Excel oeffnet die Datei sonst als eine + * Spalte mit kaputten Umlauten. + * - Jeder Wert in Anfuehrungszeichen, damit Kommas in `authorized_keys`-Optionen und + * Zeilenumbrueche nichts verschieben. + * - Formel-Injektion: Excel und LibreOffice werten Zellen, die mit `= + - @` (oder Tab/CR) + * beginnen, als Formel aus – ein Schluesselname wie `=HYPERLINK(…)` wuerde beim Oeffnen + * ausgefuehrt. Ein vorangestelltes `'` macht daraus Text (OWASP „CSV Injection“). + */ +export const CSV_BOM = ""; +export const CSV_TRENNER = ";"; + +const FORMELBEGINN = /^[=+\-@\t\r]/; + +export function csvEntschaerfen(wert: string): string { + return FORMELBEGINN.test(wert) ? `'${wert}` : wert; +} + +export function csvWert(wert: string | number | null | undefined): string { + const text = csvEntschaerfen(wert == null ? "" : String(wert)); + return `"${text.replace(/"/g, '""')}"`; +} + +export function csvZeile(werte: readonly (string | number | null | undefined)[]): string { + return werte.map(csvWert).join(CSV_TRENNER); +} + +/** Ganze Datei: BOM, Kopfzeile, Datenzeilen, CRLF wie Excel selbst. */ +export function csvDatei( + kopf: readonly string[], + zeilen: readonly (readonly (string | number | null | undefined)[])[], +): string { + return CSV_BOM + [kopf, ...zeilen].map(csvZeile).join("\r\n") + "\r\n"; +} diff --git a/src/lib/zuweisungsmatrix.ts b/src/lib/zuweisungsmatrix.ts new file mode 100644 index 0000000..e0e2268 --- /dev/null +++ b/src/lib/zuweisungsmatrix.ts @@ -0,0 +1,311 @@ +import { csvDatei } from "@/lib/csv"; + +/** + * Zuweisungsmatrix Schluessel × Host (#025). Rein: bekommt die Zeilen aus der Datenbank und + * baut daraus Matrix, Zaehler und CSV – so laesst sich die Gruppenaufloesung ohne Datenbank + * testen, und Seite und CSV-Export sehen garantiert dasselbe. + */ + +export type MatrixZustand = + "GEPLANT" | "AUSGEROLLT" | "ABWEICHUNG" | "ENTFERNEN" | "ENTFERNT" | "FEHLGESCHLAGEN"; +export type MatrixKategorie = "PERSON" | "DIENST" | "DEPLOYMENT"; + +export type MatrixSchluessel = { + readonly id: string; + readonly name: string; + readonly fingerprint: string; + readonly kategorie: MatrixKategorie; + readonly person: { readonly id: string; readonly name: string } | null; +}; + +export type MatrixHost = { + readonly id: string; + readonly name: string; + readonly gruppenIds: readonly string[]; +}; + +export type MatrixGruppe = { readonly id: string; readonly name: string }; + +export type PublicZeile = { + readonly id: string; + readonly schluesselId: string; + readonly hostId: string | null; + readonly hostgruppeId: string | null; + readonly zielbenutzer: string; + readonly optionen: string | null; + readonly zustand: MatrixZustand; +}; + +export type PrivatZeile = { + readonly id: string; + readonly schluesselId: string; + readonly hostId: string; + readonly zielbenutzer: string; + readonly pfad: string; + readonly zustand: MatrixZustand; +}; + +export type MatrixDaten = { + readonly schluessel: readonly MatrixSchluessel[]; + readonly hosts: readonly MatrixHost[]; + readonly gruppen: readonly MatrixGruppe[]; + readonly oeffentlich: readonly PublicZeile[]; + readonly privat: readonly PrivatZeile[]; +}; + +export type MatrixFilter = { + readonly gruppeId?: string; + readonly kategorie?: string; + readonly personId?: string; + readonly q?: string; + /** Nur Zeilen und Spalten, in denen mindestens eine Zuweisung steht. */ + readonly nurBelegt?: boolean; +}; + +/** „P“ direkt, „P·G“ ueber eine Hostgruppe, „K“ Private Key – Kurzformen fuer die Zelle. */ +export type Kennung = "P" | "P·G" | "K"; + +export type ZellenEintrag = { + readonly verwendungId: string; + readonly art: "public" | "privat"; + readonly kennung: Kennung; + readonly gruppe: MatrixGruppe | null; + readonly zielbenutzer: string; + readonly optionen: string | null; + readonly pfad: string | null; + readonly zustand: MatrixZustand; +}; + +export type Zelle = { + readonly schluesselId: string; + readonly hostId: string; + readonly eintraege: readonly ZellenEintrag[]; + /** Der dringendste Zustand der Eintraege – er bestimmt Punkt und Kurztext. */ + readonly zustand: MatrixZustand; + readonly kennungen: readonly Kennung[]; + readonly zielbenutzer: readonly string[]; +}; + +export type Matrix = { + readonly zeilen: readonly MatrixSchluessel[]; + readonly spalten: readonly MatrixHost[]; + /** Schluessel `${schluesselId}:${hostId}`; leere Zellen fehlen. */ + readonly zellen: ReadonlyMap; + readonly zaehler: { + readonly schluessel: number; + readonly hosts: number; + readonly zuweisungen: number; + readonly nichtSynchron: number; + }; +}; + +export const ZUSTAND_TEXT: Record = { + GEPLANT: "noch nicht ausgerollt", + AUSGEROLLT: "synchron", + ABWEICHUNG: "Drift", + ENTFERNEN: "wird entfernt", + ENTFERNT: "entfernt", + FEHLGESCHLAGEN: "fehlgeschlagen", +}; + +/** Kurztext fuer die enge Zelle; der volle Text steht in `title` und fuer Screenreader. */ +export const ZUSTAND_KURZ: Record = { + GEPLANT: "geplant", + AUSGEROLLT: "sync", + ABWEICHUNG: "Drift", + ENTFERNEN: "entf.", + ENTFERNT: "entfernt", + FEHLGESCHLAGEN: "Fehler", +}; + +export const KENNUNG_TEXT: Record = { + P: "Public Key direkt", + "P·G": "Public Key über Hostgruppe", + K: "Private Key", +}; + +/** Was zuerst ins Auge fallen muss: Fehler und Drift vor offener Arbeit vor „alles gut“. */ +const DRINGLICHKEIT: Record = { + FEHLGESCHLAGEN: 5, + ABWEICHUNG: 4, + ENTFERNEN: 3, + GEPLANT: 2, + AUSGEROLLT: 1, + ENTFERNT: 0, +}; + +export function zellenSchluessel(schluesselId: string, hostId: string): string { + return `${schluesselId}:${hostId}`; +} + +function enthaelt(text: string, q: string): boolean { + return text.toLowerCase().includes(q); +} + +const KENNUNG_REIHENFOLGE: readonly Kennung[] = ["P", "P·G", "K"]; + +export function matrixAufbauen(daten: MatrixDaten, filter: MatrixFilter = {}): Matrix { + const gruppen = new Map(daten.gruppen.map((g) => [g.id, g])); + const hostsJeGruppe = new Map(); + for (const h of daten.hosts) { + for (const g of h.gruppenIds) hostsJeGruppe.set(g, [...(hostsJeGruppe.get(g) ?? []), h.id]); + } + + // Alle Eintraege je Zelle sammeln – Gruppen-Verwendungen fuer jedes Mitglied einzeln. + const sammel = new Map(); + const hinzu = (schluesselId: string, hostId: string, eintrag: ZellenEintrag) => { + const k = zellenSchluessel(schluesselId, hostId); + sammel.set(k, [...(sammel.get(k) ?? []), eintrag]); + }; + for (const v of daten.oeffentlich) { + if (v.zustand === "ENTFERNT") continue; + const gruppe = v.hostgruppeId ? (gruppen.get(v.hostgruppeId) ?? null) : null; + const ziele = v.hostId ? [v.hostId] : v.hostgruppeId ? (hostsJeGruppe.get(v.hostgruppeId) ?? []) : []; + for (const hostId of ziele) { + hinzu(v.schluesselId, hostId, { + verwendungId: v.id, + art: "public", + kennung: v.hostId ? "P" : "P·G", + gruppe: v.hostId ? null : gruppe, + zielbenutzer: v.zielbenutzer, + optionen: v.optionen, + pfad: null, + zustand: v.zustand, + }); + } + } + for (const v of daten.privat) { + if (v.zustand === "ENTFERNT") continue; + hinzu(v.schluesselId, v.hostId, { + verwendungId: v.id, + art: "privat", + kennung: "K", + gruppe: null, + zielbenutzer: v.zielbenutzer, + optionen: null, + pfad: v.pfad, + zustand: v.zustand, + }); + } + + // Filter: Schluessel-Merkmale schraenken Zeilen ein, die Hostgruppe die Spalten. + const q = filter.q?.trim().toLowerCase() ?? ""; + let zeilen = daten.schluessel.filter( + (s) => + (!filter.kategorie || s.kategorie === filter.kategorie) && + (!filter.personId || s.person?.id === filter.personId), + ); + let spalten = daten.hosts.filter((h) => !filter.gruppeId || h.gruppenIds.includes(filter.gruppeId)); + if (q) { + // Eine Suche trifft Schluessel- oder Hostnamen: wer „srv-pve“ sucht, will alle Schluessel + // auf diesen Hosts sehen, nicht eine leere Matrix, weil kein Schluessel so heisst. + const zeilenTreffer = zeilen.filter((s) => enthaelt(s.name, q) || enthaelt(s.fingerprint, q)); + const spaltenTreffer = spalten.filter((h) => enthaelt(h.name, q)); + zeilen = zeilenTreffer.length > 0 || spaltenTreffer.length === 0 ? zeilenTreffer : zeilen; + spalten = spaltenTreffer.length > 0 || zeilenTreffer.length === 0 ? spaltenTreffer : spalten; + } + if (filter.nurBelegt) { + const belegt = (s: string, h: string) => sammel.has(zellenSchluessel(s, h)); + zeilen = zeilen.filter((s) => spalten.some((h) => belegt(s.id, h.id))); + spalten = spalten.filter((h) => zeilen.some((s) => belegt(s.id, h.id))); + } + + const zellen = new Map(); + let zuweisungen = 0; + let nichtSynchron = 0; + for (const s of zeilen) { + for (const h of spalten) { + const k = zellenSchluessel(s.id, h.id); + const eintraege = sammel.get(k); + if (!eintraege) continue; + eintraege.sort( + (a, b) => + KENNUNG_REIHENFOLGE.indexOf(a.kennung) - KENNUNG_REIHENFOLGE.indexOf(b.kennung) || + a.zielbenutzer.localeCompare(b.zielbenutzer), + ); + zuweisungen += eintraege.length; + nichtSynchron += eintraege.filter((e) => e.zustand !== "AUSGEROLLT").length; + zellen.set(k, { + schluesselId: s.id, + hostId: h.id, + eintraege, + zustand: eintraege.reduce( + (schlimmster, e) => + DRINGLICHKEIT[e.zustand] > DRINGLICHKEIT[schlimmster] ? e.zustand : schlimmster, + "ENTFERNT", + ), + kennungen: KENNUNG_REIHENFOLGE.filter((kennung) => eintraege.some((e) => e.kennung === kennung)), + zielbenutzer: [...new Set(eintraege.map((e) => e.zielbenutzer))], + }); + } + } + + return { + zeilen, + spalten, + zellen, + zaehler: { schluessel: zeilen.length, hosts: spalten.length, zuweisungen, nichtSynchron }, + }; +} + +/** Filter aus GET-Parametern; Unbekanntes faellt weg statt einen Fehler zu werfen. */ +export function matrixFilterAus(parameter: Record): MatrixFilter { + const einzeln = (name: string) => { + const wert = parameter[name]; + return ((Array.isArray(wert) ? wert[0] : wert) ?? "").trim().slice(0, 200); + }; + const kategorie = einzeln("kategorie"); + return { + gruppeId: einzeln("gruppe"), + kategorie: ["PERSON", "DIENST", "DEPLOYMENT"].includes(kategorie) ? kategorie : "", + personId: einzeln("person"), + q: einzeln("q"), + nurBelegt: einzeln("belegt") === "1", + }; +} + +/** Dieselben Filter als Query-String, z. B. fuer den CSV-Link. */ +export function matrixFilterQuery(filter: MatrixFilter): string { + const p = new URLSearchParams(); + if (filter.gruppeId) p.set("gruppe", filter.gruppeId); + if (filter.kategorie) p.set("kategorie", filter.kategorie); + if (filter.personId) p.set("person", filter.personId); + if (filter.q) p.set("q", filter.q); + if (filter.nurBelegt) p.set("belegt", "1"); + const text = p.toString(); + return text ? `?${text}` : ""; +} + +export const CSV_KOPF = [ + "Schlüssel", + "Fingerprint", + "Host", + "Zielbenutzer", + "Art", + "Über Gruppe", + "Optionen", + "Zustand", +] as const; + +/** Eine Zeile je Zelleneintrag – Gruppen-Zuweisungen also einmal je Mitglied (#025). */ +export function matrixCsv(matrix: Matrix): string { + const zeilen: string[][] = []; + for (const s of matrix.zeilen) { + for (const h of matrix.spalten) { + const zelle = matrix.zellen.get(zellenSchluessel(s.id, h.id)); + for (const e of zelle?.eintraege ?? []) { + zeilen.push([ + s.name, + s.fingerprint, + h.name, + e.zielbenutzer, + e.art === "public" ? "Public Key" : "Private Key", + e.gruppe?.name ?? "", + e.art === "public" ? (e.optionen ?? "") : `Pfad: ${e.pfad ?? ""}`, + ZUSTAND_TEXT[e.zustand], + ]); + } + } + } + return csvDatei(CSV_KOPF, zeilen); +} diff --git a/src/server/queries/zuweisungen.ts b/src/server/queries/zuweisungen.ts new file mode 100644 index 0000000..ef22224 --- /dev/null +++ b/src/server/queries/zuweisungen.ts @@ -0,0 +1,69 @@ +import { prisma } from "@/lib/db"; +import { matrixAufbauen, type MatrixDaten, type MatrixFilter } from "@/lib/zuweisungsmatrix"; + +/** + * Lesemodell der Zuweisungsmatrix (#025). Jede angemeldete Rolle darf es sehen – wie die + * Verwendungen auf Schluessel- und Hostseite auch. + * + * Feste `select`-Listen ohne Chiffrat-Felder (CLAUDE.md). Nur aktive Schluessel und nicht + * archivierte Hosts: widerrufene oder rotierte Keys haben keine Zukunft auf einem Host, und + * ein archivierter Host wird nicht mehr abgeglichen. + */ +export async function zuweisungsDaten(): Promise { + const nichtEntfernt = { zustand: { not: "ENTFERNT" as const } }; + const [schluessel, hosts, gruppen, oeffentlich, privat] = await Promise.all([ + prisma.schluessel.findMany({ + where: { status: "AKTIV" }, + select: { + id: true, + name: true, + fingerprint: true, + kategorie: true, + person: { select: { id: true, name: true } }, + }, + orderBy: [{ name: "asc" }, { id: "asc" }], + }), + prisma.host.findMany({ + where: { archiviertAm: null }, + select: { id: true, name: true, gruppen: { select: { hostgruppeId: true } } }, + orderBy: { name: "asc" }, + }), + prisma.hostgruppe.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }), + prisma.publicVerwendung.findMany({ + where: { ...nichtEntfernt, schluessel: { status: "AKTIV" } }, + select: { + id: true, + schluesselId: true, + hostId: true, + hostgruppeId: true, + zielbenutzer: true, + optionen: true, + zustand: true, + }, + orderBy: { angelegtAm: "asc" }, + }), + prisma.privateVerwendung.findMany({ + where: { ...nichtEntfernt, schluessel: { status: "AKTIV" } }, + select: { id: true, schluesselId: true, hostId: true, zielbenutzer: true, pfad: true, zustand: true }, + orderBy: { angelegtAm: "asc" }, + }), + ]); + return { + schluessel, + hosts: hosts.map((h) => ({ id: h.id, name: h.name, gruppenIds: h.gruppen.map((g) => g.hostgruppeId) })), + gruppen, + oeffentlich, + privat, + }; +} + +export async function zuweisungsMatrix(filter: MatrixFilter = {}) { + const daten = await zuweisungsDaten(); + // Personen fuer den Filter: nur die, denen ein aktiver Schluessel gehoert. + const personen = [ + ...new Map( + daten.schluessel.flatMap((s) => (s.person ? [[s.person.id, s.person] as const] : [])), + ).values(), + ].sort((a, b) => a.name.localeCompare(b.name, "de")); + return { matrix: matrixAufbauen(daten, filter), gruppen: daten.gruppen, personen }; +} diff --git a/tests/e2e/zuweisungen.spec.ts b/tests/e2e/zuweisungen.spec.ts new file mode 100644 index 0000000..232d52c --- /dev/null +++ b/tests/e2e/zuweisungen.spec.ts @@ -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(arbeit: (client: pg.Client) => Promise): Promise { + 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); +}); diff --git a/tests/zuweisungen.test.ts b/tests/zuweisungen.test.ts new file mode 100644 index 0000000..47004b0 --- /dev/null +++ b/tests/zuweisungen.test.ts @@ -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(); + 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; + +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"); + }); +}); diff --git a/tests/zuweisungsmatrix.test.ts b/tests/zuweisungsmatrix.test.ts new file mode 100644 index 0000000..b00dd89 --- /dev/null +++ b/tests/zuweisungsmatrix.test.ts @@ -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 & Pick, +): 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)"=/); + }); +});