CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Successful in 1m1s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 6s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 5s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 25s
CI / Web (dms-browser) (push) Failing after 1m20s
CI / Web (pdf-viewer) (push) Failing after 59s
CI / Web (shl) (push) Failing after 11s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 56s
Mail-Schnittstellen-Vertragstests / contract-tests (push) Successful in 17m7s
CI / Core-Modul (Build/Vet/Test) (pull_request) Successful in 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 22s
CI / Web (dms-browser) (pull_request) Failing after 1m14s
CI / Web (pdf-viewer) (pull_request) Failing after 57s
CI / Web (shl) (pull_request) Failing after 12s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 17m22s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 56s
Mail-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 16m53s
Mail-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 16m43s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m43s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 5m49s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 17m20s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 2s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 2s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
552 lines
23 KiB
Go
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/common/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)
|
|
}
|
|
}
|