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>
265 lines
9.6 KiB
Go
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)
|
|
}
|