Merge branch 'phase/025-zuweisungsmatrix'
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
@@ -1,19 +1,46 @@
|
|||||||
# #025 Zuweisungsmatrix
|
# #025 Zuweisungsmatrix
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** ui
|
**Labels:** ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0,5 h
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Matrix Schlüssel × Host.
|
Matrix Schlüssel × Host.
|
||||||
|
|
||||||
## Aufgaben
|
## 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
|
## 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
|
## Abhängigkeiten
|
||||||
#016
|
#016
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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.
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
|
|||||||
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
|
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
|
||||||
{ name: "hosts", pfad: "/hosts" },
|
{ name: "hosts", pfad: "/hosts" },
|
||||||
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/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" },
|
{ name: "vorlagen", pfad: "/vorlagen" },
|
||||||
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
|
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,18 +1,164 @@
|
|||||||
|
import { Download, Network, Search } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
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 { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { zielAuswahl } from "@/server/queries/ziele";
|
||||||
|
import { zuweisungsMatrix } from "@/server/queries/zuweisungen";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Zuweisungen" };
|
export const metadata: Metadata = { title: "Zuweisungen" };
|
||||||
|
|
||||||
export default async function Zuweisungen() {
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
// 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 (
|
return (
|
||||||
<Platzhalterseite
|
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||||
titel="Zuweisungen"
|
<Seitenkopf
|
||||||
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt"
|
titel="Zuweisungen"
|
||||||
phase={2}
|
untertitel="Welcher Schlüssel auf welchem Host verwaltet ist"
|
||||||
issue="#016"
|
aktionen={
|
||||||
/>
|
<a
|
||||||
|
href={`/api/zuweisungen.csv${matrixFilterQuery(filter)}`}
|
||||||
|
download
|
||||||
|
className={buttonKlassen("sekundaer")}
|
||||||
|
>
|
||||||
|
<Download className="size-4" aria-hidden />
|
||||||
|
CSV exportieren
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:gap-5 xl:grid-cols-4">
|
||||||
|
<Kennzahl titel="Schlüssel" wert={zaehler.schluessel} hinweis="aktiv, in der Ansicht" />
|
||||||
|
<Kennzahl titel="Hosts" wert={zaehler.hosts} hinweis="nicht archiviert" />
|
||||||
|
<Kennzahl titel="Zuweisungen" wert={zaehler.zuweisungen} hinweis="Gruppen je Host gezählt" />
|
||||||
|
<Kennzahl
|
||||||
|
titel="Nicht synchron"
|
||||||
|
wert={zaehler.nichtSynchron}
|
||||||
|
variante={zaehler.nichtSynchron > 0 ? "messing" : "neutral"}
|
||||||
|
hinweis="geplant, Drift, entfernen, Fehler"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="get"
|
||||||
|
role="search"
|
||||||
|
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_10rem_11rem]"
|
||||||
|
>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Suche"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={filter.q}
|
||||||
|
placeholder="Schlüssel- oder Hostname"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="sm:col-span-2 lg:col-span-1"
|
||||||
|
/>
|
||||||
|
<Auswahlliste beschriftung="Hostgruppe" name="gruppe" defaultValue={filter.gruppeId}>
|
||||||
|
<option value="">Alle Hosts</option>
|
||||||
|
{gruppen.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={filter.kategorie}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Person" name="person" defaultValue={filter.personId}>
|
||||||
|
<option value="">Alle Personen</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="flex flex-col gap-3 sm:col-span-2 sm:flex-row sm:items-center sm:justify-between lg:col-span-4">
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="belegt"
|
||||||
|
value="1"
|
||||||
|
defaultChecked={filter.nurBelegt}
|
||||||
|
beschriftung="Nur Zeilen und Spalten mit Zuweisungen"
|
||||||
|
className="-mx-2"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
|
||||||
|
Filtern
|
||||||
|
</Button>
|
||||||
|
{gefiltert && (
|
||||||
|
<Button href="/zuweisungen" className="flex-1 sm:flex-none">
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{leer ? (
|
||||||
|
gefiltert ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Search className="size-5" />}
|
||||||
|
titel="Keine Treffer"
|
||||||
|
text="Kein Schlüssel und kein Host passen zu Suche und Filter."
|
||||||
|
aktion={<Button href="/zuweisungen">Filter zurücksetzen</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Network className="size-5" />}
|
||||||
|
titel="Noch nichts zuzuordnen"
|
||||||
|
text="Die Matrix braucht mindestens einen aktiven Schlüssel und einen Host."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<section className="flex min-w-0 flex-col gap-3" aria-label="Matrix">
|
||||||
|
<p className="text-muted text-[13px]">
|
||||||
|
<span className="text-text font-medium">P</span> Public Key direkt ·{" "}
|
||||||
|
<span className="text-text font-medium">P·G</span> über Hostgruppe ·{" "}
|
||||||
|
<span className="text-text font-medium">K</span> Private Key · <span className="text-ok">●</span>{" "}
|
||||||
|
synchron · <span className="text-accent">○</span> geplant oder wird entfernt ·{" "}
|
||||||
|
<span className="text-danger">●</span> Drift oder Fehler
|
||||||
|
</p>
|
||||||
|
<Zuweisungsmatrix
|
||||||
|
zeilen={matrix.zeilen}
|
||||||
|
spalten={matrix.spalten}
|
||||||
|
zellen={Object.fromEntries(matrix.zellen)}
|
||||||
|
darfZuweisen={darfZuweisen}
|
||||||
|
darfPrivatEntfernen={darf(benutzer.rolle, "privateKeyAusrollen")}
|
||||||
|
{...(auswahl ? { auswahl } : {})}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,10 +14,13 @@ export function PublicZuweisenFormular({
|
|||||||
schluesselId,
|
schluesselId,
|
||||||
auswahl,
|
auswahl,
|
||||||
beiErfolg,
|
beiErfolg,
|
||||||
|
vorauswahl,
|
||||||
}: {
|
}: {
|
||||||
schluesselId: string;
|
schluesselId: string;
|
||||||
auswahl: ZielAuswahl;
|
auswahl: ZielAuswahl;
|
||||||
beiErfolg?: () => void;
|
beiErfolg?: () => void;
|
||||||
|
/** Vorausgewaehltes Ziel als „host:<id>“ oder „gruppe:<id>“, z. B. aus der Matrix (#025). */
|
||||||
|
vorauswahl?: string;
|
||||||
}) {
|
}) {
|
||||||
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||||
const ergebnis = await publicZuweisenAktion(vorher, formular);
|
const ergebnis = await publicZuweisenAktion(vorher, formular);
|
||||||
@@ -28,7 +31,7 @@ export function PublicZuweisenFormular({
|
|||||||
<form onSubmit={absenden} className="flex flex-col gap-4">
|
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||||
<input type="hidden" name="schluesselId" value={schluesselId} />
|
<input type="hidden" name="schluesselId" value={schluesselId} />
|
||||||
<Formularfehler text={zustand.fehler} />
|
<Formularfehler text={zustand.fehler} />
|
||||||
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue="" required>
|
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue={vorauswahl ?? ""} required>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
Host oder Hostgruppe wählen …
|
Host oder Hostgruppe wählen …
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { VerwendungsZustand as Zustand } from "@/generated/prisma/enums";
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
|
||||||
/** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */
|
/** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */
|
||||||
const ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = {
|
export const ZUSTAND_ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = {
|
||||||
GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" },
|
GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" },
|
||||||
AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" },
|
AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" },
|
||||||
ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" },
|
ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" },
|
||||||
@@ -12,7 +12,7 @@ const ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) {
|
export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) {
|
||||||
const anzeige = ANZEIGE[zustand];
|
const anzeige = ZUSTAND_ANZEIGE[zustand];
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowRight, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||||
|
import { PublicZuweisenFormular } from "@/components/ausrollen/PublicZuweisenFormular";
|
||||||
|
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { InnereBox } from "@/components/ui/Karte";
|
||||||
|
import { KENNUNG_TEXT, type MatrixHost, type MatrixSchluessel, type Zelle } from "@/lib/zuweisungsmatrix";
|
||||||
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
|
|
||||||
|
export type ZellenAuswahl = { readonly schluesselId: string; readonly hostId: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail einer Matrixzelle (#025): was dort verwaltet ist, Entfernen und Zuweisen mit den
|
||||||
|
* vorhandenen Aktionen. Ausgerollt wird weiter nur ueber den Dry-Run der Schluesselseite –
|
||||||
|
* die Matrix plant, sie schreibt nichts auf Hosts.
|
||||||
|
*
|
||||||
|
* Die Zelle kommt aus den Props: nach `router.refresh()` zeigt der offene Dialog sofort den
|
||||||
|
* neuen Stand, ohne eigenen Zustand nachzufuehren.
|
||||||
|
*/
|
||||||
|
export function ZellenDialog({
|
||||||
|
schluessel,
|
||||||
|
host,
|
||||||
|
zelle,
|
||||||
|
darfZuweisen,
|
||||||
|
darfPrivatEntfernen,
|
||||||
|
auswahl,
|
||||||
|
schliessen,
|
||||||
|
}: {
|
||||||
|
schluessel: MatrixSchluessel;
|
||||||
|
host: MatrixHost | undefined;
|
||||||
|
zelle: Zelle | undefined;
|
||||||
|
darfZuweisen: boolean;
|
||||||
|
darfPrivatEntfernen: boolean;
|
||||||
|
auswahl?: ZielAuswahl;
|
||||||
|
schliessen: () => void;
|
||||||
|
}) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(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 (
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-label={titel}
|
||||||
|
onClose={schliessen}
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto max-h-[calc(100dvh-32px)] w-[calc(100%-32px)] overflow-y-auto overscroll-contain max-w-xl border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="font-display text-[18px] font-semibold break-words">{schluessel.name}</h2>
|
||||||
|
<p className="text-muted mt-0.5 text-[13.5px]">
|
||||||
|
{host ? (
|
||||||
|
<>
|
||||||
|
auf <span className="font-mono">{host.name}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Einem Host oder einer Hostgruppe zuweisen"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={schliessen}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="text-muted hover:text-text hover:bg-raised rounded-feld -mt-2 -mr-2 flex size-11 shrink-0 items-center justify-center"
|
||||||
|
>
|
||||||
|
<X className="size-5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[13.5px]">
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${schluessel.id}`}
|
||||||
|
className="text-accent inline-flex min-h-11 items-center hover:underline"
|
||||||
|
>
|
||||||
|
Zum Schlüssel
|
||||||
|
</Link>
|
||||||
|
{host && (
|
||||||
|
<Link
|
||||||
|
href={`/hosts/${host.id}`}
|
||||||
|
className="text-accent inline-flex min-h-11 items-center hover:underline"
|
||||||
|
>
|
||||||
|
Zum Host
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{host && (
|
||||||
|
<section className="mt-3 flex flex-col gap-3" aria-label="Verwendungen in dieser Zelle">
|
||||||
|
{eintraege.length === 0 ? (
|
||||||
|
<p className="text-muted border-border-strong rounded-box border border-dashed px-4 py-4 text-center text-[14px]">
|
||||||
|
Auf diesem Host ist der Schlüssel nicht zugewiesen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
eintraege.map((e) => (
|
||||||
|
<InnereBox
|
||||||
|
key={`${e.art}-${e.verwendungId}`}
|
||||||
|
className="flex items-start justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<p className="text-[14px] font-medium">
|
||||||
|
{KENNUNG_TEXT[e.kennung]}
|
||||||
|
{e.gruppe && <> „{e.gruppe.name}“</>}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted text-[13px]">
|
||||||
|
Zielbenutzer <span className="text-text font-mono">{e.zielbenutzer}</span>
|
||||||
|
</p>
|
||||||
|
{e.art === "public" ? (
|
||||||
|
<p className="text-muted text-[13px] break-all">
|
||||||
|
Optionen <span className="text-text font-mono text-[12.5px]">{e.optionen ?? "–"}</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted text-[13px] break-all">
|
||||||
|
Pfad <span className="text-text font-mono text-[12.5px]">{e.pfad}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<VerwendungsZustand zustand={e.zustand} />
|
||||||
|
{e.gruppe && darfZuweisen && (
|
||||||
|
<p className="text-muted text-[12.5px]">
|
||||||
|
Entfernen gilt für alle Hosts der Gruppe „{e.gruppe.name}“.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<EintragAktionen
|
||||||
|
art={e.art}
|
||||||
|
verwendungId={e.verwendungId}
|
||||||
|
schluesselId={schluessel.id}
|
||||||
|
darfEntfernen={
|
||||||
|
e.zustand !== "ENTFERNEN" && (e.art === "public" ? darfZuweisen : darfPrivatEntfernen)
|
||||||
|
}
|
||||||
|
testbar={false}
|
||||||
|
/>
|
||||||
|
</InnereBox>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darfZuweisen && auswahl && (
|
||||||
|
<section className="border-border mt-5 flex flex-col gap-3 border-t pt-5">
|
||||||
|
<h3 className="text-[15px] font-semibold">Public Key zuweisen</h3>
|
||||||
|
<PublicZuweisenFormular
|
||||||
|
key={formularNr}
|
||||||
|
schluesselId={schluessel.id}
|
||||||
|
auswahl={auswahl}
|
||||||
|
{...(vorauswahl ? { vorauswahl } : {})}
|
||||||
|
beiErfolg={() => {
|
||||||
|
setFormularNr((n) => n + 1);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darfZuweisen && (
|
||||||
|
<div className="border-border mt-5 flex flex-col gap-2 border-t pt-4">
|
||||||
|
<p className="text-muted text-[13px]">
|
||||||
|
Zuweisen und Entfernen ändern auf dem Host noch nichts – erst der Dry-Run auf der Schlüsselseite
|
||||||
|
zeigt und schreibt die Änderung.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
href={`/schluessel/${schluessel.id}`}
|
||||||
|
klein
|
||||||
|
className="self-start"
|
||||||
|
icon={<ArrowRight className="size-4" />}
|
||||||
|
>
|
||||||
|
Zum Dry-Run
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Record<string, Zelle>>;
|
||||||
|
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<ZellenAuswahl>();
|
||||||
|
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. */}
|
||||||
|
<div className="border-border bg-surface relative hidden max-h-[calc(100dvh-11rem)] overflow-auto rounded-[12px] border md:block">
|
||||||
|
<table className="min-w-full border-separate border-spacing-0 text-left text-[13px]">
|
||||||
|
<caption className="sr-only">
|
||||||
|
Zuweisungen: Schlüssel in den Zeilen, Hosts in den Spalten. Eine Zelle öffnet die Details.
|
||||||
|
</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="bg-surface border-border-strong text-muted sticky top-0 left-0 z-30 min-w-56 border-r border-b px-3 py-2.5 text-[13px] font-medium"
|
||||||
|
>
|
||||||
|
Schlüssel
|
||||||
|
</th>
|
||||||
|
{spalten.map((h) => (
|
||||||
|
<th
|
||||||
|
key={h.id}
|
||||||
|
scope="col"
|
||||||
|
className="bg-surface border-border-strong sticky top-0 z-20 border-b px-1 py-2.5 align-bottom font-medium"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/hosts/${h.id}`}
|
||||||
|
title={h.name}
|
||||||
|
className="text-muted hover:text-accent block max-w-28 truncate px-1.5 font-mono text-[12.5px] transition-colors"
|
||||||
|
>
|
||||||
|
{h.name}
|
||||||
|
</Link>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{zeilen.map((s) => (
|
||||||
|
<tr key={s.id} className="group">
|
||||||
|
<th
|
||||||
|
scope="row"
|
||||||
|
className="bg-surface border-border group-hover:bg-raised sticky left-0 z-10 max-w-72 border-r border-b px-3 py-1.5 font-normal transition-colors"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${s.id}`}
|
||||||
|
title={s.name}
|
||||||
|
className="text-text hover:text-accent block truncate text-[13.5px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
{s.person && <span className="text-muted block truncate text-[12px]">{s.person.name}</span>}
|
||||||
|
</th>
|
||||||
|
{spalten.map((h) => {
|
||||||
|
const z = zellen[zellenSchluessel(s.id, h.id)];
|
||||||
|
return (
|
||||||
|
<td key={h.id} className="border-border border-b p-0.5 align-middle">
|
||||||
|
{z ? (
|
||||||
|
<BelegteZelle
|
||||||
|
zelle={z}
|
||||||
|
schluessel={s}
|
||||||
|
host={h}
|
||||||
|
oeffnen={() => setOffen({ schluesselId: s.id, hostId: h.id })}
|
||||||
|
/>
|
||||||
|
) : zuweisbar(s) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOffen({ schluesselId: s.id, hostId: h.id })}
|
||||||
|
title={`${s.name} auf ${h.name} zuweisen`}
|
||||||
|
className="text-border-strong hover:text-accent hover:bg-raised rounded-feld flex min-h-11 w-full min-w-20 items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="sr-only">
|
||||||
|
{s.name} auf {h.name}: nicht zugewiesen – Details und Zuweisen
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-border-strong flex min-h-11 min-w-20 items-center justify-center">
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="sr-only">nicht zugewiesen</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Telefon: je Schluessel nur die Hosts, auf denen er verwaltet ist. */}
|
||||||
|
<ul className="flex flex-col gap-3 md:hidden">
|
||||||
|
{zeilen.map((s) => {
|
||||||
|
const belegt = spalten.flatMap((h) => {
|
||||||
|
const z = zellen[zellenSchluessel(s.id, h.id)];
|
||||||
|
return z ? [{ host: h, zelle: z }] : [];
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<li key={s.id} className="border-border bg-surface rounded-karte min-w-0 border p-4">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${s.id}`}
|
||||||
|
className="text-text hover:text-accent block truncate text-[14.5px] font-medium"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted block text-[12.5px]">
|
||||||
|
{belegt.length === 1 ? "1 Host" : `${belegt.length} Hosts`}
|
||||||
|
{s.person && <> · {s.person.name}</>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{zuweisbar(s) && (
|
||||||
|
<Button
|
||||||
|
klein
|
||||||
|
icon={<Plus className="size-4" />}
|
||||||
|
onClick={() => setOffen({ schluesselId: s.id, hostId: null })}
|
||||||
|
>
|
||||||
|
Zuweisen
|
||||||
|
<span className="sr-only"> – {s.name}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{belegt.length > 0 && (
|
||||||
|
<ul className="mt-3 flex flex-col">
|
||||||
|
{belegt.map(({ host: h, zelle: z }) => {
|
||||||
|
const anzeige = ZUSTAND_ANZEIGE[z.zustand];
|
||||||
|
return (
|
||||||
|
<li key={h.id} className="border-border border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOffen({ schluesselId: s.id, hostId: h.id })}
|
||||||
|
className="hover:bg-raised/60 rounded-box -mx-2 flex min-h-11 w-[calc(100%+1rem)] min-w-0 items-center justify-between gap-3 px-2 py-2 text-left"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="text-text block truncate font-mono text-[13px]">{h.name}</span>
|
||||||
|
<span className="text-muted block truncate text-[12.5px]">
|
||||||
|
{z.kennungen.join(" ")} ·{" "}
|
||||||
|
<span className="font-mono">{zielbenutzerKurz(z)}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"inline-flex shrink-0 items-center gap-1.5 text-[13px] whitespace-nowrap",
|
||||||
|
anzeige.klasse,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{anzeige.punkt}</span>
|
||||||
|
{ZUSTAND_TEXT[z.zustand]}
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">: {zellenText(z)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{offen && schluessel && (
|
||||||
|
<ZellenDialog
|
||||||
|
schluessel={schluessel}
|
||||||
|
host={host}
|
||||||
|
zelle={zelle}
|
||||||
|
darfZuweisen={zuweisbar(schluessel)}
|
||||||
|
darfPrivatEntfernen={darfPrivatEntfernen}
|
||||||
|
{...(auswahl ? { auswahl } : {})}
|
||||||
|
schliessen={() => 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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={oeffnen}
|
||||||
|
title={text}
|
||||||
|
className="hover:bg-raised rounded-feld flex min-h-11 w-full min-w-20 flex-col items-start justify-center gap-0.5 px-2 py-1 text-left transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden className="flex items-center gap-1.5 whitespace-nowrap">
|
||||||
|
<span className={anzeige.klasse}>{anzeige.punkt}</span>
|
||||||
|
<span className="text-text font-medium">{zelle.kennungen.join(" ")}</span>
|
||||||
|
<span className={clsx("text-[11.5px]", anzeige.klasse)}>{ZUSTAND_KURZ[zelle.zustand]}</span>
|
||||||
|
</span>
|
||||||
|
<span aria-hidden className="text-muted block max-w-24 truncate font-mono text-[11.5px]">
|
||||||
|
{zielbenutzerKurz(zelle)}
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">
|
||||||
|
{schluessel.name} auf {host.name}: {text}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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<string, Zelle>;
|
||||||
|
readonly zaehler: {
|
||||||
|
readonly schluessel: number;
|
||||||
|
readonly hosts: number;
|
||||||
|
readonly zuweisungen: number;
|
||||||
|
readonly nichtSynchron: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ZUSTAND_TEXT: Record<MatrixZustand, string> = {
|
||||||
|
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<MatrixZustand, string> = {
|
||||||
|
GEPLANT: "geplant",
|
||||||
|
AUSGEROLLT: "sync",
|
||||||
|
ABWEICHUNG: "Drift",
|
||||||
|
ENTFERNEN: "entf.",
|
||||||
|
ENTFERNT: "entfernt",
|
||||||
|
FEHLGESCHLAGEN: "Fehler",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KENNUNG_TEXT: Record<Kennung, string> = {
|
||||||
|
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<MatrixZustand, number> = {
|
||||||
|
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<string, string[]>();
|
||||||
|
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<string, ZellenEintrag[]>();
|
||||||
|
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<string, Zelle>();
|
||||||
|
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<MatrixZustand>(
|
||||||
|
(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<string, string | string[] | undefined>): 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);
|
||||||
|
}
|
||||||
@@ -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<MatrixDaten> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)"=/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user