Files
nexarch/mail/cmd/mailpostfach-fixture/main.go
T
patrickandClaude Sonnet 5.5 8b9d0f8316
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
mail: moduleauth aus common v0.2.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 11:37:35 +01:00

265 lines
9.6 KiB
Go

// UX-01: startet den ECHTEN mailapi.Server (reale Routen, reale
// moduleauth-Tokenprüfung/Signaturverifikation, reale Mandantentrennung)
// auf einem echten TCP-Port, damit web/mail-postfach einen Integrations-
// test gegen eine echte Backend-API fahren kann statt fetch() zu mocken.
// Nur die Suchpersistenz ist ein In-Memory-Fake (gleiches Muster wie
// mail/internal/mailapi/mailapi_test.go) — kein Produktivpfad.
package main
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/mailapi"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
)
const (
tenantA = "fixture-tenant-a"
tenantB = "fixture-tenant-b"
mailboxInbox = "posteingang"
mailboxArchive = "archiv"
mailboxLarge = "massenordner"
)
type memorySearch struct {
docsByTenant map[string]map[string]search.Document
}
func newMemorySearch() *memorySearch {
return &memorySearch{docsByTenant: map[string]map[string]search.Document{}}
}
func (m *memorySearch) put(tenant string, doc search.Document) {
if m.docsByTenant[tenant] == nil {
m.docsByTenant[tenant] = map[string]search.Document{}
}
m.docsByTenant[tenant][doc.MessageID] = doc
}
func (m *memorySearch) Search(_ context.Context, tenantSlug, queryText string) ([]search.Result, error) {
var results []search.Result
for _, doc := range m.docsByTenant[tenantSlug] {
if queryText != "" && !strings.Contains(strings.ToLower(doc.Subject), strings.ToLower(queryText)) {
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 (m *memorySearch) SearchWithFilters(ctx context.Context, tenantSlug, queryText string, filters []search.FacetFilter) ([]search.Result, error) {
results, err := m.Search(ctx, tenantSlug, queryText)
if err != nil {
return nil, err
}
filtered := make([]search.Result, 0, len(results))
for _, res := range results {
match := true
for _, f := range filters {
if f.Field == search.FacetFieldMailbox && res.Mailbox != f.Value {
match = false
}
}
if match {
filtered = append(filtered, res)
}
}
return filtered, nil
}
func (m *memorySearch) GetByMessageID(_ context.Context, tenantSlug, messageID string) (search.Document, bool, error) {
doc, ok := m.docsByTenant[tenantSlug][messageID]
return doc, ok, nil
}
func (m *memorySearch) ListByMailbox(_ context.Context, tenantSlug, mailbox string) ([]search.Document, error) {
var docs []search.Document
for _, doc := range m.docsByTenant[tenantSlug] {
if mailbox != "" && doc.Mailbox != mailbox {
continue
}
docs = append(docs, doc)
}
return docs, nil
}
func (m *memorySearch) Facets(_ context.Context, tenantSlug, _ string, _ []search.FacetFilter) (search.FacetResult, error) {
counts := map[string]int64{}
for _, doc := range m.docsByTenant[tenantSlug] {
if doc.Mailbox != "" {
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
}
type noopUsageReporter struct{}
func (noopUsageReporter) Report(context.Context, string, string, int64) error { return nil }
type fixedStorageProvider struct {
services map[string]*storage.Service
}
func (p fixedStorageProvider) ServiceFor(tenantSlug string) (*storage.Service, error) {
svc, ok := p.services[tenantSlug]
if !ok {
return nil, fmt.Errorf("fixture: unbekannter mandant %q", tenantSlug)
}
return svc, nil
}
// performanceMessageCount ist absichtlich im "mehrere tausend"-Bereich
// (UX-01 Akzeptanzkriterium 3), nicht nur ein paar Dutzend.
const performanceMessageCount = 3000
func seed(sc *memorySearch) {
sc.put(tenantA, search.Document{MessageID: "a-rechnung-1", Subject: "Rechnung September 2026", Sender: "buchhaltung@beispiel.test", Mailbox: mailboxInbox, SentAtUnixEpoch: 1_700_000_300})
sc.put(tenantA, search.Document{MessageID: "a-rechnung-2", Subject: "Rechnung Oktober 2026", Sender: "buchhaltung@beispiel.test", Mailbox: mailboxInbox, SentAtUnixEpoch: 1_700_000_100})
sc.put(tenantA, search.Document{MessageID: "a-vertrag-1", Subject: "Vertragsunterlagen zur Unterschrift", Sender: "vertrieb@beispiel.test", Mailbox: mailboxArchive, SentAtUnixEpoch: 1_700_000_200})
for i := 0; i < performanceMessageCount; i++ {
sc.put(tenantA, search.Document{
MessageID: "a-last-" + strconv.Itoa(i),
Subject: "Massennachricht " + strconv.Itoa(i),
Sender: "absender" + strconv.Itoa(i%17) + "@beispiel.test",
Mailbox: mailboxLarge,
SentAtUnixEpoch: 1_600_000_000 + int64(i),
})
}
// Gleicher Ordnername wie bei Mandant A (posteingang), aber fremde
// Daten — Grundlage für den Mandantentrennungs-Test.
sc.put(tenantB, search.Document{MessageID: "b-fremd-1", Subject: "Vertraulich: Gehaltsabrechnung", Sender: "hr@fremde-firma.test", Mailbox: mailboxInbox, SentAtUnixEpoch: 1_700_000_050})
sc.put(tenantB, search.Document{MessageID: "b-fremd-2", Subject: "Projektplan Q1", Sender: "pm@fremde-firma.test", Mailbox: "projekte", SentAtUnixEpoch: 1_700_000_060})
}
// threadMessageIDs, threadSubject, threadAttachmentMessageID: UX-02-
// Fixturedaten für Thread-Gruppierung/Navigation und die Anhangsliste
// (Prüfungen 1+2). Dieselbe mailboxInbox wie oben, damit UX-01s
// e2e-Test (feste Ordnerliste/-anzahl) unverändert grün bleibt.
const (
threadSubjectRoot = "Projektstart Alpha"
threadMsg1 = "a-thread-1"
threadMsg2 = "a-thread-2"
threadMsg3 = "a-thread-3"
)
func seedThread(sc *memorySearch, svcA *storage.Service) error {
sc.put(tenantA, search.Document{MessageID: threadMsg1, Subject: threadSubjectRoot, Body: "Lass uns das Projekt Alpha kommende Woche starten.", Sender: "kollege@beispiel.test", Mailbox: mailboxInbox, SentAtUnixEpoch: 1_700_000_400})
sc.put(tenantA, search.Document{MessageID: threadMsg2, Subject: "Re: " + threadSubjectRoot, Body: "Passt, ich haenge den Projektplan als Anhang an.", Sender: "kundin@beispiel.test", Mailbox: mailboxInbox, SentAtUnixEpoch: 1_700_000_410})
sc.put(tenantA, search.Document{MessageID: threadMsg3, Subject: "Re: Re: " + threadSubjectRoot, Body: "Danke, habe den Plan erhalten und geprueft.", Sender: "kollege@beispiel.test", Mailbox: mailboxInbox, SentAtUnixEpoch: 1_700_000_420})
ctx := context.Background()
if _, err := svcA.Put(ctx, storage.ObjectKey(threadMsg2, 0), strings.NewReader(threadAttachmentPDF), int64(len(threadAttachmentPDF)), "application/octet-stream"); err != nil {
return fmt.Errorf("thread-anhang 0 ablegen: %w", err)
}
if _, err := svcA.Put(ctx, storage.ObjectKey(threadMsg2, 1), strings.NewReader(threadAttachmentText), int64(len(threadAttachmentText)), "text/plain"); err != nil {
return fmt.Errorf("thread-anhang 1 ablegen: %w", err)
}
return nil
}
// threadAttachmentPDF/threadAttachmentText tragen echte Signaturbytes
// (PDF) bzw. reinen Text, synthetisch, keine echten Mailinhalte (siehe
// CLAUDE.md DSGVO-Vorgabe).
const threadAttachmentPDF = "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF"
const threadAttachmentText = "Projektplan Alpha, Stand Kickoff.\n"
func newStorageService(tenant string) (*storage.Service, error) {
dir, err := os.MkdirTemp("", "ux01-fixture-storage-"+tenant)
if err != nil {
return nil, err
}
return storage.NewService(storage.NewLocalDriver(dir), noopUsageReporter{}, tenant), nil
}
func main() {
sc := newMemorySearch()
seed(sc)
svcA, err := newStorageService(tenantA)
if err != nil {
fmt.Fprintln(os.Stderr, "fixture: storage A:", err)
os.Exit(1)
}
svcB, err := newStorageService(tenantB)
if err != nil {
fmt.Fprintln(os.Stderr, "fixture: storage B:", err)
os.Exit(1)
}
sp := fixedStorageProvider{services: map[string]*storage.Service{tenantA: svcA, tenantB: svcB}}
if err := seedThread(sc, svcA); err != nil {
fmt.Fprintln(os.Stderr, "fixture: thread-fixture:", err)
os.Exit(1)
}
srv := mailapi.NewServer(sc, sp)
mux := http.NewServeMux()
verifier, issuer := moduleauth.NewTestVerifierAndIssuer()
srv.RegisterRoutes(mux, verifier)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Fprintln(os.Stderr, "fixture: listener:", err)
os.Exit(1)
}
port := listener.Addr().(*net.TCPAddr).Port
tokenA := issuer.Token("fixture-user-a", tenantA, "user", "mail", "")
tokenB := issuer.Token("fixture-user-b", tenantB, "user", "mail", "")
fmt.Println(mustJSON(map[string]any{
"port": port,
"tokenA": tokenA,
"tokenB": tokenB,
"tenantA": tenantA,
"tenantB": tenantB,
"mailboxInbox": mailboxInbox,
"mailboxArchive": mailboxArchive,
"mailboxLarge": mailboxLarge,
"performanceMessageCount": performanceMessageCount,
"threadMsg1": threadMsg1,
"threadMsg2": threadMsg2,
"threadMsg3": threadMsg3,
"threadSubjectRoot": threadSubjectRoot,
}))
httpSrv := &http.Server{Handler: mux}
go func() {
if err := httpSrv.Serve(listener); err != nil && err != http.ErrServerClosed {
fmt.Fprintln(os.Stderr, "fixture: serve:", err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
_ = httpSrv.Close()
}
func mustJSON(v any) string {
raw, err := json.Marshal(v)
if err != nil {
panic(err)
}
return string(raw)
}