audittrail.ListFiltered (Zeitraum/Akteur/Aktion) + auditapi (Liste, gestreamter CSV-Export, Allowlist-Zugriffspruefung) als Backend- Voraussetzung. Audit-Log-Seite im dms-browser mit Filterformular und Export derselben gefilterten Menge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
192 lines
6.1 KiB
Go
192 lines
6.1 KiB
Go
package auditapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/audittrail"
|
|
)
|
|
|
|
func setupTest(t *testing.T) (*pgxpool.Pool, *httptest.Server) {
|
|
t.Helper()
|
|
dsn := os.Getenv("TEST_TENANT_DSN")
|
|
if dsn == "" {
|
|
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
t.Cleanup(func() { pool.Close() })
|
|
|
|
if _, err := pool.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id BIGSERIAL PRIMARY KEY, occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), actor TEXT NOT NULL,
|
|
action TEXT NOT NULL, object_type TEXT NOT NULL, object_reference TEXT NOT NULL,
|
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, prev_hash TEXT NOT NULL, entry_hash TEXT NOT NULL
|
|
);
|
|
`); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(context.Background(), `TRUNCATE audit_log RESTART IDENTITY`)
|
|
})
|
|
|
|
server := NewServer(pool, NewAllowlistRoleChecker([]string{"auditor-1"}))
|
|
mux := http.NewServeMux()
|
|
server.RegisterRoutes(mux)
|
|
httpServer := httptest.NewServer(mux)
|
|
t.Cleanup(httpServer.Close)
|
|
|
|
return pool, httpServer
|
|
}
|
|
|
|
// Akzeptanzkriterium 1: Filter nach Zeitraum, Akteur und Aktion vorhanden.
|
|
func TestHandleList_FiltersbyActorAndAction(t *testing.T) {
|
|
pool, server := setupTest(t)
|
|
ctx := context.Background()
|
|
if _, err := audittrail.Append(ctx, pool, "user-a", "login", "session", "s1", nil); err != nil {
|
|
t.Fatalf("append 1: %v", err)
|
|
}
|
|
if _, err := audittrail.Append(ctx, pool, "user-b", "logout", "session", "s2", nil); err != nil {
|
|
t.Fatalf("append 2: %v", err)
|
|
}
|
|
|
|
resp, err := http.Get(server.URL + "/audit-log?caller=auditor-1&actor=user-a")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
var entries []entryDTO
|
|
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(entries) != 1 || entries[0].Actor != "user-a" {
|
|
t.Fatalf("erwartet 1 eintrag von user-a, habe: %+v", entries)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 / Pflichtpruefung 2: nicht berechtigter Nutzer
|
|
// erhaelt keinen Zugriff.
|
|
func TestHandleList_RejectsUnauthorizedCaller(t *testing.T) {
|
|
_, server := setupTest(t)
|
|
resp, err := http.Get(server.URL + "/audit-log?caller=irgendwer")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2: Export enthaelt exakt die gefilterten Eintraege.
|
|
func TestHandleExport_ContainsExactlyFilteredEntries(t *testing.T) {
|
|
pool, server := setupTest(t)
|
|
ctx := context.Background()
|
|
if _, err := audittrail.Append(ctx, pool, "user-a", "login", "session", "s1", nil); err != nil {
|
|
t.Fatalf("append 1: %v", err)
|
|
}
|
|
if _, err := audittrail.Append(ctx, pool, "user-b", "login", "session", "s2", nil); err != nil {
|
|
t.Fatalf("append 2: %v", err)
|
|
}
|
|
|
|
resp, err := http.Get(server.URL + "/audit-log/export?caller=auditor-1&actor=user-a")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
|
|
reader := csv.NewReader(resp.Body)
|
|
rows, err := reader.ReadAll()
|
|
if err != nil {
|
|
t.Fatalf("csv lesen: %v", err)
|
|
}
|
|
if len(rows) != 2 { // header + 1 datenzeile
|
|
t.Fatalf("erwartet header + 1 zeile, habe %d zeilen: %v", len(rows), rows)
|
|
}
|
|
if rows[1][2] != "user-a" { // spalte "actor"
|
|
t.Fatalf("erwartet actor=user-a in der exportierten zeile, habe: %v", rows[1])
|
|
}
|
|
}
|
|
|
|
// Pflichtpruefung 3: Exportdatei ist in Tabellenkalkulation korrekt lesbar
|
|
// — reale RFC4180-CSV-Parsbarkeit inkl. Kommas/Anfuehrungszeichen in
|
|
// Feldern (object_reference kann beliebigen Text enthalten).
|
|
func TestHandleExport_ProducesValidCSVWithSpecialCharacters(t *testing.T) {
|
|
pool, server := setupTest(t)
|
|
ctx := context.Background()
|
|
if _, err := audittrail.Append(ctx, pool, "user-a", "note", "document", `Titel, "mit" Sonderzeichen`, nil); err != nil {
|
|
t.Fatalf("append: %v", err)
|
|
}
|
|
|
|
resp, err := http.Get(server.URL + "/audit-log/export?caller=auditor-1")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
reader := csv.NewReader(resp.Body)
|
|
rows, err := reader.ReadAll()
|
|
if err != nil {
|
|
t.Fatalf("csv nicht parsbar: %v", err)
|
|
}
|
|
if len(rows) != 2 || rows[1][5] != `Titel, "mit" Sonderzeichen` {
|
|
t.Fatalf("sonderzeichen nicht korrekt roundgetriped: %v", rows)
|
|
}
|
|
}
|
|
|
|
// Pflichtpruefung 1: Export mit 100.000 Eintraegen laeuft ohne Abbruch.
|
|
// Direkter Bulk-Insert statt 100.000 einzelner Append-Aufrufe (die
|
|
// Advisory-Lock-Serialisierung von Append ist bereits in VER-05 separat
|
|
// getestet) - hier geht es ausschliesslich um die Skalierung des
|
|
// Export-Lesepfads selbst.
|
|
func TestHandleExport_HandlesOneHundredThousandEntriesWithoutFailure(t *testing.T) {
|
|
pool, server := setupTest(t)
|
|
ctx := context.Background()
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO audit_log (actor, action, object_type, object_reference, prev_hash, entry_hash)
|
|
SELECT 'bulk-user', 'bulk-action', 'document', 'doc-' || gs, 'x', 'x' || gs
|
|
FROM generate_series(1, 100000) AS gs
|
|
`); err != nil {
|
|
t.Fatalf("bulk-insert: %v", err)
|
|
}
|
|
|
|
start := time.Now()
|
|
resp, err := http.Get(server.URL + "/audit-log/export?caller=auditor-1")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("body lesen: %v", err)
|
|
}
|
|
elapsed := time.Since(start)
|
|
t.Logf("export von 100.000 eintraegen: %s, %d bytes", elapsed, len(body))
|
|
|
|
lineCount := strings.Count(string(body), "\n")
|
|
if lineCount != 100001 { // header + 100.000 zeilen
|
|
t.Fatalf("erwartet 100.001 zeilen (inkl. header), habe %d", lineCount)
|
|
}
|
|
if elapsed > 30*time.Second {
|
|
t.Fatalf("export hat %s gedauert, erwartet deutlich schneller", elapsed)
|
|
}
|
|
}
|