Files
nexarch/dms/internal/gates/documentcore/qa03_test.go
T
patrickandClaude Sonnet 5.5 5b8c20777d refactor: Pruefgate-Pakete nach Funktion benannt, migration* gruppiert
- dms/ai/workflow: qa0x-Pakete nach internal/gates/<thema> verschoben, Paketname = Thema
- internal/migration{acl,audit,...} -> internal/migration/{acl,audit,...}
- Importpfade, Gate-Skripte und zwei relative Migrationspfade angepasst
- keine Logikaenderung; mail-Gates bewusst unveraendert (pfadabhaengig)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 12:44:21 +01:00

403 lines
14 KiB
Go

// Package documentcore ist das Pruefgate "Dokumentenkern" (QA-03): der komplette
// Lebenszyklus hochladen, ansehen, ablegen, herunterladen, loeschen,
// wiederherstellen als automatisierter Test gegen das echte, voll migrierte
// Schema, mit Pruefsummenvergleich ueber alle Schritte, Abbruchszenarien und
// dem vollstaendigen Referenzkorpus (QA-01).
package documentcore
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/documentapi"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/downloadapi"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/folder"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/folderapi"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/legalhold/legalholdtest"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testcorpus"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testenv"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/trash"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/upload"
)
func sum(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
type app struct {
e *testenv.Env
srv *httptest.Server
}
func newApp(t *testing.T, e *testenv.Env, ttl time.Duration) *app {
t.Helper()
mux := http.NewServeMux()
documentapi.NewServer(e.Pool, e.Storage, e.Crypto, testenv.TenantSlug).RegisterRoutes(mux)
folderapi.NewServer(e.Pool).RegisterRoutes(mux)
downloadapi.NewServer(e.Pool, e.Storage, e.Crypto, testenv.TenantSlug, &downloadapi.DocumentOwnerChecker{Pool: e.Pool}, []byte("qa03-secret"), ttl).RegisterRoutes(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return &app{e: e, srv: srv}
}
func (a *app) get(t *testing.T, path string) (int, http.Header, []byte) {
t.Helper()
resp, err := http.Get(a.srv.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
return resp.StatusCode, resp.Header, body
}
func (a *app) view(t *testing.T, docID string) (int, []byte) {
status, _, body := a.get(t, "/documents/"+docID+"/content")
return status, body
}
func (a *app) download(t *testing.T, docID string) (int, []byte) {
t.Helper()
status, _, body := a.get(t, "/documents/"+docID+"/download-url?caller="+a.e.UserID)
if status != http.StatusOK {
return status, body
}
var out struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("download-url dekodieren: %v", err)
}
status, _, body = a.get(t, out.URL)
return status, body
}
func (a *app) folderDocs(t *testing.T, folderID string) []string {
t.Helper()
status, _, body := a.get(t, "/folders/"+folderID+"/documents?caller="+a.e.UserID)
if status != http.StatusOK {
t.Fatalf("ordnerinhalt: %d %s", status, body)
}
var docs []struct {
ID string `json:"id"`
}
if err := json.Unmarshal(body, &docs); err != nil {
t.Fatalf("ordnerinhalt dekodieren: %v (%s)", err, body)
}
ids := make([]string, len(docs))
for i, d := range docs {
ids[i] = d.ID
}
return ids
}
func contains(ids []string, id string) bool {
for _, x := range ids {
if x == id {
return true
}
}
return false
}
func referencePDF(t *testing.T) []byte {
t.Helper()
files, err := testcorpus.Generate()
if err != nil {
t.Fatal(err)
}
for _, f := range files {
if strings.HasSuffix(f.Path, ".pdf") {
return f.Content
}
}
t.Fatal("kein PDF im Referenzkorpus")
return nil
}
// AK 1 + 2 / Pruefung 1: Lebenszyklus vollstaendig, Pruefsumme an jedem Schritt identisch.
func TestLifecycle_ChecksumUnchangedThroughAllSteps(t *testing.T) {
e := testenv.New(t)
ctx := context.Background()
a := newApp(t, e, time.Minute)
original := referencePDF(t)
want := sum(original)
fld, err := folder.Create(ctx, e.Pool, "Eingang", nil, e.UserID)
if err != nil {
t.Fatalf("ordner: %v", err)
}
// 1 hochladen (Pruefsumme des Klartexts in der Datenbank)
docID, revID := e.UploadDocInFolder(t, "rechnung.pdf", "application/pdf", original, &fld.ID)
var stored string
if err := e.Pool.QueryRow(ctx, `SELECT checksum_sha256 FROM file_revisions WHERE id = $1`, revID).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != want {
t.Fatalf("1 hochladen: gespeicherte pruefsumme %s != %s", stored, want)
}
// 2 ansehen
status, body := a.view(t, docID)
if status != http.StatusOK || sum(body) != want {
t.Fatalf("2 ansehen: status %d, pruefsumme %s (want %s)", status, sum(body), want)
}
// 3 ablegen
if !contains(a.folderDocs(t, fld.ID), docID) {
t.Fatal("3 ablegen: dokument fehlt im zielordner")
}
// 4 herunterladen
status, body = a.download(t, docID)
if status != http.StatusOK || sum(body) != want {
t.Fatalf("4 herunterladen: status %d, pruefsumme %s (want %s)", status, sum(body), want)
}
// 5 loeschen (Papierkorb): aus allen normalen Ansichten verschwunden
if err := trash.SoftDeleteDocument(ctx, e.Pool, nil, docID, e.UserID); err != nil {
t.Fatal(err)
}
if contains(a.folderDocs(t, fld.ID), docID) {
t.Error("5 loeschen: dokument noch in der ordneransicht")
}
if status, _ := a.view(t, docID); status != http.StatusNotFound {
t.Errorf("5 loeschen: ansehen liefert %d, want 404", status)
}
if status, _ := a.download(t, docID); status != http.StatusNotFound {
t.Errorf("5 loeschen: herunterladen liefert %d, want 404 (Direktzugriff per ID muss gesperrt sein)", status)
}
// 6 wiederherstellen: alles wieder da, Inhalt unveraendert
if err := trash.RestoreDocument(ctx, e.Pool, nil, docID, e.UserID); err != nil {
t.Fatal(err)
}
if !contains(a.folderDocs(t, fld.ID), docID) {
t.Fatal("6 wiederherstellen: dokument fehlt wieder im ordner")
}
status, body = a.view(t, docID)
if status != http.StatusOK || sum(body) != want {
t.Fatalf("6 wiederherstellen/ansehen: status %d, pruefsumme %s", status, sum(body))
}
status, body = a.download(t, docID)
if status != http.StatusOK || sum(body) != want {
t.Fatalf("6 wiederherstellen/herunterladen: status %d, pruefsumme %s", status, sum(body))
}
// 7 endgueltig loeschen: Datenbank und Storage-Objekt weg
var storageKey string
if err := e.Pool.QueryRow(ctx, `SELECT storage_key FROM file_revisions WHERE id = $1`, revID).Scan(&storageKey); err != nil {
t.Fatal(err)
}
if err := trash.SoftDeleteDocument(ctx, e.Pool, nil, docID, e.UserID); err != nil {
t.Fatal(err)
}
if err := trash.PurgeDocumentNow(ctx, e.Pool, e.Storage, nil, legalholdtest.Allow{}, docID, e.UserID); err != nil {
t.Fatalf("7 endgueltig loeschen: %v", err)
}
if status, _ := a.view(t, docID); status != http.StatusNotFound {
t.Errorf("7: ansehen liefert %d, want 404", status)
}
if rc, err := e.Storage.Get(ctx, storageKey); err == nil {
_ = rc.Close()
t.Error("7: storage-objekt existiert nach endgueltigem loeschen noch")
}
}
// Pruefung 2: Abbruchszenarien in jedem Schritt.
func TestAbortScenarios_EveryStep(t *testing.T) {
e := testenv.New(t)
ctx := context.Background()
t.Run("hochladen: unvollstaendiger Upload legt kein Dokument an, Fortsetzen liefert identische Pruefsumme", func(t *testing.T) {
svc := e.UploadService(t)
content := bytes.Repeat([]byte("abcdefghij"), 100)
sid, err := svc.StartUpload(ctx, "gross.txt", "text/plain", int64(len(content)), nil, e.UserID)
if err != nil {
t.Fatal(err)
}
if _, err := svc.UploadChunk(ctx, sid, 0, bytes.NewReader(content[:400])); err != nil {
t.Fatal(err)
}
var before int
_ = e.Pool.QueryRow(ctx, `SELECT count(*) FROM documents WHERE title = 'gross.txt'`).Scan(&before)
if _, _, err := svc.Complete(ctx, sid, false); err == nil {
t.Fatal("complete auf unvollstaendiger sitzung wurde nicht abgelehnt")
}
var after int
_ = e.Pool.QueryRow(ctx, `SELECT count(*) FROM documents WHERE title = 'gross.txt'`).Scan(&after)
if before != 0 || after != 0 {
t.Fatalf("abgebrochener upload hat %d dokumente hinterlassen", after)
}
st, err := svc.Status(ctx, sid)
if err != nil || st.BytesReceived != 400 {
t.Fatalf("status nach abbruch: %+v %v", st, err)
}
if _, err := svc.UploadChunk(ctx, sid, st.BytesReceived, bytes.NewReader(content[400:])); err != nil {
t.Fatalf("fortsetzen: %v", err)
}
docID, revID, err := svc.Complete(ctx, sid, false)
if err != nil {
t.Fatalf("complete nach fortsetzen: %v", err)
}
var stored string
_ = e.Pool.QueryRow(ctx, `SELECT checksum_sha256 FROM file_revisions WHERE id = $1`, revID).Scan(&stored)
if stored != sum(content) {
t.Fatalf("pruefsumme nach fortsetzen %s != %s (dokument %s)", stored, sum(content), docID)
}
})
t.Run("hochladen: unzulaessiger Typ und Groesse werden vor dem Speichern abgewiesen", func(t *testing.T) {
svc := e.UploadService(t)
if _, err := svc.StartUpload(ctx, "x.exe", "application/x-msdownload", 10, nil, e.UserID); !errors.Is(err, upload.ErrDisallowedMimeType) {
t.Fatalf("typ: %v", err)
}
if _, err := svc.StartUpload(ctx, "riesig.txt", "text/plain", 1<<40, nil, e.UserID); err == nil {
t.Fatal("uebergroesse nicht abgewiesen")
}
})
a := newApp(t, e, time.Minute)
docID, _ := e.UploadDoc(t, "beleg.txt", "text/plain", []byte("Beleg"))
t.Run("ansehen: unbekanntes Dokument 404", func(t *testing.T) {
if status, _ := a.view(t, "00000000-0000-0000-0000-000000000000"); status != http.StatusNotFound {
t.Fatalf("status %d", status)
}
})
t.Run("ablegen: Verschieben in eigenen Unterordner (Zyklus) wird verhindert", func(t *testing.T) {
root, err := folder.Create(ctx, e.Pool, "A", nil, e.UserID)
if err != nil {
t.Fatal(err)
}
child, err := folder.Create(ctx, e.Pool, "B", &root.ID, e.UserID)
if err != nil {
t.Fatal(err)
}
if err := folder.Move(ctx, e.Pool, root.ID, &child.ID); err == nil {
t.Fatal("zyklisches verschieben nicht abgewiesen")
}
})
t.Run("herunterladen: abgelaufene und gefaelschte URL werden abgewiesen", func(t *testing.T) {
expired := newApp(t, e, -time.Second)
if status, _ := expired.download(t, docID); status != http.StatusForbidden {
t.Errorf("abgelaufene url: %d, want 403", status)
}
status, _, body := a.get(t, "/documents/"+docID+"/download-url?caller="+e.UserID)
var out struct {
URL string `json:"url"`
}
_ = json.Unmarshal(body, &out)
if status != http.StatusOK {
t.Fatalf("url: %d", status)
}
forged := strings.Replace(out.URL, "sig=", "sig=x", 1)
if status, _, _ := a.get(t, forged); status != http.StatusForbidden {
t.Errorf("gefaelschte signatur: %d, want 403", status)
}
if status, _, _ := a.get(t, "/documents/"+docID+"/download-url?caller=fremder-nutzer"); status != http.StatusForbidden {
t.Errorf("fremder nutzer: %d, want 403", status)
}
})
t.Run("loeschen: endgueltiges Loeschen eines aktiven Dokuments wird abgelehnt", func(t *testing.T) {
if err := trash.PurgeDocumentNow(ctx, e.Pool, e.Storage, nil, legalholdtest.Allow{}, docID, e.UserID); !errors.Is(err, trash.ErrNotTrashed) {
t.Fatalf("erwartet ErrNotTrashed, habe %v", err)
}
if status, _ := a.view(t, docID); status != http.StatusOK {
t.Fatalf("dokument nach abgelehntem purge nicht mehr sichtbar: %d", status)
}
})
t.Run("wiederherstellen: nach endgueltigem Loeschen kehrt nichts zurueck", func(t *testing.T) {
gone, _ := e.UploadDoc(t, "weg.txt", "text/plain", []byte("weg"))
if err := trash.SoftDeleteDocument(ctx, e.Pool, nil, gone, e.UserID); err != nil {
t.Fatal(err)
}
if err := trash.PurgeDocumentNow(ctx, e.Pool, e.Storage, nil, legalholdtest.Allow{}, gone, e.UserID); err != nil {
t.Fatal(err)
}
if err := trash.RestoreDocument(ctx, e.Pool, nil, gone, e.UserID); err != nil {
t.Fatalf("restore auf geloeschtes dokument darf nicht fehlschlagen: %v", err)
}
if status, _ := a.view(t, gone); status != http.StatusNotFound {
t.Fatalf("endgueltig geloeschtes dokument ist wieder sichtbar: %d", status)
}
})
}
var corpusMime = map[string]string{
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".tiff": "image/tiff",
}
// Pruefung 3: der vollstaendige Referenzkorpus durchlaeuft den ganzen Lebenszyklus.
func TestReferenceCorpus_FullLifecycle(t *testing.T) {
e := testenv.New(t)
ctx := context.Background()
a := newApp(t, e, time.Minute)
files, err := testcorpus.Generate()
if err != nil || len(files) == 0 {
t.Fatalf("korpus: %d dateien, %v", len(files), err)
}
seenExt := map[string]bool{}
for _, f := range files {
ext := f.Path[strings.LastIndex(f.Path, "."):]
mime, ok := corpusMime[ext]
if !ok {
t.Fatalf("korpusdatei %s: kein MIME-Typ hinterlegt (Testaufbau erweitern)", f.Path)
}
seenExt[ext] = true
t.Run(f.Path, func(t *testing.T) {
want := sum(f.Content)
docID, revID := e.UploadDoc(t, f.Path[strings.LastIndex(f.Path, "/")+1:], mime, f.Content)
var stored string
_ = e.Pool.QueryRow(ctx, `SELECT checksum_sha256 FROM file_revisions WHERE id = $1`, revID).Scan(&stored)
if stored != want {
t.Fatalf("upload: pruefsumme %s != %s", stored, want)
}
if status, body := a.view(t, docID); status != http.StatusOK || sum(body) != want {
t.Fatalf("ansehen: %d %s", status, sum(body))
}
if status, body := a.download(t, docID); status != http.StatusOK || sum(body) != want {
t.Fatalf("herunterladen: %d %s", status, sum(body))
}
if err := trash.SoftDeleteDocument(ctx, e.Pool, nil, docID, e.UserID); err != nil {
t.Fatal(err)
}
if status, _ := a.download(t, docID); status != http.StatusNotFound {
t.Fatalf("geloescht: download %d, want 404", status)
}
if err := trash.RestoreDocument(ctx, e.Pool, nil, docID, e.UserID); err != nil {
t.Fatal(err)
}
if status, body := a.download(t, docID); status != http.StatusOK || sum(body) != want {
t.Fatalf("wiederhergestellt: %d %s", status, sum(body))
}
})
}
for ext := range corpusMime {
if !seenExt[ext] {
t.Errorf("format %s kommt im referenzkorpus nicht vor", ext)
}
}
}