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();
|
||||
});
|
||||
Reference in New Issue
Block a user