CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Failing after 1m2s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 5s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 5s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 22s
CI / Web (dms-browser) (push) Failing after 1m22s
CI / Web (pdf-viewer) (push) Failing after 53s
CI / Web (shl) (push) Failing after 11s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 1m6s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 3s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 21s
CI / Web (dms-browser) (pull_request) Failing after 1m22s
CI / Web (pdf-viewer) (pull_request) Failing after 57s
CI / Web (shl) (pull_request) Failing after 12s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 10m56s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 3m16s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m46s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 11m24s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 11m44s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 2s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 3s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
329 lines
13 KiB
Go
329 lines
13 KiB
Go
package documentapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
|
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
|
|
)
|
|
|
|
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<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\nxref\n0 4\n0000000000 65535 f \ntrailer<</Size 4/Root 1 0 R>>\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)
|
|
}
|
|
})
|
|
}
|
|
}
|