package uploadapi import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "gitea.perlbach24.de/scripte/nexarch/common/testdb" 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/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) } }