import { createSocket } from "node:dgram"; import type { AddressInfo } from "node:net"; import { readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { prisma } from "@/lib/db"; import { LEERER_FILTER, protokollFilterAus, type ProtokollFilter } from "@/lib/protokoll-filter"; import { protokollAkteure, protokollSeite, protokollStapelweise, protokollZaehlen, SEITENGROESSE, } from "@/server/queries/protokoll"; import { kettePruefenZwischengespeichert, ketteZwischengespeichert, ketteZwischenspeicherLeeren, protokollieren, syslogKonfig, syslogZuruecksetzen, } from "@/server/services/protokoll"; import { datenbankLeeren } from "./hilfen/db"; /** * Protokollansicht (#027) gegen die Testdatenbank: Filter, Cursor, CSV-Route, Kettenpruefung * und Syslog-Weiterleitung. Die Sitzung der Route wird nachgebildet – geprueft wird, dass der * Handler das Recht selbst verlangt. */ const recht = vi.hoisted(() => ({ erlaubt: true })); vi.mock("@/server/auth/sitzungspflicht", async (original) => { const echt = await original(); return { ...echt, rechtErforderlich: vi.fn(async () => { if (!recht.erlaubt) throw new echt.RechteFehler("Nicht angemeldet."); return { benutzer: { id: "b-leser", name: "Lea Leser", rolle: "LESER" } }; }), }; }); type Roh = { aktion: string; akteurName?: string; ergebnis?: "ERFOLG" | "FEHLER" | "VERWEIGERT"; hostName?: string | null; zielbenutzer?: string | null; fingerprint?: string | null; zeitpunkt?: Date; diff?: string; }; async function eintragen(daten: Roh) { return prisma.protokoll.create({ data: { akteurName: daten.akteurName ?? "Florian", aktion: daten.aktion, ergebnis: daten.ergebnis ?? "ERFOLG", hostName: daten.hostName ?? null, zielbenutzer: daten.zielbenutzer ?? null, fingerprint: daten.fingerprint ?? null, diff: daten.diff ?? null, ...(daten.zeitpunkt ? { zeitpunkt: daten.zeitpunkt } : {}), }, }); } const filter = (teil: Partial): ProtokollFilter => ({ ...LEERER_FILTER, ...teil }); beforeEach(async () => { recht.erlaubt = true; ketteZwischenspeicherLeeren(); await datenbankLeeren(); }); describe("protokollSeite", () => { it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => { // IDs koennen Luecken haben (Rollbacks, alte Zeilen vor #032) – nur die Ordnung zaehlt. const ids: string[] = []; for (let i = 0; i < SEITENGROESSE + 7; i += 1) ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id)); const erste = await protokollSeite(LEERER_FILTER); expect(erste.zeilen.map((z) => z.id)).toEqual(ids.slice(0, SEITENGROESSE)); expect(erste.weiterVor).toBe(ids[SEITENGROESSE - 1]); // Ein neuer Eintrag zwischen zwei Seiten verschiebt den Cursor nicht (anders als OFFSET). await eintragen({ aktion: "host.geaendert" }); const zweite = await protokollSeite(LEERER_FILTER, BigInt(erste.weiterVor!)); expect(zweite.zeilen.map((z) => z.id)).toEqual(ids.slice(SEITENGROESSE)); expect(zweite.weiterVor).toBeNull(); }); it("liefert Hash, Vorgaenger-Hash und Details als Liste", async () => { await protokollieren({ akteur: "System", aktion: "abgleich", ergebnis: "ERFOLG", details: { befunde: 2, benutzer: ["root"], hinweis: "ok" }, }); const [zeile] = (await protokollSeite(LEERER_FILTER)).zeilen; expect(zeile!.hash).toMatch(/^[0-9a-f]{64}$/); expect(zeile!.vorgaengerHash).toBe(""); // jsonb ordnet die Schluessel selbst; die Reihenfolge ist daher nicht Teil der Zusage. expect(zeile!.details).toHaveLength(3); expect(zeile!.details).toEqual( expect.arrayContaining([ { name: "befunde", wert: "2" }, { name: "benutzer", wert: '[\n "root"\n]' }, { name: "hinweis", wert: "ok" }, ]), ); }); it("filtert nach Aktionsgruppe, Ergebnis, Akteur, Host und Freitext", async () => { await eintragen({ aktion: "anmeldung.oidc", akteurName: "Anna" }); await eintragen({ aktion: "anmeldung.verweigert", akteurName: "Mallory", ergebnis: "VERWEIGERT" }); await eintragen({ aktion: "authorized_keys.geschrieben", hostName: "srv-pve01", zielbenutzer: "backup", fingerprint: "SHA256:abcdef", }); await eintragen({ aktion: "hostkey.ersetzt", hostName: "srv-db02" }); const aktionen = async (f: Partial) => (await protokollSeite(filter(f))).zeilen.map((z) => z.aktion); expect(await aktionen({ gruppe: "anmeldung" })).toEqual(["anmeldung.verweigert", "anmeldung.oidc"]); expect(await aktionen({ gruppe: "hostkey" })).toEqual(["hostkey.ersetzt"]); expect(await aktionen({ ergebnis: "VERWEIGERT" })).toEqual(["anmeldung.verweigert"]); expect(await aktionen({ akteur: "Anna" })).toEqual(["anmeldung.oidc"]); expect(await aktionen({ host: "PVE" })).toEqual(["authorized_keys.geschrieben"]); expect(await aktionen({ q: "abcdef" })).toEqual(["authorized_keys.geschrieben"]); expect(await aktionen({ q: "BACKUP" })).toEqual(["authorized_keys.geschrieben"]); expect(await aktionen({ q: "db02" })).toEqual(["hostkey.ersetzt"]); expect(await aktionen({ gruppe: "ausrollen", host: "db02" })).toEqual([]); expect(await protokollZaehlen(filter({ gruppe: "anmeldung" }))).toEqual({ gesamt: 4, treffer: 2 }); expect(await protokollAkteure()).toEqual(["Anna", "Florian", "Mallory"]); }); it("filtert den Zeitraum nach Berliner Kalendertagen", async () => { // 11.10. 23:30 Berlin = 21:30 UTC; 12.10. 00:30 Berlin = 22:30 UTC am 11.10. await eintragen({ aktion: "host.angelegt", zeitpunkt: new Date("2026-10-11T21:30:00Z") }); await eintragen({ aktion: "host.geaendert", zeitpunkt: new Date("2026-10-11T22:30:00Z") }); await eintragen({ aktion: "host.archiviert", zeitpunkt: new Date("2026-10-12T21:59:00Z") }); await eintragen({ aktion: "kunde.angelegt", zeitpunkt: new Date("2026-10-12T22:01:00Z") }); const aktionen = async (f: Partial) => (await protokollSeite(filter(f))).zeilen.map((z) => z.aktion); expect(await aktionen({ von: "2026-10-12", bis: "2026-10-12" })).toEqual([ "host.archiviert", "host.geaendert", ]); expect(await aktionen({ bis: "2026-10-11" })).toEqual(["host.angelegt"]); expect(await aktionen({ von: "2026-10-13" })).toEqual(["kunde.angelegt"]); }); it("ignoriert unbekannte Filterwerte", () => { expect( protokollFilterAus({ gruppe: "drop table", ergebnis: "VIELLEICHT", von: "12.10.2026", bis: "2026-13-45", }), ).toEqual(LEERER_FILTER); }); }); describe("protokollStapelweise", () => { it("liest alle Treffer in Stapeln", async () => { const erwartet: bigint[] = []; for (let i = 0; i < 12; i += 1) { const zeile = await eintragen({ aktion: i % 2 ? "host.angelegt" : "anmeldung.oidc" }); if (i % 2) erwartet.unshift(zeile.id); } const groessen: number[] = []; const ids: bigint[] = []; for await (const stapel of protokollStapelweise(filter({ gruppe: "hosts" }), 4)) { groessen.push(stapel.length); ids.push(...stapel.map((z) => z.id)); } expect(groessen).toEqual([4, 2]); expect(ids).toEqual(erwartet); }); }); describe("GET /api/protokoll.csv", () => { async function abrufen(query = "") { const { GET } = await import("@/app/api/protokoll.csv/route"); return GET(new Request(`http://localhost/api/protokoll.csv${query}`)); } it("verweigert ohne Recht und protokolliert dann nichts", async () => { recht.erlaubt = false; const antwort = await abrufen(); expect(antwort.status).toBe(403); expect(await prisma.protokoll.count()).toBe(0); }); it("liefert Kopfzeile, Zeilen und entschaerft Formeln", async () => { await eintragen({ aktion: "authorized_keys.geschrieben", akteurName: "=HYPERLINK(1)", hostName: "srv-pve01", zielbenutzer: "root", fingerprint: "SHA256:abc", diff: '+ ssh-ed25519 AAAA "x"\n- ssh-rsa BBBB', }); const antwort = await abrufen(); expect(antwort.status).toBe(200); expect(antwort.headers.get("content-type")).toContain("text/csv"); expect(antwort.headers.get("content-disposition")).toMatch(/sshkv-protokoll-\d{4}-\d{2}-\d{2}\.csv/); const text = (await antwort.text()).replace(/^/, ""); const [kopf] = text.split("\r\n"); expect(kopf).toBe( '"id";"zeitpunkt";"akteur";"aktion";"ergebnis";"host";"zielbenutzer";"fingerprint";"diff";"details";"vorgaengerHash";"hash"', ); expect(text).toContain(`"'=HYPERLINK(1)";"authorized_keys.geschrieben";"ERFOLG";"srv-pve01";"root"`); // Mehrzeiliger Diff bleibt eine Zelle; das fuehrende „+“ wird wie eine Formel entschaerft. expect(text).toContain(`"'+ ssh-ed25519 AAAA ""x""\n- ssh-rsa BBBB"`); expect(text).toMatch(/\r\n"\d+";"\d{4}-\d{2}-\d{2}T[\d:.]+Z";"'=HYPERLINK/); }); it("protokolliert den Export mit den Filtern", async () => { await (await abrufen("?gruppe=hostkey&q=srv")).text(); const [eintrag] = await prisma.protokoll.findMany({ where: { aktion: "protokoll.exportiert" } }); expect(eintrag).toMatchObject({ akteurName: "Lea Leser", akteurId: "b-leser", ergebnis: "ERFOLG" }); expect(eintrag!.details).toEqual({ filter: { gruppe: "hostkey", q: "srv" } }); }); it("wendet die Filter der Seite an", async () => { await eintragen({ aktion: "hostkey.ersetzt", hostName: "srv-a" }); await eintragen({ aktion: "anmeldung.oidc" }); const text = await (await abrufen("?gruppe=hostkey")).text(); expect(text).toContain("hostkey.ersetzt"); expect(text).not.toContain("anmeldung.oidc"); }); it("streamt grosse Protokolle ueber mehrere Stapel", async () => { await prisma.protokoll.createMany({ data: Array.from({ length: 1203 }, (_, i) => ({ akteurName: "System", aktion: "abgleich", ergebnis: "ERFOLG" as const, hostName: `h${i}`, })), }); const text = await (await abrufen("?gruppe=abgleich")).text(); expect(text.trimEnd().split("\r\n")).toHaveLength(1 + 1203); }); }); describe("Kettenpruefung", () => { it("prueft auf Anfrage und haelt das Ergebnis 5 Minuten", async () => { await eintragen({ aktion: "host.angelegt" }); await eintragen({ aktion: "host.geaendert" }); expect(ketteZwischengespeichert()).toBeNull(); const ergebnis = await kettePruefenZwischengespeichert("System"); expect(ergebnis).toMatchObject({ fehlerhafteId: null, eintraege: 2 }); expect(ketteZwischengespeichert()).toBe(ergebnis); expect(ketteZwischengespeichert(new Date(Date.now() + 6 * 60 * 1000))).toBeNull(); }); it("meldet die erste fehlerhafte ID und protokolliert den Fund", async () => { await eintragen({ aktion: "host.angelegt" }); const zweiter = await eintragen({ aktion: "host.geaendert" }); await eintragen({ aktion: "host.archiviert" }); await prisma.$transaction([ prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" DISABLE TRIGGER "protokoll_kein_update"`), prisma.$executeRawUnsafe(`UPDATE "Protokoll" SET "aktion" = 'gefaelscht' WHERE "id" = ${zweiter.id}`), prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" ENABLE TRIGGER "protokoll_kein_update"`), ]); const ergebnis = await kettePruefenZwischengespeichert({ id: "b1", name: "Florian" }); expect(ergebnis.fehlerhafteId).toBe(String(zweiter.id)); expect(await prisma.protokoll.count({ where: { aktion: "protokoll.kette_beschaedigt" } })).toBe(1); }); }); describe("Syslog-Weiterleitung in protokollieren()", () => { afterEach(() => { delete process.env["SSHKV_SYSLOG_HOST"]; delete process.env["SSHKV_SYSLOG_PORT"]; syslogZuruecksetzen(); }); it("ist ohne Umgebung aus", () => { syslogZuruecksetzen(); expect(syslogKonfig()).toBeNull(); }); it("sendet nach dem Schreiben – ohne Details und Diff", async () => { const empfaenger = createSocket("udp4"); const eingang = new Promise((fertig) => empfaenger.once("message", (d) => fertig(d.toString("utf8"))), ); await new Promise((fertig) => empfaenger.bind(0, "127.0.0.1", fertig)); process.env["SSHKV_SYSLOG_HOST"] = "127.0.0.1"; process.env["SSHKV_SYSLOG_PORT"] = String((empfaenger.address() as AddressInfo).port); syslogZuruecksetzen(); try { await protokollieren({ akteur: { id: "b1", name: "Florian" }, aktion: "privatekey.angezeigt", ergebnis: "ERFOLG", fingerprint: "SHA256:xyz", diff: "+ geheimer-diff", details: { pfad: "/geheim/pfad", passwort: "x" }, }); const zeile = await eingang; const [eintrag] = await prisma.protokoll.findMany(); expect(zeile).toMatch(/^<85>1 /); expect(zeile).toContain(`hash="${eintrag!.hash}"`); expect(zeile).toContain(`id="${eintrag!.id}"`); expect(zeile).toContain('fingerprint="SHA256:xyz"'); expect(zeile).not.toContain("geheimer-diff"); expect(zeile).not.toContain("/geheim/pfad"); } finally { empfaenger.close(); } }); it("schreibt auch, wenn der Syslog-Server nicht erreichbar ist", async () => { process.env["SSHKV_SYSLOG_HOST"] = "nicht-aufloesbar.invalid"; syslogZuruecksetzen(); const fehler = vi.spyOn(console, "error").mockImplementation(() => {}); await protokollieren({ akteur: "System", aktion: "abgleich", ergebnis: "ERFOLG" }); expect(await prisma.protokoll.count()).toBe(1); fehler.mockRestore(); }); }); describe("Protokoll ist ueber die Anwendung nicht aenderbar", () => { function dateien(verzeichnis: string): string[] { return readdirSync(verzeichnis).flatMap((name) => { const pfad = path.join(verzeichnis, name); if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad); return /\.(ts|tsx|sql)$/.test(name) ? [pfad] : []; }); } it.each(dateien("src"))("%s aendert und loescht keine Protokolleintraege", (datei) => { const inhalt = readFileSync(datei, "utf8"); expect(inhalt).not.toMatch(/\.protokoll\s*\.\s*(update|updateMany|upsert|delete|deleteMany)\b/); expect(inhalt).not.toMatch(/(UPDATE|DELETE\s+FROM|TRUNCATE)\s+"?Protokoll"?/i); expect(inhalt).not.toMatch(/DISABLE\s+TRIGGER/i); }); });