Merge branch 'phase/027-protokoll-ansicht'

This commit is contained in:
2026-10-12 16:48:27 +02:00
20 changed files with 2257 additions and 15 deletions
+52
View File
@@ -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();
});
+75
View File
@@ -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");
});
});
+349
View File
@@ -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);
});
});
+219
View File
@@ -0,0 +1,219 @@
import { createSocket, type Socket } from "node:dgram";
import { createServer, type Server } from "node:net";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
HOECHSTLAENGE,
sdWertEscapen,
syslogKonfigAus,
syslogNachricht,
syslogSender,
syslogZielText,
tcpRahmen,
type SyslogEintrag,
} from "@/lib/syslog";
/** Syslog-Weiterleitung (#027): Format nach RFC 5424, Escaping, Laenge, Versand. */
const EINTRAG: SyslogEintrag = {
id: 42n,
zeitpunkt: new Date("2026-10-12T14:03:07.123Z"),
akteur: "Florian",
aktion: "authorized_keys.geschrieben",
ergebnis: "ERFOLG",
hostName: "srv-pve01",
zielbenutzer: "root",
fingerprint: "SHA256:abc",
hash: "f00",
};
const KONFIG = { appName: "sshkv", facility: 10 };
const FEST = { rechner: "vault", prozess: 7 };
describe("syslogKonfigAus", () => {
it("ist ohne Host aus", () => {
expect(syslogKonfigAus({})).toBeNull();
expect(syslogKonfigAus({ SSHKV_SYSLOG_HOST: " " })).toBeNull();
});
it("setzt Standardwerte", () => {
expect(syslogKonfigAus({ SSHKV_SYSLOG_HOST: "log.intern" })).toEqual({
host: "log.intern",
port: 514,
protokoll: "udp",
appName: "sshkv",
facility: 10,
});
});
it("liest Port, Protokoll, App-Name und Facility", () => {
const konfig = syslogKonfigAus({
SSHKV_SYSLOG_HOST: "10.0.0.5",
SSHKV_SYSLOG_PORT: "6514",
SSHKV_SYSLOG_PROTOKOLL: "TCP",
SSHKV_SYSLOG_APPNAME: "vault prod",
SSHKV_SYSLOG_FACILITY: "local3",
});
expect(konfig).toMatchObject({ port: 6514, protokoll: "tcp", appName: "vault_prod", facility: 19 });
expect(syslogZielText(konfig!)).toBe("tcp://10.0.0.5:6514");
});
it("faellt bei Unsinn auf die Standards zurueck", () => {
expect(
syslogKonfigAus({
SSHKV_SYSLOG_HOST: "x",
SSHKV_SYSLOG_PORT: "99999",
SSHKV_SYSLOG_FACILITY: "quatsch",
}),
).toMatchObject({ port: 514, facility: 10, protokoll: "udp" });
});
});
describe("syslogNachricht", () => {
it("baut eine Zeile nach RFC 5424 mit strukturierten Daten", () => {
expect(syslogNachricht(EINTRAG, KONFIG, FEST)).toBe(
"<85>1 2026-10-12T14:03:07.123Z vault sshkv 7 authorized_keys.geschrieben " +
'[sshkv@32473 id="42" akteur="Florian" aktion="authorized_keys.geschrieben" ergebnis="ERFOLG" ' +
'host="srv-pve01" zielbenutzer="root" fingerprint="SHA256:abc" hash="f00"] ' +
"Florian: authorized_keys.geschrieben ERFOLG auf srv-pve01",
);
});
it("leitet die Severity aus dem Ergebnis ab", () => {
// authpriv (10) * 8 = 80; notice 5, warning 4, err 3
expect(syslogNachricht({ ...EINTRAG, ergebnis: "VERWEIGERT" }, KONFIG, FEST)).toMatch(/^<84>1 /);
expect(syslogNachricht({ ...EINTRAG, ergebnis: "FEHLER" }, KONFIG, FEST)).toMatch(/^<83>1 /);
expect(syslogNachricht(EINTRAG, { appName: "sshkv", facility: 16 }, FEST)).toMatch(/^<133>1 /);
});
it("escapt Anfuehrungszeichen, Backslash und eckige Klammer", () => {
expect(sdWertEscapen('a"b\\c]d')).toBe('a\\"b\\\\c\\]d');
const zeile = syslogNachricht({ ...EINTRAG, akteur: 'Eve "]x', zielbenutzer: "a\nb" }, KONFIG, FEST);
expect(zeile).toContain('akteur="Eve \\"\\]x"');
expect(zeile).toContain('zielbenutzer="a b"');
expect(zeile).not.toContain("\n");
});
it("laesst leere Felder weg", () => {
const zeile = syslogNachricht(
{ ...EINTRAG, hostName: null, zielbenutzer: null, fingerprint: undefined },
KONFIG,
FEST,
);
expect(zeile).not.toContain("host=");
expect(zeile).not.toContain("fingerprint=");
});
it("bleibt unter der Hoechstlaenge, auch mit langen Werten", () => {
const lang = "ä".repeat(5000);
const zeile = syslogNachricht(
{ ...EINTRAG, akteur: lang, hostName: '"'.repeat(3000), zielbenutzer: lang, fingerprint: lang },
KONFIG,
FEST,
);
expect(Buffer.byteLength(zeile, "utf8")).toBeLessThanOrEqual(HOECHSTLAENGE);
// Strukturierte Daten bleiben vollstaendig und geschlossen.
expect(zeile).toMatch(/hash="f00"\] /);
// Kein halbes Escape am Ende eines Werts.
expect(zeile).not.toMatch(/[^\\]\\"\s/);
});
it("rahmt fuer TCP mit Oktettzaehlung", () => {
expect(tcpRahmen("<85>1 ä").toString("utf8")).toBe("8 <85>1 ä");
});
});
describe("syslogSender", () => {
let empfaenger: Socket | undefined;
let server: Server | undefined;
afterEach(() => {
empfaenger?.close();
empfaenger = undefined;
server?.close();
server = undefined;
});
async function udpEmpfaenger(): Promise<{ port: number; naechste: () => Promise<string> }> {
const socket = createSocket("udp4");
empfaenger = socket;
const eingang: string[] = [];
const wartende: ((text: string) => void)[] = [];
socket.on("message", (daten) => {
const text = daten.toString("utf8");
const warten = wartende.shift();
if (warten) warten(text);
else eingang.push(text);
});
await new Promise<void>((fertig) => socket.bind(0, "127.0.0.1", fertig));
return {
port: (socket.address() as AddressInfo).port,
naechste: () =>
new Promise((fertig) => {
const vorhanden = eingang.shift();
if (vorhanden) fertig(vorhanden);
else wartende.push(fertig);
}),
};
}
it("sendet per UDP an einen lokalen Empfaenger", async () => {
const { port, naechste } = await udpEmpfaenger();
const sender = syslogSender({
host: "127.0.0.1",
port,
protokoll: "udp",
appName: "sshkv",
facility: 10,
});
sender.senden(EINTRAG);
const zeile = await naechste();
sender.schliessen();
expect(zeile).toMatch(
/^<85>1 2026-10-12T14:03:07.123Z \S+ sshkv \d+ authorized_keys.geschrieben \[sshkv@32473 /,
);
expect(zeile).toContain('hash="f00"');
});
it("sendet per TCP mit Rahmen", async () => {
const eingang = new Promise<string>((fertig) => {
server = createServer((verbindung) => {
verbindung.on("data", (daten) => fertig(daten.toString("utf8")));
});
});
await new Promise<void>((fertig) => server!.listen(0, "127.0.0.1", fertig));
const port = (server!.address() as AddressInfo).port;
const sender = syslogSender({
host: "127.0.0.1",
port,
protokoll: "tcp",
appName: "sshkv",
facility: 10,
});
sender.senden(EINTRAG);
const text = await eingang;
sender.schliessen();
const [laenge, ...rest] = text.split(" ");
expect(Number(laenge)).toBe(Buffer.byteLength(rest.join(" "), "utf8"));
expect(rest.join(" ")).toMatch(/^<85>1 /);
});
it("wirft nie und meldet Fehler hoechstens einmal je Minute", async () => {
const melden = vi.fn();
let uhr = 0;
// Port 1 auf localhost: niemand hoert zu, die Verbindung scheitert sofort.
const sender = syslogSender(
{ host: "127.0.0.1", port: 1, protokoll: "tcp", appName: "sshkv", facility: 10 },
melden,
() => uhr,
);
expect(() => sender.senden(EINTRAG)).not.toThrow();
await vi.waitFor(() => expect(melden).toHaveBeenCalledTimes(1));
uhr = 30_000;
sender.senden(EINTRAG);
await new Promise((fertig) => setTimeout(fertig, 100));
expect(melden).toHaveBeenCalledTimes(1);
uhr = 61_000;
sender.senden(EINTRAG);
await vi.waitFor(() => expect(melden).toHaveBeenCalledTimes(2));
sender.schliessen();
});
});