Files
nexarch/mail/internal/mailapi/mailapi_test.go
T
patrickandClaude Sonnet 5 c2d1623a19 UX-03: anhang-vorschau
Backend (mail/internal/mailapi): Content-Type fuer Anhang-Download und
neue Anhangsliste (GET .../attachments) wird serverseitig per
Magic-Bytes (net/http.DetectContentType) bestimmt statt wie bisher
hartkodiert/ueber eine Dateiendung -- es gibt an dieser Schnittstelle
technisch gar keinen Dateinamen. Ergaenzt um Content-Disposition:
inline statt erzwungenem Download. mail/internal/attachments: Test
ergaenzt, der belegt, dass die bestehende IMP-02-Extraktion eine
falsche Dateiendung und einen falsch deklarierten MIME-Typ bereits
korrekt per Magic-Bytes ueberschreibt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-16 06:51:08 +00:00

387 lines
15 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
}
// 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)
}
}
// TestListAttachments_ContentTypesDetectedFromMagicBytes ist die
// geforderte Pflichtprüfung 2 (UX-03 Akzeptanzkriterium 1+2): die
// Anhangsliste, auf der die Frontend-Vorschau aufbaut, liefert für
// mehrere Anhänge unterschiedlichen Typs jeweils den tatsächlich
// gesniffeden Content-Type, unabhängig vom beim Ablegen deklarierten Typ.
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" {
t.Fatalf("anhang 0: erwartete index=0/application/pdf, habe %+v", body.Attachments[0])
}
if body.Attachments[1].Index != 1 || body.Attachments[1].ContentType != "image/png" {
t.Fatalf("anhang 1: erwartete index=1/image/png, habe %+v", body.Attachments[1])
}
}
// TestListAttachments_UnknownMessageReturns404 rundet den Vertragstest
// der neuen Route ab: eine nicht existierende Nachricht liefert 404,
// kein stiller leerer Erfolg.
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)
}
}