Files
nexarch/dms/internal/auditapi/handler_test.go
T
patrickandClaude Sonnet 5 54e322f1e1 VER-06: audit-log-ansicht & export
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>
2026-09-18 00:24:59 +02:00

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)
}
}