CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (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
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
RegisterRoutes ruft jetzt verifier.Expect("mail", "", true) selbst auf, statt sich auf den
Aufrufer zu verlassen. Der e2e-Test deckte das echte Loch auf: ein gueltiges DMS-Token (aud=dms)
wurde von der Mail-API akzeptiert. Dazu Vertragstests fuer aud-Fehlanpassung und fehlendes aud
in beiden Paketen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
289 lines
11 KiB
Go
289 lines
11 KiB
Go
package mailapi
|
|
|
|
import (
|
|
"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/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)
|
|
}
|
|
}
|