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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
199 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|