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>
262 lines
9.5 KiB
Go
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)
|
|
}
|
|
}
|