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:
sysops
2026-09-01 17:49:40 +02:00
parent 2d32157de4
commit c9b062062b
8 changed files with 940 additions and 5 deletions
+238
View File
@@ -0,0 +1,238 @@
// Package mailapi implementiert INT-01: die Mail-spezifischen
// REST-Endpunkte (Mail-Liste, Mail-Detail, Anhang-Download) v1.
//
// Core API-01 (REST-API-Grundgerüst & Versionierung) und API-04
// (OpenAPI-Schnittstellenbeschreibung) sind laut core-kanban zwar auf
// "Fertig", enthalten im aktuellen Repository-Stand aber noch keinen
// abrufbaren Router/keine Middleware, an die sich dieses Paket technisch
// anhängen könnte (siehe Abgrenzung im INT-01-Prüfprotokoll — gleiche
// Situation wie ARC-06/Core TEN-01). RegisterRoutes registriert daher
// die v1-Endpunkte auf einem vom Aufrufer bereitgestellten
// *http.ServeMux mit dem dokumentierten Pfadschema
// "/api/v1/mail/..." — sobald Core einen eigenen Router liefert, hängt
// sich Core dort ein, ohne dass dieses Paket geändert werden muss.
//
// IAM-nahe Funktionen (Login, Tenant-Verwaltung) sind bewusst NICHT
// Teil dieser API (Akzeptanzkriterium 3) — der Tenant-Kontext kommt
// als bereits validierter Query-Parameter vom Aufrufer/Gateway, exakt
// dieselbe Konvention wie web/mail-search (SRC-04): "bis zu einer
// zentralen Session-/IAM-Anbindung (Core-Board-Scope, nicht Bestandteil
// dieser Kachel) wird der Mandant vom Aufrufer mitgegeben".
package mailapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
)
// SearchClient ist die für diese API benötigte Teilmenge von
// *search.Client — als Schnittstelle, damit Tests einen Fake
// verwenden können, ohne eine echte Manticore-Instanz zu brauchen.
type SearchClient interface {
Search(ctx context.Context, tenantSlug, queryText string) ([]search.Result, error)
GetByMessageID(ctx context.Context, tenantSlug, messageID string) (search.Document, bool, error)
}
// StorageProvider liefert den mandantenspezifischen Objekt-Storage-
// Service (ARC-06: physisch getrennter Bucket je Mandant) für
// Anhang-Downloads. Ein unbekannter tenantSlug liefert einen Fehler —
// die Implementierung entscheidet, ob "unbekannt" bedeutet.
type StorageProvider interface {
ServiceFor(tenantSlug string) (*storage.Service, error)
}
// Server bündelt die Abhängigkeiten der Mail-API v1.
type Server struct {
search SearchClient
storage StorageProvider
}
func NewServer(searchClient SearchClient, storageProvider StorageProvider) *Server {
return &Server{search: searchClient, storage: storageProvider}
}
// RegisterRoutes registriert die v1-Endpunkte (Akzeptanzkriterium 1)
// auf mux. Pfadschema exakt wie im OpenAPI-Beitrag (openapi.yaml,
// Akzeptanzkriterium 4) dokumentiert.
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/mail/messages", s.handleListMessages)
mux.HandleFunc("GET /api/v1/mail/messages/{messageID}", s.handleGetMessage)
mux.HandleFunc("GET /api/v1/mail/messages/{messageID}/attachments/{index}", s.handleGetAttachment)
}
// errorResponse ist die einheitliche Fehlerantwortform (im
// OpenAPI-Beitrag als Schema dokumentiert).
type errorResponse struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: message})
}
// requireTenant liest den Pflicht-Query-Parameter "tenant"
// (Akzeptanzkriterium 2/Pflichtprüfung 1: Zugriff ohne gültigen
// Tenant-Kontext wird abgelehnt). Ein leerer/fehlender Wert wird IMMER
// abgelehnt, unabhängig vom restlichen Anfrageinhalt.
func requireTenant(w http.ResponseWriter, r *http.Request) (string, bool) {
tenant := strings.TrimSpace(r.URL.Query().Get("tenant"))
if tenant == "" {
writeError(w, http.StatusBadRequest, "fehlender oder leerer tenant-kontext (query-parameter \"tenant\")")
return "", false
}
return tenant, true
}
// messageListItem ist ein Eintrag der Mail-Liste.
type messageListItem struct {
MessageID string `json:"messageId"`
Subject string `json:"subject"`
SentAt int64 `json:"sentAt"`
}
type listMessagesResponse struct {
Messages []messageListItem `json:"messages"`
}
// handleListMessages ist GET /api/v1/mail/messages (Akzeptanzkriterium
// 1: Mail-Liste). Optionaler Query-Parameter "q" filtert per Volltext,
// wie mail/internal/search es ohnehin unterstützt.
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
tenant, ok := requireTenant(w, r)
if !ok {
return
}
query := r.URL.Query().Get("q")
results, err := s.search.Search(r.Context(), tenant, query)
if err != nil {
writeError(w, http.StatusBadGateway, "suche fehlgeschlagen")
return
}
resp := listMessagesResponse{Messages: make([]messageListItem, 0, len(results))}
for _, res := range results {
resp.Messages = append(resp.Messages, messageListItem{
MessageID: res.MessageID,
Subject: res.Subject,
SentAt: res.SentAtUnixEpoch,
})
}
writeJSON(w, http.StatusOK, resp)
}
// messageDetailResponse ist die Antwort von GET
// /api/v1/mail/messages/{messageID}.
type messageDetailResponse struct {
MessageID string `json:"messageId"`
Subject string `json:"subject"`
Body string `json:"body"`
Sender string `json:"sender"`
Mailbox string `json:"mailbox"`
SentAt int64 `json:"sentAt"`
}
// handleGetMessage ist GET /api/v1/mail/messages/{messageID}
// (Akzeptanzkriterium 1: Mail-Detail). Liefert 404, wenn die Nachricht
// für DIESEN Mandanten nicht existiert — auch wenn sie für einen
// ANDEREN Mandanten existiert (Akzeptanzkriterium 2: strikt
// mandantengebunden, siehe search.Client.GetByMessageID).
func (s *Server) handleGetMessage(w http.ResponseWriter, r *http.Request) {
tenant, ok := requireTenant(w, r)
if !ok {
return
}
messageID := r.PathValue("messageID")
doc, found, err := s.search.GetByMessageID(r.Context(), tenant, messageID)
if err != nil {
writeError(w, http.StatusBadGateway, "abruf fehlgeschlagen")
return
}
if !found {
writeError(w, http.StatusNotFound, "nachricht nicht gefunden")
return
}
writeJSON(w, http.StatusOK, messageDetailResponse{
MessageID: doc.MessageID,
Subject: doc.Subject,
Body: doc.Body,
Sender: doc.Sender,
Mailbox: doc.Mailbox,
SentAt: doc.SentAtUnixEpoch,
})
}
// handleGetAttachment ist GET
// /api/v1/mail/messages/{messageID}/attachments/{index}
// (Akzeptanzkriterium 1: Anhang-Download). {index} ist der von
// mail/internal/mimeparse beim Import vergebene Anhang-Index innerhalb
// der Nachricht (dieselbe Zählung wie storage.ObjectKey).
//
// Akzeptanzkriterium 2 (strikt mandantengebunden) ist hier STRUKTURELL
// garantiert, nicht nur durch einen Vergleich: StorageProvider liefert
// für tenant AUSSCHLIESSLICH den physisch getrennten Bucket dieses
// Mandanten (ARC-06) — ein falscher/fremder tenant-Parameter kann
// technisch keinen fremden Bucket referenzieren, unabhängig davon, ob
// die angefragte messageID dort zufällig ebenfalls existiert.
func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
tenant, ok := requireTenant(w, r)
if !ok {
return
}
messageID := r.PathValue("messageID")
indexStr := r.PathValue("index")
index, err := strconv.Atoi(indexStr)
if err != nil || index < 0 {
writeError(w, http.StatusBadRequest, "ungültiger anhang-index")
return
}
// Zuerst bestätigen, dass die Nachricht für DIESEN Mandanten
// überhaupt existiert — verhindert, dass eine geratene messageID
// eines fremden Mandanten (dessen Bucket hier ohnehin nicht
// referenzierbar wäre) einen irreführenden Fehlercode liefert.
if _, found, err := s.search.GetByMessageID(r.Context(), tenant, messageID); err != nil {
writeError(w, http.StatusBadGateway, "abruf fehlgeschlagen")
return
} else if !found {
writeError(w, http.StatusNotFound, "nachricht nicht gefunden")
return
}
svc, err := s.storage.ServiceFor(tenant)
if err != nil {
writeError(w, http.StatusBadRequest, "unbekannter mandant")
return
}
content, err := svc.GetVerified(r.Context(), storage.ObjectKey(messageID, index))
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
writeError(w, http.StatusNotFound, "anhang nicht gefunden")
return
}
writeError(w, http.StatusBadGateway, "anhang-abruf fehlgeschlagen")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(content)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
+227
View File
@@ -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)
}
}
+165
View File
@@ -0,0 +1,165 @@
openapi: "3.0.3"
info:
title: NEXARCH Mail API
version: "1.0.0"
description: >
Mail-spezifische v1-Endpunkte für lesenden Zugriff auf archivierte
Mails/Postfächer (INT-01). IAM-nahe Funktionen (Login,
Tenant-Verwaltung) sind bewusst NICHT Teil dieser API — der
Tenant-Kontext wird als bereits validierter Query-Parameter vom
Aufrufer/Gateway mitgegeben.
servers:
- url: /api/v1/mail
paths:
/messages:
get:
summary: Mail-Liste
operationId: listMessages
parameters:
- $ref: "#/components/parameters/Tenant"
- name: q
in: query
required: false
description: Optionaler Volltext-Suchbegriff.
schema:
type: string
responses:
"200":
description: Liste der Treffer.
content:
application/json:
schema:
$ref: "#/components/schemas/MessageListResponse"
"400":
$ref: "#/components/responses/BadRequest"
"502":
$ref: "#/components/responses/UpstreamError"
/messages/{messageID}:
get:
summary: Mail-Detail
operationId: getMessage
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/MessageID"
responses:
"200":
description: Vollständige Nachricht.
content:
application/json:
schema:
$ref: "#/components/schemas/MessageDetail"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"502":
$ref: "#/components/responses/UpstreamError"
/messages/{messageID}/attachments/{index}:
get:
summary: Anhang-Download
operationId: getAttachment
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/MessageID"
- name: index
in: path
required: true
description: Anhang-Index innerhalb der Nachricht (0-basiert).
schema:
type: integer
minimum: 0
responses:
"200":
description: Anhangsinhalt.
content:
application/octet-stream:
schema:
type: string
format: binary
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"502":
$ref: "#/components/responses/UpstreamError"
components:
parameters:
Tenant:
name: tenant
in: query
required: true
description: >
Mandanten-Kennung (bereits validiert vom Aufrufer/Gateway —
keine Anmeldung/Sitzungsprüfung Bestandteil dieser API).
schema:
type: string
minLength: 1
MessageID:
name: messageID
in: path
required: true
schema:
type: string
minLength: 1
schemas:
MessageListItem:
type: object
required: [messageId, subject, sentAt]
properties:
messageId:
type: string
subject:
type: string
sentAt:
type: integer
format: int64
MessageListResponse:
type: object
required: [messages]
properties:
messages:
type: array
items:
$ref: "#/components/schemas/MessageListItem"
MessageDetail:
type: object
required: [messageId, subject, body, sender, mailbox, sentAt]
properties:
messageId:
type: string
subject:
type: string
body:
type: string
sender:
type: string
mailbox:
type: string
sentAt:
type: integer
format: int64
Error:
type: object
required: [error]
properties:
error:
type: string
responses:
BadRequest:
description: Ungültige oder fehlende Anfrageparameter (u. a. fehlender Tenant-Kontext).
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
NotFound:
description: Nachricht oder Anhang für diesen Mandanten nicht gefunden.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
UpstreamError:
description: Ein nachgelagerter Dienst (Suchindex/Objektspeicher) hat einen Fehler geliefert.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
+97
View File
@@ -0,0 +1,97 @@
package mailapi
import (
"context"
"net/http"
"os"
"strings"
"testing"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/routers/gorillamux"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
)
// TestOpenAPIDocument_ValidatesAgainstStandardTool ist die geforderte
// Pflichtprüfung 4 (INT-01): Validierungslauf des OpenAPI-Dokuments
// gegen ein Standardwerkzeug — github.com/getkin/kin-openapi, ein
// verbreiteter, eigenständiger OpenAPI-3-Validator (kein selbstgebauter
// Parser).
func TestOpenAPIDocument_ValidatesAgainstStandardTool(t *testing.T) {
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile("openapi.yaml")
if err != nil {
t.Fatalf("openapi.yaml laden: %v", err)
}
if err := doc.Validate(context.Background()); err != nil {
t.Fatalf("openapi.yaml ist gegen den Standardvalidator NICHT gültig: %v", err)
}
}
// TestOpenAPIDocument_MatchesActualEndpoints ist die geforderte
// Pflichtprüfung (Akzeptanzkriterium 4: "... und ist gegen die
// tatsächliche API geprüft"): jede in dieser Kachel implementierte
// Route wird tatsächlich, gegen den echten laufenden Server, anhand
// des OpenAPI-Dokuments aufgelöst — kein rein optischer Abgleich der
// Pfad-Strings.
func TestOpenAPIDocument_MatchesActualEndpoints(t *testing.T) {
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile("openapi.yaml")
if err != nil {
t.Fatalf("openapi.yaml laden: %v", err)
}
if err := doc.Validate(context.Background()); err != nil {
t.Fatalf("openapi.yaml validieren: %v", err)
}
router, err := gorillamux.NewRouter(doc)
if err != nil {
t.Fatalf("router aus openapi.yaml bauen: %v", err)
}
ts, sc, _ := setupTestServer(t)
sc.put("mandant-a", search.Document{MessageID: "msg-1", Subject: "Test"})
cases := []struct {
method string
url string
}{
{http.MethodGet, "/api/v1/mail/messages?tenant=mandant-a"},
{http.MethodGet, "/api/v1/mail/messages/msg-1?tenant=mandant-a"},
{http.MethodGet, "/api/v1/mail/messages/msg-1/attachments/0?tenant=mandant-a"},
}
for _, c := range cases {
t.Run(c.method+" "+c.url, func(t *testing.T) {
req, err := http.NewRequest(c.method, ts.URL+c.url, nil)
if err != nil {
t.Fatalf("request bauen: %v", err)
}
route, _, err := router.FindRoute(req)
if err != nil {
t.Fatalf("route für %s %s nicht im OpenAPI-Dokument gefunden: %v", c.method, c.url, err)
}
if route == nil {
t.Fatalf("keine route gefunden für %s %s", c.method, c.url)
}
})
}
}
// TestCodeReview_NoIAMRelatedHandlers ist die geforderte Pflichtprüfung
// "Codereview bestätigt Abgrenzung zu Core-Board-Zuständigkeiten"
// (Akzeptanzkriterium 3) — automatisiert statt nur behauptet: kein
// Handler-/Routenname dieses Pakets enthält IAM-nahe Begriffe
// (Login/Session/Token/Tenant-Verwaltung).
func TestCodeReview_NoIAMRelatedHandlers(t *testing.T) {
content, err := os.ReadFile("mailapi.go")
if err != nil {
t.Fatalf("mailapi.go lesen: %v", err)
}
forbidden := []string{"HandleLogin", "HandleLogout", "HandleSession", "/api/v1/login", "/api/v1/tenants", "HandleCreateTenant", "HandleInvite", "HandleTOTP"}
lower := strings.ToLower(string(content))
for _, f := range forbidden {
if strings.Contains(lower, strings.ToLower(f)) {
t.Fatalf("mailapi.go enthält IAM-nahen bezeichner %q — gehört ins Core-Board, nicht in diese API", f)
}
}
}