Files
patrickandClaude Sonnet 5.5 0c6a943440
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Failing after 1m2s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 5s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 5s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 22s
CI / Web (dms-browser) (push) Failing after 1m22s
CI / Web (pdf-viewer) (push) Failing after 53s
CI / Web (shl) (push) Failing after 11s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 1m6s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 3s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 21s
CI / Web (dms-browser) (pull_request) Failing after 1m22s
CI / Web (pdf-viewer) (pull_request) Failing after 57s
CI / Web (shl) (pull_request) Failing after 12s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 10m56s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 3m16s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m46s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 11m24s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 11m44s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 2s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 3s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
dms: testdb aus common statt interner Kopie
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:37:00 +01:00

190 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/common/testdb"
"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 := testdb.Pool(t, dsn)
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)
}
}