Files
nexarch/dms/internal/downloadapi/handler_test.go
T
patrickandClaude Sonnet 5 4507e7f861 DOC-07: download & signierte urls
downloadapi: HMAC-signierte, zeitlich befristete Download-URLs fuer die
verschluesselten Originale. Berechtigung wird beim Ausstellen geprueft
(DocumentOwnerChecker), die Signatur selbst ist danach der Zugriffsnachweis.
RFC-5987-Dateinamenkodierung fuer Sonderzeichen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 00:48:14 +02:00

262 lines
9.5 KiB
Go

package downloadapi
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"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"
)
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
}
type testEnv struct {
pool *pgxpool.Pool
storageSvc *storage.Service
cryptoSvc *dmscrypto.Service
server *httptest.Server
}
func setupTest(t *testing.T, ttl time.Duration) (*testEnv, string) {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
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),
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)
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE file_revisions, documents, users CASCADE`) })
storageSvc := storage.NewService(storage.NewLocalDriver(t.TempDir(), []byte("storage-secret"), "https://files.example.test"), noopUsageReporter{}, "acme")
cryptoSvc := dmscrypto.NewService(&fakeKEKProvider{kek: bytes.Repeat([]byte{0x11}, dmscrypto.KEKSize)})
server := NewServer(pool, storageSvc, cryptoSvc, "acme", &DocumentOwnerChecker{Pool: pool}, []byte("download-secret"), ttl)
mux := http.NewServeMux()
server.RegisterRoutes(mux)
httpServer := httptest.NewServer(mux)
t.Cleanup(httpServer.Close)
var userID string
if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ($1, 'A') RETURNING id`, fmt.Sprintf("downloadapi-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil {
t.Fatalf("user: %v", err)
}
return &testEnv{pool: pool, storageSvc: storageSvc, cryptoSvc: cryptoSvc, server: httpServer}, userID
}
func createDocumentWithContent(t *testing.T, env *testEnv, userID, title, content, mimeType string) string {
t.Helper()
ctx := context.Background()
var documentID string
if err := env.pool.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ($1, $2) RETURNING id`, title, userID).Scan(&documentID); err != nil {
t.Fatalf("dokument anlegen: %v", err)
}
envSeal, err := env.cryptoSvc.Seal(ctx, "acme", strings.NewReader(content))
if err != nil {
t.Fatalf("seal: %v", err)
}
ciphertext, err := io.ReadAll(envSeal.Ciphertext)
if err != nil {
t.Fatalf("ciphertext lesen: %v", err)
}
storageKey := storage.ObjectKey(documentID, "1")
if _, err := env.storageSvc.Put(ctx, storageKey, bytes.NewReader(ciphertext), int64(len(ciphertext)), "application/octet-stream"); err != nil {
t.Fatalf("objekt ablegen: %v", err)
}
var revisionID string
if err := env.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, $4, $5, $6) RETURNING id
`, documentID, storageKey, len(ciphertext), mimeType, userID, envSeal.WrappedDEK).Scan(&revisionID); err != nil {
t.Fatalf("revision anlegen: %v", err)
}
if _, err := env.pool.Exec(ctx, `UPDATE documents SET current_revision_id = $2 WHERE id = $1`, documentID, revisionID); err != nil {
t.Fatalf("aktuelle revision setzen: %v", err)
}
return documentID
}
type urlResponse struct {
URL string `json:"url"`
ExpiresAt string `json:"expires_at"`
}
func requestDownloadURL(t *testing.T, env *testEnv, documentID, caller string) (urlResponse, int) {
t.Helper()
resp, err := http.Get(env.server.URL + "/documents/" + documentID + "/download-url?caller=" + caller)
if err != nil {
t.Fatalf("get download-url: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var out urlResponse
_ = json.NewDecoder(resp.Body).Decode(&out)
return out, resp.StatusCode
}
// Akzeptanzkriterium 1 / Pflichtpruefung 1: Download liefert die
// Originaldatei unveraendert (Pruefsumme vor/nach identisch).
func TestHandleDownload_DeliversUnmodifiedOriginal(t *testing.T) {
env, userID := setupTest(t, time.Minute)
content := "Vertragsinhalt mit Ümlaut und Zahlen 12345."
documentID := createDocumentWithContent(t, env, userID, "vertrag.pdf", content, "application/pdf")
urlResp, status := requestDownloadURL(t, env, documentID, userID)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
downloadResp, err := http.Get(env.server.URL + urlResp.URL)
if err != nil {
t.Fatalf("download: %v", err)
}
defer func() { _ = downloadResp.Body.Close() }()
if downloadResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(downloadResp.Body)
t.Fatalf("status = %d, want 200: %s", downloadResp.StatusCode, body)
}
body, err := io.ReadAll(downloadResp.Body)
if err != nil {
t.Fatalf("body lesen: %v", err)
}
wantSum := sha256.Sum256([]byte(content))
gotSum := sha256.Sum256(body)
if hex.EncodeToString(wantSum[:]) != hex.EncodeToString(gotSum[:]) {
t.Fatalf("pruefsumme vor/nach download unterschiedlich: original=%q, erhalten=%q", content, string(body))
}
}
// Akzeptanzkriterium 2 / Pflichtpruefung 2: eine abgelaufene URL wird
// abgewiesen.
func TestHandleDownload_RejectsExpiredURL(t *testing.T) {
env, userID := setupTest(t, -1*time.Second) // sofort abgelaufen
documentID := createDocumentWithContent(t, env, userID, "alt.pdf", "inhalt", "application/pdf")
urlResp, status := requestDownloadURL(t, env, documentID, userID)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
resp, err := http.Get(env.server.URL + urlResp.URL)
if err != nil {
t.Fatalf("download: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403 fuer abgelaufene url", resp.StatusCode)
}
}
func TestHandleDownload_RejectsTamperedSignature(t *testing.T) {
env, userID := setupTest(t, time.Minute)
documentID := createDocumentWithContent(t, env, userID, "x.pdf", "inhalt", "application/pdf")
urlResp, status := requestDownloadURL(t, env, documentID, userID)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
tampered := strings.Replace(urlResp.URL, "sig=", "sig=AAAA", 1)
resp, err := http.Get(env.server.URL + tampered)
if err != nil {
t.Fatalf("download: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403 fuer manipulierte signatur", resp.StatusCode)
}
}
// Akzeptanzkriterium 3: ohne Leseberechtigung kein Download — die
// Berechtigung wird bereits beim AUSSTELLEN der URL geprueft.
func TestHandleRequestURL_RejectsUnauthorizedCaller(t *testing.T) {
env, userID := setupTest(t, time.Minute)
ctx := context.Background()
documentID := createDocumentWithContent(t, env, userID, "geheim.pdf", "inhalt", "application/pdf")
var otherUserID string
if err := env.pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ($1, 'B') RETURNING id`, fmt.Sprintf("other-%d@example.test", time.Now().UnixNano())).Scan(&otherUserID); err != nil {
t.Fatalf("anderen benutzer anlegen: %v", err)
}
_, status := requestDownloadURL(t, env, documentID, otherUserID)
if status != http.StatusForbidden {
t.Fatalf("status = %d, want 403 fuer nicht-berechtigten aufrufer", status)
}
}
// Pflichtpruefung 3: Sonderzeichen im Dateinamen korrekt uebertragen.
func TestHandleDownload_TransfersSpecialCharactersInFilename(t *testing.T) {
env, userID := setupTest(t, time.Minute)
title := "Rechnung Müller & Söhne (Kopie) — 2026.pdf"
documentID := createDocumentWithContent(t, env, userID, title, "inhalt", "application/pdf")
urlResp, status := requestDownloadURL(t, env, documentID, userID)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
resp, err := http.Get(env.server.URL + urlResp.URL)
if err != nil {
t.Fatalf("download: %v", err)
}
defer func() { _ = resp.Body.Close() }()
disposition := resp.Header.Get("Content-Disposition")
wantEncoded := "filename*=UTF-8''" + url.PathEscape(title)
if !strings.Contains(disposition, wantEncoded) {
t.Fatalf("content-disposition = %q, erwartet enthaelt %q", disposition, wantEncoded)
}
}