Files
sshkeyvault/tests/protokoll-ansicht.test.ts
florian.wildeandClaude Opus 5.5 b4804390c4 fix(protokoll): IDs ohne Zweierschritte vergeben (#032)
Spalten-Default (BIGSERIAL) und Trigger protokoll_verketten zogen je
eine Nummer aus der Sequenz, jede Zeile verbrauchte zwei IDs. Die
Nummer aus dem Default darf der Trigger aber nicht einfach
uebernehmen, denn sie wird vor der Advisory-Sperre gezogen: Eine parallele
Transaktion mit groesserer ID koennte zuerst verketten, und die Kette
liesse sich in ID-Reihenfolge nicht mehr pruefen.

Deshalb zieht nur noch der Trigger unter der Sperre; der Default ist
ein Platzhalter 0 (Prisma: @default(0)), damit create ohne ID
funktioniert. Die Sequenz bleibt der Spalte zugeordnet. Bestehende
Zeilen werden nicht umnummeriert, weil die ID in die Hash-Eingabe
eingeht.

Tests: drei Eintraege nacheinander ergeben n, n+1, n+2; eine
mitgegebene ID wird ueberschrieben; 20 parallele Eintraege belegen
genau 20 aufeinanderfolgende IDs bei intakter Kette.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:19:28 +02:00

350 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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<typeof import("@/server/auth/sitzungspflicht")>();
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>): 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<ProtokollFilter>) =>
(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<ProtokollFilter>) =>
(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<string>((fertig) =>
empfaenger.once("message", (d) => fertig(d.toString("utf8"))),
);
await new Promise<void>((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);
});
});