package mailapi import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" ) // fakeSearchClient ist ein In-Memory-Fake für SearchClient — Tests // brauchen keine echte Manticore-Instanz. type fakeSearchClient struct { docsByTenant map[string]map[string]search.Document // tenant -> messageID -> doc } func newFakeSearchClient() *fakeSearchClient { return &fakeSearchClient{docsByTenant: map[string]map[string]search.Document{}} } func (f *fakeSearchClient) put(tenant string, doc search.Document) { if f.docsByTenant[tenant] == nil { f.docsByTenant[tenant] = map[string]search.Document{} } f.docsByTenant[tenant][doc.MessageID] = doc } func (f *fakeSearchClient) Search(_ context.Context, tenantSlug, _ string) ([]search.Result, error) { var results []search.Result for _, doc := range f.docsByTenant[tenantSlug] { results = append(results, search.Result{MessageID: doc.MessageID, Subject: doc.Subject, SentAtUnixEpoch: doc.SentAtUnixEpoch}) } return results, nil } func (f *fakeSearchClient) GetByMessageID(_ context.Context, tenantSlug, messageID string) (search.Document, bool, error) { doc, ok := f.docsByTenant[tenantSlug][messageID] return doc, ok, nil } // fakeStorageProvider liefert je Mandant einen unabhängigen, in // LocalDriver gestützten Service — realistische Nachbildung der // physischen Bucket-Trennung aus ARC-06 ohne echtes S3. type fakeStorageProvider struct { services map[string]*storage.Service } func newFakeStorageProvider(t *testing.T, tenants ...string) *fakeStorageProvider { t.Helper() p := &fakeStorageProvider{services: map[string]*storage.Service{}} for _, tenant := range tenants { p.services[tenant] = storage.NewService(storage.NewLocalDriver(t.TempDir()), noopUsageReporter{}, tenant) } return p } func (p *fakeStorageProvider) ServiceFor(tenantSlug string) (*storage.Service, error) { svc, ok := p.services[tenantSlug] if !ok { return nil, errors.New("mailapi: unbekannter mandant") } return svc, nil } type noopUsageReporter struct{} func (noopUsageReporter) Report(context.Context, string, string, int64) error { return nil } func setupTestServer(t *testing.T) (*httptest.Server, *fakeSearchClient, *fakeStorageProvider) { t.Helper() sc := newFakeSearchClient() sp := newFakeStorageProvider(t, "mandant-a", "mandant-b") srv := NewServer(sc, sp) mux := http.NewServeMux() srv.RegisterRoutes(mux) ts := httptest.NewServer(mux) t.Cleanup(ts.Close) return ts, sc, sp } // TestListMessages_RejectsMissingTenant ist die geforderte // Pflichtprüfung 1 (INT-01): Zugriff ohne gültigen Tenant-Kontext wird // abgelehnt — für alle drei Endpunkte geprüft. func TestListMessages_RejectsMissingTenant(t *testing.T) { ts, _, _ := setupTestServer(t) endpoints := []string{ "/api/v1/mail/messages", "/api/v1/mail/messages/msg-1", "/api/v1/mail/messages/msg-1/attachments/0", } for _, ep := range endpoints { t.Run(ep, func(t *testing.T) { resp, err := http.Get(ts.URL + ep) // ohne ?tenant= if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("erwartete 400 ohne tenant-kontext, habe %d", resp.StatusCode) } var body errorResponse if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("fehlerantwort dekodieren: %v", err) } if body.Error == "" { t.Fatalf("erwartete nicht-leere fehlermeldung") } }) } } // TestListMessages_ReturnsOnlyOwnTenantMessages ist der // Vertragstest für Akzeptanzkriterium 1+2 (Mail-Liste, strikt // mandantengebunden). func TestListMessages_ReturnsOnlyOwnTenantMessages(t *testing.T) { ts, sc, _ := setupTestServer(t) sc.put("mandant-a", search.Document{MessageID: "a-1", Subject: "Nachricht A", SentAtUnixEpoch: 100}) sc.put("mandant-b", search.Document{MessageID: "b-1", Subject: "Nachricht B", SentAtUnixEpoch: 200}) resp, err := http.Get(ts.URL + "/api/v1/mail/messages?tenant=mandant-a") if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("erwartete 200, habe %d", resp.StatusCode) } var body listMessagesResponse if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("antwort dekodieren: %v", err) } if len(body.Messages) != 1 || body.Messages[0].MessageID != "a-1" { t.Fatalf("erwartete genau die eine nachricht von mandant-a, habe: %+v", body.Messages) } } // TestGetMessage_NotFoundForForeignTenant ist der Vertragstest für // Akzeptanzkriterium 2: eine für Mandant B existierende Nachricht ist // über Mandant As Tenant-Kontext NICHT abrufbar (404, nicht etwa die // fremden Daten). func TestGetMessage_NotFoundForForeignTenant(t *testing.T) { ts, sc, _ := setupTestServer(t) sc.put("mandant-b", search.Document{MessageID: "b-1", Subject: "Geheim", Body: "Geheimer Inhalt"}) resp, err := http.Get(ts.URL + "/api/v1/mail/messages/b-1?tenant=mandant-a") if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNotFound { t.Fatalf("erwartete 404 für fremde nachricht, habe %d", resp.StatusCode) } } // TestGetMessage_ReturnsFullDetailForOwnTenant ist der Vertragstest für // Akzeptanzkriterium 1 (Mail-Detail). func TestGetMessage_ReturnsFullDetailForOwnTenant(t *testing.T) { ts, sc, _ := setupTestServer(t) sc.put("mandant-a", search.Document{ MessageID: "a-1", Subject: "Betreff", Body: "Inhalt der Nachricht", Sender: "absender@example.com", Mailbox: "INBOX", SentAtUnixEpoch: 42, }) resp, err := http.Get(ts.URL + "/api/v1/mail/messages/a-1?tenant=mandant-a") if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("erwartete 200, habe %d", resp.StatusCode) } var body messageDetailResponse if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("antwort dekodieren: %v", err) } if body.Body != "Inhalt der Nachricht" || body.Sender != "absender@example.com" { t.Fatalf("unerwartetes detail: %+v", body) } } // TestGetAttachment_PhysicalTenantSeparationEnforced ist der // Vertragstest für Akzeptanzkriterium 2 beim Anhang-Download: ein // Anhang, der real im Bucket von Mandant A liegt, ist über Mandant Bs // Tenant-Kontext nicht erreichbar — strukturell (ARC-06s physische // Bucket-Trennung), nicht nur durch einen Pfadfilter. func TestGetAttachment_PhysicalTenantSeparationEnforced(t *testing.T) { ts, sc, sp := setupTestServer(t) sc.put("mandant-a", search.Document{MessageID: "a-1", Subject: "Mit Anhang"}) sc.put("mandant-b", search.Document{MessageID: "a-1", Subject: "Gleiche ID, anderer Mandant"}) svcA, err := sp.ServiceFor("mandant-a") if err != nil { t.Fatalf("ServiceFor mandant-a: %v", err) } ctx := context.Background() content := "geheimer anhangsinhalt" if _, err := svcA.Put(ctx, storage.ObjectKey("a-1", 0), strings.NewReader(content), int64(len(content)), "text/plain"); err != nil { t.Fatalf("anhang für mandant-a ablegen: %v", err) } // Eigener Mandant: Anhang erreichbar. respOwn, err := http.Get(ts.URL + "/api/v1/mail/messages/a-1/attachments/0?tenant=mandant-a") if err != nil { t.Fatalf("get (eigener mandant): %v", err) } defer func() { _ = respOwn.Body.Close() }() if respOwn.StatusCode != http.StatusOK { t.Fatalf("erwartete 200 für eigenen mandanten, habe %d", respOwn.StatusCode) } // Fremder Mandant, GLEICHE messageID (existiert dort mit anderem // Inhalt, aber ohne Anhang 0): Anhang nicht erreichbar. respForeign, err := http.Get(ts.URL + "/api/v1/mail/messages/a-1/attachments/0?tenant=mandant-b") if err != nil { t.Fatalf("get (fremder mandant): %v", err) } defer func() { _ = respForeign.Body.Close() }() if respForeign.StatusCode != http.StatusNotFound { t.Fatalf("erwartete 404 für fremden mandanten, habe %d", respForeign.StatusCode) } }