feat(mail): INT-01 REST-API v1 für Mail-Zugriff & OpenAPI-Beschreibung
Neues Paket mail/internal/mailapi: drei v1-Endpunkte (Mail-Liste, Mail-Detail, Anhang-Download). Core API-01 (REST-Grundgerüst) und API-04 (OpenAPI-Beschreibung) haben im aktuellen Repository-Stand keinen abrufbaren Router — RegisterRoutes registriert die Endpunkte deshalb auf einem vom Aufrufer bereitgestellten *http.ServeMux mit dem dokumentierten Pfadschema /api/v1/mail/..., Core kann sich später dort einhängen, im Prüfprotokoll begründet (gleiche Situation wie ARC-06/Core TEN-01). tenant-Query-Parameter ist auf allen drei Endpunkten Pflicht (fehlender Kontext -> 400), keine eigene Login-/Session-Logik (IAM bleibt Core-Board-Sache). Anhang-Download nutzt storage.ObjectKey gegen den physisch getrennten Bucket des Mandanten (ARC-06) — ein Anhang mit identischer messageID in einem fremden Mandantenkontext ist strukturell nicht erreichbar. Neue Methode search.Client.GetByMessageID liefert das vollständige Suchdokument für Mail-Detail. openapi.yaml: vollständiger OpenAPI-3-Beitrag für alle drei Endpunkte inklusive Fehlerantworten. Als neue, gepinnte Abhängigkeit github.com/getkin/kin-openapi v0.135.0 (bewusst nicht @latest — hätte das Modul von go 1.24 auf go 1.25 gezwungen) für einen echten Standard-Validierungslauf gegen das Dokument sowie einen OpenAPI-Router, der jede implementierte Route real gegen das Dokument auflöst statt nur Pfad-Strings zu vergleichen. Alle vier Pflichtprüfungen mit echten Nachweisen: Zugriff ohne Tenant-Kontext auf allen drei Endpunkten abgelehnt, Vertragstests inkl. physischer Bucket-Trennung beim Anhang-Download, automatisiertes Code-Review bestätigt Abwesenheit IAM-naher Bezeichner, OpenAPI-Dokument validiert fehlerfrei gegen kin-openapi. go build/go vet/golangci-lint clean, go mod verify clean, gesamtes Mail-Modul regressionsfrei getestet.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user