package documentapi import ( "bytes" "context" "fmt" "net/http" "net/http/httptest" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" "gitea.perlbach24.de/scripte/nexarch/dms/internal/testdb" ) type fakeKEKProvider struct{ kek []byte } func (f *fakeKEKProvider) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) { return f.kek, nil } type noopUsageReporter struct{} func (noopUsageReporter) Report(ctx context.Context, tenantSlug, metric string, delta int64) error { return nil } func setupTest(t *testing.T) (*httptest.Server, *pgxpool.Pool, *storage.Service, *dmscrypto.Service, string) { 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 EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), folder_id UUID, title TEXT NOT NULL, current_revision_id UUID, created_by UUID NOT NULL REFERENCES users(id), trashed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS file_revisions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, revision_number INT NOT NULL, storage_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, size_bytes BIGINT NOT NULL, mime_type TEXT NOT NULL, created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), wrapped_dek BYTEA, UNIQUE (document_id, revision_number) ); CREATE TABLE IF NOT EXISTS revision_scans ( revision_id UUID PRIMARY KEY REFERENCES file_revisions(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK (status IN ('pending', 'processing', 'clean', 'infected')), finding TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS revision_pdf_derivatives ( revision_id UUID PRIMARY KEY REFERENCES file_revisions(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK (status IN ('pending', 'processing', 'ready', 'failed')), source_format TEXT, storage_key TEXT, mime_type TEXT, wrapped_dek BYTEA, error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE revision_pdf_derivatives, revision_scans, file_revisions, documents, users CASCADE`) }) storageSvc := storage.NewService(storage.NewLocalDriver(t.TempDir(), []byte("secret"), "https://files.example.test", 0), noopUsageReporter{}, "acme") cryptoSvc := dmscrypto.NewService(&fakeKEKProvider{kek: bytes.Repeat([]byte{0x33}, dmscrypto.KEKSize)}) server := NewServer(pool, storageSvc, cryptoSvc, "acme") mux := http.NewServeMux() server.RegisterRoutes(mux) httpServer := httptest.NewServer(mux) t.Cleanup(httpServer.Close) return httpServer, pool, storageSvc, cryptoSvc, "acme" } func createDocument(t *testing.T, pool *pgxpool.Pool, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug string, content []byte) string { t.Helper() ctx := context.Background() var userID string if err := pool.QueryRow(ctx, ` INSERT INTO users (email, name) VALUES ($1, 'Test-Benutzer') RETURNING id `, fmt.Sprintf("docapi-test-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil { t.Fatalf("testbenutzer anlegen: %v", err) } var documentID string if err := pool.QueryRow(ctx, ` INSERT INTO documents (title, created_by) VALUES ('Testdokument', $1) RETURNING id `, userID).Scan(&documentID); err != nil { t.Fatalf("dokument anlegen: %v", err) } env, err := cryptoSvc.Seal(ctx, tenantSlug, bytes.NewReader(content)) if err != nil { t.Fatalf("seal: %v", err) } var buf bytes.Buffer if _, err := buf.ReadFrom(env.Ciphertext); err != nil { t.Fatalf("chiffretext lesen: %v", err) } storageKey := storage.ObjectKey(documentID, "1") var revisionID string if err := pool.QueryRow(ctx, ` INSERT INTO file_revisions (document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by, wrapped_dek) VALUES ($1, 1, $2, 'x', $3, 'application/octet-stream', $4, $5) RETURNING id `, documentID, storageKey, len(content), userID, env.WrappedDEK).Scan(&revisionID); err != nil { t.Fatalf("revision anlegen: %v", err) } if _, err := pool.Exec(ctx, `UPDATE documents SET current_revision_id = $2 WHERE id = $1`, documentID, revisionID); err != nil { t.Fatalf("aktuelle revision setzen: %v", err) } if _, err := storageSvc.Put(ctx, storageKey, bytes.NewReader(buf.Bytes()), int64(buf.Len()), "application/octet-stream"); err != nil { t.Fatalf("objekt ablegen: %v", err) } return documentID } const minimalPDF = "%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n3 0 obj<>endobj\nxref\n0 4\n0000000000 65535 f \ntrailer<>\nstartxref\n0\n%%EOF" // Akzeptanzkriterium 1 + Pruefung: PDF wird ohne Download-Zwang // ausgeliefert - korrekter, ueber Magic-Bytes bestimmter Content-Type und // "inline" statt "attachment". func TestHandleContent_ServesPDFInlineWithCorrectContentType(t *testing.T) { server, pool, storageSvc, cryptoSvc, tenant := setupTest(t) documentID := createDocument(t, pool, storageSvc, cryptoSvc, tenant, []byte(minimalPDF)) resp, err := http.Get(server.URL + "/documents/" + documentID + "/content") if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); ct != "application/pdf" { t.Errorf("content-type = %q, want application/pdf", ct) } disposition := resp.Header.Get("Content-Disposition") if disposition != "inline" { t.Errorf("content-disposition = %q, want \"inline\" (kein download-zwang)", disposition) } var buf bytes.Buffer _, _ = buf.ReadFrom(resp.Body) if buf.String() != minimalPDF { t.Error("ausgelieferter inhalt stimmt nicht mit dem original ueberein") } } // Akzeptanzkriterium 3 + Pruefung: grosse Dokumente werden seitenweise // nachgeladen - echter HTTP-Range-Request liefert einen Teilbereich mit // Status 206, nicht das gesamte Dokument. func TestHandleContent_SupportsRangeRequestsForPartialLoading(t *testing.T) { server, pool, storageSvc, cryptoSvc, tenant := setupTest(t) // Groesserer, deterministischer Inhalt, damit ein Range-Request // tatsaechlich nur einen Teil zurueckliefert. content := bytes.Repeat([]byte("0123456789"), 10000) // 100.000 bytes documentID := createDocument(t, pool, storageSvc, cryptoSvc, tenant, content) req, _ := http.NewRequest(http.MethodGet, server.URL+"/documents/"+documentID+"/content", nil) req.Header.Set("Range", "bytes=100-199") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusPartialContent { t.Fatalf("status = %d, want 206 (Partial Content)", resp.StatusCode) } var buf bytes.Buffer _, _ = buf.ReadFrom(resp.Body) if buf.Len() != 100 { t.Fatalf("erwartet 100 bytes teilinhalt, hatte %d", buf.Len()) } if !bytes.Equal(buf.Bytes(), content[100:200]) { t.Error("teilinhalt stimmt nicht mit dem erwarteten byte-bereich des originals ueberein") } } // Nachweis des Fehlerverhaltens: unbekanntes Dokument liefert 404, kein // Absturz. func TestHandleContent_UnknownDocumentReturns404(t *testing.T) { server, _, _, _, _ := setupTest(t) resp, err := http.Get(server.URL + "/documents/00000000-0000-0000-0000-000000000000/content") if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNotFound { t.Fatalf("status = %d, want 404", resp.StatusCode) } } func addDerivative(t *testing.T, pool *pgxpool.Pool, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug, documentID, status string, pdf []byte) { t.Helper() ctx := context.Background() var revisionID string if err := pool.QueryRow(ctx, `SELECT current_revision_id FROM documents WHERE id = $1`, documentID).Scan(&revisionID); err != nil { t.Fatalf("revision lesen: %v", err) } if status != "ready" { if _, err := pool.Exec(ctx, `INSERT INTO revision_pdf_derivatives (revision_id, status) VALUES ($1, $2)`, revisionID, status); err != nil { t.Fatalf("derivat anlegen: %v", err) } return } env, err := cryptoSvc.Seal(ctx, tenantSlug, bytes.NewReader(pdf)) if err != nil { t.Fatalf("seal: %v", err) } var buf bytes.Buffer if _, err := buf.ReadFrom(env.Ciphertext); err != nil { t.Fatalf("chiffretext lesen: %v", err) } key := storage.ObjectKey(documentID, revisionID) + "/office.pdf" if _, err := storageSvc.Put(ctx, key, bytes.NewReader(buf.Bytes()), int64(buf.Len()), "application/octet-stream"); err != nil { t.Fatalf("derivat ablegen: %v", err) } if _, err := pool.Exec(ctx, ` INSERT INTO revision_pdf_derivatives (revision_id, status, source_format, storage_key, mime_type, wrapped_dek) VALUES ($1, 'ready', 'docx', $2, 'application/pdf', $3) `, revisionID, key, env.WrappedDEK); err != nil { t.Fatalf("derivat anlegen: %v", err) } } func fetchContent(t *testing.T, url string) (*http.Response, []byte) { t.Helper() resp, err := http.Get(url) if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() var buf bytes.Buffer _, _ = buf.ReadFrom(resp.Body) return resp, buf.Bytes() } // DOC-20 AK 1 / Pruefung 1: .docx-Original mit fertigem Derivat liefert // PDF-Bytes (%PDF), nicht die urspruenglichen .docx-Bytes. func TestHandleContent_ServesPDFDerivativeForOfficeOriginal(t *testing.T) { server, pool, storageSvc, cryptoSvc, tenant := setupTest(t) docx := append([]byte("PK\x03\x04"), bytes.Repeat([]byte("docx"), 100)...) documentID := createDocument(t, pool, storageSvc, cryptoSvc, tenant, docx) addDerivative(t, pool, storageSvc, cryptoSvc, tenant, documentID, "ready", []byte(minimalPDF)) resp, body := fetchContent(t, server.URL+"/documents/"+documentID+"/content") if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } if !bytes.HasPrefix(body, []byte("%PDF")) { t.Fatalf("body beginnt nicht mit %%PDF: %q", body[:8]) } if string(body) != minimalPDF { t.Error("ausgeliefertes pdf stimmt nicht mit dem derivat ueberein") } if ct := resp.Header.Get("Content-Type"); ct != "application/pdf" { t.Errorf("content-type = %q, want application/pdf", ct) } if src := resp.Header.Get("X-Content-Source"); src != "pdf-derivative" { t.Errorf("X-Content-Source = %q, want pdf-derivative", src) } } // DOC-20 AK 2 / Pruefung 2: PDF-Original ohne Derivat wird byteidentisch // ausgeliefert. func TestHandleContent_PDFOriginalWithoutDerivativeIsByteIdentical(t *testing.T) { server, pool, storageSvc, cryptoSvc, tenant := setupTest(t) documentID := createDocument(t, pool, storageSvc, cryptoSvc, tenant, []byte(minimalPDF)) resp, body := fetchContent(t, server.URL+"/documents/"+documentID+"/content") if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } if !bytes.Equal(body, []byte(minimalPDF)) { t.Error("ausgeliefertes pdf ist nicht byteidentisch mit dem original") } if src := resp.Header.Get("X-Content-Source"); src != "" { t.Errorf("X-Content-Source = %q, want leer ohne derivat", src) } } // DOC-20 Pruefung 3: pending/failed liefert das Original samt sichtbarem // Status-Header statt stillem Ausfall. func TestHandleContent_NotReadyDerivativeFallsBackToOriginalWithStatus(t *testing.T) { for _, status := range []string{"pending", "processing", "failed"} { t.Run(status, func(t *testing.T) { server, pool, storageSvc, cryptoSvc, tenant := setupTest(t) original := append([]byte("PK\x03\x04"), bytes.Repeat([]byte("docx"), 100)...) documentID := createDocument(t, pool, storageSvc, cryptoSvc, tenant, original) addDerivative(t, pool, storageSvc, cryptoSvc, tenant, documentID, status, nil) resp, body := fetchContent(t, server.URL+"/documents/"+documentID+"/content") if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } if !bytes.Equal(body, original) { t.Error("fallback lieferte nicht das original") } if got := resp.Header.Get("X-Derivative-Status"); got != status { t.Errorf("X-Derivative-Status = %q, want %q", got, status) } if src := resp.Header.Get("X-Content-Source"); src != "original" { t.Errorf("X-Content-Source = %q, want original", src) } }) } }