diff --git a/src/lib/hosts.ts b/src/lib/hosts.ts new file mode 100644 index 0000000..066550c --- /dev/null +++ b/src/lib/hosts.ts @@ -0,0 +1,155 @@ +import { z } from "@/lib/validierung"; + +/** + * Eingaben und abgeleitete Zustaende fuer Hosts und Hostgruppen (#008). + * + * Framework-frei, damit Service, Action und Tests dieselben Regeln teilen: die Action + * prueft vorab fuer Feldmeldungen im Formular, der Service prueft noch einmal, weil er + * auch von Seed, Skripten und spaeteren Assistenten gerufen wird. + */ + +/** Anzeigename eines Hosts: landet in Dateinamen, Kommentaren und ssh-config-Aliassen. */ +export const HOST_NAME = /^[A-Za-z0-9._-]{1,64}$/; +/** Wie der DB-CHECK `host_adresse_gueltig` – Hostname, IPv4 oder IPv6 ohne Klammern. */ +export const HOST_ADRESSE = /^[A-Za-z0-9._:-]{1,253}$/; +export const TAG = /^[a-z0-9._-]{1,32}$/; +export const MAX_TAGS = 20; +/** Gruppen- und Kundennamen sind reine Anzeige; Leerzeichen und Umlaute sind erlaubt. */ +export const ANZEIGE_NAME = /^[\p{L}\p{N}][\p{L}\p{N} ._&()/-]{0,63}$/u; + +/** Freitext aus einem Formular: leer heisst „nicht gesetzt“. */ +const optionalerText = (max: number) => + z + .string() + .trim() + .max(max, `Höchstens ${max} Zeichen.`) + .nullish() + .transform((wert) => (wert ? wert : null)); + +/** Mehrfachauswahl kommt aus FormData als String oder Liste – beides wird zur Liste. */ +const idListe = z.preprocess( + (wert) => (wert === undefined || wert === "" ? [] : Array.isArray(wert) ? wert : [wert]), + z.array(z.string().min(1).max(64)).max(500), +); + +export const hostEingabeSchema = z.object({ + name: z + .string() + .trim() + .regex(HOST_NAME, "1–64 Zeichen: Buchstaben, Ziffern, Punkt, Unterstrich, Bindestrich."), + adresse: z + .string() + .trim() + .regex(HOST_ADRESSE, "Hostname oder IP-Adresse (ohne Leerzeichen, Klammern oder Benutzer@)."), + port: z.coerce + .number({ error: "Bitte eine Zahl angeben." }) + .int("Bitte eine ganze Zahl angeben.") + .min(1, "Port 1–65535.") + .max(65535, "Port 1–65535.") + .default(22), + // Tags kommen im Formular als ein Feld („pve, produktion“); kleingeschrieben, damit + // „PVE“ und „pve“ nicht zwei Filter ergeben. + tags: z + .preprocess( + (wert) => (typeof wert === "string" ? wert.split(/[\s,]+/).filter(Boolean) : (wert ?? [])), + z.array( + z + .string() + .trim() + .toLowerCase() + .regex(TAG, "Tags: 1–32 Zeichen aus a–z, 0–9, Punkt, Unterstrich, Bindestrich."), + ), + ) + .transform((tags) => [...new Set(tags)]) + .refine((tags) => tags.length <= MAX_TAGS, `Höchstens ${MAX_TAGS} Tags.`), + beschreibung: optionalerText(500), + kundeName: optionalerText(64).refine( + (wert) => wert === null || ANZEIGE_NAME.test(wert), + "Kundenname: 1–64 Zeichen, beginnt mit Buchstabe oder Ziffer.", + ), + anmeldeart: z.enum(["DIENSTBENUTZER", "ROOT"]).default("DIENSTBENUTZER"), + gruppenIds: idListe.transform((ids) => [...new Set(ids)]), +}); + +/** + * Was Aufrufer uebergeben – von Hand statt `z.input`, weil die Vorverarbeitung (Tags als + * Text, Mehrfachauswahl) sonst nur `unknown` als Eingabetyp hinterliesse. + */ +export type HostEingabe = { + name: string; + adresse: string; + port?: number | string; + /** Liste oder Text „pve, produktion“. */ + tags?: string | string[]; + beschreibung?: string | null; + kundeName?: string | null; + anmeldeart?: "DIENSTBENUTZER" | "ROOT"; + gruppenIds?: string[]; +}; +export type HostDaten = z.output; + +export const hostgruppeEingabeSchema = z.object({ + name: z.string().trim().regex(ANZEIGE_NAME, "1–64 Zeichen, beginnt mit Buchstabe oder Ziffer."), + beschreibung: optionalerText(500), + hostIds: idListe.transform((ids) => [...new Set(ids)]), +}); + +export type HostgruppeEingabe = { + name: string; + beschreibung?: string | null; + hostIds?: string[]; +}; +export type HostgruppeDaten = z.output; + +/** Zod-Fehler als `{ feld: erste Meldung }` fuer die Anzeige unter dem Feld. */ +export function feldfehlerAus(fehler: z.ZodError): Record { + const ergebnis: Record = {}; + for (const problem of fehler.issues) { + const feld = String(problem.path[0] ?? "formular"); + ergebnis[feld] ??= problem.message; + } + return ergebnis; +} + +// ---------------------------------------------------------------- Abgeleitete Zustaende + +export type HostStatusArt = "synchron" | "drift" | "unerreichbar" | "unbekannt"; + +/** + * Ein Wort fuer die Liste. Unerreichbar schlaegt alles: ein Abgleichsergebnis von gestern + * sagt nichts, wenn der Host heute nicht antwortet. Offene Abweichungen zaehlen als Drift, + * auch wenn der letzte Abgleich den Zustand noch nicht nachgezogen hat. + */ +export function hostStatus(host: { + erreichbar: boolean | null; + driftZustand: "UNBEKANNT" | "SYNCHRON" | "DRIFT"; + offeneAbweichungen: number; +}): HostStatusArt { + if (host.erreichbar === false) return "unerreichbar"; + if (host.driftZustand === "DRIFT" || host.offeneAbweichungen > 0) return "drift"; + if (host.driftZustand === "SYNCHRON") return "synchron"; + return "unbekannt"; +} + +export type HostKeyZustand = "bestaetigt" | "ausstehend" | "keiner"; + +/** Zusammenfassung der Host-Keys eines Hosts – auch Props fuer HostKeyAktionen (#009). */ +export type HostKeyZusammenfassung = { + readonly zustand: HostKeyZustand; + readonly vertraut: number; + readonly ausstehend: number; + readonly abgelehnt: number; +}; + +/** + * Ein ausstehender Key verlangt eine Entscheidung und geht deshalb vor einem vertrauten – + * sonst verschwaende ein geaenderter Host-Key hinter dem gruenen „bestaetigt“. + */ +export function hostKeyZusammenfassen(stati: readonly string[]): HostKeyZusammenfassung { + const zaehlen = (s: string) => stati.filter((x) => x === s).length; + const vertraut = zaehlen("VERTRAUT"); + const ausstehend = zaehlen("AUSSTEHEND"); + const abgelehnt = zaehlen("ABGELEHNT"); + const zustand: HostKeyZustand = ausstehend > 0 ? "ausstehend" : vertraut > 0 ? "bestaetigt" : "keiner"; + return { zustand, vertraut, ausstehend, abgelehnt }; +} diff --git a/src/server/actions/formular.ts b/src/server/actions/formular.ts new file mode 100644 index 0000000..f91d8fd --- /dev/null +++ b/src/server/actions/formular.ts @@ -0,0 +1,37 @@ +import type { Benutzer } from "@/generated/prisma/client"; +import { BestaetigungFehler, RechteFehler } from "@/server/auth/sitzungspflicht"; +import { HostFehler, type Akteur } from "@/server/services/hosts"; + +/** + * Gemeinsames fuer Formular-Aktionen (#008). Kein "use server": diese Datei exportiert + * Hilfen, keine Aktionen – sonst waeren sie vom Browser aus aufrufbar. + */ +export type FormularZustand = { + readonly fehler?: string; + readonly feldfehler?: Record; +}; + +/** Fachliche Fehler werden zur Meldung im Formular; alles andere bleibt ein echter Fehler. */ +export function fehlerZustand(fehler: unknown): FormularZustand { + if (fehler instanceof HostFehler) { + return { fehler: fehler.message, feldfehler: fehler.feldfehler }; + } + if (fehler instanceof RechteFehler || fehler instanceof BestaetigungFehler) { + return { fehler: fehler.message }; + } + throw fehler; +} + +export function akteurVon(benutzer: Pick): Akteur { + return { id: benutzer.id, name: benutzer.name }; +} + +/** Einzelnes Textfeld; fehlend bleibt `undefined`, damit Standardwerte des Schemas greifen. */ +export function textfeld(formular: FormData, name: string): string | undefined { + const wert = formular.get(name); + return typeof wert === "string" && wert !== "" ? wert : undefined; +} + +export function listenfeld(formular: FormData, name: string): string[] { + return formular.getAll(name).filter((w): w is string => typeof w === "string" && w !== ""); +} diff --git a/src/server/actions/hostgruppen.ts b/src/server/actions/hostgruppen.ts new file mode 100644 index 0000000..1fbfb39 --- /dev/null +++ b/src/server/actions/hostgruppen.ts @@ -0,0 +1,68 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { feldfehlerAus, hostgruppeEingabeSchema, type HostgruppeEingabe } from "@/lib/hosts"; +import { + akteurVon, + fehlerZustand, + listenfeld, + textfeld, + type FormularZustand, +} from "@/server/actions/formular"; +import { rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { hostgruppeAnlegen, hostgruppeBearbeiten } from "@/server/services/hostgruppen"; + +/** Formular-Aktionen fuer Hostgruppen (#008). Duenn wie die fuer Hosts. */ + +function gruppenFelder(formular: FormData): HostgruppeEingabe { + return { + name: textfeld(formular, "name") ?? "", + beschreibung: textfeld(formular, "beschreibung"), + hostIds: listenfeld(formular, "hostIds"), + }; +} + +function vorpruefen(eingabe: HostgruppeEingabe): FormularZustand | undefined { + const ergebnis = hostgruppeEingabeSchema.safeParse(eingabe); + if (ergebnis.success) return undefined; + return { fehler: "Bitte die markierten Felder prüfen.", feldfehler: feldfehlerAus(ergebnis.error) }; +} + +export async function hostgruppeAnlegenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + let id: string; + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + const eingabe = gruppenFelder(formular); + const ungueltig = vorpruefen(eingabe); + if (ungueltig) return ungueltig; + ({ id } = await hostgruppeAnlegen(eingabe, akteurVon(benutzer))); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/hostgruppen"); + revalidatePath("/hosts", "layout"); + redirect(`/hostgruppen/${id}`); +} + +export async function hostgruppeBearbeitenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const id = textfeld(formular, "id") ?? ""; + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + const eingabe = gruppenFelder(formular); + const ungueltig = vorpruefen(eingabe); + if (ungueltig) return ungueltig; + await hostgruppeBearbeiten(id, eingabe, akteurVon(benutzer)); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/hostgruppen", "layout"); + revalidatePath("/hosts", "layout"); + redirect(`/hostgruppen/${id}`); +} diff --git a/src/server/actions/hosts.ts b/src/server/actions/hosts.ts new file mode 100644 index 0000000..fae93d8 --- /dev/null +++ b/src/server/actions/hosts.ts @@ -0,0 +1,93 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { feldfehlerAus, hostEingabeSchema, type HostEingabe } from "@/lib/hosts"; +import { + akteurVon, + fehlerZustand, + listenfeld, + textfeld, + type FormularZustand, +} from "@/server/actions/formular"; +import { rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { hostAnlegen, hostArchivieren, hostBearbeiten } from "@/server/services/hosts"; + +/** + * Formular-Aktionen fuer Hosts (#008). Duenn: Recht pruefen, Eingabe pruefen, Service rufen, + * Seiten neu laden lassen, weiterleiten. `redirect` steht ausserhalb von try/catch, weil es + * per Ausnahme arbeitet. + */ + +function hostFelder(formular: FormData): HostEingabe { + return { + name: textfeld(formular, "name") ?? "", + adresse: textfeld(formular, "adresse") ?? "", + port: textfeld(formular, "port"), + tags: textfeld(formular, "tags") ?? "", + beschreibung: textfeld(formular, "beschreibung"), + kundeName: textfeld(formular, "kundeName"), + anmeldeart: textfeld(formular, "anmeldeart") as HostEingabe["anmeldeart"], + gruppenIds: listenfeld(formular, "gruppenIds"), + }; +} + +function vorpruefen(eingabe: HostEingabe): FormularZustand | undefined { + const ergebnis = hostEingabeSchema.safeParse(eingabe); + if (ergebnis.success) return undefined; + return { fehler: "Bitte die markierten Felder prüfen.", feldfehler: feldfehlerAus(ergebnis.error) }; +} + +export async function hostAnlegenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + let id: string; + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + const eingabe = hostFelder(formular); + const ungueltig = vorpruefen(eingabe); + if (ungueltig) return ungueltig; + ({ id } = await hostAnlegen(eingabe, akteurVon(benutzer))); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/hosts"); + revalidatePath("/hostgruppen", "layout"); + redirect(`/hosts/${id}`); +} + +export async function hostBearbeitenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const id = textfeld(formular, "id") ?? ""; + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + const eingabe = hostFelder(formular); + const ungueltig = vorpruefen(eingabe); + if (ungueltig) return ungueltig; + await hostBearbeiten(id, eingabe, akteurVon(benutzer)); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/hosts", "layout"); + revalidatePath("/hostgruppen", "layout"); + redirect(`/hosts/${id}`); +} + +export async function hostArchivierenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const id = textfeld(formular, "id") ?? ""; + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + await hostArchivieren(id, akteurVon(benutzer)); + } catch (fehler) { + return fehlerZustand(fehler); + } + revalidatePath("/hosts", "layout"); + revalidatePath("/hostgruppen", "layout"); + redirect("/hosts"); +} diff --git a/src/server/queries/hosts.ts b/src/server/queries/hosts.ts new file mode 100644 index 0000000..80263ad --- /dev/null +++ b/src/server/queries/hosts.ts @@ -0,0 +1,252 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { hostKeyZusammenfassen, hostStatus } from "@/lib/hosts"; + +/** + * Lesemodelle fuer Hosts und Hostgruppen (#008). + * + * Alles hier darf jede angemeldete Rolle sehen (Recht `lesen`); die Seiten pruefen das per + * `sitzungErforderlich()`. Archivierte Hosts erscheinen in keiner Liste, nur ueber ihre + * Detailadresse – fuer Verweise aus dem Protokoll. + */ + +const OFFEN = { behobenAm: null } satisfies Prisma.AbweichungWhereInput; + +export type HostFilter = { readonly q?: string; readonly gruppeId?: string }; + +export async function hostListe(filter: HostFilter = {}) { + const q = filter.q?.trim().slice(0, 100); + const where: Prisma.HostWhereInput = { + archiviertAm: null, + ...(filter.gruppeId ? { gruppen: { some: { hostgruppeId: filter.gruppeId } } } : {}), + ...(q + ? { + OR: [ + { name: { contains: q, mode: "insensitive" } }, + { adresse: { contains: q, mode: "insensitive" } }, + { tags: { has: q.toLowerCase() } }, + ], + } + : {}), + }; + const hosts = await prisma.host.findMany({ + where, + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + adresse: true, + port: true, + tags: true, + erreichbar: true, + driftZustand: true, + letzterAbgleichAm: true, + kunde: { select: { name: true } }, + gruppen: { select: { hostgruppe: { select: { id: true, name: true } } } }, + hostSchluessel: { + where: { status: { in: ["VERTRAUT", "AUSSTEHEND"] } }, + select: { status: true }, + }, + _count: { select: { abweichungen: { where: OFFEN } } }, + }, + }); + return hosts.map((h) => ({ + id: h.id, + name: h.name, + adresse: h.adresse, + port: h.port, + tags: h.tags, + kunde: h.kunde?.name ?? null, + gruppen: h.gruppen.map((g) => g.hostgruppe).sort((a, b) => a.name.localeCompare(b.name, "de")), + letzterAbgleichAm: h.letzterAbgleichAm, + offeneAbweichungen: h._count.abweichungen, + status: hostStatus({ + erreichbar: h.erreichbar, + driftZustand: h.driftZustand, + offeneAbweichungen: h._count.abweichungen, + }), + hostKey: hostKeyZusammenfassen(h.hostSchluessel.map((s) => s.status)), + })); +} + +export type HostListenEintrag = Awaited>[number]; + +export async function hostDetail(id: string) { + const host = await prisma.host.findUnique({ + where: { id }, + select: { + id: true, + name: true, + adresse: true, + port: true, + beschreibung: true, + tags: true, + anmeldeart: true, + bootstrapAm: true, + erreichbar: true, + zuletztGeprueftAm: true, + letzterAbgleichAm: true, + driftZustand: true, + sshdBefund: true, + archiviertAm: true, + angelegtAm: true, + kunde: { select: { id: true, name: true } }, + gruppen: { select: { hostgruppe: { select: { id: true, name: true } } } }, + hostSchluessel: { + orderBy: [{ status: "asc" }, { algorithmus: "asc" }], + select: { + id: true, + algorithmus: true, + fingerprint: true, + status: true, + erstmalsGesehen: true, + zuletztGesehen: true, + entschiedenAm: true, + }, + }, + zielkonten: { + where: { entferntAm: null }, + orderBy: { benutzername: "asc" }, + select: { id: true, benutzername: true, sudoModus: true, vonSshkvAngelegt: true }, + }, + _count: { + select: { + abweichungen: { where: OFFEN }, + publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } }, + privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } }, + }, + }, + }, + }); + if (!host) return null; + const { _count, gruppen, ...rest } = host; + return { + ...rest, + gruppen: gruppen.map((g) => g.hostgruppe).sort((a, b) => a.name.localeCompare(b.name, "de")), + offeneAbweichungen: _count.abweichungen, + publicVerwendungen: _count.publicVerwendungen, + privateVerwendungen: _count.privateVerwendungen, + status: hostStatus({ + erreichbar: host.erreichbar, + driftZustand: host.driftZustand, + offeneAbweichungen: _count.abweichungen, + }), + hostKey: hostKeyZusammenfassen(host.hostSchluessel.map((s) => s.status)), + }; +} + +export type HostDetail = NonNullable>>; + +/** Fuer das Formular „Bearbeiten“: genau die Felder, die das Formular kennt. */ +export async function hostFormularWerte(id: string) { + const host = await prisma.host.findUnique({ + where: { id }, + select: { + id: true, + name: true, + adresse: true, + port: true, + tags: true, + beschreibung: true, + anmeldeart: true, + archiviertAm: true, + kunde: { select: { name: true } }, + gruppen: { select: { hostgruppeId: true } }, + }, + }); + if (!host) return null; + return { + id: host.id, + name: host.name, + adresse: host.adresse, + port: host.port, + tags: host.tags, + beschreibung: host.beschreibung ?? "", + anmeldeart: host.anmeldeart, + kundeName: host.kunde?.name ?? "", + gruppenIds: host.gruppen.map((g) => g.hostgruppeId), + archiviert: host.archiviertAm !== null, + }; +} + +export type HostFormularWerte = NonNullable>>; + +/** Auswahllisten fuer Formulare. */ +export async function gruppenAuswahl() { + return prisma.hostgruppe.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }); +} + +export async function hostAuswahl() { + return prisma.host.findMany({ + where: { archiviertAm: null }, + orderBy: { name: "asc" }, + select: { id: true, name: true, adresse: true }, + }); +} + +export async function kundenNamen() { + const kunden = await prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { name: true } }); + return kunden.map((k) => k.name); +} + +// ---------------------------------------------------------------- Hostgruppen + +export async function gruppenListe() { + const gruppen = await prisma.hostgruppe.findMany({ + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + beschreibung: true, + _count: { select: { mitglieder: { where: { host: { archiviertAm: null } } } } }, + }, + }); + return gruppen.map(({ _count, ...g }) => ({ ...g, mitglieder: _count.mitglieder })); +} + +export async function gruppeDetail(id: string) { + const gruppe = await prisma.hostgruppe.findUnique({ + where: { id }, + select: { + id: true, + name: true, + beschreibung: true, + angelegtAm: true, + mitglieder: { + where: { host: { archiviertAm: null } }, + orderBy: { host: { name: "asc" } }, + select: { + host: { + select: { + id: true, + name: true, + adresse: true, + port: true, + erreichbar: true, + driftZustand: true, + _count: { select: { abweichungen: { where: OFFEN } } }, + }, + }, + }, + }, + }, + }); + if (!gruppe) return null; + return { + id: gruppe.id, + name: gruppe.name, + beschreibung: gruppe.beschreibung, + angelegtAm: gruppe.angelegtAm, + mitglieder: gruppe.mitglieder.map(({ host: { _count, ...h } }) => ({ + id: h.id, + name: h.name, + adresse: h.adresse, + port: h.port, + status: hostStatus({ + erreichbar: h.erreichbar, + driftZustand: h.driftZustand, + offeneAbweichungen: _count.abweichungen, + }), + })), + }; +} diff --git a/src/server/services/hostgruppen.ts b/src/server/services/hostgruppen.ts new file mode 100644 index 0000000..d92ec45 --- /dev/null +++ b/src/server/services/hostgruppen.ts @@ -0,0 +1,147 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + feldfehlerAus, + hostgruppeEingabeSchema, + type HostgruppeDaten, + type HostgruppeEingabe, +} from "@/lib/hosts"; +import { HostFehler, istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts"; +import { protokollieren } from "@/server/services/protokoll"; + +/** + * Hostgruppen anlegen und bearbeiten, Mitglieder setzen (#008). + * + * Loeschen gibt es bewusst noch nicht: an einer Gruppe haengen spaeter Public-Verwendungen + * (Cascade) – ein Loeschen wuerde Zuweisungen still mitnehmen. Das kommt mit #016, wenn + * klar ist, was mit den Verwendungen passieren soll. + */ + +function pruefen(eingabe: HostgruppeEingabe): HostgruppeDaten { + const ergebnis = hostgruppeEingabeSchema.safeParse(eingabe); + if (!ergebnis.success) { + throw new HostFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error)); + } + return ergebnis.data; +} + +function nameVergeben(name: string): HostFehler { + return new HostFehler(`Eine Hostgruppe namens „${name}“ existiert bereits.`, { + name: "Dieser Name ist schon vergeben.", + }); +} + +/** Nur aktive Hosts duerfen Mitglied werden; liefert die Namen fuer das Protokoll. */ +async function hostNamen(tx: Prisma.TransactionClient, ids: readonly string[]): Promise> { + if (ids.length === 0) return new Map(); + const hosts = await tx.host.findMany({ + where: { id: { in: [...ids] }, archiviertAm: null }, + select: { id: true, name: true }, + }); + if (hosts.length !== ids.length) { + throw new HostFehler("Mindestens ein gewählter Host ist archiviert oder existiert nicht.", { + hostIds: "Bitte die Auswahl prüfen.", + }); + } + return new Map(hosts.map((h) => [h.id, h.name])); +} + +export async function hostgruppeAnlegen(eingabe: HostgruppeEingabe, akteur: Akteur) { + const daten = pruefen(eingabe); + if (await prisma.hostgruppe.findUnique({ where: { name: daten.name }, select: { id: true } })) { + throw nameVergeben(daten.name); + } + try { + return await prisma.$transaction(async (tx) => { + const namen = await hostNamen(tx, daten.hostIds); + const gruppe = await tx.hostgruppe.create({ + data: { + name: daten.name, + beschreibung: daten.beschreibung, + mitglieder: { create: daten.hostIds.map((hostId) => ({ hostId })) }, + }, + }); + await protokollieren( + { + akteur, + aktion: "hostgruppe.angelegt", + ergebnis: "ERFOLG", + details: { + name: gruppe.name, + beschreibung: gruppe.beschreibung, + mitglieder: [...namen.values()].sort(), + }, + }, + tx, + ); + return gruppe; + }); + } catch (fehler) { + if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name); + throw fehler; + } +} + +/** + * Name, Beschreibung und Mitglieder in einem Zug. Mitgliedschaften archivierter Hosts gibt + * es nicht (siehe hostArchivieren), deshalb darf die Auswahl die Liste komplett ersetzen. + */ +export async function hostgruppeBearbeiten(id: string, eingabe: HostgruppeEingabe, akteur: Akteur) { + const daten = pruefen(eingabe); + const alt = await prisma.hostgruppe.findUnique({ + where: { id }, + include: { mitglieder: { include: { host: { select: { id: true, name: true } } } } }, + }); + if (!alt) throw new HostFehler("Diese Hostgruppe gibt es nicht."); + + try { + return await prisma.$transaction(async (tx) => { + const neu = await hostNamen(tx, daten.hostIds); + const vorher = new Map(alt.mitglieder.map((m) => [m.host.id, m.host.name])); + const hinzu = [...neu].filter(([hid]) => !vorher.has(hid)); + const entfernt = [...vorher].filter(([hid]) => !neu.has(hid)); + + const geaendert: Record = {}; + if (alt.name !== daten.name) geaendert["name"] = { vorher: alt.name, nachher: daten.name }; + if (alt.beschreibung !== daten.beschreibung) { + geaendert["beschreibung"] = { vorher: alt.beschreibung, nachher: daten.beschreibung }; + } + if (hinzu.length > 0 || entfernt.length > 0) { + geaendert["mitglieder"] = { + hinzu: hinzu.map(([, n]) => n).sort(), + entfernt: entfernt.map(([, n]) => n).sort(), + }; + } + + const gruppe = await tx.hostgruppe.update({ + where: { id }, + data: { name: daten.name, beschreibung: daten.beschreibung }, + }); + if (entfernt.length > 0) { + await tx.hostgruppeMitglied.deleteMany({ + where: { hostgruppeId: id, hostId: { in: entfernt.map(([hid]) => hid) } }, + }); + } + if (hinzu.length > 0) { + await tx.hostgruppeMitglied.createMany({ + data: hinzu.map(([hostId]) => ({ hostId, hostgruppeId: id })), + }); + } + if (Object.keys(geaendert).length > 0) { + await protokollieren( + { + akteur, + aktion: "hostgruppe.geaendert", + ergebnis: "ERFOLG", + details: { gruppe: gruppe.name, ...geaendert }, + }, + tx, + ); + } + return gruppe; + }); + } catch (fehler) { + if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name); + throw fehler; + } +} diff --git a/src/server/services/hosts.ts b/src/server/services/hosts.ts new file mode 100644 index 0000000..420fba3 --- /dev/null +++ b/src/server/services/hosts.ts @@ -0,0 +1,217 @@ +import { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { feldfehlerAus, hostEingabeSchema, type HostDaten, type HostEingabe } from "@/lib/hosts"; +import { protokollieren } from "@/server/services/protokoll"; + +/** + * Hosts anlegen, bearbeiten, archivieren (#008). + * + * Archivieren statt Loeschen: am Host haengen Protokoll-Bezuege, Beobachtungen und spaeter + * Verwendungen. Ein geloeschter Host liesse Fragen wie „wer hatte bis Mai Zugang zu + * srv-pve01?“ unbeantwortet. + */ + +export type Akteur = { readonly id: string; readonly name: string }; + +/** Fachlicher Fehler mit Meldung fuer die Oberflaeche und optional je Feld. */ +export class HostFehler extends Error { + constructor( + nachricht: string, + readonly feldfehler: Record = {}, + ) { + super(nachricht); + this.name = "HostFehler"; + } +} + +export function istEindeutigkeitsFehler(fehler: unknown): boolean { + return fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002"; +} + +function pruefen(eingabe: HostEingabe): HostDaten { + const ergebnis = hostEingabeSchema.safeParse(eingabe); + if (!ergebnis.success) { + throw new HostFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error)); + } + return ergebnis.data; +} + +function nameVergeben(name: string): HostFehler { + return new HostFehler(`Ein Host namens „${name}“ existiert bereits (auch archivierte zählen).`, { + name: "Dieser Name ist schon vergeben.", + }); +} + +/** Kunde per Name finden oder anlegen; `null` heisst „kein Kunde“. */ +async function kundeZuordnen( + tx: Prisma.TransactionClient, + kundeName: string | null, + akteur: Akteur, +): Promise { + if (!kundeName) return null; + const vorhanden = await tx.kunde.findUnique({ where: { name: kundeName }, select: { id: true } }); + if (vorhanden) return vorhanden.id; + const kunde = await tx.kunde.create({ data: { name: kundeName }, select: { id: true } }); + await protokollieren( + { akteur, aktion: "kunde.angelegt", ergebnis: "ERFOLG", details: { name: kundeName } }, + tx, + ); + return kunde.id; +} + +/** Prueft, dass alle Gruppen existieren, und liefert ihre Namen fuer das Protokoll. */ +async function gruppenNamen(tx: Prisma.TransactionClient, ids: readonly string[]): Promise { + if (ids.length === 0) return []; + const gruppen = await tx.hostgruppe.findMany({ where: { id: { in: [...ids] } }, select: { name: true } }); + if (gruppen.length !== ids.length) { + throw new HostFehler("Mindestens eine gewählte Hostgruppe gibt es nicht mehr.", { + gruppenIds: "Bitte die Auswahl prüfen.", + }); + } + return gruppen.map((g) => g.name).sort(); +} + +export async function hostAnlegen(eingabe: HostEingabe, akteur: Akteur) { + const daten = pruefen(eingabe); + if (await prisma.host.findUnique({ where: { name: daten.name }, select: { id: true } })) { + throw nameVergeben(daten.name); + } + try { + return await prisma.$transaction(async (tx) => { + const gruppen = await gruppenNamen(tx, daten.gruppenIds); + const kundeId = await kundeZuordnen(tx, daten.kundeName, akteur); + const host = await tx.host.create({ + data: { + name: daten.name, + adresse: daten.adresse, + port: daten.port, + tags: daten.tags, + beschreibung: daten.beschreibung, + anmeldeart: daten.anmeldeart, + kundeId, + gruppen: { create: daten.gruppenIds.map((hostgruppeId) => ({ hostgruppeId })) }, + }, + }); + await protokollieren( + { + akteur, + aktion: "host.angelegt", + ergebnis: "ERFOLG", + hostName: host.name, + details: { + adresse: host.adresse, + port: host.port, + anmeldeart: host.anmeldeart, + tags: host.tags, + kunde: daten.kundeName, + gruppen, + }, + }, + tx, + ); + return host; + }); + } catch (fehler) { + // Zwischen Vorpruefung und Insert kann jemand denselben Namen angelegt haben. + if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name); + throw fehler; + } +} + +type Aenderung = { vorher: unknown; nachher: unknown }; + +function gleich(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +export async function hostBearbeiten(id: string, eingabe: HostEingabe, akteur: Akteur) { + const daten = pruefen(eingabe); + const alt = await prisma.host.findUnique({ + where: { id }, + include: { kunde: true, gruppen: { include: { hostgruppe: { select: { name: true } } } } }, + }); + if (!alt) throw new HostFehler("Diesen Host gibt es nicht."); + if (alt.archiviertAm) throw new HostFehler("Archivierte Hosts lassen sich nicht bearbeiten."); + + try { + return await prisma.$transaction(async (tx) => { + const neueGruppen = await gruppenNamen(tx, daten.gruppenIds); + const kundeId = await kundeZuordnen(tx, daten.kundeName, akteur); + + // Nur geaenderte Felder ins Protokoll – „wer hat den Port auf 2222 gestellt?“ soll + // ohne Vergleich zweier Vollstaende beantwortbar sein. + const vergleich: Record = { + name: { vorher: alt.name, nachher: daten.name }, + adresse: { vorher: alt.adresse, nachher: daten.adresse }, + port: { vorher: alt.port, nachher: daten.port }, + tags: { vorher: alt.tags, nachher: daten.tags }, + beschreibung: { vorher: alt.beschreibung, nachher: daten.beschreibung }, + anmeldeart: { vorher: alt.anmeldeart, nachher: daten.anmeldeart }, + kunde: { vorher: alt.kunde?.name ?? null, nachher: daten.kundeName }, + gruppen: { vorher: alt.gruppen.map((g) => g.hostgruppe.name).sort(), nachher: neueGruppen }, + }; + const geaendert = Object.fromEntries( + Object.entries(vergleich).filter(([, a]) => !gleich(a.vorher, a.nachher)), + ); + + const host = await tx.host.update({ + where: { id }, + data: { + name: daten.name, + adresse: daten.adresse, + port: daten.port, + tags: daten.tags, + beschreibung: daten.beschreibung, + anmeldeart: daten.anmeldeart, + kundeId, + }, + }); + if ("gruppen" in geaendert) { + await tx.hostgruppeMitglied.deleteMany({ where: { hostId: id } }); + await tx.hostgruppeMitglied.createMany({ + data: daten.gruppenIds.map((hostgruppeId) => ({ hostId: id, hostgruppeId })), + }); + } + if (Object.keys(geaendert).length > 0) { + await protokollieren( + { akteur, aktion: "host.geaendert", ergebnis: "ERFOLG", hostName: host.name, details: geaendert }, + tx, + ); + } + return host; + }); + } catch (fehler) { + if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name); + throw fehler; + } +} + +/** + * Archiviert einen Host. Gruppenmitgliedschaften fallen dabei weg, damit spaetere + * Zuweisungen an eine Gruppe nie einen archivierten Host treffen; welche es waren, steht + * im Protokoll. + */ +export async function hostArchivieren(id: string, akteur: Akteur) { + const host = await prisma.host.findUnique({ + where: { id }, + include: { gruppen: { include: { hostgruppe: { select: { name: true } } } } }, + }); + if (!host) throw new HostFehler("Diesen Host gibt es nicht."); + if (host.archiviertAm) throw new HostFehler("Dieser Host ist bereits archiviert."); + + return prisma.$transaction(async (tx) => { + await tx.hostgruppeMitglied.deleteMany({ where: { hostId: id } }); + const archiviert = await tx.host.update({ where: { id }, data: { archiviertAm: new Date() } }); + await protokollieren( + { + akteur, + aktion: "host.archiviert", + ergebnis: "ERFOLG", + hostName: host.name, + details: { gruppen: host.gruppen.map((g) => g.hostgruppe.name).sort() }, + }, + tx, + ); + return archiviert; + }); +} diff --git a/tests/hosts.test.ts b/tests/hosts.test.ts new file mode 100644 index 0000000..29835cd --- /dev/null +++ b/tests/hosts.test.ts @@ -0,0 +1,268 @@ +import { readFileSync } from "node:fs"; +import { beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { hostKeyZusammenfassen, hostStatus } from "@/lib/hosts"; +import { gruppeDetail, gruppenListe, hostDetail, hostListe } from "@/server/queries/hosts"; +import { hostgruppeAnlegen, hostgruppeBearbeiten } from "@/server/services/hostgruppen"; +import { HostFehler, hostAnlegen, hostArchivieren, hostBearbeiten } from "@/server/services/hosts"; +import { datenbankLeeren } from "./hilfen/db"; + +/** Hosts und Hostgruppen (#008): Services gegen die Testdatenbank, Queries, Rechte. */ + +let akteur: { id: string; name: string }; + +beforeEach(async () => { + await datenbankLeeren(); + const benutzer = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } }); + akteur = { id: benutzer.id, name: benutzer.name }; +}); + +const GRUND = { name: "srv-pve01", adresse: "10.0.0.11" }; + +async function fehlerVon(versuch: Promise): Promise { + const fehler = await versuch.then( + () => undefined, + (f: unknown) => f, + ); + expect(fehler).toBeInstanceOf(HostFehler); + return fehler as HostFehler; +} + +describe("Host anlegen", () => { + it("legt mit Standardwerten an und protokolliert", async () => { + const host = await hostAnlegen({ ...GRUND, tags: "PVE, produktion pve" }, akteur); + expect(host.port).toBe(22); + expect(host.anmeldeart).toBe("DIENSTBENUTZER"); + // kleingeschrieben und ohne Doppelte + expect(host.tags).toEqual(["pve", "produktion"]); + expect(host.beschreibung).toBeNull(); + + const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "host.angelegt" } }); + expect(eintrag.hostName).toBe("srv-pve01"); + expect(eintrag.akteurId).toBe(akteur.id); + expect(eintrag.akteurName).toBe("Olga Operator"); + expect(eintrag.ergebnis).toBe("ERFOLG"); + }); + + it("legt einen Kunden per Name an und verwendet ihn wieder", async () => { + const a = await hostAnlegen({ ...GRUND, kundeName: "Kunde X" }, akteur); + const b = await hostAnlegen({ name: "srv-app01", adresse: "10.0.0.20", kundeName: "Kunde X" }, akteur); + expect(a.kundeId).not.toBeNull(); + expect(b.kundeId).toBe(a.kundeId); + expect(await prisma.kunde.count()).toBe(1); + expect(await prisma.protokoll.count({ where: { aktion: "kunde.angelegt" } })).toBe(1); + }); + + it.each([ + ["name", { name: "srv pve01" }], + ["name", { name: "srv;rm -rf /" }], + ["name", { name: "x".repeat(65) }], + ["name", { name: "" }], + ["adresse", { adresse: "10.0.0.1; reboot" }], + ["adresse", { adresse: "root@10.0.0.1" }], + ["adresse", { adresse: "$(id)" }], + ["adresse", { adresse: "host name" }], + ["adresse", { adresse: "a".repeat(254) }], + ["port", { port: 0 }], + ["port", { port: 65536 }], + ["port", { port: "zwei" }], + ["tags", { tags: "gut, bö$e" }], + ["tags", { tags: Array.from({ length: 21 }, (_, i) => `t${i}`) }], + ["beschreibung", { beschreibung: "x".repeat(501) }], + ["anmeldeart", { anmeldeart: "ADMIN" }], + ])("weist ein ungueltiges Feld %s ab (%j)", async (feld, abweichung) => { + const fehler = await fehlerVon(hostAnlegen({ ...GRUND, ...abweichung } as never, akteur)); + expect(Object.keys(fehler.feldfehler)).toContain(feld); + expect(await prisma.host.count()).toBe(0); + }); + + it("nimmt IPv6, FQDN und hohe Ports an", async () => { + await hostAnlegen({ name: "v6", adresse: "fd00::11", port: 2222 }, akteur); + await hostAnlegen({ name: "fqdn", adresse: "pve01.intern.example.org", port: "65535" }, akteur); + expect(await prisma.host.count()).toBe(2); + }); + + it("meldet einen vergebenen Namen verstaendlich", async () => { + await hostAnlegen(GRUND, akteur); + const fehler = await fehlerVon(hostAnlegen({ ...GRUND, adresse: "10.0.0.99" }, akteur)); + expect(fehler.message).toMatch(/srv-pve01.*existiert bereits/); + expect(fehler.feldfehler["name"]).toBeDefined(); + }); + + it("verweigert unbekannte Gruppen", async () => { + const fehler = await fehlerVon(hostAnlegen({ ...GRUND, gruppenIds: ["gibt-es-nicht"] }, akteur)); + expect(fehler.feldfehler["gruppenIds"]).toBeDefined(); + expect(await prisma.host.count()).toBe(0); + }); +}); + +describe("Host bearbeiten und archivieren", () => { + it("protokolliert nur geaenderte Felder", async () => { + const host = await hostAnlegen({ ...GRUND, tags: "pve" }, akteur); + await hostBearbeiten(host.id, { ...GRUND, tags: "pve", port: 2222, anmeldeart: "ROOT" }, akteur); + const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "host.geaendert" } }); + expect(eintrag.details).toEqual({ + port: { vorher: 22, nachher: 2222 }, + anmeldeart: { vorher: "DIENSTBENUTZER", nachher: "ROOT" }, + }); + }); + + it("schreibt ohne Aenderung keinen Eintrag", async () => { + const host = await hostAnlegen(GRUND, akteur); + await hostBearbeiten(host.id, GRUND, akteur); + expect(await prisma.protokoll.count({ where: { aktion: "host.geaendert" } })).toBe(0); + }); + + it("meldet beim Umbenennen einen vergebenen Namen", async () => { + await hostAnlegen(GRUND, akteur); + const zweiter = await hostAnlegen({ name: "srv-pve02", adresse: "10.0.0.12" }, akteur); + const fehler = await fehlerVon(hostBearbeiten(zweiter.id, { ...GRUND, adresse: "10.0.0.12" }, akteur)); + expect(fehler.feldfehler["name"]).toBeDefined(); + }); + + it("archiviert statt zu loeschen, nimmt den Host aus Gruppen und Liste", async () => { + const gruppe = await hostgruppeAnlegen({ name: "Proxmox" }, akteur); + const host = await hostAnlegen({ ...GRUND, gruppenIds: [gruppe.id] }, akteur); + await hostArchivieren(host.id, akteur); + + const gespeichert = await prisma.host.findUniqueOrThrow({ where: { id: host.id } }); + expect(gespeichert.archiviertAm).not.toBeNull(); + expect(await prisma.hostgruppeMitglied.count()).toBe(0); + expect(await hostListe()).toHaveLength(0); + const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "host.archiviert" } }); + expect(eintrag.details).toEqual({ gruppen: ["Proxmox"] }); + + await fehlerVon(hostArchivieren(host.id, akteur)); + await fehlerVon(hostBearbeiten(host.id, GRUND, akteur)); + // Der Name bleibt belegt – sonst gaebe es zwei „srv-pve01“ im Protokoll. + await fehlerVon(hostAnlegen(GRUND, akteur)); + }); +}); + +describe("Hostgruppen", () => { + it("legt mit Mitgliedern an und setzt sie neu", async () => { + const a = await hostAnlegen(GRUND, akteur); + const b = await hostAnlegen({ name: "srv-pve02", adresse: "10.0.0.12" }, akteur); + const gruppe = await hostgruppeAnlegen( + { name: "Proxmox", beschreibung: "Cluster", hostIds: [a.id] }, + akteur, + ); + expect((await gruppeDetail(gruppe.id))?.mitglieder.map((m) => m.name)).toEqual(["srv-pve01"]); + + await hostgruppeBearbeiten(gruppe.id, { name: "Proxmox VE", hostIds: [b.id] }, akteur); + const detail = await gruppeDetail(gruppe.id); + expect(detail?.name).toBe("Proxmox VE"); + expect(detail?.beschreibung).toBeNull(); + expect(detail?.mitglieder.map((m) => m.name)).toEqual(["srv-pve02"]); + + const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "hostgruppe.geaendert" } }); + expect(eintrag.details).toMatchObject({ + name: { vorher: "Proxmox", nachher: "Proxmox VE" }, + mitglieder: { hinzu: ["srv-pve02"], entfernt: ["srv-pve01"] }, + }); + expect(await prisma.protokoll.count({ where: { aktion: "hostgruppe.angelegt" } })).toBe(1); + }); + + it("meldet doppelte Namen und ungueltige Mitglieder", async () => { + await hostgruppeAnlegen({ name: "DMZ" }, akteur); + const doppelt = await fehlerVon(hostgruppeAnlegen({ name: "DMZ" }, akteur)); + expect(doppelt.message).toMatch(/existiert bereits/); + + const host = await hostAnlegen(GRUND, akteur); + await hostArchivieren(host.id, akteur); + const archiviert = await fehlerVon(hostgruppeAnlegen({ name: "Neu", hostIds: [host.id] }, akteur)); + expect(archiviert.feldfehler["hostIds"]).toBeDefined(); + + const leer = await fehlerVon(hostgruppeAnlegen({ name: " " }, akteur)); + expect(leer.feldfehler["name"]).toBeDefined(); + }); + + it("zaehlt Mitglieder in der Gruppenliste", async () => { + const a = await hostAnlegen(GRUND, akteur); + await hostgruppeAnlegen({ name: "Proxmox", hostIds: [a.id] }, akteur); + await hostgruppeAnlegen({ name: "DMZ" }, akteur); + expect((await gruppenListe()).map((g) => [g.name, g.mitglieder])).toEqual([ + ["DMZ", 0], + ["Proxmox", 1], + ]); + }); +}); + +describe("Lesemodelle", () => { + it("filtert nach Name, Adresse, Tag und Gruppe", async () => { + const gruppe = await hostgruppeAnlegen({ name: "DMZ" }, akteur); + await hostAnlegen({ ...GRUND, tags: "pve" }, akteur); + await hostAnlegen({ name: "fw-edge01", adresse: "192.168.1.1", gruppenIds: [gruppe.id] }, akteur); + + expect((await hostListe({ q: "PVE01" })).map((h) => h.name)).toEqual(["srv-pve01"]); + expect((await hostListe({ q: "192.168" })).map((h) => h.name)).toEqual(["fw-edge01"]); + expect((await hostListe({ q: "pve" })).map((h) => h.name)).toEqual(["srv-pve01"]); + expect((await hostListe({ gruppeId: gruppe.id })).map((h) => h.name)).toEqual(["fw-edge01"]); + expect(await hostListe()).toHaveLength(2); + }); + + it("fasst Host-Keys und Abweichungen zusammen", async () => { + const host = await hostAnlegen(GRUND, akteur); + await prisma.hostSchluessel.create({ + data: { + hostId: host.id, + algorithmus: "ssh-ed25519", + publicKey: "AAAA", + fingerprint: "SHA256:a", + status: "VERTRAUT", + }, + }); + let [eintrag] = await hostListe(); + expect(eintrag?.hostKey.zustand).toBe("bestaetigt"); + expect(eintrag?.status).toBe("unbekannt"); + + await prisma.hostSchluessel.create({ + data: { hostId: host.id, algorithmus: "ssh-rsa", publicKey: "BBBB", fingerprint: "SHA256:b" }, + }); + await prisma.abweichung.create({ data: { hostId: host.id, art: "UNBEKANNTER_SCHLUESSEL" } }); + [eintrag] = await hostListe(); + expect(eintrag?.hostKey.zustand).toBe("ausstehend"); + expect(eintrag?.offeneAbweichungen).toBe(1); + expect(eintrag?.status).toBe("drift"); + + const detail = await hostDetail(host.id); + expect(detail?.hostSchluessel).toHaveLength(2); + expect(detail?.hostKey).toMatchObject({ vertraut: 1, ausstehend: 1 }); + }); +}); + +describe("Abgeleitete Zustaende", () => { + it("unerreichbar schlaegt Drift", () => { + expect(hostStatus({ erreichbar: false, driftZustand: "DRIFT", offeneAbweichungen: 3 })).toBe( + "unerreichbar", + ); + expect(hostStatus({ erreichbar: true, driftZustand: "SYNCHRON", offeneAbweichungen: 0 })).toBe( + "synchron", + ); + expect(hostStatus({ erreichbar: null, driftZustand: "UNBEKANNT", offeneAbweichungen: 0 })).toBe( + "unbekannt", + ); + }); + + it("ein ausstehender Host-Key geht vor einem vertrauten", () => { + expect(hostKeyZusammenfassen([]).zustand).toBe("keiner"); + expect(hostKeyZusammenfassen(["VERTRAUT", "ERSETZT"]).zustand).toBe("bestaetigt"); + expect(hostKeyZusammenfassen(["VERTRAUT", "AUSSTEHEND"]).zustand).toBe("ausstehend"); + }); +}); + +describe("Rechte der Aktionen", () => { + // Strukturell wie tests/zugang.test.ts: jede exportierte Aktion prueft das Recht selbst. + it.each(["src/server/actions/hosts.ts", "src/server/actions/hostgruppen.ts"])( + "%s verlangt hostsVerwalten in jeder Aktion", + (datei) => { + const inhalt = readFileSync(datei, "utf8"); + expect(inhalt.startsWith('"use server";')).toBe(true); + const aktionen = inhalt.split(/^export async function /m).slice(1); + expect(aktionen.length).toBeGreaterThanOrEqual(2); + for (const rumpf of aktionen) { + expect(rumpf, rumpf.slice(0, 40)).toContain('rechtErforderlich("hostsVerwalten")'); + } + }, + ); +});