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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
201 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|