Files
sshkeyvault/tests/hosts.test.ts
T
florian.wildeandClaude Opus 5.5 4f9d5d9680 feat(hosts): Services, Lesemodelle und Aktionen fuer Hosts (#008)
Hosts und Hostgruppen brauchen eine Schreibschicht, bevor Host-Key-Pruefung
(#009) und Bootstrap (#010) darauf aufsetzen. Die Regeln (Name, Adresse wie
der DB-CHECK, Port, Tags, Anmeldeart) liegen framework-frei in src/lib/hosts.ts,
damit Action (Feldmeldungen) und Service (Schutz fuer Seed und Assistenten)
dieselben pruefen.

Archivieren statt Loeschen haelt Protokollbezuege lesbar; dabei fallen die
Gruppenmitgliedschaften weg, damit Zuweisungen an eine Gruppe nie einen
archivierten Host treffen. Jede Aenderung landet mit den geaenderten Feldern
im Protokoll, Eindeutigkeitsverletzungen kommen als deutsche HostFehler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:42:56 +02:00

269 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")');
}
},
);
});