- 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>
403 lines
14 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|