Files
nexarch/dms/internal/uploadapi/handler_test.go
T
patrickandClaude Sonnet 5 00214d205b
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Integration: NewLocalDriver-Aufrufer an FDN-10-Signatur angepasst (maxTTL 0 = Standard)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 00:22:02 +02:00

201 lines
6.9 KiB
Go

package uploadapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testdb"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/upload"
)
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, 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()
);
`); err != nil {
t.Fatalf("users: %v", err)
}
migrations, err := migrate.Load(filepath.Join("..", "..", "migrations", "tenant"))
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(ctx, pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE upload_sessions, file_revisions, documents, folders, users CASCADE`)
})
var userID string
if err := pool.QueryRow(ctx, `
INSERT INTO users (email, name) VALUES ($1, 'Test-Benutzer') RETURNING id
`, fmt.Sprintf("uploadapi-test-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil {
t.Fatalf("testbenutzer anlegen: %v", err)
}
sessions := upload.NewSessionStore(pool)
staging := upload.NewStaging(t.TempDir())
storageSvc := storage.NewService(storage.NewLocalDriver(t.TempDir(), []byte("secret"), "https://files.example.test", 0), noopUsageReporter{}, "acme")
cryptoSvc := dmscrypto.NewService(&fakeKEKProvider{kek: bytes.Repeat([]byte{0x22}, dmscrypto.KEKSize)})
validator := upload.NewValidator(10*1024*1024, []string{"application/pdf", "text/plain"})
svc := upload.NewService(pool, sessions, staging, storageSvc, cryptoSvc, validator, "acme")
server := NewServer(svc)
mux := http.NewServeMux()
server.RegisterRoutes(mux)
httpServer := httptest.NewServer(mux)
t.Cleanup(httpServer.Close)
return httpServer, userID
}
// TestUploadFlow_StartChunkStatusComplete ist Akzeptanzkriterium 1/2 aus
// Sicht des HTTP-Kontrakts: eine vollstaendige Session ueber die
// oeffentliche Schnittstelle, wie es das Frontend tut.
func TestUploadFlow_StartChunkStatusComplete(t *testing.T) {
server, userID := setupTest(t)
content := []byte("Testinhalt fuer DOC-03 HTTP-Kontrakt")
startBody, _ := json.Marshal(map[string]any{
"filename": "test.pdf", "mimeType": "application/pdf", "totalSize": len(content),
})
req, _ := http.NewRequest(http.MethodPost, server.URL+"/uploads", bytes.NewReader(startBody))
req.Header.Set("X-User-Id", userID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("start: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("start status = %d, want 201", resp.StatusCode)
}
var startResp startResponse
if err := json.NewDecoder(resp.Body).Decode(&startResp); err != nil {
t.Fatalf("start decode: %v", err)
}
if startResp.SessionID == "" {
t.Fatal("erwartet nicht-leere session-id")
}
chunkReq, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/uploads/%s/chunk?offset=0", server.URL, startResp.SessionID), bytes.NewReader(content))
chunkResp, err := http.DefaultClient.Do(chunkReq)
if err != nil {
t.Fatalf("chunk: %v", err)
}
defer chunkResp.Body.Close()
if chunkResp.StatusCode != http.StatusOK {
t.Fatalf("chunk status = %d, want 200", chunkResp.StatusCode)
}
var chunkR chunkResponse
if err := json.NewDecoder(chunkResp.Body).Decode(&chunkR); err != nil {
t.Fatalf("chunk decode: %v", err)
}
if chunkR.BytesReceived != int64(len(content)) {
t.Fatalf("bytesReceived = %d, want %d", chunkR.BytesReceived, len(content))
}
statusResp, err := http.Get(fmt.Sprintf("%s/uploads/%s", server.URL, startResp.SessionID))
if err != nil {
t.Fatalf("status: %v", err)
}
defer statusResp.Body.Close()
var statusR statusResponse
if err := json.NewDecoder(statusResp.Body).Decode(&statusR); err != nil {
t.Fatalf("status decode: %v", err)
}
if statusR.BytesReceived != int64(len(content)) || statusR.Status != "uploading" {
t.Fatalf("status = %+v, want bytesReceived=%d status=uploading", statusR, len(content))
}
completeResp, err := http.Post(fmt.Sprintf("%s/uploads/%s/complete", server.URL, startResp.SessionID), "application/json", nil)
if err != nil {
t.Fatalf("complete: %v", err)
}
defer completeResp.Body.Close()
if completeResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(completeResp.Body)
t.Fatalf("complete status = %d, want 200: %s", completeResp.StatusCode, body)
}
var completeR completeResponse
if err := json.NewDecoder(completeResp.Body).Decode(&completeR); err != nil {
t.Fatalf("complete decode: %v", err)
}
if completeR.DocumentID == "" || completeR.RevisionID == "" {
t.Fatal("erwartet nicht-leere document/revision-ids")
}
}
// TestHandleStart_DisallowedMimeTypeReturns400 ist der HTTP-Kontrakt
// fuer Akzeptanzkriterium 2 (Fehlermeldung, die die Oberflaeche dem Nutzer
// verstaendlich anzeigen kann).
func TestHandleStart_DisallowedMimeTypeReturns400(t *testing.T) {
server, userID := setupTest(t)
startBody, _ := json.Marshal(map[string]any{
"filename": "schadcode.exe", "mimeType": "application/x-msdownload", "totalSize": 100,
})
req, _ := http.NewRequest(http.MethodPost, server.URL+"/uploads", bytes.NewReader(startBody))
req.Header.Set("X-User-Id", userID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("start: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
// TestHandleStatus_UnknownSessionReturns404 ist der HTTP-Kontrakt fuer
// Akzeptanzkriterium 3 (Retry braucht einen unterscheidbaren
// "nicht gefunden"-Fall, kein genereller 500er).
func TestHandleStatus_UnknownSessionReturns404(t *testing.T) {
server, _ := setupTest(t)
resp, err := http.Get(server.URL + "/uploads/00000000-0000-0000-0000-000000000000")
if err != nil {
t.Fatalf("status: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}