feat(protokoll): Audit-Log mit Filtern, Kettenpruefung und CSV (#027)
Das Protokoll war bisher nur in der Datenbank lesbar. Die Ansicht zeigt es jeder Rolle mit lesbaren Aktionstexten, aufklappbarem Diff, Details und Hashes; ein Test haelt fest, dass jede Aktion im Code einen Text hat. Geblaettert wird ueber die ID statt OFFSET, weil das Protokoll waehrend des Lesens waechst. Die Kettenpruefung liest jede Zeile und laeuft deshalb nur auf Knopfdruck, mit 5 Minuten Zwischenspeicher; ein Bruch wird selbst protokolliert. Der CSV-Export streamt in Stapeln, entschaerft Formeln und steht selbst im Protokoll. protokollieren() reicht jeden Eintrag nach dem Schreiben an Syslog weiter, ohne Details und Diff. Kein Weg aendert oder loescht Eintraege; ein struktureller Test sichert das ab, zusaetzlich zum Trigger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { anmelden } from "./hilfen";
|
||||
|
||||
/**
|
||||
* Audit-Log (#027) gegen den gebauten Stand: Liste, Filter, Aufklappen, Kettenpruefung und
|
||||
* CSV-Export. Die Anmeldung selbst erzeugt den ersten Eintrag – ohne SSH-Testziele lauffaehig.
|
||||
*/
|
||||
test("Audit-Log zeigt, filtert, prüft und exportiert", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/protokoll");
|
||||
await expect(page.getByRole("heading", { name: "Audit-Log" })).toBeVisible();
|
||||
|
||||
const tabelle = page.getByRole("table", { name: "Protokolleinträge" });
|
||||
await expect(tabelle.getByRole("row").nth(1)).toBeVisible();
|
||||
|
||||
// Filter nach Aktionsgruppe: nur noch Anmeldungen.
|
||||
await page.getByLabel("Aktion").selectOption({ label: "Anmeldung" });
|
||||
await page.getByRole("button", { name: "Filtern" }).click();
|
||||
await expect(page).toHaveURL(/gruppe=anmeldung/);
|
||||
const zeilen = tabelle.locator("tbody > tr");
|
||||
await expect(zeilen.first()).toBeVisible();
|
||||
const aktionen = await zeilen.locator("td:nth-child(2)").allInnerTexts();
|
||||
expect(aktionen.length).toBeGreaterThan(0);
|
||||
for (const aktion of aktionen) expect(aktion).toMatch(/Anmeld|TOTP/);
|
||||
|
||||
// Zeile aufklappen: Hash und Vorgaenger-Hash erscheinen.
|
||||
await tabelle
|
||||
.getByRole("button", { name: /Einzelheiten zu Eintrag/ })
|
||||
.first()
|
||||
.click();
|
||||
await expect(tabelle.locator('[data-feld="hash"]').first()).toHaveText(/^[0-9a-f]{64}$/);
|
||||
|
||||
// Kettenpruefung auf Knopfdruck.
|
||||
await page.getByRole("button", { name: "Kette prüfen" }).click();
|
||||
await expect(page.getByText("Hash-Kette unversehrt")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// CSV mit denselben Filtern; der Export landet selbst im Protokoll.
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("link", { name: "CSV exportieren" }).click(),
|
||||
]);
|
||||
expect(download.suggestedFilename()).toMatch(/^sshkv-protokoll-\d{4}-\d{2}-\d{2}\.csv$/);
|
||||
const text = (await readFile((await download.path())!, "utf8")).replace(/^/, "");
|
||||
expect(text.split("\r\n")[0]).toBe(
|
||||
'"id";"zeitpunkt";"akteur";"aktion";"ergebnis";"host";"zielbenutzer";"fingerprint";"diff";"details";"vorgaengerHash";"hash"',
|
||||
);
|
||||
expect(text).toContain("anmeldung.");
|
||||
|
||||
await page.goto("/protokoll?gruppe=protokoll");
|
||||
await expect(tabelle.getByText("Protokoll exportiert (CSV)").first()).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AKTIONEN, AKTIONSGRUPPEN, aktionenDerGruppe, aktionText, aktionTon } from "@/lib/protokoll-aktionen";
|
||||
|
||||
/**
|
||||
* Jede Protokoll-Aktion im Quellcode hat einen lesbaren Text (#027). Gesucht wird in allen
|
||||
* Dateien, die `protokollieren(` rufen, nach `aktion: …`/`aktion = …` (auch in Ternaries), und
|
||||
* ueberall nach Literalen, die mit einem bekannten Praefix beginnen (`"schluessel.uebernommen"`
|
||||
* wird z. B. als Argument durchgereicht).
|
||||
*/
|
||||
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 /\.tsx?$/.test(name) ? [pfad] : [];
|
||||
});
|
||||
}
|
||||
|
||||
const QUELLEN = dateien("src").map((datei) => ({ datei, inhalt: readFileSync(datei, "utf8") }));
|
||||
const LITERAL = /"([^"\n]+)"/g;
|
||||
|
||||
function aktionenImCode(): Map<string, string> {
|
||||
const gefunden = new Map<string, string>();
|
||||
const praefixe = new Set(Object.keys(AKTIONEN).map((a) => a.split(".")[0]!));
|
||||
for (const { datei, inhalt } of QUELLEN) {
|
||||
if (inhalt.includes("protokollieren(")) {
|
||||
for (const treffer of inhalt.matchAll(/\baktion\s*[:=]\s*([^,\n]+)/g)) {
|
||||
// Bei `a === "x" ? "eins" : "zwei"` zaehlen nur die Zweige, nicht die Bedingung.
|
||||
const ausdruck = treffer[1]!.includes("?")
|
||||
? treffer[1]!.slice(treffer[1]!.indexOf("?"))
|
||||
: treffer[1]!;
|
||||
for (const [, literal] of ausdruck.matchAll(LITERAL)) gefunden.set(literal!, datei);
|
||||
}
|
||||
}
|
||||
for (const [, literal] of inhalt.matchAll(/"([a-z_]+(?:\.[a-z_]+)+)"/g)) {
|
||||
if (praefixe.has(literal!.split(".")[0]!) && !literal!.endsWith(".ts")) gefunden.set(literal!, datei);
|
||||
}
|
||||
}
|
||||
return gefunden;
|
||||
}
|
||||
|
||||
describe("Protokoll-Aktionen", () => {
|
||||
const imCode = aktionenImCode();
|
||||
|
||||
it("findet die Aktionen im Quellcode", () => {
|
||||
expect(imCode.size).toBeGreaterThan(40);
|
||||
expect(imCode.has("authorized_keys.geschrieben")).toBe(true);
|
||||
expect(imCode.has("hostkey.ersetzt")).toBe(true);
|
||||
expect(imCode.has("privatekey.angezeigt")).toBe(true);
|
||||
expect(imCode.has("schluessel.uebernommen")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([...imCode])("%s hat einen lesbaren Text (%s)", (aktion) => {
|
||||
expect(AKTIONEN[aktion], `${aktion} fehlt in src/lib/protokoll-aktionen.ts`).toBeDefined();
|
||||
});
|
||||
|
||||
it("jede Gruppe hat Aktionen", () => {
|
||||
for (const gruppe of Object.keys(AKTIONSGRUPPEN) as (keyof typeof AKTIONSGRUPPEN)[]) {
|
||||
expect(aktionenDerGruppe(gruppe).length, gruppe).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("faellt fuer unbekannte Aktionen auf den technischen Namen zurueck", () => {
|
||||
expect(aktionText("hostkey.ersetzt")).toBe("Host-Key ersetzt");
|
||||
expect(aktionText("gibt.es.nicht")).toBe("gibt.es.nicht");
|
||||
});
|
||||
|
||||
it("faerbt nach Ergebnis und Art", () => {
|
||||
expect(aktionTon("authorized_keys.geschrieben", "ERFOLG")).toBe("ok");
|
||||
expect(aktionTon("anmeldung.oidc", "ERFOLG")).toBe("neutral");
|
||||
expect(aktionTon("anmeldung.verweigert", "VERWEIGERT")).toBe("gefahr");
|
||||
expect(aktionTon("verbindungstest", "FEHLER")).toBe("gefahr");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
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 sind nicht lueckenlos (der Trigger zieht die Sequenz selbst) – 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user