Files
nexarch/dms/internal/auditcoverage/coverage_test.go
patrickandClaude Sonnet 5 7c4932b16b
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
INT-11: gofmt und Pruefprotokoll
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:50:03 +02:00

199 lines
7.5 KiB
Go

// Package auditcoverage prueft VER-09 Ende zu Ende: ein Referenzablauf ueber
// alle protokollpflichtigen Aktionen gegen das echte Schema (alle
// Migrationen, eigenes Schema je Test) erzeugt exakt die erwarteten
// Audit-Eintraege in der erwarteten Reihenfolge.
package auditcoverage
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/audittrail"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/downloadapi"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/export"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/legalhold/legalholdtest"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/metadataapi"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/metadatafield"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/tag"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/tagapi"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testenv"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/trash"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/versioning"
)
func do(t *testing.T, method, url string, body []byte) *http.Response {
t.Helper()
req, _ := http.NewRequest(method, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
return resp
}
type expected struct{ actor, action, objectType string }
// Pruefung 1 + AK 1: Referenzablauf, exakte Eintraege in exakter Reihenfolge.
func TestReferenceFlow_AuditLogContainsExactlyTheExpectedEntries(t *testing.T) {
e := testenv.New(t)
ctx := context.Background()
var want []expected
add := func(actor, action, objectType string) { want = append(want, expected{actor, action, objectType}) }
// Upload
docID, rev1 := e.UploadDoc(t, "rechnung.txt", "text/plain", []byte("Rechnung 1"))
add(e.UserID, "document_created", "document")
// Neue Revision
if _, err := versioning.CheckOut(ctx, e.Pool, docID, e.UserID, time.Minute); err != nil {
t.Fatalf("checkout: %v", err)
}
if _, err := versioning.CheckIn(ctx, e.Pool, e.Storage, e.Crypto, testenv.TenantSlug, docID, e.UserID, "v2", "text/plain", strings.NewReader("Rechnung 2")); err != nil {
t.Fatalf("checkin: %v", err)
}
add(e.UserID, "revision_created", "document")
// Wiederherstellen
if _, err := versioning.Restore(ctx, e.Pool, e.Storage, e.Crypto, testenv.TenantSlug, docID, rev1, e.UserID); err != nil {
t.Fatalf("restore: %v", err)
}
add(e.UserID, "revision_restored", "document")
// Tags
tg, err := tag.Create(ctx, e.Pool, "review", "", nil)
if err != nil {
t.Fatalf("tag: %v", err)
}
mux := http.NewServeMux()
tagapi.NewServer(e.Pool).RegisterRoutes(mux)
metadataapi.NewServer(e.Pool).RegisterRoutes(mux)
dl := downloadapi.NewServer(e.Pool, e.Storage, e.Crypto, testenv.TenantSlug, &downloadapi.DocumentOwnerChecker{Pool: e.Pool}, []byte("secret"), time.Minute)
dl.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]string{"tag_id": tg.ID})
if r := do(t, http.MethodPost, srv.URL+"/documents/"+docID+"/tags?caller="+e.UserID, body); r.StatusCode != http.StatusNoContent {
t.Fatalf("tag zuweisen: %d", r.StatusCode)
}
add(e.UserID, "tag_assigned", "document")
if r := do(t, http.MethodDelete, srv.URL+"/documents/"+docID+"/tags/"+tg.ID+"?caller="+e.UserID, nil); r.StatusCode != http.StatusNoContent {
t.Fatalf("tag entfernen: %d", r.StatusCode)
}
add(e.UserID, "tag_removed", "document")
// Metadaten
field, err := metadatafield.Create(ctx, e.Pool, "betrag", "Betrag", "number", false, nil, nil)
if err != nil {
t.Fatalf("feld: %v", err)
}
body, _ = json.Marshal(map[string]string{field.ID: "12.50"})
if r := do(t, http.MethodPut, srv.URL+"/documents/"+docID+"/metadata?caller="+e.UserID, body); r.StatusCode != http.StatusNoContent {
t.Fatalf("metadaten: %d", r.StatusCode)
}
add(e.UserID, "metadata_changed", "document")
// Download-URL + Download
resp := do(t, http.MethodGet, srv.URL+"/documents/"+docID+"/download-url?caller="+e.UserID, nil)
var issued struct {
URL string `json:"url"`
}
_ = json.NewDecoder(resp.Body).Decode(&issued)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK || issued.URL == "" {
t.Fatalf("download-url: %d", resp.StatusCode)
}
add(e.UserID, "download_url_issued", "document")
dr := do(t, http.MethodGet, srv.URL+issued.URL, nil)
if _, err := io.Copy(io.Discard, dr.Body); err != nil || dr.StatusCode != http.StatusOK {
t.Fatalf("download: %d %v", dr.StatusCode, err)
}
_ = dr.Body.Close()
add(downloadapi.SignedURLActor, "document_downloaded", "document")
// Export
if _, err := export.Enqueue(ctx, e.Pool, jobqueue.NewQueue(e.Pool, time.Minute), e.UserID, []string{docID}, export.Limits{}); err != nil {
t.Fatalf("export: %v", err)
}
add(e.UserID, "export_requested", "export")
// Papierkorb: loeschen, wiederherstellen, loeschen, endgueltig loeschen
if err := trash.SoftDeleteDocument(ctx, e.Pool, nil, docID, e.UserID); err != nil {
t.Fatal(err)
}
add(e.UserID, "document_trashed", "document")
if err := trash.RestoreDocument(ctx, e.Pool, nil, docID, e.UserID); err != nil {
t.Fatal(err)
}
add(e.UserID, "document_restored_from_trash", "document")
if err := trash.SoftDeleteDocument(ctx, e.Pool, nil, docID, e.UserID); err != nil {
t.Fatal(err)
}
add(e.UserID, "document_trashed", "document")
if err := trash.PurgeDocumentNow(ctx, e.Pool, e.Storage, nil, legalholdtest.Allow{}, docID, e.UserID); err != nil {
t.Fatal(err)
}
add(e.UserID, "document_purged", "document")
// Automatische Endgueltig-Loeschung
doc2, _ := e.UploadDoc(t, "alt.txt", "text/plain", []byte("alt"))
add(e.UserID, "document_created", "document")
if err := trash.SoftDeleteDocument(ctx, e.Pool, nil, doc2, e.UserID); err != nil {
t.Fatal(err)
}
add(e.UserID, "document_trashed", "document")
if _, err := e.Pool.Exec(ctx, `UPDATE documents SET trashed_at = now() - interval '40 days' WHERE id = $1`, doc2); err != nil {
t.Fatal(err)
}
if n, _, err := trash.PurgeExpired(ctx, e.Pool, e.Storage, nil, legalholdtest.Allow{}, 30*24*time.Hour); err != nil || n != 1 {
t.Fatalf("purgeexpired: n=%d err=%v", n, err)
}
add(audittrail.SystemActor, "document_purged", "document")
entries, err := audittrail.List(ctx, e.Pool)
if err != nil {
t.Fatalf("list: %v", err)
}
var got []expected
for _, en := range entries {
got = append(got, expected{en.Actor, en.Action, en.ObjectType})
}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("protokoll weicht ab.\n got: %v\nwant: %v", got, want)
}
// Pruefung 3: Kette lueckenlos gueltig.
res, err := audittrail.VerifyChain(ctx, e.Pool)
if err != nil {
t.Fatalf("verifychain: %v", err)
}
if !res.Valid {
t.Fatalf("hash-kette ungueltig nach referenzablauf: %+v", res)
}
}
// Pruefung 2 + AK 2: kann nicht protokolliert werden, meldet die Aktion einen Fehler.
func TestActionFailsVisiblyWhenAuditLogCannotBeWritten(t *testing.T) {
e := testenv.New(t)
ctx := context.Background()
docID, _ := e.UploadDoc(t, "x.txt", "text/plain", []byte("x"))
if _, err := e.Pool.Exec(ctx, `ALTER TABLE audit_log ADD CONSTRAINT audit_log_offline CHECK (false) NOT VALID`); err != nil {
t.Fatalf("audit_log schreibsperre setzen: %v", err)
}
err := trash.SoftDeleteDocument(ctx, e.Pool, nil, docID, e.UserID)
if err == nil || !strings.Contains(err.Error(), "nicht protokolliert") {
t.Fatalf("erwartet sichtbaren protokollierungsfehler, habe %v", err)
}
}