Files
nexarch/mail/internal/mailapi/mailapi_test.go
patrickandClaude Sonnet 5 4e61badac5
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (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
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Mail-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
Merge PR #306: UX-02: E-Mail-Detailansicht & Thread-Darstellung (Mail, stackt auf PR #298 UX-01)
Groesster bisheriger Konflikt dieser Merge-Runde (4 Dateien, davon 2
add/add: MessageDetail.tsx/test.tsx existierten bereits als UX-04-
Platzhalter, UX-02 ersetzt sie durch die echte Implementierung mit
Thread-Gruppierung/Anhang-Download):

- mail/internal/mailapi/mailapi.go + openapi.yaml: beide Branches
  implementierten unabhaengig denselben Anhangslisten-Endpunkt
  (UX-03/UX-02), Kommentare zusammengefuehrt, keine Logikaenderung.
- mail/internal/mailapi/mailapi_test.go: dadurch doppelte Tests +
  doppelte realPDFBytes/realPNGBytes-Konstante automerged, die
  schwaechere Kopie entfernt (reichere Variante mit Size-Pruefung behalten).
- components/MessageDetail.tsx/test.tsx: UX-04-Platzhalter durch
  UX-02s vollstaendige Implementierung ersetzt (neue Props:
  messageId/messages/onSelectMessage/onClose statt message/onBack).
- components/MessageList.tsx/test.tsx: UX-02s natives <button>-Pattern
  (Tastaturaktivierbarkeit ohne eigene Keydown-Logik) uebernommen,
  Prop onSelect -> onSelectMessage umbenannt, Tests entsprechend
  angepasst (user-event fuer echte Enter-Taste-Simulation).
- app/page.tsx: UX-04s responsives Layout (Drawer/mobileView) als
  Grundlage behalten, UX-02s Detail-Wiring (messageId-State,
  MessageDetail-Props) eingebaut.
- lib/messages.ts: mail.detail.*-Schluessel fehlten auf Englisch,
  nachuebersetzt statt nur dupliziert.
- app/page.test.tsx: fetchMessageDetail/fetchAttachments mussten in
  allen Tests ein aufgeloestes Promise liefern (MessageDetail ruft sie
  jetzt immer bei Auswahl auf), nicht nur als Export existieren.

go build/vet sauber, go test ./internal/mailapi/...: alle gruen bis auf
den bereits vor diesem Merge auf main bestehenden Testfehler
(anhang_download_erfolg, siehe PR #298). npx vitest run: 82/82 Tests
gruen (web/mail-postfach), beides auf Testhost 131 verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 12:50:22 +02:00

552 lines
23 KiB
Go

package mailapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
"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
}
func (f *fakeSearchClient) SearchWithFilters(_ context.Context, tenantSlug, _ string, filters []search.FacetFilter) ([]search.Result, error) {
var results []search.Result
for _, doc := range f.docsByTenant[tenantSlug] {
if !matchesFacetFilters(doc, filters) {
continue
}
results = append(results, search.Result{
MessageID: doc.MessageID, Subject: doc.Subject, SentAtUnixEpoch: doc.SentAtUnixEpoch,
Sender: doc.Sender, Mailbox: doc.Mailbox,
})
}
return results, nil
}
func (f *fakeSearchClient) ListByMailbox(_ context.Context, tenantSlug, mailbox string) ([]search.Document, error) {
var docs []search.Document
for _, doc := range f.docsByTenant[tenantSlug] {
if doc.Mailbox == mailbox {
docs = append(docs, doc)
}
}
return docs, nil
}
func (f *fakeSearchClient) Facets(_ context.Context, tenantSlug, _ string, _ []search.FacetFilter) (search.FacetResult, error) {
counts := map[string]int64{}
for _, doc := range f.docsByTenant[tenantSlug] {
counts[doc.Mailbox]++
}
values := make([]search.FacetValue, 0, len(counts))
for mailbox, count := range counts {
values = append(values, search.FacetValue{Value: mailbox, Count: count})
}
return search.FacetResult{Values: map[string][]search.FacetValue{string(search.FacetFieldMailbox): values}}, nil
}
func matchesFacetFilters(doc search.Document, filters []search.FacetFilter) bool {
for _, f := range filters {
if f.Field == search.FacetFieldMailbox && doc.Mailbox != f.Value {
return false
}
}
return true
}
// 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 }
// testServer buendelt den httptest-Server mit dem Testschluessel, damit
// jeder Test eigene Tokens fuer beliebige Mandanten ausstellen kann
// (INT-18: der Mandant kommt aus dem Token, es gibt keinen Testpfad mehr
// ohne echte Signaturpruefung).
type testServer struct {
url string
issuer *moduleauth.TestIssuer
}
func (ts *testServer) get(t *testing.T, path, bearer string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodGet, ts.url+path, nil)
if err != nil {
t.Fatalf("request bauen: %v", err)
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
return resp
}
func (ts *testServer) token(subject, tenant string) string {
return ts.issuer.Token(subject, tenant, "user", "mail", "")
}
func setupTestServer(t *testing.T) (*testServer, *fakeSearchClient, *fakeStorageProvider) {
t.Helper()
sc := newFakeSearchClient()
sp := newFakeStorageProvider(t, "mandant-a", "mandant-b")
srv := NewServer(sc, sp)
mux := http.NewServeMux()
verifier, issuer := moduleauth.NewTestVerifierAndIssuer()
srv.RegisterRoutes(mux, verifier)
httpSrv := httptest.NewServer(mux)
t.Cleanup(httpSrv.Close)
return &testServer{url: httpSrv.URL, issuer: issuer}, sc, sp
}
// TestRoutes_RejectMissingOrInvalidToken ist die geforderte
// Pflichtprüfung 1 (INT-18/Akzeptanzkriterium 1): jede Route verlangt ein
// gültiges Modul-Token — geprüft für alle drei Endpunkte, ohne Token und
// mit einer fremd signierten (ungültigen) Signatur.
func TestRoutes_RejectMissingOrInvalidToken(t *testing.T) {
ts, _, _ := setupTestServer(t)
_, otherIssuer := moduleauth.NewTestVerifierAndIssuer() // fremder Schlüssel, dem der Verifier nicht vertraut
endpoints := []string{
"/api/v1/mail/messages",
"/api/v1/mail/messages/msg-1",
"/api/v1/mail/messages/msg-1/attachments",
"/api/v1/mail/messages/msg-1/attachments/0",
}
for _, ep := range endpoints {
t.Run(ep+"/ohne_token", func(t *testing.T) {
resp := ts.get(t, ep, "")
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("erwartete 401 ohne token, 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")
}
})
t.Run(ep+"/fremde_signatur", func(t *testing.T) {
resp := ts.get(t, ep, otherIssuer.Token("angreifer", "mandant-a", "user", "", ""))
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("erwartete 401 bei fremder signatur, habe %d", resp.StatusCode)
}
})
t.Run(ep+"/falsches_zielmodul", func(t *testing.T) {
// Echter Fund (siehe RegisterRoutes-Kommentar): ein gueltig
// signiertes Token mit aud="dms" muss abgewiesen werden.
resp := ts.get(t, ep, ts.issuer.Token("u1", "mandant-a", "user", "dms", ""))
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("erwartete 401 bei aud=dms gegen die mail-api, habe %d", resp.StatusCode)
}
})
t.Run(ep+"/token_ohne_aud", func(t *testing.T) {
resp := ts.get(t, ep, ts.issuer.Token("u1", "mandant-a", "user", "", ""))
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("erwartete 401 bei fehlendem aud (enforce=true), habe %d", resp.StatusCode)
}
})
}
}
// TestListMessages_ReturnsOnlyOwnTenantMessages ist der
// Vertragstest für Akzeptanzkriterium 1+3 (Mail-Liste, strikt
// mandantengebunden über den Token-Mandanten).
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 := ts.get(t, "/api/v1/mail/messages", ts.token("u1", "mandant-a"))
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)
}
}
// TestListMessages_IgnoresTenantQueryParameter ist der Vertragstest für
// Akzeptanzkriterium 2: ein mitgesendeter "tenant"-Parameter hat keine
// Wirkung — der Token-Mandant (mandant-a) bestimmt das Ergebnis, obwohl
// die Anfrage vorgibt, mandant-b zu wollen.
func TestListMessages_IgnoresTenantQueryParameter(t *testing.T) {
ts, sc, _ := setupTestServer(t)
sc.put("mandant-a", search.Document{MessageID: "a-1", Subject: "Nachricht A"})
sc.put("mandant-b", search.Document{MessageID: "b-1", Subject: "Nachricht B"})
resp := ts.get(t, "/api/v1/mail/messages?tenant=mandant-b", ts.token("u1", "mandant-a"))
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("tenant-parameter haette ignoriert werden muessen, habe: %+v", body.Messages)
}
}
// TestGetMessage_NotFoundForForeignTenant ist der Vertragstest für
// Akzeptanzkriterium 3: eine für Mandant B existierende Nachricht ist
// über Mandant As Token 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 := ts.get(t, "/api/v1/mail/messages/b-1", ts.token("u1", "mandant-a"))
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 := ts.get(t, "/api/v1/mail/messages/a-1", ts.token("u1", "mandant-a"))
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 3 beim Anhang-Download: ein
// Anhang, der real im Bucket von Mandant A liegt, ist über Mandant Bs
// Token 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 := ts.get(t, "/api/v1/mail/messages/a-1/attachments/0", ts.token("u1", "mandant-a"))
defer func() { _ = respOwn.Body.Close() }()
if respOwn.StatusCode != http.StatusOK {
t.Fatalf("erwartete 200 für eigenen mandanten, habe %d", respOwn.StatusCode)
}
// Fremder Mandant (eigenes gültiges Token für mandant-b), GLEICHE
// messageID (existiert dort mit anderem Inhalt, aber ohne Anhang 0):
// Anhang nicht erreichbar.
respForeign := ts.get(t, "/api/v1/mail/messages/a-1/attachments/0", ts.token("u2", "mandant-b"))
defer func() { _ = respForeign.Body.Close() }()
if respForeign.StatusCode != http.StatusNotFound {
t.Fatalf("erwartete 404 für fremden mandanten, habe %d", respForeign.StatusCode)
}
}
// realPDFBytes und realPNGBytes sind synthetische Testfixtures mit
// ECHTEN Signaturbytes der jeweiligen Formate (keine echten
// Kunden-/Mailinhalte, siehe CLAUDE.md DSGVO-Vorgabe) — ausreichend,
// damit net/http.DetectContentType sie korrekt erkennt.
const realPDFBytes = "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF"
var realPNGBytes = []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 1, 2, 3, 4, 5}
// TestGetAttachment_ContentTypeFromMagicBytesIgnoresDeclaredType ist die
// geforderte Pflichtprüfung 1/3 (UX-03): der beim Ablegen angegebene
// contentType ("declared", z. B. aus einer irreführenden MIME-Deklaration
// oder einer falschen Dateiendung weiter oben in der Kette) wird beim
// Abruf VOLLSTÄNDIG ignoriert — der tatsächlich ausgelieferte Content-Type
// kommt ausschließlich aus den echten Bytes.
func TestGetAttachment_ContentTypeFromMagicBytesIgnoresDeclaredType(t *testing.T) {
ts, sc, sp := setupTestServer(t)
sc.put("mandant-a", search.Document{MessageID: "m-1", Subject: "Rechnung"})
svc, err := sp.ServiceFor("mandant-a")
if err != nil {
t.Fatalf("ServiceFor: %v", err)
}
ctx := context.Background()
// Absichtlich falsch deklariert: echter PDF-Inhalt, aber als
// "text/plain" abgelegt — simuliert eine Nachricht mit falscher
// Dateiendung/MIME-Angabe (Prüfung 1 der Kachel).
if _, err := svc.Put(ctx, storage.ObjectKey("m-1", 0), strings.NewReader(realPDFBytes), int64(len(realPDFBytes)), "text/plain"); err != nil {
t.Fatalf("anhang ablegen: %v", err)
}
resp := ts.get(t, "/api/v1/mail/messages/m-1/attachments/0", ts.token("u1", "mandant-a"))
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("erwartete 200, habe %d", resp.StatusCode)
}
if got := resp.Header.Get("Content-Type"); got != "application/pdf" {
t.Fatalf("erwartete per magic-bytes erkanntes 'application/pdf', habe %q (deklariert war 'text/plain')", got)
}
if got := resp.Header.Get("Content-Disposition"); got != "inline" {
t.Fatalf("erwartete 'inline' (Vorschau, kein erzwungener Download), habe %q", got)
}
}
// TestListMailboxes_ReturnsOnlyOwnTenantFolders ist der Vertragstest für
// UX-01 Akzeptanzkriterium 1 + Prüfung 3: der Ordnerbaum zeigt
// ausschließlich Postfächer/Ordner des angemeldeten Mandanten, auch wenn
// ein anderer Mandant einen gleichnamigen Ordner hat.
func TestListMailboxes_ReturnsOnlyOwnTenantFolders(t *testing.T) {
ts, sc, _ := setupTestServer(t)
sc.put("mandant-a", search.Document{MessageID: "a-1", Subject: "Eins", Mailbox: "posteingang"})
sc.put("mandant-a", search.Document{MessageID: "a-2", Subject: "Zwei", Mailbox: "archiv"})
sc.put("mandant-b", search.Document{MessageID: "b-1", Subject: "Fremd", Mailbox: "posteingang"})
sc.put("mandant-b", search.Document{MessageID: "b-2", Subject: "Fremd2", Mailbox: "projekte"})
resp := ts.get(t, "/api/v1/mail/mailboxes", ts.token("u1", "mandant-a"))
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("erwartete 200, habe %d", resp.StatusCode)
}
var body listMailboxesResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
names := map[string]int64{}
for _, mb := range body.Mailboxes {
names[mb.Name] = mb.MessageCount
}
if len(names) != 2 || names["posteingang"] != 1 || names["archiv"] != 1 {
t.Fatalf("erwartete genau die zwei ordner von mandant-a mit je einer nachricht, habe: %+v", body.Mailboxes)
}
if _, ok := names["projekte"]; ok {
t.Fatalf("ordner eines fremden mandanten ist sichtbar: %+v", body.Mailboxes)
}
}
// TestListMessages_FiltersByMailbox ist der Vertragstest für
// Akzeptanzkriterium 1 (Ordnerauswahl grenzt die Liste ein) und prüft
// gleichzeitig, dass Sender/Mailbox (Grundlage für Akzeptanzkriterium 2,
// Sortierung nach Absender) in der Liste mitgeliefert werden.
func TestListMessages_FiltersByMailbox(t *testing.T) {
ts, sc, _ := setupTestServer(t)
sc.put("mandant-a", search.Document{MessageID: "a-1", Subject: "Rechnung", Sender: "buchhaltung@beispiel.test", Mailbox: "posteingang", SentAtUnixEpoch: 100})
sc.put("mandant-a", search.Document{MessageID: "a-2", Subject: "Vertrag", Sender: "vertrieb@beispiel.test", Mailbox: "archiv", SentAtUnixEpoch: 200})
resp := ts.get(t, "/api/v1/mail/messages?mailbox=posteingang", ts.token("u1", "mandant-a"))
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 nur die nachricht aus 'posteingang', habe: %+v", body.Messages)
}
if body.Messages[0].Sender != "buchhaltung@beispiel.test" || body.Messages[0].Mailbox != "posteingang" {
t.Fatalf("sender/mailbox fehlen in der listenantwort: %+v", body.Messages[0])
}
}
// TestListMessages_MailboxFilterIsTenantBound stellt sicher, dass ein
// Ordnerfilter keinen Umweg um die Mandantentrennung öffnet: derselbe
// Ordnername bei einem fremden Mandanten liefert trotzdem nichts.
func TestListMessages_MailboxFilterIsTenantBound(t *testing.T) {
ts, sc, _ := setupTestServer(t)
sc.put("mandant-b", search.Document{MessageID: "b-1", Subject: "Fremd", Mailbox: "posteingang"})
resp := ts.get(t, "/api/v1/mail/messages?mailbox=posteingang", ts.token("u1", "mandant-a"))
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) != 0 {
t.Fatalf("erwartete keine nachrichten (fremder mandant), habe: %+v", body.Messages)
}
}
// TestListAttachments_ContentTypesDetectedFromMagicBytes ist die
// Pflichtprüfung 2 (UX-02: Anhangsliste stimmt mit den tatsächlich
// archivierten Anhängen überein) — Index/ContentType/Size kommen aus
// den echten gespeicherten Bytes, nicht aus einer Behauptung.
func TestListAttachments_ContentTypesDetectedFromMagicBytes(t *testing.T) {
ts, sc, sp := setupTestServer(t)
sc.put("mandant-a", search.Document{MessageID: "m-2", Subject: "Mit zwei Anhängen"})
svc, err := sp.ServiceFor("mandant-a")
if err != nil {
t.Fatalf("ServiceFor: %v", err)
}
ctx := context.Background()
if _, err := svc.Put(ctx, storage.ObjectKey("m-2", 0), strings.NewReader(realPDFBytes), int64(len(realPDFBytes)), "application/octet-stream"); err != nil {
t.Fatalf("pdf-anhang ablegen: %v", err)
}
if _, err := svc.Put(ctx, storage.ObjectKey("m-2", 1), bytes.NewReader(realPNGBytes), int64(len(realPNGBytes)), "application/octet-stream"); err != nil {
t.Fatalf("png-anhang ablegen: %v", err)
}
resp := ts.get(t, "/api/v1/mail/messages/m-2/attachments", ts.token("u1", "mandant-a"))
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("erwartete 200, habe %d", resp.StatusCode)
}
var body listAttachmentsResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if len(body.Attachments) != 2 {
t.Fatalf("erwartete 2 anhänge, habe %d: %+v", len(body.Attachments), body.Attachments)
}
if body.Attachments[0].Index != 0 || body.Attachments[0].ContentType != "application/pdf" || body.Attachments[0].Size != int64(len(realPDFBytes)) {
t.Fatalf("anhang 0: erwartete index=0/application/pdf/korrekte größe, habe %+v", body.Attachments[0])
}
if body.Attachments[1].Index != 1 || body.Attachments[1].ContentType != "image/png" || body.Attachments[1].Size != int64(len(realPNGBytes)) {
t.Fatalf("anhang 1: erwartete index=1/image/png/korrekte größe, habe %+v", body.Attachments[1])
}
}
// TestListAttachments_NoAttachmentsReturnsEmptyList stellt sicher, dass
// eine Nachricht ohne Anhänge eine LEERE Liste liefert (kein Fehler,
// keine erfundenen Einträge).
func TestListAttachments_NoAttachmentsReturnsEmptyList(t *testing.T) {
ts, sc, _ := setupTestServer(t)
sc.put("mandant-a", search.Document{MessageID: "m-3", Subject: "Ohne Anhang"})
resp := ts.get(t, "/api/v1/mail/messages/m-3/attachments", ts.token("u1", "mandant-a"))
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("erwartete 200, habe %d", resp.StatusCode)
}
var body listAttachmentsResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if len(body.Attachments) != 0 {
t.Fatalf("erwartete leere liste, habe: %+v", body.Attachments)
}
}
// TestListAttachments_UnknownMessageReturns404 rundet den Vertragstest
// der neuen Route ab: eine nicht existierende Nachricht liefert 404.
func TestListAttachments_UnknownMessageReturns404(t *testing.T) {
ts, _, _ := setupTestServer(t)
resp := ts.get(t, "/api/v1/mail/messages/unbekannt/attachments", ts.token("u1", "mandant-a"))
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("erwartete 404, habe %d", resp.StatusCode)
}
}
// TestListAttachments_PhysicalTenantSeparationEnforced: eine fremde
// messageID liefert 404, auch wenn unter diesem Index zufällig ein
// Objekt im EIGENEN Bucket existieren würde — Mandant A kann die
// Anhangsliste von Mandant B nicht über dessen messageID abrufen.
func TestListAttachments_PhysicalTenantSeparationEnforced(t *testing.T) {
ts, sc, sp := setupTestServer(t)
sc.put("mandant-b", search.Document{MessageID: "b-geheim", Subject: "Geheim"})
svcB, err := sp.ServiceFor("mandant-b")
if err != nil {
t.Fatalf("ServiceFor: %v", err)
}
if _, err := svcB.Put(context.Background(), storage.ObjectKey("b-geheim", 0), strings.NewReader(realPDFBytes), int64(len(realPDFBytes)), "application/pdf"); err != nil {
t.Fatalf("anhang ablegen: %v", err)
}
resp := ts.get(t, "/api/v1/mail/messages/b-geheim/attachments", ts.token("u1", "mandant-a"))
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("erwartete 404 für fremden mandanten, habe %d", resp.StatusCode)
}
}