Merge branch 'phase/008-hosts-gruppen'

This commit is contained in:
2026-10-12 09:51:58 +02:00
31 changed files with 2649 additions and 39 deletions
+268
View File
@@ -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<unknown>): Promise<HostFehler> {
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")');
}
},
);
});