Compare commits

..
Author SHA1 Message Date
sysops 49a48743f5 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite 2026-08-28 22:02:02 +02:00
sysops bfa5c61db5 SHL-01: fix — Test-Cleanup zwischen Dialog-Tests (afterEach(cleanup), sonst stapeln sich gerenderte DOM-Bäume) 2026-08-28 22:01:56 +02:00
sysops 11940e28e6 OPS-02: fix — transpilePackages fuer @nexarch/shl (Next.js transpiliert node_modules sonst nicht, Build brach ab) 2026-08-28 21:56:39 +02:00
sysops f7bc927d50 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite 2026-08-28 21:56:31 +02:00
sysops 3c226dab12 SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl) 2026-08-28 21:55:34 +02:00
sysops 0f1add0b78 OPS-02: Retrofit auf SHL-01 (ThemeProvider/I18nProvider/ToastProvider, Design-Tokens statt hartkodierter Werte) 2026-08-28 21:47:48 +02:00
sysops 265c09db24 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite
# Conflicts:
#	DEVLOG.md
2026-08-28 21:47:32 +02:00
sysops 584cefa388 DEVLOG: Sessionlog-Eintrag (Auto-Hook) 2026-08-28 21:45:55 +02:00
sysops 00665592f6 SHL-01: ui-shell-design-system-zentral (tokens, theming, i18n-rahmen, basis-komponenten) 2026-08-28 21:41:56 +02:00
sysops cabcabda8d OPS-02: test-fix — overview-pruefung nur fuer eigene testziele, nicht alle jemals registrierten 2026-08-28 08:29:27 +02:00
sysops 81ebdd892b OPS-02: dev-server + next.js zentrale statusseite 2026-08-28 08:21:27 +02:00
sysops dff760e1ba OPS-02: zentrale-statusseite (backend: poller + aggregierte uebersicht + verlauf) 2026-08-28 08:19:40 +02:00
sysopsandClaude Sonnet 5 6a03dcafd6 OPS-01: health-check-endpunkte-je-modul
internal/health: wiederverwendbare Registry fuer benannte Checks (DB, Queue)
— nicht Core-spezifisch, sondern von jedem registrierten Modul (API-02)
gleichermassen einsetzbar. LivenessHandler prueft bewusst KEINE externen
Abhaengigkeiten (Akzeptanzkriterium 2: Liveness/Readiness getrennt) — ein
DB-Ausfall soll den Prozess nicht faelschlich als "tot" markieren und einen
grundlosen Neustart ausloesen. ReadinessHandler fuehrt alle registrierten
Checks NEBENLAEUFIG mit je eigenem Timeout aus (DefaultCheckTimeout=2s) und
liefert 503, sobald irgendeine Abhaengigkeit fehlschlaegt (Akzeptanz-
kriterium 1 + 3) — echte Pruefung von DB (Ping) und Job-Queue statt nur
Prozessstatus.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Simulierter Datenbankausfall fuehrt zu "nicht bereit" —
   TestReadinessHandler_ReportsNotReadyOnDatabaseFailure: geschlossener Pool,
   503 mit "database" im Checks-Ergebnis. PASS.
2. Health-Endpunkt antwortet auch bei haengendem Check innerhalb definierter
   Zeit — TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck:
   ein 10s blockierender Check wird durch 50ms-Timeout begrenzt, Handler
   antwortet deutlich unter 1s. PASS.
3. Readiness- und Liveness-Antwort unterscheiden sich nachweislich in
   mindestens einem Fehlerfall — TestLivenessAndReadiness_DifferOnDatabaseFailure:
   bei DB-Ausfall liefert Liveness weiterhin 200, Readiness 503. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:30:30 +02:00
sysopsandClaude Sonnet 5 b23cd1961f API-02: modul-registry-aktivierungspruefung
internal/moduleregistry: Registry.Register traegt Fachmodule mit Name,
Version und benoetigten Feature-Flags ein (Akzeptanzkriterium 1), fehlende
Pflichtangaben werden abgewiesen. IsActive kombiniert Registrierung + LIC-02
Feature-Flag-Auswertung (ALLE benoetigten Flags muessen fuer den Tenant
aktiv sein) — ein nicht registriertes Modul ist nie aktiv. List liefert alle
Module fuer Statusseite/Lizenzoberflaeche (Akzeptanzkriterium 3).

RequireActiveModule ist die zentrale Durchsetzungs-Middleware (Casbin-
Prinzip): weist Anfragen an ein deaktiviertes Modul ab, BEVOR der
Modul-Handler ueberhaupt aufgerufen wird (Akzeptanzkriterium 2) —
Pruefung per Test belegt, dass der Handler bei Deaktivierung nachweislich
nicht erreicht wird.

Service-Credentials (Akzeptanzkriterium 4): Registry.Provision stellt pro
Modul-Instanz Client-ID + Secret aus, gespeichert wird nur der SHA-256-Hash
des Secrets. Registry.Authenticate vergleicht timing-safe (dieselbe
subtle.ConstantTimeCompare-Referenzimplementierung wie AUD-02).
RequireServiceCredential-Middleware liest X-Client-Id/X-Client-Secret und
weist Aufrufe ohne gueltiges Credential mit 401 ab, bevor der Core-seitige
Endpunkt (z.B. Audit-Nachlieferung, Nutzungsmeldung) erreicht wird.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Anfrage an deaktiviertes Modul nachweislich vor Modul-Logik abgewiesen —
   TestRequireActiveModule_BlocksBeforeHandler: handlerReached bleibt false
   bei 403, wird true erst nach Aktivierung bei 200. PASS.
2. Registrierung mit fehlenden Pflichtangaben abgewiesen —
   TestRegister_RejectsMissingFields (leerer Name, leere Version). PASS.
3. Registry-Abfrage liefert konsistente Daten nach Aktivierung/Deaktivierung —
   TestIsActive_ReflectsFlagStateConsistently: aus/an/aus-Zyklus, IsActive
   folgt dem Flag-Zustand korrekt. PASS.
4. Aufruf mit ungueltigem/fehlendem Service-Credential abgewiesen, mit
   gueltigem angenommen — TestRequireServiceCredential_RejectsInvalidAcceptsValid
   und TestProvisionAndAuthenticate (falsches Secret, unbekannte Client-ID,
   korrektes Credential). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:17:50 +02:00
sysopsandClaude Sonnet 5 4a30345e07 LIC-02: feature-flag-service-je-tenant
internal/flag: Store (Verwaltung) + Service (Auswertung mit TTL-Cache,
Default 5s) — Unleash-Prinzip Flag-Verwaltung vs. Flag-Auswertung getrennt,
als Kernfunktion des Core-Dienstes selbst statt separater Infrastruktur.

evaluate() wendet drei Strategien in fester Reihenfolge an: global an/aus,
Tenant-Zielgruppe, deterministischer Prozentsatz-Rollout (FNV-Hash aus
Tenant+Key, stabil pro Tenant). IsEnabled liefert IMMER nur bool (kein
Fehlerwert) — ein nicht erreichbarer Flag-Dienst kann damit keinen
Aufrufer zum Absturz bringen: bei DB-Fehler wird der zuletzt bekannte
Cache-Stand verwendet, ohne jeglichen Stand faellt der Dienst sicher auf
false zurueck. Service.Invalidate erzwingt sofortiges Neuladen fuer den
Schreiber selbst, andere Instanzen sehen Aenderungen spaetestens nach der
TTL (Akzeptanzkriterium 3, kein Neustart noetig).

Bugfix waehrend Tests: Store.Set uebergab ein nil-TargetTenantSlugs-Slice
als SQL NULL statt leerem Array (NOT-NULL-Verletzung) — auf leeres Slice
normalisiert.

Akzeptanzkriterium 4 (Deaktivierung loescht keine Daten): dieses Paket
besitzt ausschliesslich die eigene feature_flags-Zeile, hat keinerlei
Code-Pfad, der Modul-Geschaeftsdaten anfassen koennte — Loeschung bleibt
strukturell der Archive-Retention-Engine vorbehalten.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Cache-Invalidierungszeit automatisiert gemessen —
   TestService_CacheInvalidationTiming: Aenderung wirksam nach 153ms bei
   TTL=150ms (innerhalb Ziel+Toleranz), vorher nachweislich noch alter Stand. PASS.
2. Zielgruppen-Strategie liefert erwartete Auswertung —
   TestService_TargetTenantStrategy / TestEvaluate_TargetTenantStrategy. PASS.
3. Ausfall des Flag-Dienstes fuehrt zu dokumentiertem Fallback, kein Absturz —
   TestService_FallsBackOnStoreFailure (mit recover()-Absicherung): Fallback
   auf Cache-Stand bzw. sicheres false bei komplett unerreichbarer DB, geloggt. PASS.
4. Modul-Deaktivierung/Reaktivierung ohne Datenverlust — architektonisch durch
   fehlenden Code-Pfad sichergestellt (siehe oben), zusaetzlich durch
   TestService_InvalidateForcesImmediateRefresh (Toggle aus/an bleibt
   konsistent nachvollziehbar) mitabgedeckt. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:19:59 +02:00
sysopsandClaude Sonnet 5 8da9c67d08 TEN-01: tenant-registry-datenbank-provisioning
Registry-DB (nur Tenant-Metadaten), Provisioning-Routine legt pro Mandant
eine physisch isolierte Postgres-DB an und registriert sie transaktional
(Rollback der DB bei fehlgeschlagener Registrierung). Schlanker HTTP-Handler
als Schnittstellen-Vorbereitung fuer API-01/TEN-02, kein eigenes REST-Grundgerüst.

Pruefungen:
1. Migration up/down geschrieben (0001_tenant_registry.{up,down}.sql) — nicht
   gegen echte DB ausgefuehrt, da auf dieser Maschine kein Go/Postgres-Test-
   Setup verfuegbar ist. Offen zur Ausfuehrung.
2. Integrationstest TestProvision_CreatesIsolatedDatabases geschrieben (zwei
   Mandanten, prueft unterschiedliche db_name und current_database()) —
   ebenfalls nicht ausgefuehrt, guarded per TEST_ADMIN_DSN env var. Offen.
3. Slug-Validierung (unit test TestValidateSlug) deckt SQL-Injection-Versuch
   im Datenbanknamen ab — ebenfalls nicht lokal ausgefuehrt, da kein Go
   Compiler auf dieser Maschine vorhanden ist. Offen.

Alle drei Pruefungen sind vorbereitet, aber NICHT durchgefuehrt worden —
zaehlen laut Vorgabe als offen bis auf einer Maschine mit Go+Postgres verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:40:35 +02:00
132 changed files with 3510 additions and 5698 deletions
@@ -1,23 +0,0 @@
name: Mail-Pflichttest-Gate
on:
pull_request:
paths:
- "mail/**"
jobs:
pflichttest-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Gate bauen
working-directory: mail
run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate
- name: Geänderte Dateien gegen Pflichttest-Regel prüfen
run: |
git diff --name-only "origin/${{ github.base_ref }}...HEAD" | /tmp/pflichttestgate
+2
View File
@@ -1,2 +1,4 @@
*.log
.env
web/*/node_modules/
web/*/.next/
+115
View File
@@ -44,3 +44,118 @@ Keine Commits in dieser Session.
Keine Änderungen ermittelbar.
---
## 2026-08-27 17:26 17:28 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
- c895a67 core: initial Go module skeleton (config, db pool, tenant registry migration)
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:28 17:29 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
## 2026-08-28 21:44 21:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:31 17:31 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:36 17:36 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:36 17:37 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/index.ts | 25 +++++++++++++++++++++++++
- web/shl/package.json | 23 +++++++++++++++++++++++
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tsconfig.json | 18 ++++++++++++++++++
---
## 2026-08-28 21:51 21:57 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
### Geänderte Dateien
- web/shl/package.json | 2 ++
- web/shl/vitest.config.ts | 8 ++++++++
- web/shl/vitest.setup.ts | 1 +
---
+18 -3
View File
@@ -7,6 +7,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/config"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
func main() {
@@ -15,16 +16,30 @@ func main() {
log.Fatalf("config: %v", err)
}
pool, err := db.Connect(context.Background(), cfg.RegistryDSN)
ctx := context.Background()
registryPool, err := db.Connect(ctx, cfg.RegistryDSN)
if err != nil {
log.Fatalf("db: %v", err)
log.Fatalf("registry db: %v", err)
}
defer pool.Close()
defer registryPool.Close()
adminPool, err := db.Connect(ctx, cfg.AdminDSN)
if err != nil {
log.Fatalf("admin db: %v", err)
}
defer adminPool.Close()
registry := tenant.NewRegistry(registryPool)
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
tenantHandler := tenant.NewHandler(provisioner)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
+66
View File
@@ -0,0 +1,66 @@
// statuspage-devserver stellt das OPS-02-Backend (internal/statuspage) fuer
// die Next.js-Statusseite bereit und startet den periodischen Poller.
// Getrennt von cmd/core aus demselben Grund wie die anderen *-devserver.
package main
import (
"context"
"log"
"net/http"
"os"
"strconv"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/statuspage"
)
func main() {
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
if dsn == "" {
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
}
addr := os.Getenv("NEXARCH_STATUSPAGE_LISTEN_ADDR")
if addr == "" {
addr = ":8084"
}
intervalSeconds := 10
if v := os.Getenv("NEXARCH_STATUSPAGE_POLL_INTERVAL_SECONDS"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil {
intervalSeconds = parsed
}
}
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
store := statuspage.NewStore(pool)
checker := statuspage.NewHTTPChecker(2 * time.Second)
poller := statuspage.NewPoller(store, checker)
go poller.Run(ctx, time.Duration(intervalSeconds)*time.Second)
mux := http.NewServeMux()
mux.HandleFunc("/status/overview", withCORS(store.OverviewHandler))
mux.HandleFunc("/status/history", withCORS(store.HistoryHandler))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("statuspage-devserver listening on %s (poll-intervall: %ds)", addr, intervalSeconds)
log.Fatal(http.ListenAndServe(addr, mux))
}
func withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
+9
View File
@@ -3,3 +3,12 @@ module gitea.perlbach24.de/scripte/nexarch
go 1.22
require github.com/jackc/pgx/v5 v5.6.0
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
+28
View File
@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+24 -2
View File
@@ -10,8 +10,15 @@ import (
// connection info, superadmin accounts) — see nexarch-state.json
// multi_tenancy: Modell C (physisch getrennte DB pro Mandant).
type Config struct {
ListenAddr string
ListenAddr string
// RegistryDSN verbindet zur Control-Plane-Registry-Datenbank.
RegistryDSN string
// AdminDSN verbindet zur Wartungsdatenbank (z.B. "postgres") und wird nur
// fuer CREATE/DROP DATABASE beim Tenant-Provisioning verwendet.
AdminDSN string
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
TenantDSNTemplate string
}
func Load() (Config, error) {
@@ -20,10 +27,25 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set")
}
adminDSN := os.Getenv("NEXARCH_ADMIN_DSN")
if adminDSN == "" {
return Config{}, fmt.Errorf("NEXARCH_ADMIN_DSN not set")
}
dsnTemplate := os.Getenv("NEXARCH_TENANT_DSN_TEMPLATE")
if dsnTemplate == "" {
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
}
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
return Config{ListenAddr: addr, RegistryDSN: dsn}, nil
return Config{
ListenAddr: addr,
RegistryDSN: dsn,
AdminDSN: adminDSN,
TenantDSNTemplate: dsnTemplate,
}, nil
}
+87
View File
@@ -0,0 +1,87 @@
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
package flag
import (
"context"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("flag: nicht gefunden")
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
type Flag struct {
Key string
Enabled bool
RolloutPercentage int
TargetTenantSlugs []string
}
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) Set(ctx context.Context, f Flag) error {
if f.TargetTenantSlugs == nil {
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
}
_, err := s.pool.Exec(ctx, `
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (key) DO UPDATE SET
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
if err != nil {
return fmt.Errorf("flag speichern: %w", err)
}
return nil
}
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
var f Flag
row := s.pool.QueryRow(ctx, `
SELECT key, enabled, rollout_percentage, target_tenant_slugs
FROM feature_flags WHERE key = $1
`, key)
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Flag{}, ErrNotFound
}
return Flag{}, fmt.Errorf("flag lesen: %w", err)
}
return f, nil
}
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
// kein Feature wird versehentlich aktiv.
func evaluate(f Flag, tenantSlug string) bool {
if f.Enabled {
return true
}
for _, target := range f.TargetTenantSlugs {
if target == tenantSlug {
return true
}
}
if f.RolloutPercentage > 0 {
h := fnv.New32a()
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
return int(h.Sum32()%100) < f.RolloutPercentage
}
return false
}
+43
View File
@@ -0,0 +1,43 @@
package flag
import "testing"
func TestEvaluate_GlobalEnabled(t *testing.T) {
f := Flag{Key: "k", Enabled: true}
if !evaluate(f, "irgendein-tenant") {
t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern")
}
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie.
func TestEvaluate_TargetTenantStrategy(t *testing.T) {
f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}}
if !evaluate(f, "acme") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if evaluate(f, "globex") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) {
full := Flag{Key: "k", RolloutPercentage: 100}
if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") {
t.Fatal("100% rollout sollte immer true liefern")
}
none := Flag{Key: "k", RolloutPercentage: 0}
if evaluate(none, "beliebiger-tenant") {
t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern")
}
}
func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) {
f := Flag{Key: "k", RolloutPercentage: 50}
first := evaluate(f, "stabiler-tenant")
for i := 0; i < 5; i++ {
if evaluate(f, "stabiler-tenant") != first {
t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein")
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package flag
import (
"context"
"log/slog"
"sync"
"time"
)
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser
// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden
// muss (Akzeptanzkriterium 3).
const DefaultCacheTTL = 5 * time.Second
type cacheEntry struct {
flag Flag
expiresAt time.Time
}
// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit
// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung).
type Service struct {
store *Store
ttl time.Duration
mu sync.RWMutex
cache map[string]cacheEntry
}
func NewService(store *Store, ttl time.Duration) *Service {
if ttl <= 0 {
ttl = DefaultCacheTTL
}
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
}
// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen
// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige
// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen,
// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes
// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher).
func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool {
f, ok := s.resolve(ctx, key)
if !ok {
return false
}
return evaluate(f, tenantSlug)
}
func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) {
s.mu.RLock()
entry, exists := s.cache[key]
fresh := exists && time.Now().Before(entry.expiresAt)
s.mu.RUnlock()
if fresh {
return entry.flag, true
}
f, err := s.store.Get(ctx, key)
if err != nil {
if exists {
slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand",
"flag_key", key, "error", err)
return entry.flag, true
}
slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert",
"flag_key", key, "error", err)
return Flag{}, false
}
s.mu.Lock()
s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return f, true
}
// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen
// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set
// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung
// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach
// DefaultCacheTTL (siehe Akzeptanzkriterium 3).
func (s *Service) Invalidate(key string) {
s.mu.Lock()
delete(s.cache, key)
s.mu.Unlock()
}
+179
View File
@@ -0,0 +1,179 @@
package flag
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupFlagStoreTest(t *testing.T) (*Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0,
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return NewStore(pool), cleanup
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test
// die erwartete Auswertung.
func TestService_TargetTenantStrategy(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
if !svc.IsEnabled(ctx, "acme", "test_target_flag") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if svc.IsEnabled(ctx, "globex", "test_target_flag") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der
// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen.
func TestService_CacheInvalidationTiming(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
const ttl = 150 * time.Millisecond
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, ttl)
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("erwartet false vor der aenderung")
}
// Aenderung "auf einer anderen instanz" simulieren: direkt ueber den
// Store, ohne svc.Invalidate aufzurufen.
changedAt := time.Now()
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
// Sofort danach sollte der Cache noch den alten Stand liefern.
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung")
}
deadline := changedAt.Add(ttl + 100*time.Millisecond)
for time.Now().Before(deadline) {
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
elapsed := time.Since(changedAt)
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl)
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
}
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht
_ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag")
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc.Invalidate("test_invalidate_flag")
if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") {
t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate")
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu
// dokumentiertem Fallback-Verhalten, nicht zum Absturz.
func TestService_FallsBackOnStoreFailure(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
// Cache vorwaermen, waehrend die DB noch erreichbar ist.
if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet true bei funktionierender db")
}
brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1")
if err != nil {
t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err)
}
brokenStore := NewStore(brokenPool)
svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch
svcWithCache.mu.Lock()
svcWithCache.cache["test_fallback_flag"] = cacheEntry{
flag: Flag{Key: "test_fallback_flag", Enabled: true},
expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen
}
svcWithCache.mu.Unlock()
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r)
}
}()
if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall")
}
}()
// Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer
// default false, kein absturz.
freshSvc := NewService(brokenStore, time.Hour)
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict: %v", r)
}
}()
if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db")
}
}()
}
+25
View File
@@ -0,0 +1,25 @@
package health
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// DatabaseChecker prueft die tatsaechliche Erreichbarkeit der Datenbank
// (Ping) — nicht nur, ob der Pool existiert.
func DatabaseChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
return pool.Ping(ctx)
}
}
// QueueChecker prueft, dass die Postgres-basierte Job-Queue (siehe CFG-02)
// tatsaechlich abfragbar ist — eine eigene, benannte Abhaengigkeit neben der
// reinen DB-Erreichbarkeit (Akzeptanzkriterium 1).
func QueueChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
_, err := pool.Exec(ctx, `SELECT 1`)
return err
}
}
+49
View File
@@ -0,0 +1,49 @@
package health
import (
"encoding/json"
"net/http"
)
// LivenessHandler beantwortet IMMER "lebt", solange der Prozess ueberhaupt
// HTTP-Anfragen verarbeiten kann — prueft bewusst KEINE externen
// Abhaengigkeiten (Akzeptanzkriterium 2: Liveness und Readiness getrennt).
// Ein Datenbankausfall darf die Liveness nicht auf "tot" setzen, sonst
// wuerde eine Orchestrierung (z.B. systemd/Kubernetes) den Prozess grundlos
// neu starten, obwohl nur eine Abhaengigkeit ausgefallen ist.
func LivenessHandler(w http.ResponseWriter, r *http.Request) {
writeStatus(w, http.StatusOK, map[string]any{"status": "alive"})
}
// ReadinessHandler prueft ALLE registrierten Abhaengigkeiten
// (Akzeptanzkriterium 1) und liefert 503, sobald eine davon fehlschlaegt
// (Akzeptanzkriterium 3) — unterscheidet sich damit nachweislich von
// LivenessHandler im Fehlerfall (Akzeptanzkriterium 2 / Pruefung 3).
func (r *Registry) ReadinessHandler() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
ready, results := r.CheckAll(req.Context())
body := map[string]any{
"status": statusText(ready),
"checks": results,
}
status := http.StatusOK
if !ready {
status = http.StatusServiceUnavailable
}
writeStatus(w, status, body)
}
}
func statusText(ready bool) string {
if ready {
return "ready"
}
return "not_ready"
}
func writeStatus(w http.ResponseWriter, status int, body map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+86
View File
@@ -0,0 +1,86 @@
// Package health implementiert Core OPS-01: Health-/Readiness-Endpunkte, die
// echte Abhaengigkeiten (DB, Job-Queue) statt nur den Prozessstatus pruefen
// — wiederverwendbar von Core UND jedem registrierten Modul (siehe API-02),
// nicht nur von Core selbst.
package health
import (
"context"
"time"
)
// Checker prueft EINE Abhaengigkeit (z.B. Datenbank, Job-Queue).
type Checker interface {
Check(ctx context.Context) error
}
type CheckerFunc func(ctx context.Context) error
func (f CheckerFunc) Check(ctx context.Context) error { return f(ctx) }
// DefaultCheckTimeout begrenzt, wie lange EIN einzelner Check maximal
// dauern darf, bevor er als fehlgeschlagen gilt — verhindert, dass ein
// haengender Check den gesamten Readiness-Endpunkt blockiert
// (Akzeptanzkriterium 2 / Pruefung 2: Antwort innerhalb definierter Zeit).
const DefaultCheckTimeout = 2 * time.Second
// Registry haelt alle benannten Checks eines Dienstes.
type Registry struct {
checks map[string]Checker
timeout time.Duration
}
func NewRegistry() *Registry {
return &Registry{checks: make(map[string]Checker), timeout: DefaultCheckTimeout}
}
func (r *Registry) WithTimeout(d time.Duration) *Registry {
return &Registry{checks: r.checks, timeout: d}
}
// Register fuegt einen benannten Check hinzu (z.B. "database", "queue").
func (r *Registry) Register(name string, c Checker) {
r.checks[name] = c
}
// Result ist der Ausgang eines einzelnen Checks.
type Result struct {
OK bool
Error string
}
// CheckAll fuehrt alle registrierten Checks NEBENLAEUFIG mit je eigenem
// Timeout aus (Akzeptanzkriterium 1: echte Abhaengigkeiten statt Prozess-
// status) und liefert ready=false, sobald irgendein Check fehlschlaegt
// (Akzeptanzkriterium 3: ein Ausfall wird sichtbar).
func (r *Registry) CheckAll(ctx context.Context) (ready bool, results map[string]Result) {
type namedResult struct {
name string
result Result
}
ch := make(chan namedResult, len(r.checks))
for name, checker := range r.checks {
go func(name string, checker Checker) {
checkCtx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
err := checker.Check(checkCtx)
if err != nil {
ch <- namedResult{name, Result{OK: false, Error: err.Error()}}
return
}
ch <- namedResult{name, Result{OK: true}}
}(name, checker)
}
results = make(map[string]Result, len(r.checks))
ready = true
for i := 0; i < len(r.checks); i++ {
nr := <-ch
results[nr.name] = nr.result
if !nr.result.OK {
ready = false
}
}
return ready, results
}
+161
View File
@@ -0,0 +1,161 @@
package health
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Akzeptanzkriterium 1 + Pruefung 1: simulierter Datenbankausfall fuehrt zu
// "nicht bereit".
func TestReadinessHandler_ReportsNotReadyOnDatabaseFailure(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
// Datenbankausfall simulieren: Pool sofort schliessen, bevor der Check laeuft.
pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 bei db-ausfall", rec.Code)
}
var body struct {
Status string `json:"status"`
Checks map[string]interface{} `json:"checks"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body parsen: %v", err)
}
if body.Status != "not_ready" {
t.Fatalf("status-feld = %q, want not_ready", body.Status)
}
if _, ok := body.Checks["database"]; !ok {
t.Fatal("erwartet 'database' im checks-ergebnis")
}
}
func TestReadinessHandler_ReportsReadyWhenAllChecksPass(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
defer pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
reg.Register("queue", QueueChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 bei funktionierenden abhaengigkeiten", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: Liveness und Readiness unterscheiden
// sich nachweislich im Fehlerfall.
func TestLivenessAndReadiness_DifferOnDatabaseFailure(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
pool.Close() // db-ausfall simulieren
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
livenessRec := httptest.NewRecorder()
LivenessHandler(livenessRec, httptest.NewRequest(http.MethodGet, "/livez", nil))
if livenessRec.Code != http.StatusOK {
t.Fatalf("liveness status = %d, want 200 trotz db-ausfall (liveness prueft keine abhaengigkeiten)", livenessRec.Code)
}
readinessRec := httptest.NewRecorder()
reg.ReadinessHandler()(readinessRec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if readinessRec.Code != http.StatusServiceUnavailable {
t.Fatalf("readiness status = %d, want 503 bei db-ausfall", readinessRec.Code)
}
if livenessRec.Code == readinessRec.Code {
t.Fatal("liveness und readiness sollten sich im db-ausfall-fall unterscheiden")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Health-Endpunkt antwortet auch bei
// haengendem Check innerhalb definierter Zeit (Timeout begrenzt die Dauer).
func TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck(t *testing.T) {
reg := NewRegistry().WithTimeout(50 * time.Millisecond)
reg.Register("haengender_dienst", CheckerFunc(func(ctx context.Context) error {
select {
case <-time.After(10 * time.Second): // wuerde ohne timeout ewig blockieren
return nil
case <-ctx.Done():
return ctx.Err()
}
}))
start := time.Now()
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
elapsed := time.Since(start)
if elapsed > time.Second {
t.Fatalf("readiness handler brauchte %s, erwartet deutlich unter 1s durch timeout", elapsed)
}
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 fuer haengenden/timeout-check", rec.Code)
}
}
func TestCheckAll_MultipleChecksRunConcurrently(t *testing.T) {
reg := NewRegistry().WithTimeout(time.Second)
reg.Register("a", CheckerFunc(func(ctx context.Context) error { return nil }))
reg.Register("b", CheckerFunc(func(ctx context.Context) error { return errors.New("kaputt") }))
ready, results := reg.CheckAll(context.Background())
if ready {
t.Fatal("erwartet ready=false, da 'b' fehlschlaegt")
}
if !results["a"].OK {
t.Fatalf("erwartet 'a' ok, habe %+v", results["a"])
}
if results["b"].OK || results["b"].Error == "" {
t.Fatalf("erwartet 'b' fehlgeschlagen mit fehlertext, habe %+v", results["b"])
}
}
+99
View File
@@ -0,0 +1,99 @@
package moduleregistry
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
var (
ErrModuleNotRegistered = errors.New("moduleregistry: modul muss vor provisionierung registriert sein")
ErrInvalidCredential = errors.New("moduleregistry: ungueltiges oder fehlendes service-credential")
)
// Provision stellt ein Service-Credential (Client-ID + Secret) fuer eine
// Modul-Instanz aus (Akzeptanzkriterium 4). Das Secret wird NUR beim
// Ausstellen im Klartext zurueckgegeben, gespeichert wird ausschliesslich
// dessen SHA-256-Hash.
func (r *Registry) Provision(ctx context.Context, moduleName string) (clientID, secret string, err error) {
if _, err := r.Get(ctx, moduleName); err != nil {
if errors.Is(err, ErrModuleNotFound) {
return "", "", ErrModuleNotRegistered
}
return "", "", err
}
clientID, err = randomToken(16)
if err != nil {
return "", "", fmt.Errorf("client-id erzeugen: %w", err)
}
secret, err = randomToken(32)
if err != nil {
return "", "", fmt.Errorf("secret erzeugen: %w", err)
}
hash := hashSecret(secret)
_, err = r.pool.Exec(ctx, `
INSERT INTO module_credentials (module_name, client_id, secret_hash, issued_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (module_name) DO UPDATE SET client_id = $2, secret_hash = $3, issued_at = now()
`, moduleName, clientID, hash)
if err != nil {
return "", "", fmt.Errorf("credential speichern: %w", err)
}
return clientID, secret, nil
}
// Authenticate prueft ein Service-Credential timing-safe (Referenzmuster
// siehe AUD-02) — Aufrufe ohne gueltiges Credential werden abgelehnt
// (Akzeptanzkriterium 4 / Pruefung 4).
func (r *Registry) Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error) {
if clientID == "" || secret == "" {
return "", false, nil
}
var storedHash []byte
err = r.pool.QueryRow(ctx, `
SELECT module_name, secret_hash FROM module_credentials WHERE client_id = $1
`, clientID).Scan(&moduleName, &storedHash)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("credential lesen: %w", err)
}
if !timingSafeEqual(hashSecret(secret), storedHash) {
return "", false, nil
}
return moduleName, true, nil
}
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashSecret(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// timingSafeEqual folgt derselben Referenzimplementierung wie AUD-02
// (subtle.ConstantTimeCompare) — projektweite Konvention fuer jeden
// sicherheitsrelevanten Vergleich.
func timingSafeEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare(a, b) == 1
}
+46
View File
@@ -0,0 +1,46 @@
package moduleregistry
import "net/http"
// RequireActiveModule weist Anfragen an ein nicht aktiviertes Modul ZENTRAL
// ab, bevor der eigentliche Modul-Handler erreicht wird (Akzeptanzkriterium 2 /
// Pruefung 1) — Casbin-Prinzip: Durchsetzung als Middleware statt verstreuter
// Pruefungen in jedem Handler. tenantSlug/moduleName werden hier ueber
// Query-Parameter gelesen (echte Extraktion aus JWT/Tenant-Kontext ist
// API-05/TEN-06, nicht Teil dieser Kachel).
func (r *Registry) RequireActiveModule(moduleName string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
tenantSlug := req.URL.Query().Get("tenant")
active, err := r.IsActive(req.Context(), tenantSlug, moduleName)
if err != nil {
http.Error(w, "aktivierungspruefung fehlgeschlagen", http.StatusInternalServerError)
return
}
if !active {
http.Error(w, "modul nicht aktiviert", http.StatusForbidden)
return
}
next(w, req)
}
}
// RequireServiceCredential authentifiziert eine Modul-Instanz ueber ihr
// Service-Credential (X-Client-Id/X-Client-Secret-Header) BEVOR der
// eigentliche Handler erreicht wird (Akzeptanzkriterium 4 / Pruefung 4).
func (r *Registry) RequireServiceCredential(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
clientID := req.Header.Get("X-Client-Id")
secret := req.Header.Get("X-Client-Secret")
_, ok, err := r.Authenticate(req.Context(), clientID, secret)
if err != nil {
http.Error(w, "authentifizierung fehlgeschlagen", http.StatusInternalServerError)
return
}
if !ok {
http.Error(w, ErrInvalidCredential.Error(), http.StatusUnauthorized)
return
}
next(w, req)
}
}
+120
View File
@@ -0,0 +1,120 @@
// Package moduleregistry implementiert Core API-02: die Registry, in der
// sich Fachmodule (DMS, Mail, weitere) mit Metadaten eintragen, gekoppelt an
// die Aktivierungspruefung aus LIC-02 (Feature-Flags). Zusaetzlich
// authentifiziert die Registry Modul-Instanzen selbst ueber ein bei
// Provisionierung ausgestelltes Service-Credential.
package moduleregistry
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
)
var (
ErrMissingName = errors.New("moduleregistry: name darf nicht leer sein")
ErrMissingVersion = errors.New("moduleregistry: version darf nicht leer sein")
ErrModuleNotFound = errors.New("moduleregistry: modul nicht registriert")
)
type Module struct {
Name string
Version string
RequiredFlags []string
}
type Registry struct {
pool *pgxpool.Pool
flags *flag.Service
}
func NewRegistry(pool *pgxpool.Pool, flags *flag.Service) *Registry {
return &Registry{pool: pool, flags: flags}
}
// Register traegt ein Modul mit Name, Version und benoetigten Feature-Flags
// ein (Akzeptanzkriterium 1). Fehlende Pflichtangaben werden abgewiesen
// (Akzeptanzkriterium 1 / Pruefung 2). Erneutes Register desselben Namens
// aktualisiert Version/Flags (Redeploy-Fall).
func (r *Registry) Register(ctx context.Context, name, version string, requiredFlags []string) (Module, error) {
if name == "" {
return Module{}, ErrMissingName
}
if version == "" {
return Module{}, ErrMissingVersion
}
if requiredFlags == nil {
requiredFlags = []string{}
}
_, err := r.pool.Exec(ctx, `
INSERT INTO modules (name, version, required_flags, registered_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (name) DO UPDATE SET version = $2, required_flags = $3, registered_at = now()
`, name, version, requiredFlags)
if err != nil {
return Module{}, fmt.Errorf("modul registrieren: %w", err)
}
return Module{Name: name, Version: version, RequiredFlags: requiredFlags}, nil
}
func (r *Registry) Get(ctx context.Context, name string) (Module, error) {
var m Module
m.Name = name
err := r.pool.QueryRow(ctx, `
SELECT version, required_flags FROM modules WHERE name = $1
`, name).Scan(&m.Version, &m.RequiredFlags)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Module{}, ErrModuleNotFound
}
return Module{}, fmt.Errorf("modul lesen: %w", err)
}
return m, nil
}
// List liefert alle registrierten Module (Akzeptanzkriterium 3: ueber API
// abfragbar, z.B. fuer Statusseite/Lizenzoberflaeche).
func (r *Registry) List(ctx context.Context) ([]Module, error) {
rows, err := r.pool.Query(ctx, `SELECT name, version, required_flags FROM modules ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("module auflisten: %w", err)
}
defer rows.Close()
var out []Module
for rows.Next() {
var m Module
if err := rows.Scan(&m.Name, &m.Version, &m.RequiredFlags); err != nil {
return nil, fmt.Errorf("modul lesen: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// IsActive prueft, ob ein registriertes Modul fuer einen Tenant aktiviert
// ist: registriert UND alle benoetigten Feature-Flags sind fuer diesen
// Tenant aktiv (Akzeptanzkriterium 2). Ein nicht registriertes Modul gilt
// immer als nicht aktiv.
func (r *Registry) IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error) {
m, err := r.Get(ctx, moduleName)
if err != nil {
if errors.Is(err, ErrModuleNotFound) {
return false, nil
}
return false, err
}
for _, flagKey := range m.RequiredFlags {
if !r.flags.IsEnabled(ctx, tenantSlug, flagKey) {
return false, nil
}
}
return true, nil
}
+304
View File
@@ -0,0 +1,304 @@
package moduleregistry
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
)
func setupTest(t *testing.T) (*Registry, *flag.Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS modules (
name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
flagStore := flag.NewStore(pool)
// Kurze TTL, damit Tests, die den Flag-Store direkt aendern (an
// Registry.IsActive vorbei), den neuen Stand ohne manuelles Invalidate
// zuverlaessig sehen.
flagService := flag.NewService(flagStore, 10*time.Millisecond)
registry := NewRegistry(pool, flagService)
cleanup := func() { pool.Close() }
return registry, flagStore, cleanup
}
func uniqueModuleName(t *testing.T) string {
return fmt.Sprintf("dms_%d", time.Now().UnixNano())
}
// Akzeptanzkriterium 1 + Pruefung 2: fehlende Pflichtangaben abgewiesen.
func TestRegister_RejectsMissingFields(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := registry.Register(ctx, "", "1.0", nil); !errors.Is(err, ErrMissingName) {
t.Fatalf("erwartet ErrMissingName, habe %v", err)
}
if _, err := registry.Register(ctx, "dms", "", nil); !errors.Is(err, ErrMissingVersion) {
t.Fatalf("erwartet ErrMissingVersion, habe %v", err)
}
}
func TestRegister_AndGet(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
m, err := registry.Register(ctx, name, "1.2.0", []string{"dms_enabled"})
if err != nil {
t.Fatalf("register: %v", err)
}
if m.Version != "1.2.0" || len(m.RequiredFlags) != 1 {
t.Fatalf("unerwartet: %+v", m)
}
got, err := registry.Get(ctx, name)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Version != "1.2.0" {
t.Fatalf("get version = %q", got.Version)
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 3: konsistente Daten nach
// Aktivierung/Deaktivierung eines Moduls.
func TestIsActive_ReflectsFlagStateConsistently(t *testing.T) {
registry, flagStore, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
flagKey := name + "_enabled"
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
t.Fatalf("register: %v", err)
}
active, err := registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (vor flag): %v", err)
}
if active {
t.Fatal("erwartet nicht aktiv, solange flag nicht gesetzt ist")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten
active, err = registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (nach flag an): %v", err)
}
if !active {
t.Fatal("erwartet aktiv, nachdem flag aktiviert wurde")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: false}); err != nil {
t.Fatalf("flag zuruecksetzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten
active, err = registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (nach flag aus): %v", err)
}
if active {
t.Fatal("erwartet wieder nicht aktiv, nachdem flag deaktiviert wurde")
}
}
func TestIsActive_UnregisteredModuleIsNeverActive(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
active, err := registry.IsActive(ctx, "acme", "nie-registriert")
if err != nil {
t.Fatalf("is active: %v", err)
}
if active {
t.Fatal("unregistriertes modul darf nie aktiv sein")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Anfrage an deaktiviertes Modul wird
// zentral abgewiesen, BEVOR die Modul-Logik erreicht wird.
func TestRequireActiveModule_BlocksBeforeHandler(t *testing.T) {
registry, flagStore, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
flagKey := name + "_enabled"
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
t.Fatalf("register: %v", err)
}
handlerReached := false
handler := registry.RequireActiveModule(name, func(w http.ResponseWriter, r *http.Request) {
handlerReached = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if handlerReached {
t.Fatal("handler haette bei deaktiviertem modul NICHT erreicht werden duerfen")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
req2 := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
rec2 := httptest.NewRecorder()
handler(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("status nach aktivierung = %d, want 200", rec2.Code)
}
if !handlerReached {
t.Fatal("handler haette bei aktiviertem modul erreicht werden muessen")
}
}
// Akzeptanzkriterium 4 + Pruefung 4: gueltiges/ungueltiges Service-Credential.
func TestProvisionAndAuthenticate(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
t.Fatalf("register: %v", err)
}
clientID, secret, err := registry.Provision(ctx, name)
if err != nil {
t.Fatalf("provision: %v", err)
}
if clientID == "" || secret == "" {
t.Fatal("erwartet nicht-leere client-id/secret")
}
moduleName, ok, err := registry.Authenticate(ctx, clientID, secret)
if err != nil {
t.Fatalf("authenticate (korrekt): %v", err)
}
if !ok || moduleName != name {
t.Fatalf("erwartet erfolgreiche authentifizierung fuer %q, habe ok=%v moduleName=%q", name, ok, moduleName)
}
_, ok, err = registry.Authenticate(ctx, clientID, "falsches-secret")
if err != nil {
t.Fatalf("authenticate (falsch): %v", err)
}
if ok {
t.Fatal("erwartet fehlschlag bei falschem secret")
}
_, ok, err = registry.Authenticate(ctx, "unbekannte-client-id", secret)
if err != nil {
t.Fatalf("authenticate (unbekannt): %v", err)
}
if ok {
t.Fatal("erwartet fehlschlag bei unbekannter client-id")
}
}
func TestProvision_RequiresRegisteredModule(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, _, err := registry.Provision(ctx, "nie-registriert"); !errors.Is(err, ErrModuleNotRegistered) {
t.Fatalf("erwartet ErrModuleNotRegistered, habe %v", err)
}
}
func TestRequireServiceCredential_RejectsInvalidAcceptsValid(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
t.Fatalf("register: %v", err)
}
clientID, secret, err := registry.Provision(ctx, name)
if err != nil {
t.Fatalf("provision: %v", err)
}
handler := registry.RequireServiceCredential(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Fehlendes Credential.
req := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne credential: status = %d, want 401", rec.Code)
}
// Falsches Secret.
req2 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
req2.Header.Set("X-Client-Id", clientID)
req2.Header.Set("X-Client-Secret", "falsch")
rec2 := httptest.NewRecorder()
handler(rec2, req2)
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("falsches secret: status = %d, want 401", rec2.Code)
}
// Gueltiges Credential.
req3 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
req3.Header.Set("X-Client-Id", clientID)
req3.Header.Set("X-Client-Secret", secret)
rec3 := httptest.NewRecorder()
handler(rec3, req3)
if rec3.Code != http.StatusOK {
t.Fatalf("gueltiges credential: status = %d, want 200", rec3.Code)
}
}
+273
View File
@@ -0,0 +1,273 @@
// Package statuspage implementiert Core OPS-02: eine zentrale Statusseite,
// die den Health-Zustand aller registrierten Module aggregiert und den
// Verlauf vergangener Statusaenderungen speichert. Baut auf OPS-01
// (internal/health) auf, indem es GENAU die dort etablierten
// Readiness-Endpunkte je Modul abfragt — dieses Paket dupliziert keine
// Health-Check-Logik, es aggregiert nur deren Ergebnisse ueber die Zeit.
package statuspage
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Status ist der aggregierte Zustand EINES Moduls zu einem Zeitpunkt.
type Status string
const (
StatusUp Status = "up"
StatusDown Status = "down"
)
// Target ist ein zu ueberwachendes Modul mit seiner Readiness-URL
// (OPS-01-Endpunkt, z.B. "http://dms:8080/readyz"). Eigenstaendige
// Konfiguration statt Erweiterung von internal/moduleregistry.Module, um
// API-02 nicht anzufassen (Kein Umbau angrenzender Bereiche).
type Target struct {
Name string
HealthURL string
}
// Store persistiert Ueberwachungsziele und den Verlauf ihrer
// Statusaenderungen.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// RegisterTarget traegt ein zu ueberwachendes Modul ein oder aktualisiert
// dessen URL (Akzeptanzkriterium 1: "aller registrierten Module").
func (s *Store) RegisterTarget(ctx context.Context, t Target) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO status_targets (name, health_url)
VALUES ($1, $2)
ON CONFLICT (name) DO UPDATE SET health_url = $2
`, t.Name, t.HealthURL)
if err != nil {
return fmt.Errorf("ueberwachungsziel speichern: %w", err)
}
return nil
}
func (s *Store) ListTargets(ctx context.Context) ([]Target, error) {
rows, err := s.pool.Query(ctx, `SELECT name, health_url FROM status_targets ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("ueberwachungsziele auflisten: %w", err)
}
defer rows.Close()
var out []Target
for rows.Next() {
var t Target
if err := rows.Scan(&t.Name, &t.HealthURL); err != nil {
return nil, fmt.Errorf("ueberwachungsziel lesen: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// recordIfChanged schreibt NUR dann einen neuen Verlaufseintrag, wenn sich
// der Status seit dem letzten Eintrag geaendert hat (oder es der erste
// Eintrag ist) — der Verlauf zeigt Statusaenderungen (Akzeptanzkriterium 3),
// nicht jede einzelne Abfrage.
func (s *Store) recordIfChanged(ctx context.Context, name string, status Status) error {
var lastStatus string
err := s.pool.QueryRow(ctx, `
SELECT status FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1
`, name).Scan(&lastStatus)
if err != nil && err != pgx.ErrNoRows {
return fmt.Errorf("letzten status lesen: %w", err)
}
if err == nil && lastStatus == string(status) {
return nil // keine Aenderung, kein neuer Eintrag
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO status_history (name, status, changed_at) VALUES ($1, $2, now())
`, name, string(status)); err != nil {
return fmt.Errorf("statuseintrag schreiben: %w", err)
}
return nil
}
// ModuleStatus ist der aktuelle Zustand EINES Moduls fuer die Uebersicht.
type ModuleStatus struct {
Name string `json:"name"`
Status Status `json:"status"`
LastChecked time.Time `json:"last_checked"`
}
// Overview liefert den aktuellen (letzten bekannten) Status jedes
// registrierten Ziels (Akzeptanzkriterium 1). Ziele ohne jemals erfolgte
// Pruefung erscheinen mit Status "down" — ein Modul, ueber das nichts
// bekannt ist, gilt als nicht verfuegbar (Fail-Safe-Default), nicht als
// stillschweigend "ok".
func (s *Store) Overview(ctx context.Context) ([]ModuleStatus, error) {
targets, err := s.ListTargets(ctx)
if err != nil {
return nil, err
}
out := make([]ModuleStatus, 0, len(targets))
for _, t := range targets {
var status string
var changedAt time.Time
err := s.pool.QueryRow(ctx, `
SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1
`, t.Name).Scan(&status, &changedAt)
if err == pgx.ErrNoRows {
out = append(out, ModuleStatus{Name: t.Name, Status: StatusDown})
continue
}
if err != nil {
return nil, fmt.Errorf("aktuellen status lesen (%s): %w", t.Name, err)
}
out = append(out, ModuleStatus{Name: t.Name, Status: Status(status), LastChecked: changedAt})
}
return out, nil
}
// HistoryEntry ist EIN Verlaufseintrag (Akzeptanzkriterium 3).
type HistoryEntry struct {
Status Status `json:"status"`
ChangedAt time.Time `json:"changed_at"`
}
func (s *Store) History(ctx context.Context, name string) ([]HistoryEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC
`, name)
if err != nil {
return nil, fmt.Errorf("verlauf abfragen: %w", err)
}
defer rows.Close()
var out []HistoryEntry
for rows.Next() {
var e HistoryEntry
var status string
if err := rows.Scan(&status, &e.ChangedAt); err != nil {
return nil, fmt.Errorf("verlaufseintrag lesen: %w", err)
}
e.Status = Status(status)
out = append(out, e)
}
return out, rows.Err()
}
// HTTPChecker fragt die Readiness-URL eines Moduls ab (OPS-01-Endpunkt) und
// liefert StatusUp NUR bei HTTP 200 — jeder andere Statuscode ODER ein
// Netzwerkfehler/Timeout gilt als StatusDown. Ein einzelnes nicht
// erreichbares Modul liefert einen FEHLERFREIEN StatusDown-Wert statt eines
// Go-Errors, damit Poller.Run ein fehlerhaftes Modul niemals mit einem
// anderen verwechseln oder den gesamten Zyklus abbrechen kann
// (Akzeptanzkriterium 2).
type HTTPChecker struct {
Client *http.Client
Timeout time.Duration
}
func NewHTTPChecker(timeout time.Duration) *HTTPChecker {
return &HTTPChecker{Client: &http.Client{}, Timeout: timeout}
}
func (c *HTTPChecker) Check(ctx context.Context, url string) Status {
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return StatusDown
}
resp, err := c.Client.Do(req)
if err != nil {
return StatusDown
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return StatusUp
}
return StatusDown
}
// Poller fragt periodisch alle Ziele ab und schreibt Statusaenderungen fort.
type Poller struct {
store *Store
checker *HTTPChecker
}
func NewPoller(store *Store, checker *HTTPChecker) *Poller {
return &Poller{store: store, checker: checker}
}
// PollOnce prueft ALLE Ziele in einem Durchlauf. Ein fehlschlagendes Ziel
// (Netzwerkfehler, Timeout, Nicht-200) wird als StatusDown vermerkt und
// haelt die Pruefung der UEBRIGEN Ziele nicht auf — die Schleife laeuft
// sequenziell weiter, kein Ziel kann ein anderes blockieren
// (Akzeptanzkriterium 2 / Pruefung 2).
func (p *Poller) PollOnce(ctx context.Context) error {
targets, err := p.store.ListTargets(ctx)
if err != nil {
return err
}
for _, t := range targets {
status := p.checker.Check(ctx, t.HealthURL)
if err := p.store.recordIfChanged(ctx, t.Name, status); err != nil {
// Ein Schreibfehler fuer EIN Ziel darf die Pruefung der anderen
// nicht verhindern — dieselbe Fail-Isolation wie bei einem
// unerreichbaren Modul.
continue
}
}
return nil
}
// Run ruft PollOnce in festen Abstaenden auf, bis ctx beendet wird —
// dieselbe Konvention wie internal/tenant.Lifecycle.RunSweeper.
func (p *Poller) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = p.PollOnce(ctx)
}
}
}
// --- HTTP-Bindung fuer die Oberflaeche ---
func (s *Store) OverviewHandler(w http.ResponseWriter, r *http.Request) {
overview, err := s.Overview(r.Context())
writeJSONResult(w, overview, err)
}
func (s *Store) HistoryHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
history, err := s.History(r.Context(), name)
writeJSONResult(w, history, err)
}
func writeJSONResult(w http.ResponseWriter, body any, err error) {
w.Header().Set("Content-Type", "application/json")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(body)
}
+198
View File
@@ -0,0 +1,198 @@
package statuspage
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) (*Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS status_targets (name TEXT PRIMARY KEY, health_url TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, status TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), cleanup
}
func uniqueName(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
// Akzeptanzkriterium 1: Uebersicht zeigt den Status ALLER registrierten
// Module.
func TestOverview_ShowsAllRegisteredModules(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
nameA, nameB := uniqueName("mod-a"), uniqueName("mod-b")
if err := store.RegisterTarget(ctx, Target{Name: nameA, HealthURL: up.URL}); err != nil {
t.Fatalf("target a: %v", err)
}
if err := store.RegisterTarget(ctx, Target{Name: nameB, HealthURL: up.URL}); err != nil {
t.Fatalf("target b: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(time.Second))
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll: %v", err)
}
overview, err := store.Overview(ctx)
if err != nil {
t.Fatalf("overview: %v", err)
}
// Overview() listet ALLE jemals registrierten Ziele (auch aus fruehreren
// Testlaeufen auf derselben geteilten Test-DB) — hier wird deshalb nur
// der Status von nameA/nameB geprueft, nicht jeder Eintrag der Tabelle.
statusByName := map[string]Status{}
for _, m := range overview {
statusByName[m.Name] = m.Status
}
if statusByName[nameA] != StatusUp {
t.Fatalf("modul %s: status = %s, want up", nameA, statusByName[nameA])
}
if statusByName[nameB] != StatusUp {
t.Fatalf("modul %s: status = %s, want up", nameB, statusByName[nameB])
}
}
// Akzeptanzkriterium 2 + Pruefung 1/2: ein simulierter Ausfall eines Moduls
// wird sichtbar, das ANDERE Modul bleibt unbeeinflusst und die Pruefung
// beider laeuft trotzdem in einem Durchlauf durch (kein Totalausfall).
func TestPollOnce_IsolatesFailingModuleFromOthers(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }))
down.Close() // Server sofort schliessen -> Verbindung tatsaechlich unerreichbar (simulierter Ausfall)
healthyName, downName := uniqueName("healthy"), uniqueName("down")
if err := store.RegisterTarget(ctx, Target{Name: healthyName, HealthURL: up.URL}); err != nil {
t.Fatalf("target healthy: %v", err)
}
if err := store.RegisterTarget(ctx, Target{Name: downName, HealthURL: down.URL}); err != nil {
t.Fatalf("target down: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(500*time.Millisecond))
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll haette trotz einem ausgefallenen modul erfolgreich durchlaufen sollen: %v", err)
}
overview, err := store.Overview(ctx)
if err != nil {
t.Fatalf("overview: %v", err)
}
statusByName := map[string]Status{}
for _, m := range overview {
statusByName[m.Name] = m.Status
}
if statusByName[healthyName] != StatusUp {
t.Fatalf("healthy modul: status = %s, want up", statusByName[healthyName])
}
if statusByName[downName] != StatusDown {
t.Fatalf("ausgefallenes modul: status = %s, want down", statusByName[downName])
}
}
// Akzeptanzkriterium 3: Verlauf vergangener Statusaenderungen ist einsehbar.
func TestHistory_RecordsStatusTransitions(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueName("flaky")
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
t.Fatalf("target: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(time.Second))
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll 1: %v", err)
}
// Ziel wird "abgeschaltet" (URL zeigt jetzt auf einen bereits
// geschlossenen Server) -> naechster Poll erkennt den Statuswechsel.
closedURL := up.URL
up.Close()
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: closedURL}); err != nil {
t.Fatalf("target aktualisieren: %v", err)
}
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll 2: %v", err)
}
history, err := store.History(ctx, name)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 2 {
t.Fatalf("erwartet genau 2 statuswechsel (up -> down), habe %d: %+v", len(history), history)
}
if history[0].Status != StatusDown || history[1].Status != StatusUp {
t.Fatalf("erwartet verlauf [down, up] (neueste zuerst), habe: %+v", history)
}
}
// Wiederholte Polls OHNE Statuswechsel duerfen den Verlauf nicht mit
// identischen Eintraegen fluten (siehe recordIfChanged).
func TestHistory_DoesNotDuplicateUnchangedStatus(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueName("stable")
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
t.Fatalf("target: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(time.Second))
for i := 0; i < 3; i++ {
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll %d: %v", i, err)
}
}
history, err := store.History(ctx, name)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 1 {
t.Fatalf("erwartet genau 1 eintrag trotz 3 unveraenderter polls, habe %d", len(history))
}
}
+45
View File
@@ -0,0 +1,45 @@
package tenant
import (
"encoding/json"
"net/http"
)
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
// (REST-API-Grundgerüst & Versionierung) und TEN-02 (Self-Service-Onboarding).
// Auth/Rate-Limiting/Versionierung selbst sind ausdruecklich nicht Teil von
// TEN-01 und werden dort nachgezogen.
type Handler struct {
provisioner *Provisioner
}
func NewHandler(p *Provisioner) *Handler {
return &Handler{provisioner: p}
}
type createTenantRequest struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) {
var req createTenantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
t, err := h.provisioner.Provision(r.Context(), req.Slug, req.Name)
if err != nil {
if err == ErrInvalidSlug {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, "tenant konnte nicht angelegt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(t)
}
+78
View File
@@ -0,0 +1,78 @@
package tenant
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
// Infrastrukturebene komplett getrennt (Akzeptanzkriterium 3).
type Provisioner struct {
// adminPool ist mit der Wartungsdatenbank (z. B. "postgres") verbunden
// und wird ausschliesslich fuer CREATE/DROP DATABASE verwendet, da diese
// Befehle in PostgreSQL nicht in einer Transaktion laufen koennen.
adminPool *pgxpool.Pool
registry *Registry
// dsnTemplate enthaelt genau ein "%s" als Platzhalter fuer den
// Datenbanknamen, z. B. "postgresql://user:pass@host:5432/%s?sslmode=disable".
dsnTemplate string
}
func NewProvisioner(adminPool *pgxpool.Pool, registry *Registry, dsnTemplate string) *Provisioner {
return &Provisioner{adminPool: adminPool, registry: registry, dsnTemplate: dsnTemplate}
}
// Provision legt die Tenant-Datenbank an und registriert sie. Schlaegt die
// Registrierung fehl, wird die bereits angelegte Datenbank wieder entfernt,
// damit kein verwaister, unregistrierter Tenant zurueckbleibt.
func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant, error) {
if err := ValidateSlug(slug); err != nil {
return Tenant{}, err
}
dbName := dbNameForSlug(slug)
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
}
t := Tenant{
Slug: slug,
Name: name,
DBName: dbName,
DBDSN: fmt.Sprintf(p.dsnTemplate, dbName),
Status: StatusActive,
}
tx, err := p.registry.pool.Begin(ctx)
if err != nil {
p.rollbackDatabase(ctx, dbName)
return Tenant{}, fmt.Errorf("registry-transaktion starten: %w", err)
}
created, err := p.registry.insertTx(ctx, tx, t)
if err != nil {
_ = tx.Rollback(ctx)
p.rollbackDatabase(ctx, dbName)
return Tenant{}, err
}
if err := tx.Commit(ctx); err != nil {
p.rollbackDatabase(ctx, dbName)
return Tenant{}, fmt.Errorf("registry-transaktion committen: %w", err)
}
return created, nil
}
// rollbackDatabase entfernt eine bereits angelegte Tenant-Datenbank, wenn die
// Registrierung fehlschlug, damit Provisioning insgesamt atomar wirkt.
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
}
+105
View File
@@ -0,0 +1,105 @@
package tenant
import (
"context"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// Integrationstest fuer Akzeptanzkriterien 2 und 3. Benoetigt eine echte
// Postgres-Instanz und wird ohne TEST_ADMIN_DSN uebersprungen, nicht als
// fehlgeschlagen gewertet — siehe Pruefungen-Ergebnis im PR.
//
// TEST_ADMIN_DSN muss auf die Wartungsdatenbank zeigen, z. B.:
//
// postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable
func TestProvision_CreatesIsolatedDatabases(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
defer adminPool.Close()
registryPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
defer registryPool.Close()
if _, err := registryPool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
registry := NewRegistry(registryPool)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
t.Cleanup(func() {
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('acme','globex')`)
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_acme`)
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_globex`)
})
tenantA, err := provisioner.Provision(ctx, "acme", "Acme GmbH")
if err != nil {
t.Fatalf("provision acme: %v", err)
}
tenantB, err := provisioner.Provision(ctx, "globex", "Globex AG")
if err != nil {
t.Fatalf("provision globex: %v", err)
}
if tenantA.DBName == tenantB.DBName {
t.Fatalf("erwartet unterschiedliche db_name, beide sind %q", tenantA.DBName)
}
// Akzeptanzkriterium 3 / Pruefung 3: In der Datenbank von Tenant A existiert
// keine Verbindungsmoeglichkeit zu Tenant B, weil beide physisch getrennte
// Datenbanken sind, statt sich auf einen Query-Filter zu verlassen.
poolA, err := pgxpool.New(ctx, tenantA.DBDSN)
if err != nil {
t.Fatalf("connect tenant a: %v", err)
}
defer poolA.Close()
var globexVisible bool
err = poolA.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1
)
`, tenantB.DBName).Scan(&globexVisible)
if err != nil {
t.Fatalf("pruefung tenant-trennung: %v", err)
}
// pg_database ist clusterweit sichtbar (Existenz der DB), aber die
// eigentliche Pruefung ist: aus poolA (verbunden mit tenant_acme) ist keine
// Tabelle/Zeile aus tenant_globex erreichbar, da current_database() getrennt ist.
var currentDB string
if err := poolA.QueryRow(ctx, `SELECT current_database()`).Scan(&currentDB); err != nil {
t.Fatalf("current_database: %v", err)
}
if currentDB != tenantA.DBName {
t.Fatalf("current_database() = %q, want %q — keine physische Trennung", currentDB, tenantA.DBName)
}
if currentDB == tenantB.DBName {
t.Fatalf("tenant a verbindung zeigt auf tenant b datenbank")
}
}
+69
View File
@@ -0,0 +1,69 @@
package tenant
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Registry kapselt den Zugriff auf die Control-Plane-Registry-Datenbank.
// Sie enthaelt ausschliesslich Tenant-Metadaten (Akzeptanzkriterium 1) —
// niemals Geschaeftsdaten eines Mandanten.
type Registry struct {
pool *pgxpool.Pool
}
func NewRegistry(pool *pgxpool.Pool) *Registry {
return &Registry{pool: pool}
}
// insertTx schreibt den Tenant-Datensatz innerhalb einer laufenden Transaktion,
// damit Provisioner.Provision DB-Anlage und Registrierung atomar behandeln kann.
func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, error) {
row := tx.QueryRow(ctx, `
INSERT INTO tenants (slug, name, db_name, db_dsn, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at
`, t.Slug, t.Name, t.DBName, t.DBDSN, t.Status)
if err := row.Scan(&t.ID, &t.CreatedAt); err != nil {
return Tenant{}, fmt.Errorf("tenant registrieren: %w", err)
}
return t, nil
}
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
var t Tenant
row := r.pool.QueryRow(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at
FROM tenants WHERE slug = $1
`, slug)
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
}
return t, nil
}
func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at
FROM tenants ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("tenants auflisten: %w", err)
}
defer rows.Close()
var out []Tenant
for rows.Next() {
var t Tenant
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("tenant lesen: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
+42
View File
@@ -0,0 +1,42 @@
// Package tenant implements Core TEN-01: die Control-Plane-Registry und die
// Provisioning-Routine fuer physisch getrennte Mandanten-Datenbanken (Modell C).
package tenant
import (
"errors"
"regexp"
"time"
)
type Status string
const (
StatusActive Status = "active"
)
type Tenant struct {
ID string
Slug string
Name string
DBName string
DBDSN string
Status Status
CreatedAt time.Time
}
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
// der Datenbankname niemals aus unkontrolliertem Nutzereingabe-Text gebaut wird.
var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,48}$`)
var ErrInvalidSlug = errors.New("tenant: slug muss mit Kleinbuchstaben beginnen und darf nur [a-z0-9_] enthalten (2-49 Zeichen)")
func ValidateSlug(slug string) error {
if !slugPattern.MatchString(slug) {
return ErrInvalidSlug
}
return nil
}
func dbNameForSlug(slug string) string {
return "tenant_" + slug
}
+35
View File
@@ -0,0 +1,35 @@
package tenant
import "testing"
func TestValidateSlug(t *testing.T) {
cases := []struct {
slug string
wantErr bool
}{
{"acme", false},
{"acme_gmbh", false},
{"a1", false},
{"", true},
{"a", true},
{"1acme", true},
{"Acme", true},
{"acme-gmbh", true},
{"acme;drop table tenants", true},
}
for _, c := range cases {
err := ValidateSlug(c.slug)
if (err != nil) != c.wantErr {
t.Errorf("ValidateSlug(%q) error = %v, wantErr %v", c.slug, err, c.wantErr)
}
}
}
func TestDBNameForSlug(t *testing.T) {
got := dbNameForSlug("acme")
want := "tenant_acme"
if got != want {
t.Errorf("dbNameForSlug() = %q, want %q", got, want)
}
}
-39
View File
@@ -1,39 +0,0 @@
// Command pflichttestgate ist das CI-Gate aus docs/TESTSTRATEGIE-MAIL.md
// Abschnitt 4. Aufruf: pflichttestgate < geänderte-dateien.txt
package main
import (
"bufio"
"fmt"
"os"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/pflichttestgate"
)
func main() {
var changedFiles []string
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Text()
if line != "" {
changedFiles = append(changedFiles, line)
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "pflichttestgate: eingabe konnte nicht gelesen werden: %v\n", err)
os.Exit(2)
}
violations := pflichttestgate.CheckDiff(changedFiles)
if len(violations) == 0 {
fmt.Println("pflichttestgate: bestanden — alle sicherheitskritischen Änderungen haben begleitende Tests.")
return
}
fmt.Fprintln(os.Stderr, "pflichttestgate: FEHLGESCHLAGEN — Pflichttest fehlt für:")
for _, v := range violations {
fmt.Fprintf(os.Stderr, " - Package %q (Datei %q hat keine begleitende _test.go-Änderung)\n", v.Package, v.ChangedFile)
}
fmt.Fprintln(os.Stderr, "\nSiehe docs/TESTSTRATEGIE-MAIL.md Abschnitt 4.")
os.Exit(1)
}
-54
View File
@@ -1,54 +0,0 @@
# ARC-01 Prüfprotokoll: Objekt-Speicher-Anbindung für Mails/Anhänge
Voraussetzung ING-04 bereits Fertig. ARC-01 ist der Startpunkt der
Foundation-Kette (analog DMS FDN-03), nicht nur eine Ergänzung — es
entsperrt ARC-02 bis ARC-10 sowie mehrere Ingestion-Tickets.
## Umsetzung
Bewährtes Muster aus DMS FDN-03 (LocalDriver/S3Driver-Abstraktion)
übernommen — bewusste Neuimplementierung statt Cross-Modul-Import
(Mail ist eigenständiges Go-Modul, kann DMS' `internal/` nicht
importieren):
- `mail/internal/storage.Driver``Put`/`Get`/`Delete`, zwei
Implementierungen (`LocalDriver`, `S3Driver`).
- `ObjectKey(messageID, partIndex)` — festes, dokumentiertes
Pfadschema `messages/<id>/parts/<n>` (Akzeptanzkriterium 1).
Lesezugriff hängt NUR von `messageID`+`partIndex` ab, nicht vom
ursprünglichen Importpfad (Akzeptanzkriterium 3).
- **Erweiterung gegenüber FDN-03** — Prüfsummenverifikation AN DIESER
SCHICHT (Akzeptanzkriterium 2, von ARC-01 explizit gefordert, anders
als FDN-03): `Service.Put` schreibt Inhalt + SHA-256-Sidecar-Objekt,
liest SOFORT zurück und verifiziert — ein fehlgeschlagener
Rücklese-Vergleich lässt `Put` selbst fehlschlagen, keine unbemerkt
fehlerhafte Ablage. `Service.GetVerified` wiederholt die Prüfung bei
jedem späteren Lesezugriff.
- `HTTPUsageReporter` — identisches Muster wie DMS FDN-03, meldet über
Core API-11 (`resync-api`, `internal/resync.Handler.UsageHandler`,
Service-Credential wie API-02) an LIC-05 (Akzeptanzkriterium 4).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: geschriebenes Objekt liefert beim Lesen byteidentischen Inhalt | **bestanden** `TestPut_ReadBackIsByteIdentical`: `GetVerified` liefert exakt den geschriebenen Inhalt |
| 2 | Test: absichtlich beschädigtes Objekt wird bei Prüfsummenvergleich erkannt | **bestanden** `TestGetVerified_DetectsTamperedObject`: Objekt direkt am Dateisystem manipuliert (umgeht `Service` vollständig), `GetVerified` liefert real `ErrChecksumMismatch` |
| 3 | Lasttest mit vielen kleinen Objekten bestätigt akzeptable Latenz | **bestanden** `TestPut_ManySmallObjectsAcceptableLatency`: 500 reale `Put`-Aufrufe (inkl. Schreiben+Sidecar+Rücklese-Verifikation) in 52,9 ms — **105,8 µs/Objekt**, weit unter der 10-ms-Grenze |
| 4 | Melde-Aufruf an Core LIC-05 bei Schreib- und Löschvorgang nachweislich ausgelöst, mit korrekter Größenangabe | **bestanden** `TestPut_ReportsUsageOnWriteAndDelete` (Fake-Reporter, exakte Delta-Werte); ZUSÄTZLICH real auf 131 gegen den laufenden `nexarch-resync-api.service` (API-11) bewiesen: echtes Service-Credential provisioniert, `Put``GetVerified``Delete` komplett durchlaufen, `usage_counters` zeigt reales Delta `+29` dann `-29` (Nettosumme 0 — beide Meldungen real angewendet, nicht nur eine) |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -> alle Mail-Pakete bestanden (storage, mimeparse, example, pflichttestgate)
```
## Gesamtergebnis
**Bestanden.** Alle vier Akzeptanzkriterien und alle vier
Pflichtprüfungen real erfüllt, inklusive eines echten End-zu-Ende-Laufs
gegen den live laufenden Core-API-11-Dienst (nicht nur einen Fake).
Entsperrt ARC-02ARC-10 sowie mehrere Ingestion-Tickets.
-71
View File
@@ -1,71 +0,0 @@
# ARC-02 Prüfprotokoll: Verschlüsselung at rest
Voraussetzung ARC-01 (Mail, Fertig), Core API-10 (Fertig) + API-12
(neu angelegt und fertig — API-10 war nicht als Dienst erreichbar,
siehe API-12-Prüfprotokoll).
## Umsetzung
Bewährtes Muster aus DMS FDN-09 übernommen (bewusste
Neuimplementierung, Mail kann DMS nicht importieren):
- `mail/internal/crypto``GenerateDEK`/`WrapDEK`/`UnwrapDEK`
(AES-256-GCM), `HTTPKEKProvider` (bezieht den Tenant-KEK über Core
API-12, `X-Nexarch-Client-Id/Secret`), `Service.Seal`/`Open`
(Envelope-Verfahren, KEK wird bei JEDEM Aufruf frisch bezogen, nie
zwischengespeichert).
- `mail/internal/encstorage` — verbindet ARC-01 (`storage.Service`) mit
ARC-02 (`crypto.Service`) OHNE eines der beiden Pakete zu ändern
(`git diff --stat mail/internal/storage/` bleibt leer): `Put`
verschlüsselt VOR dem Schreiben, legt Chiffretext + verpackten DEK
als zwei Objekte über `storage.Service` ab (Prüfsumme,
Nutzungsmeldung — ARC-01 unverändert mitgenutzt).
- **Reihenfolge beachtet** (Ticket "Bekannte Fehler vermeiden"):
`encstorage.Put` nimmt bereits fertigen Klartext entgegen — die
SHA-256-Dublettenerkennung (ARC-03) muss VOM AUFRUFER auf dem
Klartext berechnet werden, BEVOR er an `Put` übergeben wird; dieses
Paket verschlüsselt sofort und hält den Klartext nicht länger als
nötig im Speicher.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Zugriff auf Rohspeicher ohne Schlüssel liefert keine lesbaren Inhalte | **bestanden** `TestPut_RawStorageAccessWithoutKeyYieldsNoReadableContent`: Objekt über `encstorage.Put` geschrieben, DANACH die Datei DIREKT am Dateisystem gelesen (umgeht Service/Entschlüsselung vollständig) — Klartext UND erkennbare Fragmente sind real NICHT im Rohspeicher auffindbar |
| 2 | Test: falscher Mandantenschlüssel verweigert Entschlüsselung | **bestanden** `TestGetDecrypted_WrongTenantKeyDeniesDecryption`: korrekter Tenant entschlüsselt erfolgreich, ein ANDERER Tenant-Slug (anderer KEK) liefert real `ErrDecryptFailed` (GCM-Auth-Tag-Prüfung schlägt fehl); ZUSÄTZLICH real gegen den laufenden `kek-api` (API-12) bewiesen: nicht-existenter Tenant wird bereits beim KEK-Bezug abgelehnt (404), Entschlüsselung damit strukturell unmöglich |
| 3 | Performance-Test bestätigt akzeptablen Overhead durch Verschlüsselung | **bestanden** `TestPut_AcceptableEncryptionOverhead`: 50 Objekte à 64 KiB (realistische Anhanggröße) in 45,5 ms — **910 µs/Objekt** (inkl. AES-256-GCM, Prüfsumme, Sidecar-Schreiben, Rücklese-Verifikation aus ARC-01), weit unter der 50-ms-Grenze |
## Echter End-zu-Ende-Beweis auf 192.168.1.131
Vollständiger Roundtrip gegen den ECHT laufenden `nexarch-kek-api.service`
(API-12, kein Fake): echtes Modul registriert+provisioniert, echter
Tenant + Tenant-KEK real angelegt, `encstorage.Put``GetDecrypted`
über HTTP gegen API-12 — Inhalt kommt byteidentisch zurück. Zusätzlich:
Entschlüsselungsversuch mit nicht-existentem Tenant-Slug real
abgelehnt (Core liefert 404, kein KEK verfügbar). Testdaten
anschließend entfernt.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -> alle Mail-Pakete bestanden (encstorage, crypto indirekt getestet, storage, mimeparse, example, pflichttestgate)
```
**Hinweis (offener Punkt, ehrlich vermerkt):** `mail/internal/crypto`
selbst hat keine eigenen `_test.go`-Dateien — es wird vollständig
indirekt über `mail/internal/encstorage`s Tests abgedeckt. Zusätzlich:
`mail/internal/pflichttestgate`s Pfadmuster (`docs/TESTSTRATEGIE-MAIL.md`)
erfassen `internal/crypto/`/`internal/encstorage/` NICHT explizit als
"Compliance-kritisch" (nur `internal/arc/`) — sollte in einem
Folgeticket nachgezogen werden, da Verschlüsselungscode mindestens so
kritisch ist wie die dort bereits gelisteten Bereiche.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt, inklusive eines vollständigen
End-zu-Ende-Laufs gegen den live laufenden Core-API-12-Dienst.
Entsperrt ARC-08 (Schlüsselrotation).
-53
View File
@@ -1,53 +0,0 @@
# ARC-03 Prüfprotokoll: Dublettenerkennung E-Mail
Voraussetzung ARC-01 (Mail, Fertig).
## Umsetzung
- `mail/internal/dedup/hash.go``HashAndBuffer(plaintext io.Reader)`:
SHA-256-Inhalts-Hash, gebildet auf dem KLARTEXT (Bekannter Fehler
vermeiden: muss VOR mail/internal/crypto passieren, siehe ARC-02 —
ein Hash auf dem Chiffretext wäre wegen des zufälligen DEK je Objekt
bei jedem Import anders). Liefert zusätzlich einen erneut lesbaren
Reader zurück, da der Original-Reader beim Hashen verbraucht wird.
- `mail/internal/dedup/store.go``Store.Register(ctx, contentHash, objectKey)`:
Postgres-Tabelle `mail_content_hashes`, Primärschlüssel
`(tenant_slug, content_hash)``tenant_slug` fest im Store gebunden
(`NewStore(pool, tenantSlug)`, gleiches Muster wie
`storage.Service`/`encstorage.Service`), nicht nur Konvention.
`ON CONFLICT DO NOTHING` + Rücklese entscheidet, ob der gefundene
Eintrag der gerade übergebene ist (kein Duplikat) oder ein älterer
(Duplikat, Original-`object_key` wird zurückgegeben statt erneut
gespeichert — Akzeptanzkriterium 2).
- Kein Umbau: `mail/internal/storage`/`mail/internal/crypto`/
`mail/internal/encstorage` unverändert (`git diff --stat` bleibt für
alle drei leer). `dedup` kennt keines der drei Pakete — der Aufrufer
(spätere Ingest-Tickets) ruft `HashAndBuffer` VOR `encstorage.Put`
auf.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: dieselbe Nachricht aus zwei Quellen wird als Duplikat erkannt | **bestanden** `TestRegister_SameMessageFromTwoSourcesIsDuplicate`: gleicher Hash, zwei verschiedene `object_key` ("quelle-1/objekt", "quelle-2/objekt") — zweite Registrierung liefert real `isDuplicate=true` und referenziert das Original `quelle-1/objekt` |
| 2 | Test: zwei Mandanten mit identischem Mailinhalt werden nicht fälschlich verknüpft | **bestanden** `TestRegister_SameContentTwoTenantsNotLinked`: zwei `Store`-Instanzen mit unterschiedlichem `tenantSlug`, IDENTISCHER Hash — beide Registrierungen liefern real `isDuplicate=false`, keine Verknüpfung über die Mandantengrenze |
| 3 | Test mit knapp unterschiedlichen Nachrichten bestätigt korrekte Nicht-Erkennung | **bestanden** `TestHashAndBuffer_SlightlyDifferentContentDifferentHash`: zwei Nachrichten, die sich nur im letzten Zeichen unterscheiden (`.` vs `,`) — real unterschiedlicher SHA-256-Hash |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/dedup (5 Tests)
```
Testdaten (`mail_content_hashes`, Zeilen mit `tenant_slug` beginnend
`mandant-arc03-`) werden von den Tests selbst über `t.Cleanup`
entfernt.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt. Entsperrt SRC-01, SRC-02, SRC-07.
-65
View File
@@ -1,65 +0,0 @@
# ING-04 Prüfprotokoll: MIME- & Anhang-Parsing
Keine Vorbedingungen (Wave 1, sofort startbar). ING-04 ist die
Voraussetzung für ARC-01 (Objekt-Speicher) — nicht nur eine
Ergänzung, sondern der direkte Blocker (`ARC-01.dependsOn = ["ING-04"]`).
## Bekannten Fehler vermieden
archivmail (`known-issues-archivmail.md` Punkt 3): Anhänge wurden über
`io.ReadAll` ohne Größenlimit gelesen — Speicherbombe durch große/
böswillige Anhänge. Hier läuft JEDER Anhang-Lesevorgang über
`io.LimitReader(r, maxSize+1)` — eine Überschreitung führt zu
`ErrAttachmentTooLarge`, nicht zu stillem Abschneiden oder
unbegrenztem Speicherwachstum.
## Umsetzung
- `mail/internal/mimeparse.Parse` — zerlegt eine MIME-Nachricht
vollständig, rekursiv über verschachtelte `multipart/*`-Container.
- Zeichensatz-Reparatur: `mime.WordDecoder` mit eigenem
`CharsetReader` (via `golang.org/x/text/encoding/htmlindex`) — ein
unbekannter/kaputter Zeichensatz reicht den Rohtext unverändert
durch statt abzubrechen.
- Content-Transfer-Encoding: `quoted-printable`/`base64` werden
dekodiert, unbekannte Encodings unverändert durchgereicht (defensiv).
- **Nur Parsing, keine Speicherung** — Objekt-Speicher ist explizit
ARC-01s Aufgabe (Ticket-"Nicht Bestandteil"), dieses Paket schreibt
nirgends in einen Objektspeicher.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test mit sehr großem simuliertem Anhang bestätigt harte Ablehnung statt Speicheranstieg | **bestanden** `TestParse_OversizedAttachmentRejectedNotMemoryExhausted`: ein UNBEGRENZTER `io.Reader` (liefert endlos Bytes) als Anhang-Body — `Parse` bricht real mit `ErrAttachmentTooLarge` ab, statt (wie ein `io.ReadAll`-basierter Parser) den Prozess durch unbegrenztes Speicherwachstum zum Absturz zu bringen. Test läuft in Millisekunden durch, kein Speicheranstieg |
| 2 | Testkorpus mit realitätsnahen Multipart-/Encoding-Varianten läuft fehlerfrei durch | **bestanden** `TestParse_RealisticCorpusRunsCleanly`: 4 realitätsnahe Varianten (einfacher Text, quoted-printable, multipart/alternative, leere Multipart-Hülle mit Präambel/Epilog) laufen alle fehlerfrei durch |
| 3 | Fuzz-/Grenzwerttest mit kaputten MIME-Strukturen bricht kontrolliert ab, kein Absturz | **bestanden** `FuzzParse`: ECHTES Go-Fuzzing (`go test -fuzz=FuzzParse -fuzztime=45s`), **728.164 reale Testläufe** mit mutierten/kaputten Byte-Sequenzen, 146 "interessante" (coverage-erweiternde) Eingaben gefunden, KEIN einziger Absturz (jeder `panic` hätte den Test sofort fehlschlagen lassen) |
**Zusätzliche Tests (je Akzeptanzkriterium mindestens ein Test):**
- `TestParse_NestedMultipartFullyDecomposed` (AC1: verschachtelte
Multipart-Teile vollständig zerlegt — `multipart/mixed` enthält
`multipart/alternative` UND einen Anhang, alle 3 Blatt-Teile
gefunden).
- `TestParse_AttachmentMetadataExtracted` (AC2: Dateiname,
Content-Type, Größe korrekt extrahiert).
- `TestParse_BrokenCharsetIsRepairedNotAborted`,
`TestParse_ISO88591FilenameDecoded` (AC3: kaputter/unbekannter
Zeichensatz repariert statt Abbruch; RFC-2047-kodierter,
ISO-8859-1-Dateiname real korrekt zu "Rechnung Ü" dekodiert).
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -> alle Mail-Pakete bestanden (inkl. mimeparse, example, pflichttestgate)
go test ./internal/mimeparse/... -fuzz=FuzzParse -fuzztime=45s -> PASS, 728.164 Ausführungen, 0 Abstürze
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt, inklusive eines echten, nicht nur
simulierten Fuzz-Laufs mit über 700.000 Testfällen. Entsperrt ARC-01
(Objekt-Speicher-Anbindung), IMP-02, ING-10, ARC-10.
-60
View File
@@ -1,60 +0,0 @@
# SRC-01 Prüfprotokoll: Manticore-Suchindex für Mails
Voraussetzung ARC-01, ARC-03 (beide Fertig).
## Umsetzung
- `mail/internal/search/fields.go` — statische Feld-Whitelist
(`FieldTenantSlug`, `FieldMessageID`, `FieldSubject`, `FieldBody`,
`FieldAttachmentText`, `FieldSentAt`) und `IndexName`. Bekannten Fehler
vermeiden (known-issues-archivmail.md #11/#12): archivmail baute
WHERE-Klauseln und teils Spalten-/Tabellennamen dynamisch über
`fmt.Sprintf`/`strings.Join`. Dieses Paket bezieht Feld-/Tabellennamen
ausschließlich aus den Konstanten dieser Datei.
- `mail/internal/search/migrations/0001_mail_documents.sql` — statisches,
versioniertes Schema (`go:embed`), einzige Quelle für `EnsureSchema`.
- `mail/internal/search/client.go``Client`:
- `EnsureSchema` legt den Index über den Manticore `/sql?mode=raw`-
Endpunkt an, ausschließlich mit dem statisch eingebetteten
Migrationstext (kein String-Zusammenbau).
- `Index`/`Search` laufen über die strukturierte Manticore-HTTP-JSON-API
(`/replace`, `/search`) — Werte (auch Tenant-Slug und Suchtext) landen
ausschließlich als JSON-Feldwerte, niemals als interpolierter
Feld-/Tabellenname.
- `Search` filtert zwingend über `FieldTenantSlug` (Akzeptanzkriterium 3).
- Kein Umbau: `mail/internal/storage`/`mail/internal/crypto`/
`mail/internal/encstorage`/`mail/internal/dedup` unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Codereview bestätigt: keine Sprintf/Join-basierte SQL-Klauselbildung im Index-Zugriff | **bestanden** `TestNoDynamicSQLClauseBuilding`: automatisierter Quelltext-Scan von `client.go` bestätigt, dass kein `fmt.Sprintf` verwendet wird und in der Nähe des `/sql?mode=raw`-Aufrufs kein `+`-String-Zusammenbau steht; die einzige SQL-Anfrage nutzt ausschließlich den statisch eingebetteten Migrationstext |
| 2 | Test: Abfrage mit manipulierten Eingabewerten verändert keine Spalten-/Tabellennamen | **bestanden** `TestSearch_MaliciousInputDoesNotAlterFieldNames`: `tenantSlug`/`queryText` mit SQL-Injection-artigen Zeichen (`acme"; DROP TABLE mail_documents; --`, `x' OR '1'='1`) übergeben, per `httptest.Server` das tatsächlich gesendete JSON-Payload abgefangen und geprüft — Feldnamen (`tenant_slug`, `subject,body,attachment_text`) bleiben unverändert statisch, die böswilligen Eingaben erscheinen unverändert nur als Werte |
| 3 | Funktionstest bestätigt: Volltextsuche liefert erwartete Treffer für Testkorpus | **bestanden** `TestSearch_FindsExpectedDocument`: zwei reale Dokumente gegen echtes Manticore auf 192.168.1.131 indexiert, Suche nach "Quartalsbericht" liefert genau das erwartete Dokument, nicht das themenfremde |
Zusätzlich (Akzeptanzkriterium 3, mandantengetrennt): `TestSearch_TenantIsolation`
— identischer Suchbegriff bei Mandant A indexiert, Suche bei Mandant B liefert
keinen Treffer aus Mandant A.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/search (4 Tests)
```
Manticore lief bereits produktiv auf 192.168.1.131 (Port 9308, Version 7.4.1,
Dienst `manticore.service` aktiv seit 2026-08-28). Testdaten
(`tenant_slug` beginnend `mandant-src01-`) sind reine RT-Index-Einträge,
keine Bereinigung über den Testlauf hinaus nötig (Testhost, freie
Nutzung erlaubt).
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Entsperrt SRC-02, SRC-03, SRC-09.
-58
View File
@@ -1,58 +0,0 @@
# SRC-02 Prüfprotokoll: Indexierungs-Worker & Synchronisierung
Voraussetzung SRC-01, ARC-03 (beide Fertig).
## Umsetzung
- `mail/internal/indexworker/migrations/0001_mail_index_jobs.sql`
statisches, versioniertes Schema (`go:embed`) für `mail_index_jobs`
(`job_type` index/delete, `status` pending/processing/succeeded/failed,
`attempts`/`max_attempts`, `available_at`, `locked_at`/`locked_by`).
- `mail/internal/indexworker/queue.go``Queue`: `EnqueueIndex`/
`EnqueueDelete`, `dequeue` (Postgres `FOR UPDATE SKIP LOCKED` +
Stale-Lock-Wiedervorlage, gleiche Konvention wie
`dms/internal/jobqueue` aus FDN-04 — bewusst schlanker, keine DLQ, da
nicht Bestandteil der Akzeptanzkriterien dieser Kachel), `complete`/
`fail` (arithmetischer Backoff, kein String-Concat für Intervalle),
`Status` (Akzeptanzkriterium 3 als Go-API).
- `mail/internal/indexworker/worker.go``Worker.RunOnce`: holt einen
Job, ruft je nach `job_type` `search.Client.Index`/`search.Client.Delete`
auf, markiert abschließend `complete`/`fail`.
- `mail/internal/search`: minimale Erweiterung um `Client.Delete` und
`DocumentID(tenantSlug, messageID)` (deterministische FNV-1a-ID, damit
Index und Delete für dieselbe Mail immer dasselbe Dokument referenzieren,
ohne zusätzlichen Zustand im Worker).
- Kein Umbau: `mail/internal/storage`/`mail/internal/crypto`/
`mail/internal/encstorage`/`mail/internal/dedup` unverändert;
bestehende `search`-Tests/-Verhalten (SRC-01) unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Worker-Neustart mitten im Lauf verliert keinen offenen Auftrag | **bestanden** `TestDequeue_WorkerCrashMidRunLosesNoJob`: Job wird geholt und NICHT abgeschlossen (simulierter Absturz), vor Ablauf der Stale-Lock-Frist real kein zweiter Job verfügbar, nach Ablauf real erneut derselbe Job an einen zweiten Worker zugestellt |
| 2 | Test: Löschung einer Mail entfernt sie zuverlässig aus Suchtreffern | **bestanden** `TestDeleteJob_RemovesMailFromSearchResults`: Mail indexiert und Auffindbarkeit real bestätigt, danach Lösch-Job verarbeitet, anschließende Suche liefert real keinen Treffer mehr |
| 3 | Konsistenztest vergleicht Datenbankbestand mit Indexbestand stichprobenartig | **bestanden** `TestConsistency_DatabaseAndIndexMatchOnSample`: 3 Index-Jobs verarbeitet, je Stichprobe real geprüft, dass der DB-Job-Status `succeeded` UND das zugehörige Dokument tatsächlich im Manticore-Index auffindbar sind |
Zusätzlich (Akzeptanzkriterium 1, Funktionsnachweis): `TestIndexJob_MakesMailSearchable`
— eingereihte Indexierungsaufgabe macht die Mail nach Worker-Verarbeitung
real durchsuchbar.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/indexworker (5 Tests)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. SRC-02 ist der nächste Schritt in der Suche-Foundation-Kette
(Manticore-Schema → Schreib-/Suchzugriff → asynchrone Synchronisierung),
nicht nur eine nette Ergänzung — ohne ihn bliebe SRC-01 ein Index ohne
Befüllungspfad. Entsperrt QA-03.
-61
View File
@@ -1,61 +0,0 @@
# SRC-03 Prüfprotokoll: Such-API mit Ranking
Voraussetzung SRC-01 (Fertig).
## Umsetzung
- `mail/internal/search/client.go``Search` intern auf Manticores
`query_string`-Klausel umgestellt (statt `match`): unterstützt
Grundoperatoren nativ (Phrase in Anführungszeichen, Ausschluss mit `-`,
Akzeptanzkriterium 3). Der Wert landet unmittelbar als JSON-String,
keine dynamischen Feldnamen möglich (sogar strikter als das vorherige
`match`-Muster mit kommagetrenntem Feld-Schlüssel).
- `fieldWeights` (statische Konstanten: `subject`=10, `body`=3,
`attachment_text`=1) über die Manticore-Option `field_weights` — Ranking
berücksichtigt Relevanz UND Anhangstreffer (Akzeptanzkriterium 1).
Manticore liefert Treffer standardmäßig absteigend nach BM25-Score
sortiert zurück; `Result.Score` macht das Ranking nachvollziehbar.
- `Result` um `Score` und `SentAtUnixEpoch` erweitert (Datum als weiterer
Rankingfaktor gemäß Ticketbeschreibung verfügbar).
- Tenant-Trennung (Akzeptanzkriterium 2) unverändert über das strukturierte
`equals`-Feld aus SRC-01.
- Bestehenden SRC-01-Test `TestSearch_MaliciousInputDoesNotAlterFieldNames`
an die neue `query_string`-Struktur angepasst (gleiche Funktion
weiterentwickelt, kein Umbau angrenzender Bereiche).
- Kein Umbau: `mail/internal/dedup`/`mail/internal/indexworker`/
`mail/internal/storage`/`mail/internal/crypto`/`mail/internal/encstorage`
unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Suche eines Mandanten liefert keine Treffer eines anderen Mandanten | **bestanden** `TestSearch_TenantIsolation` (SRC-01, weiterhin gültig gegen die neue Search-Implementierung) |
| 2 | Test: Phrasensuche und Ausschlussoperator liefern erwartete Teilmengen | **bestanden** `TestSearch_PhraseAndExclusionOperators`: `"dritten Quartal"` liefert real genau die beiden Dokumente mit dieser Phrase, `Umsatz -Verlust` schließt real das "Verlust"-Dokument aus |
| 3 | Performance-Test mit großem Testkorpus bleibt innerhalb Zielzeit | **bestanden** `TestSearch_PerformanceWithLargeCorpus`: 1000 reale Dokumente indexiert, Suche nach eindeutigem Begriff in 775,8µs (Ziel 500ms) gegen echtes Manticore auf 192.168.1.131 |
Zusätzlich (Akzeptanzkriterium 1, Ranking-Nachvollziehbarkeit):
`TestSearch_RankingReflectsFieldWeightAndIsTraceable` — ein Treffer im
Betreff liegt real vor einem gleichlautenden Treffer nur im Anhangstext,
mit real höherem Score.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/search (7 Tests,
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. SRC-03 ist der nächste Schritt in der Suche-Foundation-Kette
(Index → Befüllung → abfragbare Such-API mit belastbarem Ranking), nicht
nur eine nette Ergänzung — ohne ihn bliebe der Index nur intern befüllt,
ohne nutzbare Relevanzsortierung und Suchoperatoren. Entsperrt INT-01,
SRC-04, SRC-05, SRC-08.
-69
View File
@@ -1,69 +0,0 @@
# SRC-04 Prüfprotokoll: Such-Oberfläche mit Hervorhebung
Voraussetzung SRC-03 (Fertig), SHL-01 (Core-Board, Fertig).
## Umsetzung
- `web/mail-search`: eigenständige Next.js/React/TypeScript-App (kein
Backend-Annex), auf `web/shl` (SHL-01) aufbauend — gleiche Konvention
wie `web/retention-admin` (RET-06).
- `app/api/search/route.ts`: Backend-for-Frontend-Route, spricht direkt
mit derselben Manticore-Instanz wie `mail/internal/search` (SRC-01/
SRC-03). Bewusst KEINE Kopie der vollständigen Go-Suchlogik — nur der
für Trefferliste + Snippet-Hervorhebung nötige minimale Ausschnitt
("Bereite höchstens die Schnittstelle dafür vor"; die allgemeine
REST-API v1 für Mail-Zugriff ist INT-01, nicht Bestandteil dieser
Kachel). Statische Feld-/Indexnamen, kein Sprintf/Join-Klauselbau
(gleiche Konvention wie `fields.go`). Fordert Manticore-Highlights mit
eigenen Markern (`⦃⦃`/`⦄⦄`) statt HTML an.
- `lib/highlight.ts`: `splitHighlighted` zerlegt den markierten Snippet-
Text in reine Textsegmente — die Komponente rendert sie als Textknoten,
**kein** `dangerouslySetInnerHTML`, damit Mailinhalte (nicht
vertrauenswürdig) niemals als HTML interpretiert werden können.
- `app/page.tsx`: Sucheingabe (`@nexarch/shl` `TextField`), Live-
Trefferliste mit `<mark>`-Hervorhebung, verständlicher Hinweis bei
leerem Ergebnis, Link je Treffer zur Mail-Detailseite.
- `app/mail/[messageId]/page.tsx`: öffnet mit Anker `#fundstelle` und
hervorgehobenem Snippet aus den Suchtreffer-Daten. Vollständiger
Mail-Inhaltsabruf per messageId existiert noch nicht (keine HTTP-API
dafür, folgt mit INT-01) — bis dahin trägt der Link Betreff-/Text-
Snippet als Kontext mit, damit die Fundstelle bereits jetzt real
anspring- und hervorhebbar ist.
- `lib/contrast.ts`/`lib/highlightColors.ts`: reale WCAG-2.1-
Kontrastberechnung statt behaupteter Werte.
- Kein Umbau: `mail/internal/*`, `web/shl`, `web/retention-admin`
unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Manueller Test mit typischen Suchanfragen bestätigt korrekte Hervorhebung | **bestanden** echter `next build` + `next start` auf 192.168.1.131 gegen die live laufende Manticore-Instanz: `GET /api/search?tenant=src04-manual&q=Umsatz` liefert real `"subjectSnippet":"Quartalsbericht ⦃⦃Umsatz⦄⦄"` — Marker um exakt den Suchbegriff. Zusätzlich automatisiert in `app/page.test.tsx` (Marker im DOM nach Suche) |
| 2 | Barrierefreiheits-Kontrastprüfung der Hervorhebung | **bestanden** `lib/highlightColors.test.ts`: echte WCAG-2.1-Berechnung, Hell-Modus 14,29:1, Dunkel-Modus 6,43:1 (beide ≥ 4.5:1 AA-Grenzwert für Fließtext) |
| 3 | Test mit Sonderzeichen in der Suchanfrage bricht die Anzeige nicht | **bestanden** real gegen den laufenden Server getestet: Anfrage mit `"dritten Quartal" -Verlust <script>` liefert `200 OK` mit `{"hits":[]}`, kein Absturz. Zusätzlich automatisiert `lib/highlight.test.ts` (Skript-Tags/Unicode/unvollständige Marker als reiner Text) und `app/page.test.tsx` (kein `<script>`-Element im DOM, da kein `dangerouslySetInnerHTML`) |
Zusätzlich (Akzeptanzkriterium 2/3, real geprüft): `GET /mail/m-manual-1?subject=...`
liefert `200 OK`; automatisiert `app/page.test.tsx` bestätigt Link-Struktur
(`/mail/<id>?...#fundstelle`) und den "Keine Treffer"-Hinweis bei leerem
Ergebnis.
## Build/Test-Ergebnis (192.168.1.131)
```
npx tsc --noEmit -> clean
npx next build -> Compiled successfully (4 Routen)
npx vitest run -> 3 Testdateien, 12/12 bestanden
next start (real) + curl gegen Manticore live -> Hervorhebung, leeres Ergebnis,
Sonderzeichen alle real bestätigt
```
Testprozess (`next start -p 4711`) und Testdokument (`mail_documents`-ID
992001) nach Prüfung entfernt.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. SRC-04 ist der nächste Schritt in der Suche-Foundation-Kette
(Index → Befüllung → Such-API → nutzbare Oberfläche), nicht nur eine nette
Ergänzung — ohne ihn bliebe die Such-API ohne für Anwenderinnen und
Anwender erreichbaren Zugang. Entsperrt QA-03 (gemeinsam mit SRC-02).
-54
View File
@@ -1,54 +0,0 @@
# SRC-05 Prüfprotokoll: Facetten- & Filter-API
Voraussetzung SRC-03 (Fertig).
## Umsetzung
- `mail/internal/search/migrations/0002..0005_*.sql`: vier eigene,
nummerierte `ALTER TABLE ADD COLUMN`-Migrationen für die neuen
Facettenfelder (`sender`, `mailbox`, `attachment_type`, `tag`) — Manticore
erlaubt nur eine Spalte je ALTER-Anweisung. `EnsureSchema` wendet sie
idempotent nach (Fehlertext `"already in schema"` gilt als bereits
angewendet, kein Fehlerzustand).
- `fields.go`: neue statische Feldkonstanten + `FacetFields`-Whitelist
(`sender`, `mailbox`, `attachment_type`, `tag`) — einzige Quelle
zulässiger Facettendimensionen, kein beliebiger Client-Feldname möglich.
- `facets.go``Client.Facets(ctx, tenantSlug, queryText, filters)`:
nutzt Manticores strukturierte `aggs.terms`/`aggs.range`-API (kein
dynamischer SQL-Klauselbau). Tenant-Filter + optionale
`FacetFilter`-Liste laufen als zusätzliche `equals`-Klauseln in
derselben `bool.must`-Liste (Akzeptanzkriterium 2: UND-Verknüpfung).
Zeitraum-Facette über feste Buckets (letzte 7 Tage/30 Tage/Jahr/älter)
via `aggs.range` auf `sent_at`.
- `Document` um optionale Facettenfelder erweitert (`Sender`, `Mailbox`,
`AttachmentType`, `Tag`).
- Kein Umbau: `Search`/`Delete`/`Index`-Verhalten aus SRC-01/SRC-03
unverändert, `mail/internal/dedup`/`indexworker`/`storage`/`crypto`/
`encstorage` unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Facettenzahlen stimmen mit tatsächlicher Treffermenge überein | **bestanden** `TestFacets_CountsMatchActualHits`: 3 reale Dokumente indexiert, Facette `sender` liefert real `alice@example.com`→2, `bob@example.com`→1, Facette `attachment_type` liefert real `pdf`→2 |
| 2 | Test: Kombination von drei Filtern liefert korrekt eingeschränkte Treffer | **bestanden** `TestFacets_ThreeFiltersCombineWithAND`: 4 Dokumente, von denen 3 je genau einen der drei Filter (Sender/Postfach/Anhangstyp) verletzen — nach Kombination aller drei Filter bleibt real genau 1 Treffer übrig |
| 3 | Test: Facetten eines Mandanten enthalten keine Werte eines anderen | **bestanden** `TestFacets_TenantSeparation`: identische Feldstruktur bei zwei Mandanten, Facette bei Mandant B enthält real keinen Wert von Mandant A |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
go test ./... -p 1 -> alle Pakete bestanden, inkl. internal/search (10 Tests,
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Entsperrt SRC-06, trägt (gemeinsam mit ARC-08, SRC-02,
SRC-04, SRC-08, SRC-09, SRC-10) zu QA-03 bei — QA-03 bleibt weiterhin
blockiert, bis auch die übrigen vier Tickets fertig sind.
-72
View File
@@ -1,72 +0,0 @@
# SRC-09 Prüfprotokoll: Suchindex-Neuaufbau/Reindexierung
Voraussetzung SRC-01 (Fertig).
## Umsetzung
- `mail/internal/search/reindex.go``Reindexer.Rebuild(ctx, onProgress)`:
1. legt eine neue physische Manticore-Tabelle an (Name aus striktem
Muster `mail_documents_reindex_<Ziffern>`, per Regex validiert —
Verteidigung in der Tiefe, obwohl der Wert ausschließlich
paketintern erzeugt wird),
2. kopiert alle Dokumente aus der lebenden Tabelle seitenweise
(Cursor-Paginierung über `id`, strukturierte JSON-API, kein
dynamischer SQL-Klauselbau) — die lebende Tabelle wird dabei nur
gelesen, nie verändert (Akzeptanzkriterium 1),
3. meldet Fortschritt über einen `onProgress`-Callback
(Akzeptanzkriterium 2),
4. vergleicht Trefferzahlen alt/neu — bei Abweichung kein Umschalten,
5. schaltet erst danach per Manticore `ALTER TABLE ... RENAME`
(reine Metadaten-Operation) atomar um. Schlägt ein Schritt vor dem
Umschalten fehl, wird die Zwischentabelle entfernt, die lebende
Tabelle bleibt unverändert (Akzeptanzkriterium 3 / Pflichtprüfung 2).
- Echtes Manticore-Verhalten entdeckt und behandelt: frisch eingefügte
Dokumente einer neu angelegten RT-Tabelle sind für `match_all`-Zählungen
erst nach explizitem `FLUSH RAMCHUNK` zuverlässig sichtbar (SQL-`SELECT`
sah sie sofort, `/search`-Zählung zeigte 0) — vor der
Konsistenzprüfung eingebaut.
- Echte Plattformgrenze gefunden und abgefangen: Manticore unterstützt kein
atomares Mehrfach-`RENAME` in einer Anweisung — zwischen den zwei
nötigen Einzel-`RENAME`s existiert ein Sub-Millisekunden-Fenster ohne
`mail_documents`-Tabelle. `Client.Search` bekam dafür einen begrenzten
Retry (bis zu 2 Wiederholungen, 20ms Pause) speziell auf den
Manticore-Fehler `"unknown local table"` — real durch eine parallele
Suchlast während des Umschaltens nachgewiesen (Pflichtprüfung 1).
- Nebenbei einen echten, latenten Fehler in `Search` gefunden und behoben:
ohne explizites `limit` begrenzte Manticore Ergebnisse standardmäßig auf
20 Treffer — unbemerkt, weil bisherige Tests (SRC-01/03/05) nur auf das
Vorhandensein einzelner Treffer prüften, nie auf die Gesamtzahl. Jetzt
`searchResultLimit = 1000`.
- Kein Umbau: `Index`/`Delete`/`Facets`-Verhalten sonst unverändert,
`mail/internal/dedup`/`indexworker`/`storage`/`crypto`/`encstorage`
unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Reindex während laufender Suchanfragen unterbricht die Suche nicht | **bestanden** `TestRebuild_SearchKeepsWorkingDuringReindex`: 30 reale Dokumente indexiert, parallele Sucher-Goroutine (alle 2ms) läuft während `Rebuild` mit — 0 fehlgeschlagene Suchen über den gesamten Umschaltvorgang, danach weiterhin real alle 30 Treffer auffindbar |
| 2 | Test: abgebrochener Reindex hinterlässt keinen inkonsistenten Zustand | **bestanden** `TestRebuild_AbortedReindexLeavesNoInconsistentState`: Kontext vor `Rebuild` abgebrochen, Fehler kommt real zurück, lebende Tabelle bleibt danach unverändert (weiterhin 1 Treffer real auffindbar), keine verwaisten Zwischentabellen über `SHOW TABLES` real bestätigt |
| 3 | Stichprobenvergleich Alt-/Neuindex bestätigt gleiche Trefferzahlen | **bestanden** `TestRebuild_SampleComparisonMatchesOldAndNewIndex`: 3 unterschiedliche Suchbegriffe vor und nach Reindex real verglichen, identische Trefferzahlen je Stichprobe |
Zusätzlich (Akzeptanzkriterium 2): `TestRebuild_ReportsProgress` bestätigt
reale Fortschrittsmeldungen bis zum vollständigen Abschluss.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/search (14 Tests,
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Trägt (gemeinsam mit ARC-08, SRC-02, SRC-04, SRC-05,
SRC-08, SRC-10) zu QA-03 bei — QA-03 bleibt weiterhin blockiert, bis auch
ARC-08, SRC-08 und SRC-10 fertig sind.
-110
View File
@@ -1,110 +0,0 @@
# NEXARCH Mail Teststrategie
Stand: 2026-08-30. Ticket: QA-01. Vorbild: Core `QA-01` (`docs/TESTSTRATEGIE-CORE.md`,
Fertig) — dieselbe Struktur, für das Mail-Modul übernommen, wo sinnvoll um
protokollspezifische Aspekte (IMAP/SMTP/MIME) ergänzt.
## 1. Warum dieses Dokument existiert
archivmail (Vorgängerprojekt) testete 2 von 18 Modulen trotz hoher Kritikalität
(Compliance-/Protokoll-Logik). Kein zentrales Issue-Tracking — Bugs wurden nur als
`BUG-N`-Kommentare im Code festgehalten (`known-issues-archivmail.md`). NEXARCH Mail
übernimmt denselben Grundsatz wie Core: **Testpflicht für Auth, Tenant-Scoping und
Protokoll-/Compliance-kritische Logik ist ein Merge-Gate, keine Nachrüstung.**
## 2. Testpyramide
| Ebene | Werkzeug | Umfang |
|---|---|---|
| Unit | `go test` (Standardbibliothek) | Einzelne Funktionen/Typen, keine externe Abhängigkeit (DB, Netzwerk, IMAP/SMTP-Socket) |
| Integration | `go test` gegen echte PostgreSQL-Instanz (`nexarch_test`-Rolle) | Repository-/Handler-Schicht, Tenant-Scoping, Objekt-Speicher |
| Protokoll-Zustandsmaschinen | `go test` gegen echten IMAP-/SMTP-Client-Roundtrip (kein reiner Parser-Unit-Test) | ING-01/ING-02/ING-03: Login-Zustände, Befehlssequenzen, Fehlerpfade |
| E2E | Echter HTTP-Roundtrip (`httptest.Server`) bis zum ersten Mail-Frontend-Ticket, danach Playwright/Jest gegen die echte UI | Vollständiger Request-Response-Zyklus, kein reiner Funktionsaufruf |
| Vertragstests | Analog Core `QA-07`/DMS-Äquivalent, sobald Mail öffentliche Modul-Adapter-Schnittstellen (RET-05-Konsument, siehe `ARC-11`) hat | Wire-Contract-Stabilität |
**E2E-Zwischenlösung begründet:** Mail hat aktuell kein Frontend-Ticket (0/66 Board).
Playwright/Jest bräuchte eine echte Browser-UI zum Testen — bis zum ersten
Mail-Frontend-Ticket ist ein echter HTTP-Roundtrip (kein reiner In-Process-Funktionsaufruf)
die ehrliche, tatsächlich verfügbare Untergrenze für "E2E". Siehe Beispiel in
Abschnitt 3.
## 3. Beispieltests je Testart (Akzeptanzkriterium/Pflichtprüfung 2)
`mail/internal/example` — kein Wegwerf-Demo, sondern eine kleine, tatsächlich nützliche
Funktion (E-Mail-Adress-Normalisierung), die spätere Ticket ohnehin brauchen:
- **Unit:** `normalize_test.go``TestNormalizeAddress_*`, keine externe Abhängigkeit.
- **Integration:** `store_integration_test.go``TestAddressStore_SaveAndCheckExists`,
echte Postgres-Instanz, `TEST_TENANT_DSN`, `t.Cleanup`.
- **E2E:** `handler_e2e_test.go``TestNormalizeHandler_RealHTTPRoundTrip`, echter
`httptest.Server`-Roundtrip (TCP, nicht nur Funktionsaufruf).
Alle sechs Tests real ausgeführt (siehe Prüfungen, Abschnitt 6).
## 4. Pflichttests als Merge-Gate (Akzeptanzkriterium 3/4)
Verbindlich für jeden Pull Request, der Dateien in einem der folgenden Bereiche ändert:
- **Auth** (`mail/internal/auth/` — sobald durch ein späteres Ticket angelegt)
- **Tenant-Scoping** (`mail/internal/tenant/`, jede Repository-Schicht mit Mandanten-Bezug)
- **Protokoll-kritisch** (`mail/internal/ingest/`, `mail/internal/imap/`,
`mail/internal/smtp/` — Zustandsmaschinen, Auth-Handshakes der Protokolle selbst)
- **Compliance-kritisch** (`mail/internal/arc/` oder gleichwertig — RET-05-Konsument,
Löschung/Archivierung, siehe `ARC-11`)
Regel (identisch zu Core `QA-01`): **jede geänderte `.go`-Datei in einem dieser
Bereiche muss von einer geänderten oder neuen `_test.go`-Datei im selben Package
begleitet sein.**
`mail/internal/pflichttestgate` implementiert das Gate (Code-Kopie des Musters aus
Core `internal/pflichttestgate`, mit mail-spezifischen Pfadmustern statt Core-Pfaden
— bewusst keine Cross-Modul-Abhängigkeit, da Mail als eigenständiges Go-Modul Core
nicht importieren kann). `.gitea/workflows/mail-pflichttest-gate.yml` führt es gegen
jeden PR-Diff aus.
Negativtest des Gates selbst (Prüfung 1 dieses Tickets):
`mail/internal/pflichttestgate/gate_test.go` simuliert einen Diff mit geänderter
`mail/internal/auth/login.go` ohne begleitende Testdatei und erwartet, dass das Gate
das als Verstoß erkennt.
## 5. Bug-Tracking (Akzeptanzkriterium 3)
**Konvention: Gitea-Issues** auf `gitea.perlbach24.de/scripte/nexarch`, Label `mail`
plus Schweregrad-Label (`bug-kritisch`/`bug-normal`/`bug-kosmetisch`). Durchsuchbar
über Gitea-Suche/Label-Filter — explizit KEIN Code-Kommentar-Tracking (`BUG-N` wie in
archivmail), das laut `known-issues-archivmail.md` genau diese Sichtbarkeitslücke
verursacht hat.
**Realer Durchspiel-Nachweis (Prüfung 3):** Diese Session (nicht Mail-spezifisch, aber
derselbe reale Vorgang) fand mehrere echte Bugs, dokumentiert nach exakt diesem
Muster in den jeweiligen `*-PRUEFPROTOKOLL.md`-Dateien statt als Code-Kommentar, z. B.
`archive/docs/RET-10-PRUEFPROTOKOLL.md`: fehlende CORS-Header bei RET-06-API,
gefunden bei einer Sichtprüfung, Symptom (Browser hätte Fetch blockiert), Ursache
(kein `Access-Control-Allow-Origin`), Fix (RET-10-Ticket), Nachweis (curl-Test vorher/
nachher) — alles durchsuchbar in der Protokolldatei, nicht im Quelltext verstreut.
**Ehrlich vermerkt:** Ein ECHTER Gitea-Issue konnte in dieser Session nicht angelegt
werden (kein Gitea-API-Token verfügbar, nur Git-SSH/HTTPS-Push-Zugriff). Das oben
verlinkte Beispiel demonstriert das Vorgehen strukturell (Symptom → Ursache → Fix →
Nachweis, durchsuchbar abgelegt), aber NICHT über die Gitea-Issue-Oberfläche selbst.
Sobald ein Gitea-Zugriffstoken verfügbar ist, sollte mindestens ein Test-Issue real
angelegt werden, um die Konvention vollständig nachzuweisen — offener Punkt, siehe
Abschnitt 7.
## 6. Prüfungen (real durchgeführt)
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Dokument liegt vor und wurde von zweiter Person gegengelesen | **bestanden** — Dokument von der Nutzerin/dem Nutzer (zweite Person) gegengelesen und freigegeben (2026-08-30) |
| 2 | Stichprobe: mindestens ein Beispieltest je benannter Testart ist umgesetzt | **bestanden** — 6 Tests real ausgeführt auf 131: `go test ./mail/internal/example/... -v -p 1`, alle grün (3 Unit, 1 Integration, 2 E2E) |
| 3 | Bug-Tracking-Vorgehen wurde einmal exemplarisch für einen realen Befund durchgespielt | **teilweise bestanden** — Vorgehen strukturell durchgespielt anhand eines realen, bereits dokumentierten Befunds (RET-10), aber NICHT über die echte Gitea-Issue-Oberfläche (kein API-Token verfügbar). Siehe Abschnitt 5, offener Punkt in Abschnitt 7 |
## 7. Offene Punkte
- Echter Gitea-Issue als Nachweis der Bug-Tracking-Konvention noch nicht angelegt
(fehlendes API-Token in dieser Session). Sollte nachgeholt werden, sobald Zugriff
besteht.
- `mail/internal/auth/`, `mail/internal/tenant/`, `mail/internal/ingest/` etc. existieren
noch nicht — die Pflichttest-Gate-Pfadmuster sind auf Basis der geplanten
Modulstruktur vordefiniert, nicht an echtem Code verifiziert. Erste Nagelprobe: das
erste Ticket, das einen dieser Pfade tatsächlich anlegt (voraussichtlich `ING-01`).
-36
View File
@@ -1,36 +0,0 @@
module gitea.perlbach24.de/scripte/nexarch/mail
go 1.24
toolchain go1.24.4
require (
github.com/aws/aws-sdk-go-v2 v1.45.1
github.com/aws/aws-sdk-go-v2/config v1.33.1
github.com/aws/aws-sdk-go-v2/credentials v1.20.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1
github.com/aws/smithy-go v1.28.1
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/text v0.14.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
)
-64
View File
@@ -1,64 +0,0 @@
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw=
github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 h1:s67hBfG5t9rn1NCvDuB4E3QIep3UFhHPtaIqFDjV3N8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1/go.mod h1:FpvjBMXtSNMLPmDJsWwcY5cRnqJlpS2y1R6n4pvzs4k=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 h1:ZMbtPZZQRca+3+XYQne9PBvRiYpHZlNJJOZfE9WNfT0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1/go.mod h1:YAGWQdCYlVCoqrzvfv3RLxO6zKwti7gsAULOGWPLYv4=
github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 h1:kVpzaDBzOdRtOftmiSpTdQbWVqRg0kONLXijktiwXnk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-118
View File
@@ -1,118 +0,0 @@
// Package crypto implementiert ARC-02: Envelope-Encryption für Objekte
// at rest. Jedes Objekt bekommt einen eigenen, zufälligen
// Datenverschlüsselungsschlüssel (DEK, Akzeptanzkriterium 1), der mit
// dem Tenant-Hauptschlüssel (KEK) verpackt wird — der KEK selbst kommt
// AUSSCHLIESSLICH von Core API-10/API-12 (Akzeptanzkriterium 2), wird
// hier nie persistiert, nur flüchtig für eine Wrap-/Unwrap-Operation
// gehalten. Bewusste Neuimplementierung des bewährten DMS-FDN-09-
// Musters (Mail kann DMS' internal/ nicht importieren, eigenständiges
// Go-Modul).
package crypto
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
)
const (
DEKSize = 32
KEKSize = 32
)
// ErrDecryptFailed wird geliefert, wenn ein Chiffretext nicht
// entschlüsselt werden kann — falscher Schlüssel ODER manipulierte
// Daten (Pflichtprüfung 2: GCM-Auth-Tag erkennt Manipulation
// zuverlässig, AEAD unterscheidet die beiden Ursachen bewusst nicht).
var ErrDecryptFailed = errors.New("crypto: entschlüsselung fehlgeschlagen (falscher schlüssel oder manipulierte daten)")
func GenerateDEK() ([]byte, error) {
dek := make([]byte, DEKSize)
if _, err := rand.Read(dek); err != nil {
return nil, fmt.Errorf("crypto: dek erzeugen: %w", err)
}
return dek, nil
}
func seal(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("crypto: aes-cipher erstellen: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("crypto: gcm erstellen: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("crypto: nonce erzeugen: %w", err)
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
func open(key, sealed []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("crypto: aes-cipher erstellen: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("crypto: gcm erstellen: %w", err)
}
if len(sealed) < gcm.NonceSize() {
return nil, ErrDecryptFailed
}
nonce, ciphertext := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, ErrDecryptFailed
}
return plaintext, nil
}
func WrapDEK(kek, dek []byte) ([]byte, error) {
wrapped, err := seal(kek, dek)
if err != nil {
return nil, fmt.Errorf("crypto: dek verpacken: %w", err)
}
return wrapped, nil
}
func UnwrapDEK(kek, wrappedDEK []byte) ([]byte, error) {
return open(kek, wrappedDEK)
}
// EncryptStream verschlüsselt den gesamten Inhalt von r mit dek
// (AES-256-GCM). Liest r vollständig in den Speicher — dasselbe Muster
// wie mail/internal/storage.S3Driver.Put (ARC-01), das S3-PutObject
// ebenfalls vollständig puffert; ein segmentiertes AEAD-Verfahren für
// sehr große Anhänge ist bewusst nicht Teil der "kleinsten Lösung".
func EncryptStream(dek []byte, r io.Reader) (io.Reader, error) {
plaintext, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("crypto: klartext lesen: %w", err)
}
ciphertext, err := seal(dek, plaintext)
if err != nil {
return nil, fmt.Errorf("crypto: verschlüsseln: %w", err)
}
return bytes.NewReader(ciphertext), nil
}
// DecryptStream entschlüsselt einen zuvor mit EncryptStream erzeugten
// Chiffretext-Stream.
func DecryptStream(dek []byte, r io.Reader) (io.Reader, error) {
ciphertext, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("crypto: chiffretext lesen: %w", err)
}
plaintext, err := open(dek, ciphertext)
if err != nil {
return nil, err
}
return bytes.NewReader(plaintext), nil
}
-80
View File
@@ -1,80 +0,0 @@
package crypto
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// KEKProvider liefert den aktuellen, entschlüsselten Tenant-KEK
// (Akzeptanzkriterium 2: KEK kommt ausschließlich von Core API-10/
// API-12). Schmale Schnittstelle, damit Tests einen Fake statt eines
// echten HTTP-Aufrufs einsetzen können.
type KEKProvider interface {
TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error)
}
// tenantKEKResponse entspricht Core internal/kek.tenantKEKResponse
// (JSON-Vertrag: tenant_kek_base64) — dieselbe Struktur, hier
// gespiegelt, da Mail Cores internal/-Pakete nicht importieren kann.
type tenantKEKResponse struct {
TenantKEKBase64 string `json:"tenant_kek_base64"`
}
// HTTPKEKProvider bezieht den Tenant-KEK über Core API-12
// (internal/kek.Handler.TenantKEKHandler, echt laufender Dienst),
// authentifiziert über dasselbe Service-Credential-Verfahren wie jeder
// andere Modul-Core-Aufruf (API-02) — identisches Muster wie DMS
// FDN-09/internal/storage.HTTPUsageReporter aus ARC-01.
type HTTPKEKProvider struct {
endpointURL string
clientID string
clientSecret string
httpClient *http.Client
}
func NewHTTPKEKProvider(endpointURL, clientID, clientSecret string, httpClient *http.Client) *HTTPKEKProvider {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &HTTPKEKProvider{endpointURL: endpointURL, clientID: clientID, clientSecret: clientSecret, httpClient: httpClient}
}
func (p *HTTPKEKProvider) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) {
u, err := url.Parse(p.endpointURL)
if err != nil {
return nil, fmt.Errorf("crypto: kek-endpunkt-url ungültig: %w", err)
}
q := u.Query()
q.Set("tenant", tenantSlug)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("crypto: kek-anfrage aufbauen: %w", err)
}
req.Header.Set("X-Nexarch-Client-Id", p.clientID)
req.Header.Set("X-Nexarch-Client-Secret", p.clientSecret)
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("crypto: kek-anfrage senden: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("crypto: kek-bezug von core abgelehnt: status %d", resp.StatusCode)
}
var body tenantKEKResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("crypto: kek-antwort dekodieren: %w", err)
}
kek, err := base64.StdEncoding.DecodeString(body.TenantKEKBase64)
if err != nil {
return nil, fmt.Errorf("crypto: kek base64-dekodieren: %w", err)
}
return kek, nil
}
-67
View File
@@ -1,67 +0,0 @@
package crypto
import (
"context"
"fmt"
"io"
)
// Envelope ist das Ergebnis einer Seal-Operation: der Chiffretext-
// Stream plus der mit dem Tenant-KEK verpackte DEK, der zusammen mit
// dem Objekt persistiert werden muss (siehe mail/internal/encstorage).
type Envelope struct {
Ciphertext io.Reader
WrappedDEK []byte
}
// Service verbindet KEKProvider mit den Envelope-Operationen — Aufrufer
// (mail/internal/encstorage) rufen ausschließlich Service auf, nie die
// Einzelfunktionen aus envelope.go direkt.
type Service struct {
kek KEKProvider
}
func NewService(kek KEKProvider) *Service {
return &Service{kek: kek}
}
// Seal erzeugt einen neuen DEK (Akzeptanzkriterium 1), verschlüsselt
// plaintext damit und verpackt den DEK mit dem aktuellen Tenant-KEK
// (Akzeptanzkriterium 2 — der KEK wird bei JEDEM Aufruf frisch von Core
// bezogen, nie zwischengespeichert).
func (s *Service) Seal(ctx context.Context, tenantSlug string, plaintext io.Reader) (*Envelope, error) {
dek, err := GenerateDEK()
if err != nil {
return nil, err
}
ciphertext, err := EncryptStream(dek, plaintext)
if err != nil {
return nil, err
}
kek, err := s.kek.TenantKEK(ctx, tenantSlug)
if err != nil {
return nil, fmt.Errorf("crypto: tenant-kek beziehen: %w", err)
}
wrappedDEK, err := WrapDEK(kek, dek)
if err != nil {
return nil, err
}
return &Envelope{Ciphertext: ciphertext, WrappedDEK: wrappedDEK}, nil
}
// Open entpackt den DEK mit dem aktuellen Tenant-KEK (Akzeptanzkriterium
// 3: nur mit gültigem, mandantenbezogenem Schlüssel möglich — ein
// falscher Tenant-Slug liefert entweder einen falschen KEK von Core
// [dann schlägt UnwrapDEK fehl] oder Core verweigert den Zugriff direkt)
// und entschlüsselt ciphertext damit.
func (s *Service) Open(ctx context.Context, tenantSlug string, wrappedDEK []byte, ciphertext io.Reader) (io.Reader, error) {
kek, err := s.kek.TenantKEK(ctx, tenantSlug)
if err != nil {
return nil, fmt.Errorf("crypto: tenant-kek beziehen: %w", err)
}
dek, err := UnwrapDEK(kek, wrappedDEK)
if err != nil {
return nil, err
}
return DecryptStream(dek, ciphertext)
}
-35
View File
@@ -1,35 +0,0 @@
// Package dedup implementiert ARC-03: Dublettenerkennung für
// archivierte E-Mails über einen Inhalts-Hash. Kombiniert bewusst NICHT
// mit mail/internal/storage oder mail/internal/crypto — dieses Paket
// kennt beide nicht, der Aufrufer (spätere Ingest-Tickets) ruft es VOR
// mail/internal/crypto auf.
package dedup
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
)
// HashAndBuffer berechnet den SHA-256-Inhalts-Hash von plaintext.
//
// Bekannter Fehler vermeiden (siehe ARC-03-Ticket): der Hash MUSS auf
// dem Klartext berechnet werden, BEVOR mail/internal/crypto verschlüsselt
// — ein Hash auf dem Chiffretext wäre bei jedem Import anders (neuer
// DEK je Objekt, siehe ARC-02) und Dublettenerkennung würde vollständig
// versagen. Reihenfolge: Mail/Anhang empfangen -> HashAndBuffer (dieses
// Paket) -> verschlüsseln (ARC-02) -> ablegen.
//
// plaintext wird beim Hashen vollständig verbraucht — HashAndBuffer
// liefert deshalb einen erneut lesbaren Reader mit demselben Inhalt für
// den nachfolgenden Verschlüsselungsschritt zurück.
func HashAndBuffer(plaintext io.Reader) (contentHash string, buffered io.Reader, err error) {
var buf bytes.Buffer
hasher := sha256.New()
if _, err := io.Copy(hasher, io.TeeReader(plaintext, &buf)); err != nil {
return "", nil, fmt.Errorf("dedup: klartext hashen: %w", err)
}
return hex.EncodeToString(hasher.Sum(nil)), &buf, nil
}
-58
View File
@@ -1,58 +0,0 @@
package dedup
import (
"io"
"strings"
"testing"
)
func TestHashAndBuffer_SameContentSameHash(t *testing.T) {
h1, buf1, err := HashAndBuffer(strings.NewReader("identischer inhalt"))
if err != nil {
t.Fatal(err)
}
h2, buf2, err := HashAndBuffer(strings.NewReader("identischer inhalt"))
if err != nil {
t.Fatal(err)
}
if h1 != h2 {
t.Fatalf("erwartet identischen hash für identischen inhalt, habe %q vs %q", h1, h2)
}
got1, _ := io.ReadAll(buf1)
got2, _ := io.ReadAll(buf2)
if string(got1) != "identischer inhalt" || string(got2) != "identischer inhalt" {
t.Fatal("buffered reader liefert nicht denselben inhalt zurück wie der ursprüngliche klartext")
}
}
func TestHashAndBuffer_DifferentContentDifferentHash(t *testing.T) {
h1, _, err := HashAndBuffer(strings.NewReader("nachricht a"))
if err != nil {
t.Fatal(err)
}
h2, _, err := HashAndBuffer(strings.NewReader("nachricht b"))
if err != nil {
t.Fatal(err)
}
if h1 == h2 {
t.Fatal("unterschiedlicher inhalt hätte unterschiedlichen hash liefern müssen")
}
}
// TestHashAndBuffer_SlightlyDifferentContentDifferentHash ist die
// geforderte Pflichtprüfung 3: knapp unterschiedliche Nachrichten
// werden korrekt NICHT als Duplikat erkannt.
func TestHashAndBuffer_SlightlyDifferentContentDifferentHash(t *testing.T) {
h1, _, err := HashAndBuffer(strings.NewReader("Betreff: Test\r\n\r\nInhalt der Nachricht."))
if err != nil {
t.Fatal(err)
}
h2, _, err := HashAndBuffer(strings.NewReader("Betreff: Test\r\n\r\nInhalt der Nachricht,"))
if err != nil {
t.Fatal(err)
}
if h1 == h2 {
t.Fatal("ein einziges abweichendes zeichen hätte den hash ändern müssen")
}
}
-75
View File
@@ -1,75 +0,0 @@
package dedup
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store verwaltet bekannte Inhalts-Hashes je Mandant. tenant_slug ist
// fester Bestandteil des Primärschlüssels (Akzeptanzkriterium 3:
// mandantenübergreifend korrekt getrennt) — auch wenn Store einen mit
// anderen Mandanten geteilten Pool erhält, kann ein Hash-Treffer nie
// über Mandantengrenzen hinweg entstehen.
type Store struct {
pool *pgxpool.Pool
tenantSlug string
}
func NewStore(pool *pgxpool.Pool, tenantSlug string) *Store {
return &Store{pool: pool, tenantSlug: tenantSlug}
}
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert —
// gleiches Muster wie mail/internal/example (kein zentraler
// Migrationsläufer für Mandanten-Datenbanken im Mail-Modul vorhanden).
func (s *Store) EnsureSchema(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS mail_content_hashes (
tenant_slug TEXT NOT NULL,
content_hash TEXT NOT NULL,
object_key TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_slug, content_hash)
)
`); err != nil {
return fmt.Errorf("dedup: schema anlegen: %w", err)
}
return nil
}
// Register trägt contentHash für den Mandanten als neu bekannt ein
// (Akzeptanzkriterium 1) und referenziert das Original, statt es
// redundant zu speichern (Akzeptanzkriterium 2): existiert derselbe
// Hash für DIESEN Mandanten bereits mit einem ANDEREN object_key,
// liefert Register isDuplicate=true und den object_key des Originals —
// der Aufrufer legt den neuen Inhalt dann NICHT ab.
func (s *Store) Register(ctx context.Context, contentHash, objectKey string) (isDuplicate bool, existingKey string, err error) {
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_content_hashes (tenant_slug, content_hash, object_key)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_slug, content_hash) DO NOTHING
`, s.tenantSlug, contentHash, objectKey); err != nil {
return false, "", fmt.Errorf("dedup: hash eintragen: %w", err)
}
var storedKey string
err = s.pool.QueryRow(ctx, `
SELECT object_key FROM mail_content_hashes
WHERE tenant_slug = $1 AND content_hash = $2
`, s.tenantSlug, contentHash).Scan(&storedKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, "", fmt.Errorf("dedup: gerade eingetragenen hash nicht wiedergefunden")
}
return false, "", fmt.Errorf("dedup: eintrag lesen: %w", err)
}
if storedKey != objectKey {
return true, storedKey, nil
}
return false, "", nil
}
@@ -1,94 +0,0 @@
// Integrationstest (ARC-03): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/example (QA-01) — TEST_TENANT_DSN.
package dedup
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupStore(t *testing.T, tenantSlug string) *Store {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
store := NewStore(pool, tenantSlug)
if err := store.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_content_hashes WHERE tenant_slug = $1`, tenantSlug)
})
return store
}
// TestRegister_SameMessageFromTwoSourcesIsDuplicate ist die geforderte
// Pflichtprüfung 1: dieselbe Nachricht aus zwei Quellen wird als
// Duplikat erkannt.
func TestRegister_SameMessageFromTwoSourcesIsDuplicate(t *testing.T) {
store := setupStore(t, "mandant-arc03-a")
ctx := context.Background()
hash := "fixierter-inhalts-hash-fuer-test-1"
isDup, _, err := store.Register(ctx, hash, "quelle-1/objekt")
if err != nil {
t.Fatalf("erste registrierung: %v", err)
}
if isDup {
t.Fatal("erste registrierung eines hashes darf kein duplikat sein")
}
isDup, existing, err := store.Register(ctx, hash, "quelle-2/objekt")
if err != nil {
t.Fatalf("zweite registrierung: %v", err)
}
if !isDup {
t.Fatal("erwartet: dieselbe nachricht aus zweiter quelle wird als duplikat erkannt")
}
if existing != "quelle-1/objekt" {
t.Fatalf("erwartet referenz auf das original quelle-1/objekt, habe %q", existing)
}
}
// TestRegister_SameContentTwoTenantsNotLinked ist die geforderte
// Pflichtprüfung 2: zwei Mandanten mit identischem Mailinhalt werden
// nicht fälschlich verknüpft.
func TestRegister_SameContentTwoTenantsNotLinked(t *testing.T) {
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
storeA := setupStore(t, "mandant-arc03-x")
storeB := setupStore(t, "mandant-arc03-y")
ctx := context.Background()
hash := "identischer-inhalt-ueber-zwei-mandanten-hinweg"
isDupA, _, err := storeA.Register(ctx, hash, "mandant-x/objekt")
if err != nil {
t.Fatalf("mandant a: %v", err)
}
if isDupA {
t.Fatal("erste registrierung bei mandant a darf kein duplikat sein")
}
isDupB, existingB, err := storeB.Register(ctx, hash, "mandant-y/objekt")
if err != nil {
t.Fatalf("mandant b: %v", err)
}
if isDupB {
t.Fatalf("mandant b wurde fälschlich mit mandant a verknüpft, existing=%q", existingB)
}
}
-93
View File
@@ -1,93 +0,0 @@
// Package encstorage implementiert ARC-02s Anbindung an ARC-01: jedes
// archivierte Objekt wird VOR dem Schreiben über
// mail/internal/storage.Service verschlüsselt (Akzeptanzkriterium 1).
// Kombiniert bewusst zwei bereits fertige, unveränderte Pakete
// (mail/internal/storage aus ARC-01, mail/internal/crypto aus ARC-02)
// statt eines davon zu erweitern — kein Umbau angrenzender Bereiche.
//
// Reihenfolge beachtet (Ticket "Bekannte Fehler vermeiden"): ein
// SHA-256-Hash für Dublettenerkennung (ARC-03) muss auf dem KLARTEXT
// berechnet werden, BEVOR diese Verschlüsselung angewendet wird — das
// ist Sache des Aufrufers (ARC-03, außerhalb dieses Pakets), dieses
// Paket nimmt bereits fertigen Klartext entgegen und verschlüsselt ihn
// sofort, hält ihn nicht länger als nötig im Speicher.
package encstorage
import (
"bytes"
"context"
"fmt"
"io"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
)
// wrappedDEKKey ist der Sidecar-Objektschlüssel für den mit dem
// Tenant-KEK verpackten DEK — liegt im selben Storage-Backend wie der
// Chiffretext, analog zum Prüfsummen-Sidecar aus ARC-01.
func wrappedDEKKey(key string) string {
return key + ".dek"
}
// Service verbindet Storage (ARC-01) und Crypto (ARC-02): der Rest von
// Mail ruft AUSSCHLIESSLICH diesen Service auf, nie storage.Service
// direkt mit Klartext — das verhindert einen Schreibpfad, der die
// Verschlüsselung vergisst (Akzeptanzkriterium 1).
type Service struct {
storage *storage.Service
crypto *crypto.Service
}
func NewService(storageSvc *storage.Service, cryptoSvc *crypto.Service) *Service {
return &Service{storage: storageSvc, crypto: cryptoSvc}
}
// Put verschlüsselt plaintext (Akzeptanzkriterium 1/2: neuer DEK je
// Objekt, mit dem per API-12 bezogenen Tenant-KEK verpackt) und legt
// Chiffretext + verpackten DEK über storage.Service ab (Prüfsumme,
// Nutzungsmeldung — ARC-01 unverändert wiederverwendet).
func (s *Service) Put(ctx context.Context, tenantSlug, key string, plaintext io.Reader, contentType string) error {
env, err := s.crypto.Seal(ctx, tenantSlug, plaintext)
if err != nil {
return fmt.Errorf("encstorage: verschlüsseln: %w", err)
}
ciphertext, err := io.ReadAll(env.Ciphertext)
if err != nil {
return fmt.Errorf("encstorage: chiffretext lesen: %w", err)
}
if _, err := s.storage.Put(ctx, key, bytes.NewReader(ciphertext), int64(len(ciphertext)), contentType); err != nil {
return fmt.Errorf("encstorage: chiffretext speichern: %w", err)
}
if _, err := s.storage.Put(ctx, wrappedDEKKey(key), bytes.NewReader(env.WrappedDEK), int64(len(env.WrappedDEK)), "application/octet-stream"); err != nil {
return fmt.Errorf("encstorage: verpackten dek speichern: %w", err)
}
return nil
}
// GetDecrypted liest Chiffretext (über storage.GetVerified — ARC-01s
// Prüfsummenverifikation wird dabei mitgenutzt) und verpackten DEK,
// entpackt den DEK mit dem AKTUELLEN Tenant-KEK und entschlüsselt den
// Inhalt (Akzeptanzkriterium 3: nur mit gültigem, mandantenbezogenem
// Schlüssel möglich).
func (s *Service) GetDecrypted(ctx context.Context, tenantSlug, key string) ([]byte, error) {
ciphertext, err := s.storage.GetVerified(ctx, key)
if err != nil {
return nil, fmt.Errorf("encstorage: chiffretext lesen: %w", err)
}
dekReader, err := s.storage.Get(ctx, wrappedDEKKey(key))
if err != nil {
return nil, fmt.Errorf("encstorage: verpackten dek lesen: %w", err)
}
defer func() { _ = dekReader.Close() }()
wrappedDEK, err := io.ReadAll(dekReader)
if err != nil {
return nil, fmt.Errorf("encstorage: verpackten dek lesen: %w", err)
}
plaintextReader, err := s.crypto.Open(ctx, tenantSlug, wrappedDEK, bytes.NewReader(ciphertext))
if err != nil {
return nil, err
}
return io.ReadAll(plaintextReader)
}
-138
View File
@@ -1,138 +0,0 @@
package encstorage
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
)
type fakeUsageReporter struct{}
func (fakeUsageReporter) Report(_ context.Context, _, _ string, _ int64) error { return nil }
// fakeKEKProvider liefert einen festen, mandantenspezifischen KEK —
// simuliert Core API-12, ohne den echten Dienst für Unit-Tests zu
// brauchen. "richtiger-tenant" hat einen bekannten KEK, jeder andere
// Tenant-Slug liefert einen ANDEREN (falschen) KEK — genau der Fall,
// den Pflichtprüfung 2 testet.
type fakeKEKProvider struct {
keksByTenant map[string][]byte
}
func (f fakeKEKProvider) TenantKEK(_ context.Context, tenantSlug string) ([]byte, error) {
if kek, ok := f.keksByTenant[tenantSlug]; ok {
return kek, nil
}
// Unbekannter Tenant bekommt einen ANDEREN zufälligen KEK (nicht
// abgelehnt auf Core-Seite in diesem Fake, aber garantiert falsch —
// entspricht dem realen Fall, dass ein falscher Tenant-Slug einen
// anderen KEK liefert und die Entschlüsselung deshalb scheitert).
return bytes.Repeat([]byte{0xFF}, crypto.KEKSize), nil
}
func newTestServiceWithDir(t *testing.T) (*Service, string) {
t.Helper()
dir := t.TempDir()
storageSvc := storage.NewService(storage.NewLocalDriver(dir), fakeUsageReporter{}, "acme")
kekProvider := fakeKEKProvider{keksByTenant: map[string][]byte{
"richtiger-tenant": bytes.Repeat([]byte{0x42}, crypto.KEKSize),
}}
cryptoSvc := crypto.NewService(kekProvider)
return NewService(storageSvc, cryptoSvc), dir
}
// TestPut_RawStorageAccessWithoutKeyYieldsNoReadableContent ist die
// geforderte Pflichtprüfung 1: Zugriff auf Rohspeicher ohne Schlüssel
// liefert keine lesbaren Inhalte.
func TestPut_RawStorageAccessWithoutKeyYieldsNoReadableContent(t *testing.T) {
svc, dir := newTestServiceWithDir(t)
ctx := context.Background()
key := storage.ObjectKey("msg-enc-1", 0)
plaintext := "STRENG GEHEIMER E-MAIL-INHALT, NICHT IM KLARTEXT AUF DER PLATTE"
if err := svc.Put(ctx, "richtiger-tenant", key, strings.NewReader(plaintext), "text/plain"); err != nil {
t.Fatalf("put: %v", err)
}
// Direkter Dateisystemzugriff, UMGEHT den Service/die Entschlüsselung
// vollständig — simuliert "Rohspeicher ohne Schlüssel".
raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(key)))
if err != nil {
t.Fatalf("rohdatei lesen: %v", err)
}
if bytes.Contains(raw, []byte(plaintext)) {
t.Fatalf("klartext ist unverschlüsselt im rohspeicher auffindbar: %q enthält %q", raw, plaintext)
}
if bytes.Contains(raw, []byte("STRENG GEHEIMER")) || bytes.Contains(raw, []byte("GEHEIM")) {
t.Fatal("erkennbare klartext-fragmente im rohspeicher gefunden")
}
}
// TestGetDecrypted_WrongTenantKeyDeniesDecryption ist die geforderte
// Pflichtprüfung 2: falscher Mandantenschlüssel verweigert
// Entschlüsselung.
func TestGetDecrypted_WrongTenantKeyDeniesDecryption(t *testing.T) {
svc, _ := newTestServiceWithDir(t)
ctx := context.Background()
key := storage.ObjectKey("msg-enc-2", 0)
plaintext := "inhalt fuer richtiger-tenant"
if err := svc.Put(ctx, "richtiger-tenant", key, strings.NewReader(plaintext), "text/plain"); err != nil {
t.Fatalf("put: %v", err)
}
// Korrekter Tenant kann entschlüsseln.
got, err := svc.GetDecrypted(ctx, "richtiger-tenant", key)
if err != nil {
t.Fatalf("getdecrypted (richtiger tenant): %v", err)
}
if string(got) != plaintext {
t.Fatalf("falscher inhalt: %q", got)
}
// FALSCHER Mandant (anderer Tenant-Slug -> anderer KEK) muss
// abgelehnt werden.
_, err = svc.GetDecrypted(ctx, "falscher-tenant", key)
if !errors.Is(err, crypto.ErrDecryptFailed) {
t.Fatalf("erwartet ErrDecryptFailed bei falschem mandantenschlüssel, habe: %v", err)
}
}
// TestPut_AcceptableEncryptionOverhead ist die geforderte
// Pflichtprüfung 3: Performance-Test bestätigt akzeptablen Overhead
// durch Verschlüsselung.
func TestPut_AcceptableEncryptionOverhead(t *testing.T) {
svc, _ := newTestServiceWithDir(t)
ctx := context.Background()
// ~64 KiB Nutzlast, realistische Anhanggröße.
payload := strings.Repeat("x", 64*1024)
const count = 50
start := time.Now()
for i := 0; i < count; i++ {
key := storage.ObjectKey("msg-perf", i)
if err := svc.Put(ctx, "richtiger-tenant", key, strings.NewReader(payload), "application/octet-stream"); err != nil {
t.Fatalf("put #%d: %v", i, err)
}
}
elapsed := time.Since(start)
avgPerObject := elapsed / count
// Großzügige Grenze (50ms je 64-KiB-Objekt inkl. AES-GCM,
// Prüfsumme, Sidecar-Schreiben, Rücklese-Verifikation aus ARC-01) —
// Ziel: Nachweis, dass Verschlüsselung keinen unverhältnismäßigen
// Overhead verursacht, nicht ein knallhartes SLA.
if avgPerObject > 50*time.Millisecond {
t.Fatalf("erwartet akzeptablen verschlüsselungs-overhead (<50ms/64KiB-objekt), habe %v/objekt (gesamt %v für %d objekte)", avgPerObject, elapsed, count)
}
t.Logf("Verschlüsselungs-Performance: %d×64KiB-Objekte in %v (%v/Objekt)", count, elapsed, avgPerObject)
}
-36
View File
@@ -1,36 +0,0 @@
package example
import (
"encoding/json"
"net/http"
)
// NormalizeHandler ist das E2E-Test-Beispiel (QA-01): ein echter
// HTTP-Endpunkt, gegen den ein Test einen vollständigen Request-Response-
// Zyklus fährt (httptest.Server, echter TCP-Roundtrip, kein reiner
// Funktionsaufruf). Sobald das erste Mail-Frontend-Ticket eine echte
// Browser-UI mitbringt, wird die E2E-Ebene um Playwright/Jest ergänzt
// (siehe QA-01-Teststrategiedokument, Abschnitt 2) — bis dahin ist ein
// echter HTTP-Roundtrip die ehrliche, verfügbare Untergrenze für "E2E".
type normalizeRequest struct {
Address string `json:"address"`
}
type normalizeResponse struct {
Normalized string `json:"normalized"`
}
func NormalizeHandler(w http.ResponseWriter, r *http.Request) {
var req normalizeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body", http.StatusBadRequest)
return
}
normalized, err := NormalizeAddress(req.Address)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(normalizeResponse{Normalized: normalized})
}
-51
View File
@@ -1,51 +0,0 @@
// E2E-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2): echter
// HTTP-Request über einen laufenden httptest.Server (TCP-Roundtrip),
// nicht nur ein Funktionsaufruf im selben Prozess.
package example
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestNormalizeHandler_RealHTTPRoundTrip(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(NormalizeHandler))
defer server.Close()
body, _ := json.Marshal(normalizeRequest{Address: "Kunde@Beispiel.DE"})
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var out normalizeResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if out.Normalized != "Kunde@beispiel.de" {
t.Fatalf("got %q", out.Normalized)
}
}
func TestNormalizeHandler_InvalidAddressReturns400(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(NormalizeHandler))
defer server.Close()
body, _ := json.Marshal(normalizeRequest{Address: "keine-gueltige-adresse"})
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
-26
View File
@@ -1,26 +0,0 @@
// Package example dient QA-01 (Mail): liefert je Testart (Unit,
// Integration, E2E) genau EIN reales, lauffähiges Beispiel, an dem sich
// spätere Mail-Tickets orientieren können — keine Wegwerf-Demo, sondern
// eine tatsächlich nützliche, kleine Funktion (Adress-Normalisierung),
// die spätere Ticket (z. B. ING-01/ING-04) ohnehin brauchen werden.
package example
import (
"errors"
"strings"
)
var ErrInvalidAddress = errors.New("example: keine gültige e-mail-adresse")
// NormalizeAddress normalisiert eine E-Mail-Adresse für den
// tenant-scoped Vergleich: Kleinschreibung der Domain-Hälfte
// (lokaler Teil bleibt case-sensitive, RFC 5321), Leerraum entfernt.
func NormalizeAddress(addr string) (string, error) {
addr = strings.TrimSpace(addr)
at := strings.LastIndex(addr, "@")
if at <= 0 || at == len(addr)-1 {
return "", ErrInvalidAddress
}
local, domain := addr[:at], addr[at+1:]
return local + "@" + strings.ToLower(domain), nil
}
-38
View File
@@ -1,38 +0,0 @@
// Unit-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2): keine
// externe Abhängigkeit (DB, Netzwerk), reine Funktionsprüfung.
package example
import (
"errors"
"testing"
)
func TestNormalizeAddress_LowercasesDomainOnly(t *testing.T) {
got, err := NormalizeAddress("User.Name@Example.COM")
if err != nil {
t.Fatalf("unerwarteter fehler: %v", err)
}
want := "User.Name@example.com"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestNormalizeAddress_TrimsWhitespace(t *testing.T) {
got, err := NormalizeAddress(" user@example.com ")
if err != nil {
t.Fatalf("unerwarteter fehler: %v", err)
}
if got != "user@example.com" {
t.Fatalf("got %q", got)
}
}
func TestNormalizeAddress_RejectsInvalidInput(t *testing.T) {
cases := []string{"", "no-at-sign", "@nolocalpart.com", "trailing@"}
for _, c := range cases {
if _, err := NormalizeAddress(c); !errors.Is(err, ErrInvalidAddress) {
t.Fatalf("input %q: erwartet ErrInvalidAddress, habe: %v", c, err)
}
}
}
-42
View File
@@ -1,42 +0,0 @@
package example
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// AddressStore ist das Integrationstest-Beispiel (QA-01): eine
// minimale, aber echte DB-gestützte Komponente — nutzt dieselbe
// Tenant-DB-Isolationskonvention wie DMS/Archive (t.Cleanup, geteilte
// physische Postgres-Instanz auf dem Testhost).
type AddressStore struct {
pool *pgxpool.Pool
}
func NewAddressStore(pool *pgxpool.Pool) *AddressStore {
return &AddressStore{pool: pool}
}
func (s *AddressStore) SaveNormalized(ctx context.Context, addr string) (string, error) {
normalized, err := NormalizeAddress(addr)
if err != nil {
return "", err
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO example_addresses (address) VALUES ($1)
ON CONFLICT (address) DO NOTHING
`, normalized); err != nil {
return "", fmt.Errorf("example: adresse speichern: %w", err)
}
return normalized, nil
}
func (s *AddressStore) Exists(ctx context.Context, addr string) (bool, error) {
var exists bool
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM example_addresses WHERE address = $1)`, addr).Scan(&exists); err != nil {
return false, fmt.Errorf("example: existenz prüfen: %w", err)
}
return exists, nil
}
@@ -1,69 +0,0 @@
// Integrations-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2):
// echte Postgres-Instanz, folgt derselben Testhost-Konvention wie
// DMS/Archive/Core (TEST_TENANT_DSN, t.Cleanup, geteilte physische
// Instanz auf 192.168.1.131 — siehe project-nexarch-test-infra).
package example
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS example_addresses (
address TEXT PRIMARY KEY
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE example_addresses`)
})
return pool
}
func TestAddressStore_SaveAndCheckExists(t *testing.T) {
pool := setupTest(t)
store := NewAddressStore(pool)
ctx := context.Background()
normalized, err := store.SaveNormalized(ctx, "Kunde@Beispiel.DE")
if err != nil {
t.Fatalf("savenormalized: %v", err)
}
if normalized != "Kunde@beispiel.de" {
t.Fatalf("erwartet normalisierte adresse, habe %q", normalized)
}
exists, err := store.Exists(ctx, normalized)
if err != nil {
t.Fatal(err)
}
if !exists {
t.Fatal("erwartet real gespeicherte adresse")
}
notExists, err := store.Exists(ctx, "unbekannt@beispiel.de")
if err != nil {
t.Fatal(err)
}
if notExists {
t.Fatal("nie gespeicherte adresse haette nicht existieren duerfen")
}
}
@@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS mail_index_jobs (
id BIGSERIAL PRIMARY KEY,
job_type TEXT NOT NULL CHECK (job_type IN ('index', 'delete')),
tenant_slug TEXT NOT NULL,
message_id TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'succeeded', 'failed')),
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_at TIMESTAMPTZ,
locked_by TEXT,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
-213
View File
@@ -1,213 +0,0 @@
// Package indexworker implementiert SRC-02: einen Indexierungs-Worker,
// der neu archivierte Mails asynchron in den Manticore-Index (SRC-01)
// einpflegt und Löschungen/Metadatenänderungen nachzieht. Postgres-
// Jobqueue mit FOR UPDATE SKIP LOCKED, Stale-Lock-Wiedervorlage bei
// Worker-Absturz — dieselbe Konvention wie dms/internal/jobqueue (FDN-04),
// hier bewusst schlanker (kein Redis/AMQP, keine DLQ — nicht Bestandteil
// der Akzeptanzkriterien dieser Kachel).
package indexworker
import (
"context"
_ "embed"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/0001_mail_index_jobs.sql
var schemaMigration string
const (
JobTypeIndex = "index"
JobTypeDelete = "delete"
)
const (
StatusPending = "pending"
StatusProcessing = "processing"
StatusSucceeded = "succeeded"
StatusFailed = "failed"
)
// ErrNoJobAvailable wird von Dequeue geliefert, wenn aktuell kein
// abholbarer Job vorhanden ist (Normalfall bei leerer Queue).
var ErrNoJobAvailable = errors.New("indexworker: kein job verfügbar")
// ErrNotFound wird geliefert, wenn ein angefragter Job nicht existiert.
var ErrNotFound = errors.New("indexworker: job nicht gefunden")
const defaultMaxAttempts = 5
// Job ist eine einzelne Indexierungs-/Löschaufgabe.
type Job struct {
ID int64
JobType string
TenantSlug string
MessageID string
Status string
Attempts int
}
// Queue kapselt den Zugriff auf mail_index_jobs.
type Queue struct {
pool *pgxpool.Pool
staleLockAfter time.Duration
}
// NewQueue erzeugt eine Queue. staleLockAfter legt fest, ab wann ein
// als "processing" markierter Job wieder abholbar gilt, weil sein Worker
// vermutlich abgestürzt ist (Akzeptanzkriterium 3: Worker-Ausfall verliert
// keine Indexierungsaufträge).
func NewQueue(pool *pgxpool.Pool, staleLockAfter time.Duration) *Queue {
return &Queue{pool: pool, staleLockAfter: staleLockAfter}
}
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert —
// gleiches Muster wie mail/internal/dedup (kein zentraler Migrationsläufer
// für Mandanten-Datenbanken im Mail-Modul vorhanden).
func (q *Queue) EnsureSchema(ctx context.Context) error {
if _, err := q.pool.Exec(ctx, schemaMigration); err != nil {
return fmt.Errorf("indexworker: schema anlegen: %w", err)
}
return nil
}
// EnqueueIndex reiht eine Indexierungsaufgabe ein (Akzeptanzkriterium 1).
// payload enthält die für die Indexierung nötigen Felder (Betreff, Text,
// Anhangstext) als JSON — der Worker kennt keine Klartext-Beschaffung
// selbst, das ist Aufgabe des Aufrufers (analog dedup, das ebenfalls
// storage/crypto nicht kennt).
func (q *Queue) EnqueueIndex(ctx context.Context, tenantSlug, messageID string, payload []byte) (int64, error) {
return q.enqueue(ctx, JobTypeIndex, tenantSlug, messageID, payload)
}
// EnqueueDelete reiht eine Löschaufgabe ein (Akzeptanzkriterium 2).
func (q *Queue) EnqueueDelete(ctx context.Context, tenantSlug, messageID string) (int64, error) {
return q.enqueue(ctx, JobTypeDelete, tenantSlug, messageID, []byte(`{}`))
}
func (q *Queue) enqueue(ctx context.Context, jobType, tenantSlug, messageID string, payload []byte) (int64, error) {
var id int64
err := q.pool.QueryRow(ctx, `
INSERT INTO mail_index_jobs (job_type, tenant_slug, message_id, payload, max_attempts)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, jobType, tenantSlug, messageID, payload, defaultMaxAttempts).Scan(&id)
if err != nil {
return 0, fmt.Errorf("indexworker: job einreihen: %w", err)
}
return id, nil
}
// dequeuedJob trägt zusätzlich den Payload, den nur das Paket selbst
// (worker.go) benötigt.
type dequeuedJob struct {
Job
Payload []byte
}
// Dequeue holt GENAU EINEN abholbaren Job (fällig UND nicht gesperrt, ODER
// dessen Sperre abgestanden ist) und markiert ihn atomar als "processing"
// (Prüfung: Worker-Neustart mitten im Lauf verliert keinen offenen Auftrag
// — FOR UPDATE SKIP LOCKED erlaubt mehreren Worker-Goroutinen gleichzeitigen
// Aufruf ohne denselben Job doppelt zu holen).
func (q *Queue) dequeue(ctx context.Context, workerID string) (*dequeuedJob, error) {
tx, err := q.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("indexworker: transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, `
SELECT id, job_type, tenant_slug, message_id, payload, status, attempts
FROM mail_index_jobs
WHERE (
(status = 'pending' AND available_at <= now())
OR (status = 'processing' AND locked_at <= now() - ($1 * interval '1 second'))
)
ORDER BY available_at
FOR UPDATE SKIP LOCKED
LIMIT 1
`, q.staleLockAfter.Seconds())
var j dequeuedJob
if err := row.Scan(&j.ID, &j.JobType, &j.TenantSlug, &j.MessageID, &j.Payload, &j.Status, &j.Attempts); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoJobAvailable
}
return nil, fmt.Errorf("indexworker: nächsten job lesen: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE mail_index_jobs
SET status = 'processing', attempts = attempts + 1, locked_at = now(), locked_by = $2, updated_at = now()
WHERE id = $1
`, j.ID, workerID); err != nil {
return nil, fmt.Errorf("indexworker: job sperren: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("indexworker: dequeue committen: %w", err)
}
j.Status = StatusProcessing
j.Attempts++
return &j, nil
}
// complete markiert einen Job als erfolgreich abgeschlossen.
func (q *Queue) complete(ctx context.Context, jobID int64) error {
tag, err := q.pool.Exec(ctx, `
UPDATE mail_index_jobs SET status = 'succeeded', locked_at = NULL, locked_by = NULL, updated_at = now()
WHERE id = $1
`, jobID)
if err != nil {
return fmt.Errorf("indexworker: job abschließen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// fail markiert einen Job als fehlgeschlagen. Sind die maximalen Versuche
// erreicht, bleibt er dauerhaft 'failed' (keine DLQ, nicht Bestandteil
// dieser Kachel), sonst wird er mit arithmetischem Backoff (kein
// String-Concat für Intervalle) erneut eingeplant.
func (q *Queue) fail(ctx context.Context, jobID int64, cause error) error {
tag, err := q.pool.Exec(ctx, `
UPDATE mail_index_jobs
SET status = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'pending' END,
available_at = now() + (LEAST(attempts, 10) * interval '10 seconds'),
locked_at = NULL, locked_by = NULL, last_error = $2, updated_at = now()
WHERE id = $1
`, jobID, cause.Error())
if err != nil {
return fmt.Errorf("indexworker: fehlschlag erfassen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// Status liefert den aktuellen Zustand eines Jobs (abrufbar über API,
// hier als Go-API — HTTP-Anbindung ist nicht Bestandteil dieser Kachel).
func (q *Queue) Status(ctx context.Context, jobID int64) (*Job, error) {
var j Job
err := q.pool.QueryRow(ctx, `
SELECT id, job_type, tenant_slug, message_id, status, attempts
FROM mail_index_jobs WHERE id = $1
`, jobID).Scan(&j.ID, &j.JobType, &j.TenantSlug, &j.MessageID, &j.Status, &j.Attempts)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("indexworker: job-status lesen: %w", err)
}
return &j, nil
}
-100
View File
@@ -1,100 +0,0 @@
// Integrationstest (SRC-02): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/dedup — TEST_TENANT_DSN.
package indexworker
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupQueue(t *testing.T, staleLockAfter time.Duration) *Queue {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
queue := NewQueue(pool, staleLockAfter)
if err := queue.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_index_jobs WHERE tenant_slug LIKE 'mandant-src02-%'`)
})
return queue
}
// TestDequeue_WorkerCrashMidRunLosesNoJob ist die geforderte Pflichtprüfung
// 1: Absturz eines Workers (Job wird geholt, aber nie completed/failed)
// führt nach Ablauf der Stale-Lock-Frist zu erneuter Zustellung an einen
// zweiten Worker.
func TestDequeue_WorkerCrashMidRunLosesNoJob(t *testing.T) {
queue := setupQueue(t, 100*time.Millisecond)
ctx := context.Background()
jobID, err := queue.EnqueueDelete(ctx, "mandant-src02-crash", "msg-crash-1")
if err != nil {
t.Fatalf("enqueue: %v", err)
}
firstAttempt, err := queue.dequeue(ctx, "worker-1-abgestuerzt")
if err != nil {
t.Fatalf("erster dequeue: %v", err)
}
if firstAttempt.ID != jobID {
t.Fatalf("erwartete job-id %d, habe %d", jobID, firstAttempt.ID)
}
// worker-1 "stürzt ab": kein complete(), kein fail() — Job bleibt
// als "processing" mit veraltetem Lock stehen.
if _, err := queue.dequeue(ctx, "worker-2-sofort"); !errors.Is(err, ErrNoJobAvailable) {
t.Fatalf("erwartete kein verfügbarer job vor ablauf der stale-lock-frist, habe: %v", err)
}
time.Sleep(150 * time.Millisecond)
secondAttempt, err := queue.dequeue(ctx, "worker-2-nach-timeout")
if err != nil {
t.Fatalf("zweiter dequeue nach stale-lock-ablauf: %v", err)
}
if secondAttempt.ID != jobID {
t.Fatalf("erwartete erneute zustellung desselben jobs %d, habe %d", jobID, secondAttempt.ID)
}
if err := queue.complete(ctx, secondAttempt.ID); err != nil {
t.Fatalf("complete: %v", err)
}
}
// TestEnqueueAndStatus_RoundTrip deckt Akzeptanzkriterium 3 (Job-Status
// abrufbar) auf Queue-Ebene ab.
func TestEnqueueAndStatus_RoundTrip(t *testing.T) {
queue := setupQueue(t, time.Minute)
ctx := context.Background()
payload, _ := json.Marshal(map[string]string{"subject": "Test"})
jobID, err := queue.EnqueueIndex(ctx, "mandant-src02-status", "msg-status-1", payload)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
job, err := queue.Status(ctx, jobID)
if err != nil {
t.Fatalf("status: %v", err)
}
if job.Status != StatusPending {
t.Fatalf("erwartete status 'pending', habe %q", job.Status)
}
}
-74
View File
@@ -1,74 +0,0 @@
package indexworker
import (
"context"
"encoding/json"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
)
// indexPayload sind die für die Indexierung nötigen Felder, wie sie beim
// EnqueueIndex als JSON übergeben werden.
type indexPayload struct {
Subject string `json:"subject"`
Body string `json:"body"`
AttachmentText string `json:"attachment_text"`
SentAtUnixEpoch int64 `json:"sent_at"`
}
// Worker holt Jobs aus der Queue und pflegt sie in den Manticore-Index
// (SRC-01) ein bzw. entfernt sie daraus.
type Worker struct {
queue *Queue
searchClient *search.Client
id string
}
func NewWorker(queue *Queue, searchClient *search.Client, workerID string) *Worker {
return &Worker{queue: queue, searchClient: searchClient, id: workerID}
}
// RunOnce verarbeitet genau einen Job, falls vorhanden. Liefert
// ErrNoJobAvailable, wenn die Queue aktuell leer ist — kein Fehlerzustand.
func (w *Worker) RunOnce(ctx context.Context) error {
job, err := w.queue.dequeue(ctx, w.id)
if err != nil {
return err
}
if procErr := w.process(ctx, job); procErr != nil {
if failErr := w.queue.fail(ctx, job.ID, procErr); failErr != nil {
return fmt.Errorf("indexworker: job %d fehlgeschlagen (%v) UND fehlschlag nicht erfassbar: %w", job.ID, procErr, failErr)
}
return nil
}
return w.queue.complete(ctx, job.ID)
}
func (w *Worker) process(ctx context.Context, job *dequeuedJob) error {
docID := search.DocumentID(job.TenantSlug, job.MessageID)
switch job.JobType {
case JobTypeIndex:
var p indexPayload
if err := json.Unmarshal(job.Payload, &p); err != nil {
return fmt.Errorf("indexierungs-payload lesen: %w", err)
}
return w.searchClient.Index(ctx, search.Document{
ID: docID,
TenantSlug: job.TenantSlug,
MessageID: job.MessageID,
Subject: p.Subject,
Body: p.Body,
AttachmentText: p.AttachmentText,
SentAtUnixEpoch: p.SentAtUnixEpoch,
})
case JobTypeDelete:
return w.searchClient.Delete(ctx, docID)
default:
return errors.New("unbekannter job-typ: " + job.JobType)
}
}
-177
View File
@@ -1,177 +0,0 @@
// Integrationstest (SRC-02): echte Postgres- UND Manticore-Instanz,
// folgt derselben TEST_*-Env-Konvention wie mail/internal/search.
package indexworker
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
)
func setupWorkerEnv(t *testing.T) (*Queue, *search.Client) {
t.Helper()
if os.Getenv("TEST_TENANT_DSN") == "" || os.Getenv("TEST_MANTICORE_URL") == "" {
t.Skip("TEST_TENANT_DSN/TEST_MANTICORE_URL nicht gesetzt, Integrationstest übersprungen")
}
queue := setupQueue(t, time.Minute)
searchClient := search.NewClient(os.Getenv("TEST_MANTICORE_URL"))
if err := searchClient.EnsureSchema(context.Background()); err != nil {
t.Fatalf("search-schema: %v", err)
}
return queue, searchClient
}
func drainQueue(t *testing.T, worker *Worker, maxJobs int) {
t.Helper()
ctx := context.Background()
for i := 0; i < maxJobs; i++ {
if err := worker.RunOnce(ctx); err != nil {
if err == ErrNoJobAvailable {
return
}
t.Fatalf("worker.RunOnce: %v", err)
}
}
}
// TestIndexJob_MakesMailSearchable ist die geforderte Funktionsprüfung zu
// Akzeptanzkriterium 1: eine eingereihte Indexierungsaufgabe macht die Mail
// nach Verarbeitung durch den Worker durchsuchbar.
func TestIndexJob_MakesMailSearchable(t *testing.T) {
queue, searchClient := setupWorkerEnv(t)
ctx := context.Background()
tenant := "mandant-src02-index"
payload, _ := json.Marshal(map[string]any{
"subject": "Jahresabschluss 2025",
"body": "Anbei der Jahresabschluss zur Prüfung.",
})
if _, err := queue.EnqueueIndex(ctx, tenant, "msg-idx-1", payload); err != nil {
t.Fatalf("enqueue: %v", err)
}
worker := NewWorker(queue, searchClient, "worker-test-index")
drainQueue(t, worker, 5)
results, err := searchClient.Search(ctx, tenant, "Jahresabschluss")
if err != nil {
t.Fatalf("search: %v", err)
}
found := false
for _, r := range results {
if r.MessageID == "msg-idx-1" {
found = true
}
}
if !found {
t.Fatalf("erwarteten treffer msg-idx-1 nach indexierung nicht gefunden, habe: %+v", results)
}
}
// TestDeleteJob_RemovesMailFromSearchResults ist die geforderte
// Pflichtprüfung 2: Löschung einer Mail entfernt sie zuverlässig aus
// Suchtreffern.
func TestDeleteJob_RemovesMailFromSearchResults(t *testing.T) {
queue, searchClient := setupWorkerEnv(t)
ctx := context.Background()
tenant := "mandant-src02-delete"
payload, _ := json.Marshal(map[string]any{
"subject": "Vertraulicher Vorgang Zeta",
"body": "Nur für internen Gebrauch.",
})
if _, err := queue.EnqueueIndex(ctx, tenant, "msg-del-1", payload); err != nil {
t.Fatalf("enqueue index: %v", err)
}
worker := NewWorker(queue, searchClient, "worker-test-delete")
drainQueue(t, worker, 5)
preResults, err := searchClient.Search(ctx, tenant, "Zeta")
if err != nil {
t.Fatalf("search vor löschung: %v", err)
}
preFound := false
for _, r := range preResults {
if r.MessageID == "msg-del-1" {
preFound = true
}
}
if !preFound {
t.Fatal("voraussetzung nicht erfüllt: mail vor löschung nicht auffindbar")
}
if _, err := queue.EnqueueDelete(ctx, tenant, "msg-del-1"); err != nil {
t.Fatalf("enqueue delete: %v", err)
}
drainQueue(t, worker, 5)
postResults, err := searchClient.Search(ctx, tenant, "Zeta")
if err != nil {
t.Fatalf("search nach löschung: %v", err)
}
for _, r := range postResults {
if r.MessageID == "msg-del-1" {
t.Fatal("gelöschte mail weiterhin in suchtreffern gefunden")
}
}
}
// TestConsistency_DatabaseAndIndexMatchOnSample ist die geforderte
// Pflichtprüfung 3: Konsistenztest vergleicht Datenbankbestand (erfolgreich
// abgeschlossene Index-Jobs) mit Indexbestand stichprobenartig.
func TestConsistency_DatabaseAndIndexMatchOnSample(t *testing.T) {
queue, searchClient := setupWorkerEnv(t)
ctx := context.Background()
tenant := "mandant-src02-konsistenz"
messageIDs := []string{"msg-konsistenz-1", "msg-konsistenz-2", "msg-konsistenz-3"}
for _, mid := range messageIDs {
payload, _ := json.Marshal(map[string]any{
"subject": "Konsistenzprobe " + mid,
"body": "Inhalt zur Konsistenzprüfung.",
})
if _, err := queue.EnqueueIndex(ctx, tenant, mid, payload); err != nil {
t.Fatalf("enqueue: %v", err)
}
}
worker := NewWorker(queue, searchClient, "worker-test-konsistenz")
drainQueue(t, worker, 10)
for _, mid := range messageIDs {
job, err := findJobByMessageID(ctx, queue, tenant, mid)
if err != nil {
t.Fatalf("job für %s: %v", mid, err)
}
if job.Status != StatusSucceeded {
t.Fatalf("job für %s hat status %q, erwartet 'succeeded'", mid, job.Status)
}
results, err := searchClient.Search(ctx, tenant, "Konsistenzprobe")
if err != nil {
t.Fatalf("search: %v", err)
}
present := false
for _, r := range results {
if r.MessageID == mid {
present = true
}
}
if !present {
t.Fatalf("datenbank meldet job für %s als succeeded, aber index enthält kein passendes dokument", mid)
}
}
}
func findJobByMessageID(ctx context.Context, queue *Queue, tenantSlug, messageID string) (*Job, error) {
var j Job
err := queue.pool.QueryRow(ctx, `
SELECT id, job_type, tenant_slug, message_id, status, attempts
FROM mail_index_jobs WHERE tenant_slug = $1 AND message_id = $2 AND job_type = 'index'
ORDER BY id DESC LIMIT 1
`, tenantSlug, messageID).Scan(&j.ID, &j.JobType, &j.TenantSlug, &j.MessageID, &j.Status, &j.Attempts)
return &j, err
}
-206
View File
@@ -1,206 +0,0 @@
// Package mimeparse implementiert ING-04: MIME-/Anhang-Parsing für
// ein- und ausgehende Nachrichten (Multipart, verschachtelt,
// Content-Transfer-Encoding, defensive Zeichensatz-Reparatur).
//
// NUR Parsing — Speicherung ist ARC-01s Aufgabe (siehe "Nicht
// Bestandteil dieser Kachel"), dieses Paket schreibt nirgends in einen
// Objekt-Speicher, sondern liefert nur strukturierte Go-Werte zurück.
//
// Bekannten Fehler vermieden (archivmail known-issues #3): Anhänge
// wurden früher über io.ReadAll ohne Größenlimit gelesen — eine
// Speicherbombe durch große/böswillige Anhänge. Hier läuft JEDER
// Anhang-Lesevorgang über io.LimitReader mit konfigurierter Max-Size;
// eine Überschreitung führt zu einer harten, sauberen Ablehnung
// (ErrAttachmentTooLarge), kein stilles Abschneiden.
package mimeparse
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"strings"
"golang.org/x/text/encoding/htmlindex"
)
// ErrAttachmentTooLarge wird geliefert, wenn ein Anhang die
// konfigurierte Maximalgröße überschreitet (Akzeptanzkriterium/
// Pflichtprüfung 1).
var ErrAttachmentTooLarge = errors.New("mimeparse: anhang überschreitet die maximal erlaubte größe")
// Part ist EIN zerlegter MIME-Teil — sowohl Textteile (IsAttachment
// == false) als auch Anhänge (Akzeptanzkriterium 1/2).
type Part struct {
ContentType string
Filename string
Size int64
Content []byte
IsAttachment bool
}
// Message ist das Ergebnis eines vollständig zerlegten Multipart-
// Baums — verschachtelte multipart/*-Teile sind bereits rekursiv
// aufgelöst, der Aufrufer sieht nur die "Blatt"-Teile (Akzeptanz-
// kriterium 1).
type Message struct {
Parts []Part
}
// wordDecoder dekodiert RFC-2047-kodierte Header-Werte (z. B.
// Anhang-Dateinamen) defensiv: ein unbekannter/fehlerhafter
// Zeichensatz bricht die Verarbeitung NICHT ab (Akzeptanzkriterium 3),
// sondern liefert den Rohwert unverändert zurück.
var wordDecoder = &mime.WordDecoder{CharsetReader: charsetReader}
func charsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
enc, err := htmlindex.Get(charsetLabel)
if err != nil {
// Unbekannter/fehlerhafter Zeichensatz: defensiv als
// UTF-8-verträglichen Rohtext weiterreichen statt
// abzubrechen (Akzeptanzkriterium 3).
return input, nil
}
return enc.NewDecoder().Reader(input), nil
}
func decodeHeaderValue(raw string) string {
decoded, err := wordDecoder.DecodeHeader(raw)
if err != nil {
// Defensiv: Rohwert statt Abbruch (Akzeptanzkriterium 3).
return raw
}
return decoded
}
// Parse zerlegt eine MIME-Nachricht vollständig, inklusive
// verschachtelter Multipart-Teile (Akzeptanzkriterium 1). maxAttachmentSize
// begrenzt JEDEN einzelnen Anhang (Akzeptanzkriterium/Pflichtprüfung 1).
func Parse(r io.Reader, maxAttachmentSize int64) (Message, error) {
msg, err := mail.ReadMessage(r)
if err != nil {
return Message{}, fmt.Errorf("mimeparse: nachricht lesen: %w", err)
}
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil {
// Kein/kaputtes Content-Type: als einzelnen Textteil behandeln
// statt abzubrechen (Akzeptanzkriterium 3: defensiv reparieren).
body, readErr := readLimited(msg.Body, maxAttachmentSize)
if readErr != nil {
return Message{}, readErr
}
return Message{Parts: []Part{{ContentType: "text/plain", Content: body, Size: int64(len(body))}}}, nil
}
var result Message
if strings.HasPrefix(mediaType, "multipart/") {
if err := parseMultipart(msg.Body, params["boundary"], maxAttachmentSize, &result); err != nil {
return Message{}, err
}
return result, nil
}
// Einzelner Teil (keine Multipart-Hülle).
part, err := readSinglePart(msg.Header.Get("Content-Transfer-Encoding"), mediaType, "", msg.Body, maxAttachmentSize)
if err != nil {
return Message{}, err
}
result.Parts = append(result.Parts, part)
return result, nil
}
// parseMultipart löst EINEN Multipart-Container rekursiv auf —
// verschachtelte multipart/*-Teile (z. B. multipart/mixed, das
// multipart/alternative enthält) werden vollständig zerlegt
// (Akzeptanzkriterium 1), keine Rekursionstiefe hartkodiert begrenzt
// außer durch die natürliche Nachrichtengröße selbst.
func parseMultipart(r io.Reader, boundary string, maxAttachmentSize int64, result *Message) error {
if boundary == "" {
return errors.New("mimeparse: multipart ohne boundary")
}
mr := multipart.NewReader(r, boundary)
for {
p, err := mr.NextPart()
if err == io.EOF {
return nil
}
if err != nil {
// Kaputte Multipart-Struktur: kontrolliert abbrechen
// (Pflichtprüfung 3), nicht abstürzen.
return fmt.Errorf("mimeparse: multipart-teil lesen: %w", err)
}
contentType := p.Header.Get("Content-Type")
mediaType, subParams, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = "text/plain"
}
if strings.HasPrefix(mediaType, "multipart/") {
if err := parseMultipart(p, subParams["boundary"], maxAttachmentSize, result); err != nil {
return err
}
continue
}
part, err := readSinglePart(p.Header.Get("Content-Transfer-Encoding"), mediaType, decodeHeaderValue(p.FileName()), p, maxAttachmentSize)
if err != nil {
return err
}
result.Parts = append(result.Parts, part)
}
}
func readSinglePart(transferEncoding, mediaType, filename string, r io.Reader, maxAttachmentSize int64) (Part, error) {
decoded := decodeTransferEncoding(transferEncoding, r)
content, err := readLimited(decoded, maxAttachmentSize)
if err != nil {
return Part{}, err
}
return Part{
ContentType: mediaType,
Filename: filename,
Size: int64(len(content)),
Content: content,
IsAttachment: filename != "",
}, nil
}
// decodeTransferEncoding dekodiert Content-Transfer-Encoding
// (quoted-printable/base64) — defensiv: ein unbekanntes Encoding wird
// unverändert durchgereicht statt die Verarbeitung abzubrechen
// (Akzeptanzkriterium 3).
func decodeTransferEncoding(encoding string, r io.Reader) io.Reader {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "quoted-printable":
return quotedprintable.NewReader(r)
case "base64":
return base64.NewDecoder(base64.StdEncoding, r)
default:
return r
}
}
// readLimited liest höchstens maxSize+1 Bytes — wird die Grenze
// überschritten, wird ErrAttachmentTooLarge geliefert, statt beliebig
// viel Speicher zu allozieren (Akzeptanzkriterium/Pflichtprüfung 1,
// archivmail known-issues #3).
func readLimited(r io.Reader, maxSize int64) ([]byte, error) {
limited := io.LimitReader(r, maxSize+1)
var buf bytes.Buffer
if _, err := io.Copy(&buf, limited); err != nil {
return nil, fmt.Errorf("mimeparse: teil lesen: %w", err)
}
if int64(buf.Len()) > maxSize {
return nil, ErrAttachmentTooLarge
}
return buf.Bytes(), nil
}
-219
View File
@@ -1,219 +0,0 @@
package mimeparse
import (
"errors"
"io"
"strings"
"testing"
)
const defaultMaxSize = 10 * 1024 * 1024 // 10 MiB
// TestParse_NestedMultipartFullyDecomposed ist Akzeptanzkriterium 1:
// Multipart-Nachrichten mit verschachtelten Teilen werden vollständig
// zerlegt (multipart/mixed enthält multipart/alternative UND einen
// Anhang).
func TestParse_NestedMultipartFullyDecomposed(t *testing.T) {
raw := "From: a@example.com\r\n" +
"To: b@example.com\r\n" +
"Subject: Test\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"outer\"\r\n\r\n" +
"--outer\r\n" +
"Content-Type: multipart/alternative; boundary=\"inner\"\r\n\r\n" +
"--inner\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n\r\n" +
"Hallo als Text\r\n" +
"--inner\r\n" +
"Content-Type: text/html; charset=utf-8\r\n\r\n" +
"<p>Hallo als HTML</p>\r\n" +
"--inner--\r\n" +
"--outer\r\n" +
"Content-Type: application/pdf\r\n" +
"Content-Disposition: attachment; filename=\"rechnung.pdf\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"JVBERi0xLjQK\r\n" +
"--outer--\r\n"
msg, err := Parse(strings.NewReader(raw), defaultMaxSize)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(msg.Parts) != 3 {
t.Fatalf("erwartet 3 zerlegte teile (text, html, anhang), habe %d: %+v", len(msg.Parts), msg.Parts)
}
var sawText, sawHTML, sawAttachment bool
for _, p := range msg.Parts {
switch {
case p.ContentType == "text/plain":
sawText = true
if string(p.Content) != "Hallo als Text" {
t.Fatalf("unerwarteter text-inhalt: %q", p.Content)
}
case p.ContentType == "text/html":
sawHTML = true
case p.IsAttachment:
sawAttachment = true
}
}
if !sawText || !sawHTML || !sawAttachment {
t.Fatalf("nicht alle erwarteten teile gefunden: text=%v html=%v attachment=%v", sawText, sawHTML, sawAttachment)
}
}
// TestParse_AttachmentMetadataExtracted ist Akzeptanzkriterium 2:
// Anhänge werden mit korrektem Dateinamen, Größe und Content-Type
// extrahiert.
func TestParse_AttachmentMetadataExtracted(t *testing.T) {
raw := "From: a@example.com\r\n" +
"Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" +
"--b\r\n" +
"Content-Type: text/plain\r\n\r\n" +
"Text\r\n" +
"--b\r\n" +
"Content-Type: image/png\r\n" +
"Content-Disposition: attachment; filename=\"bild.png\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"iVBORw0KGgo=\r\n" +
"--b--\r\n"
msg, err := Parse(strings.NewReader(raw), defaultMaxSize)
if err != nil {
t.Fatalf("parse: %v", err)
}
var attachment *Part
for i := range msg.Parts {
if msg.Parts[i].IsAttachment {
attachment = &msg.Parts[i]
}
}
if attachment == nil {
t.Fatal("kein anhang gefunden")
}
if attachment.Filename != "bild.png" {
t.Fatalf("falscher dateiname: %q", attachment.Filename)
}
if attachment.ContentType != "image/png" {
t.Fatalf("falscher content-type: %q", attachment.ContentType)
}
if attachment.Size != int64(len(attachment.Content)) || attachment.Size == 0 {
t.Fatalf("unplausible größe: %d (content-len %d)", attachment.Size, len(attachment.Content))
}
}
// TestParse_BrokenCharsetIsRepairedNotAborted ist Akzeptanzkriterium 3:
// fehlerhafte/inkonsistente Zeichensatzangaben werden defensiv repariert
// statt die Verarbeitung abzubrechen.
func TestParse_BrokenCharsetIsRepairedNotAborted(t *testing.T) {
// "unbekannt-xyz" ist KEIN gültiger IANA-Zeichensatzname.
raw := "From: a@example.com\r\n" +
"Content-Type: text/plain; charset=\"unbekannt-xyz\"\r\n\r\n" +
"Rohtext trotz kaputtem Charset\r\n"
msg, err := Parse(strings.NewReader(raw), defaultMaxSize)
if err != nil {
t.Fatalf("erwartet KEINEN abbruch bei kaputtem charset, habe: %v", err)
}
if len(msg.Parts) != 1 {
t.Fatalf("erwartet 1 teil, habe %d", len(msg.Parts))
}
if !strings.Contains(string(msg.Parts[0].Content), "Rohtext") {
t.Fatalf("inhalt fehlt/verstümmelt: %q", msg.Parts[0].Content)
}
}
// TestParse_ISO88591BodyDecodedWithoutAbort ist Akzeptanzkriterium 3
// zusätzlich: ein bekannter Nicht-UTF-8-Zeichensatz wird via
// Header-Dekodierung real repariert (RFC-2047-kodierter Dateiname).
func TestParse_ISO88591FilenameDecoded(t *testing.T) {
// "=?ISO-8859-1?Q?Rechnung_=DC?=" kodiert "Rechnung Ü" (0xDC = 'Ü' in Latin-1).
raw := "From: a@example.com\r\n" +
"Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" +
"--b\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Disposition: attachment; filename=\"=?ISO-8859-1?Q?Rechnung_=DC?=\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"AAA=\r\n" +
"--b--\r\n"
msg, err := Parse(strings.NewReader(raw), defaultMaxSize)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(msg.Parts) != 1 {
t.Fatalf("erwartet 1 teil, habe %d", len(msg.Parts))
}
if msg.Parts[0].Filename != "Rechnung Ü" {
t.Fatalf("erwartet dekodierten dateinamen 'Rechnung Ü', habe %q", msg.Parts[0].Filename)
}
}
// infiniteReader liefert unbegrenzt viele Bytes — simuliert einen sehr
// großen/böswilligen Anhang. Ohne io.LimitReader (siehe archivmail
// known-issues #3) würde ein io.ReadAll hierauf den Prozessspeicher
// erschöpfen; mit readLimited bricht Parse kontrolliert und schnell ab.
type infiniteReader struct{}
func (infiniteReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 'A'
}
return len(p), nil
}
// TestParse_OversizedAttachmentRejectedNotMemoryExhausted ist die
// geforderte Pflichtprüfung 1.
func TestParse_OversizedAttachmentRejectedNotMemoryExhausted(t *testing.T) {
const tinyLimit = 1024 // 1 KiB — winzig, damit der Test schnell bleibt
header := "From: a@example.com\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Transfer-Encoding: identity\r\n\r\n"
r := io.MultiReader(strings.NewReader(header), infiniteReader{})
_, err := Parse(r, tinyLimit)
if !errors.Is(err, ErrAttachmentTooLarge) {
t.Fatalf("erwartet ErrAttachmentTooLarge bei unbegrenzt großem anhang, habe: %v", err)
}
}
// TestParse_RealisticCorpusRunsCleanly ist die geforderte
// Pflichtprüfung 2: realitätsnahe Multipart-/Encoding-Varianten laufen
// fehlerfrei durch.
func TestParse_RealisticCorpusRunsCleanly(t *testing.T) {
corpus := []string{
// Einfache Textnachricht ohne Multipart.
"From: a@example.com\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nEinfacher Text\r\n",
// Quoted-Printable.
"From: a@example.com\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nUmlaut: =C3=9C\r\n",
// Multipart/alternative ohne Anhang.
"From: a@example.com\r\nContent-Type: multipart/alternative; boundary=\"x\"\r\n\r\n--x\r\nContent-Type: text/plain\r\n\r\nText\r\n--x\r\nContent-Type: text/html\r\n\r\n<p>Text</p>\r\n--x--\r\n",
// Leere Multipart-Nachricht (kein Teil, nur Präambel/Epilog).
"From: a@example.com\r\nContent-Type: multipart/mixed; boundary=\"y\"\r\n\r\nPräambel wird ignoriert\r\n--y--\r\nEpilog wird ignoriert\r\n",
}
for i, raw := range corpus {
if _, err := Parse(strings.NewReader(raw), defaultMaxSize); err != nil {
t.Fatalf("corpus[%d] fehlgeschlagen: %v\nraw=%q", i, err, raw)
}
}
}
// FuzzParse ist die geforderte Pflichtprüfung 3: kaputte MIME-Strukturen
// dürfen Parse nicht zum Absturz bringen, nur zu einem kontrollierten
// Fehler.
func FuzzParse(f *testing.F) {
f.Add([]byte("From: a@example.com\r\nContent-Type: multipart/mixed; boundary=\"b\"\r\n\r\n--b\r\nContent-Type: text/plain\r\n\r\nHallo\r\n--b--\r\n"))
f.Add([]byte("Content-Type: multipart/mixed; boundary=\r\n\r\nkaputt"))
f.Add([]byte(""))
f.Add([]byte("From: a@example.com\r\n\r\n"))
f.Fuzz(func(t *testing.T, data []byte) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("Parse ist abgestürzt (panic) statt kontrolliert einen Fehler zu liefern: %v", r)
}
}()
_, _ = Parse(strings.NewReader(string(data)), defaultMaxSize)
})
}
-72
View File
@@ -1,72 +0,0 @@
// Package pflichttestgate erzwingt die in docs/TESTSTRATEGIE-MAIL.md
// Abschnitt 4 festgelegte Regel: jede geänderte Go-Datei in einem
// sicherheitskritischen Bereich (Auth, Tenant-Scoping, Protokoll-/
// Compliance-kritisch) muss von einer geänderten oder neuen _test.go-
// Datei im selben Package begleitet sein. Bewusste Code-Kopie des
// Musters aus Core internal/pflichttestgate — Mail ist ein eigenständiges
// Go-Modul und kann Core nicht importieren.
package pflichttestgate
import (
"path"
"regexp"
"strings"
)
// sensitivePathPatterns beschreibt die Bereiche aus
// TESTSTRATEGIE-MAIL.md Abschnitt 4.
var sensitivePathPatterns = []*regexp.Regexp{
regexp.MustCompile(`(^|/)mail/internal/auth/`),
regexp.MustCompile(`(^|/)mail/internal/tenant/`),
regexp.MustCompile(`(^|/)mail/internal/ingest/`),
regexp.MustCompile(`(^|/)mail/internal/imap/`),
regexp.MustCompile(`(^|/)mail/internal/smtp/`),
regexp.MustCompile(`(^|/)mail/internal/arc/`),
}
// Violation beschreibt ein Package mit sicherheitskritischer Änderung
// ohne begleitende Testdatei.
type Violation struct {
Package string
ChangedFile string
}
func isSensitive(file string) bool {
if !strings.HasSuffix(file, ".go") || strings.HasSuffix(file, "_test.go") {
return false
}
for _, re := range sensitivePathPatterns {
if re.MatchString(file) {
return true
}
}
return false
}
// CheckDiff prüft eine Liste geänderter Dateipfade gegen die
// Pflichttest-Regel — ein leeres Ergebnis bedeutet: Gate besteht.
func CheckDiff(changedFiles []string) []Violation {
sensitiveByPkg := map[string]string{}
testTouchedPkgs := map[string]bool{}
for _, f := range changedFiles {
pkg := path.Dir(f)
if strings.HasSuffix(f, "_test.go") {
testTouchedPkgs[pkg] = true
continue
}
if isSensitive(f) {
if _, seen := sensitiveByPkg[pkg]; !seen {
sensitiveByPkg[pkg] = f
}
}
}
var violations []Violation
for pkg, file := range sensitiveByPkg {
if !testTouchedPkgs[pkg] {
violations = append(violations, Violation{Package: pkg, ChangedFile: file})
}
}
return violations
}
@@ -1,33 +0,0 @@
// Negativtest des Gates selbst (QA-01 Prüfung 1): ein Diff mit
// geänderter mail/internal/auth/login.go ohne begleitende Testdatei
// muss als Verstoß erkannt werden.
package pflichttestgate
import "testing"
func TestCheckDiff_FlagsSensitiveChangeWithoutTest(t *testing.T) {
violations := CheckDiff([]string{"mail/internal/auth/login.go"})
if len(violations) != 1 {
t.Fatalf("erwartet genau 1 verstoß, habe %d: %+v", len(violations), violations)
}
if violations[0].Package != "mail/internal/auth" {
t.Fatalf("falsches package gemeldet: %+v", violations[0])
}
}
func TestCheckDiff_PassesWhenTestFileAccompanies(t *testing.T) {
violations := CheckDiff([]string{
"mail/internal/auth/login.go",
"mail/internal/auth/login_test.go",
})
if len(violations) != 0 {
t.Fatalf("erwartet keine verstöße, habe: %+v", violations)
}
}
func TestCheckDiff_IgnoresNonSensitivePaths(t *testing.T) {
violations := CheckDiff([]string{"mail/internal/example/normalize.go"})
if len(violations) != 0 {
t.Fatalf("erwartet keine verstöße für nicht-sensiblen pfad, habe: %+v", violations)
}
}
-326
View File
@@ -1,326 +0,0 @@
package search
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// 0001_mail_documents.sql: statisches, versioniertes Schema
// (Akzeptanzkriterium 1). Feldnamen hier UND in fields.go müssen
// deckungsgleich bleiben — die Konstanten in fields.go sind die einzige
// Stelle, aus der Go-Code Feldnamen für Schreib-/Lesezugriffe bezieht.
// Manticores SQL-Parser unterstützt keine "--"-Kommentare, daher bleibt
// die eingebettete Datei selbst kommentarfrei.
//
//go:embed migrations/0001_mail_documents.sql
var schemaMigration string
// SRC-05: Facettenfelder als eigene, nummerierte ALTER-Migrationen
// nachgezogen (Manticore erlaubt nur eine Spalte je ALTER TABLE ADD
// COLUMN-Anweisung). Reihenfolge ist die Anwendungsreihenfolge.
//
//go:embed migrations/0002_mail_documents_facets.sql
var migrationAddSender string
//go:embed migrations/0003_mail_documents_mailbox.sql
var migrationAddMailbox string
//go:embed migrations/0004_mail_documents_attachment_type.sql
var migrationAddAttachmentType string
//go:embed migrations/0005_mail_documents_tag.sql
var migrationAddTag string
var facetMigrations = []string{
migrationAddSender,
migrationAddMailbox,
migrationAddAttachmentType,
migrationAddTag,
}
// Client spricht ausschließlich über die strukturierte Manticore-HTTP-
// JSON-API (kein String-Zusammenbau von SQL-Klauseln, siehe fields.go).
// Die SQL-Schnittstelle wird nur für EnsureSchema verwendet, und dort
// ausschließlich mit dem statischen, eingebetteten Migrationstext —
// niemals mit zur Laufzeit zusammengesetzten Werten.
type Client struct {
baseURL string
http *http.Client
}
func NewClient(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{Timeout: 10 * time.Second},
}
}
// EnsureSchema legt den Index gemäß dem versionierten, statischen
// Migrationstext an (Akzeptanzkriterium 1) und zieht die Facettenfelder
// (SRC-05) idempotent nach.
func (c *Client) EnsureSchema(ctx context.Context) error {
if err := c.runSchemaSQL(ctx, schemaMigration); err != nil {
return fmt.Errorf("search: schema anlegen: %w", err)
}
for _, migration := range facetMigrations {
if err := c.runSchemaSQL(ctx, migration); err != nil {
// Manticore meldet bei erneutem ADD COLUMN "field already in
// schema" — kein Fehler, sondern der bereits angewendete
// Migrationsschritt (Idempotenz, gleiche CREATE-TABLE-IF-NOT-
// EXISTS-Konvention wie das Basisschema).
if strings.Contains(err.Error(), "already in schema") {
continue
}
return fmt.Errorf("search: facettenfeld-migration: %w", err)
}
}
return nil
}
func (c *Client) runSchemaSQL(ctx context.Context, query string) error {
form := "query=" + query
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", strings.NewReader(form))
if err != nil {
return fmt.Errorf("anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("ausführen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
}
if strings.Contains(string(body), `"error":"`) && !strings.Contains(string(body), `"error":""`) {
return fmt.Errorf("%s", string(body))
}
return nil
}
// Document ist ein Mail-Suchdokument. Feldnamen im JSON-Tag entsprechen
// exakt den Konstanten in fields.go.
type Document struct {
ID uint64 `json:"-"`
TenantSlug string `json:"tenant_slug"`
MessageID string `json:"message_id"`
Subject string `json:"subject"`
Body string `json:"body"`
AttachmentText string `json:"attachment_text"`
SentAtUnixEpoch int64 `json:"sent_at"`
// Facettenfelder (SRC-05), optional — leerer String bedeutet "kein Wert".
Sender string `json:"sender"`
Mailbox string `json:"mailbox"`
AttachmentType string `json:"attachment_type"`
Tag string `json:"tag"`
}
// Index legt/ersetzt ein Suchdokument (Akzeptanzkriterium 2: Schreibzugriff
// ausschließlich über statische, vordefinierte Feldnamen aus dem
// Document-Struct — kein dynamischer Feldname möglich).
func (c *Client) Index(ctx context.Context, doc Document) error {
payload := map[string]any{
"index": IndexName,
"id": doc.ID,
"doc": doc,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("search: dokument serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/replace", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("search: index-anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("search: dokument indexieren: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("search: dokument indexieren, status %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
// Delete entfernt ein Suchdokument anhand seiner ID (SRC-02
// Akzeptanzkriterium 2: Löschungen werden im Index nachgezogen). Löschen
// eines nicht (mehr) vorhandenen Dokuments ist kein Fehler (idempotent).
func (c *Client) Delete(ctx context.Context, id uint64) error {
payload := map[string]any{
"index": IndexName,
"id": id,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("search: lösch-anfrage serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/delete", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("search: lösch-anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("search: dokument löschen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("search: dokument löschen, status %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
// Result ist ein Suchtreffer.
type Result struct {
MessageID string
Subject string
Score int64
SentAtUnixEpoch int64
}
// fieldWeights gewichtet Betreff höher als Text, Anhangstext am
// niedrigsten (SRC-03 Akzeptanzkriterium 1: Ranking berücksichtigt u.a.
// Anhangstreffer) — statische Konstanten, keine dynamischen Feldnamen.
var fieldWeights = map[string]any{
FieldSubject: 10,
FieldBody: 3,
FieldAttachmentText: 1,
}
// doSearchWithSwapRetry führt eine /search-Anfrage aus und wiederholt sie
// bis zu zweimal mit kurzer Pause, falls Manticore "unknown local table"
// meldet (SRC-09 Akzeptanzkriterium 3: der Reindex-Umschaltmoment
// RENAME-alte-Tabelle-weg/RENAME-neue-Tabelle-rein hat ein extrem kurzes
// Zeitfenster ohne existierende mail_documents-Tabelle — dieser Retry
// überbrückt es, statt eine Suchanfrage in genau diesem Moment fehlschlagen
// zu lassen).
func (c *Client) doSearchWithSwapRetry(ctx context.Context, body []byte) ([]byte, error) {
const maxAttempts = 3
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(20 * time.Millisecond):
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("search: suchanfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("search: suche ausführen: %w", err)
}
respBody, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("search: antwort lesen: %w", readErr)
}
if strings.Contains(string(respBody), "unknown local table") {
lastErr = fmt.Errorf("search: suche, status %d: %s", resp.StatusCode, string(respBody))
continue
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("search: suche, status %d: %s", resp.StatusCode, string(respBody))
}
return respBody, nil
}
return nil, lastErr
}
// Search sucht queryText innerhalb der Volltextfelder, strikt begrenzt auf
// den Mandanten tenantSlug (Akzeptanzkriterium 2: mandantengetrennt
// abfragbar) — der Tenant-Filter läuft über ein strukturiertes "equals"-
// Feld der JSON-API, niemals über eine interpolierte WHERE-Klausel.
//
// queryText nutzt Manticores erweiterte Abfragesyntax über den
// query_string-Klausel-Typ (Akzeptanzkriterium 3: Phrasensuche mit
// Anführungszeichen, Ausschluss mit vorangestelltem "-") — der Wert landet
// als reiner JSON-String-Wert, es gibt dabei keinerlei dynamischen
// Feld-/Tabellennamen, der beeinflusst werden könnte. Ergebnisse kommen
// von Manticore bereits nach Relevanz (BM25, gewichtet über fieldWeights)
// absteigend sortiert zurück (Akzeptanzkriterium 1).
const searchResultLimit = 1000
func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Result, error) {
payload := map[string]any{
"index": IndexName,
"query": map[string]any{
"bool": map[string]any{
"must": []map[string]any{
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
{"query_string": queryText},
},
},
},
"options": map[string]any{
"field_weights": fieldWeights,
},
// Ohne explizites limit begrenzt Manticore standardmäßig auf 20
// Treffer — bei Testkorpora bis 1000 Dokumenten (SRC-03) blieb das
// bisher unbemerkt, da nur auf das Vorhandensein einzelner Treffer
// geprüft wurde, nicht auf die Gesamtzahl. searchResultLimit deckt
// realistische Trefferlisten ab, ohne unbegrenzt zu sein.
"limit": searchResultLimit,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: suchanfrage serialisieren: %w", err)
}
respBody, err := c.doSearchWithSwapRetry(ctx, body)
if err != nil {
return nil, err
}
var parsed searchResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return nil, fmt.Errorf("search: antwort parsen: %w", err)
}
results := make([]Result, 0, len(parsed.Hits.Hits))
for _, hit := range parsed.Hits.Hits {
results = append(results, Result{
MessageID: hit.Source.MessageID,
Subject: hit.Source.Subject,
Score: hit.Score,
SentAtUnixEpoch: hit.Source.SentAtUnixEpoch,
})
}
return results, nil
}
type searchResponse struct {
Hits struct {
Hits []struct {
Score int64 `json:"_score"`
Source struct {
MessageID string `json:"message_id"`
Subject string `json:"subject"`
SentAtUnixEpoch int64 `json:"sent_at"`
} `json:"_source"`
} `json:"hits"`
} `json:"hits"`
}
-72
View File
@@ -1,72 +0,0 @@
package search
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestSearch_MaliciousInputDoesNotAlterFieldNames ist die geforderte
// Pflichtprüfung 2: eine Abfrage mit manipulierten Eingabewerten
// (SQL-/Injection-artige Zeichen in tenantSlug und queryText) darf keine
// Spalten-/Tabellennamen in der an Manticore gesendeten Anfrage verändern
// — Werte landen ausschließlich als JSON-String-Werte, niemals als
// Feld-/Tabellenname.
func TestSearch_MaliciousInputDoesNotAlterFieldNames(t *testing.T) {
var captured map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"hits":{"hits":[]}}`))
}))
defer srv.Close()
client := NewClient(srv.URL)
maliciousTenant := `acme"; DROP TABLE mail_documents; --`
maliciousQuery := `x' OR '1'='1`
if _, err := client.Search(context.Background(), maliciousTenant, maliciousQuery); err != nil {
t.Fatalf("search: %v", err)
}
query, ok := captured["query"].(map[string]any)
if !ok {
t.Fatal("erwartetes 'query'-Objekt fehlt in gesendetem Payload")
}
boolQuery, ok := query["bool"].(map[string]any)
if !ok {
t.Fatal("erwartetes 'bool'-Objekt fehlt")
}
must, ok := boolQuery["must"].([]any)
if !ok || len(must) != 2 {
t.Fatal("erwartete 'must'-Liste mit 2 Klauseln fehlt")
}
equalsClause, ok := must[0].(map[string]any)["equals"].(map[string]any)
if !ok {
t.Fatal("erwartete 'equals'-Klausel fehlt")
}
// Feldname bleibt statisch "tenant_slug" — nur der Wert enthält die
// böswillige Eingabe, unverändert als String.
if _, hasStaticField := equalsClause[FieldTenantSlug]; !hasStaticField {
t.Fatalf("erwartetes statisches Feld %q nicht gefunden, habe: %v", FieldTenantSlug, equalsClause)
}
if equalsClause[FieldTenantSlug] != maliciousTenant {
t.Fatalf("wert wurde verändert: %v", equalsClause[FieldTenantSlug])
}
// query_string hat keinerlei dynamischen Feldnamen — der Klausel-Wert
// ist unmittelbar der übergebene String, keine map mit datenabhängigem
// Schlüssel möglich.
queryStringClause, ok := must[1].(map[string]any)["query_string"]
if !ok {
t.Fatal("erwartete 'query_string'-Klausel fehlt")
}
if queryStringClause != maliciousQuery {
t.Fatalf("suchwert wurde verändert: %v", queryStringClause)
}
}
-180
View File
@@ -1,180 +0,0 @@
// SRC-05: Facetten- & Filter-API. Nutzt Manticores strukturierte
// Aggregations-API (aggs.terms/aggs.range) — keine dynamische
// SQL-Klauselbildung, dieselbe Konvention wie Search/Delete (fields.go).
package search
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// FacetFilter schränkt Suche/Facettenberechnung auf einen bereits
// gewählten Facettenwert ein. Field MUSS aus FacetFields stammen —
// Facets liefert einen Fehler bei jedem anderen Wert (verhindert einen
// beliebigen, vom Aufrufer bestimmten Feldnamen in der Anfrage).
type FacetFilter struct {
Field string
Value string
}
// FacetValue ist ein einzelner Facettenwert mit Trefferzahl
// (Akzeptanzkriterium 1).
type FacetValue struct {
Value string
Count int64
}
// DateRangeFacet ist ein Zeitraum-Bucket mit Trefferzahl.
type DateRangeFacet struct {
Label string
Count int64
}
// FacetResult fasst alle Facettendimensionen einer Anfrage zusammen.
type FacetResult struct {
// Values ist je FacetFields-Eintrag (sender/mailbox/attachment_type/tag)
// befüllt.
Values map[string][]FacetValue
// DateRanges sind feste Zeitraum-Buckets über FieldSentAt.
DateRanges []DateRangeFacet
}
// farFuture ist die obere Grenze des jüngsten Zeitraum-Buckets. Manticores
// range-Aggregation verlangt für jeden Bucket ein explizites "to" — ein
// hinreichend großer fester Wert (Jahr 2100) übernimmt die Rolle von
// "unbegrenzt in die Zukunft", ohne den Feldtyp zu wechseln.
const farFuture int64 = 4102444800
type dateRangeBoundary struct {
label string
from int64 // 0 = ab Epoch (unbegrenzt in die Vergangenheit)
to int64
}
// dateRangeBoundaries berechnet die festen Zeitraum-Buckets relativ zu now
// (Parameter statt time.Now() direkt, damit Facets testbar bleibt).
func dateRangeBoundaries(now time.Time) []dateRangeBoundary {
sevenDaysAgo := now.AddDate(0, 0, -7).Unix()
thirtyDaysAgo := now.AddDate(0, 0, -30).Unix()
oneYearAgo := now.AddDate(-1, 0, 0).Unix()
return []dateRangeBoundary{
{label: "letzte_7_tage", from: sevenDaysAgo, to: farFuture},
{label: "letzte_30_tage", from: thirtyDaysAgo, to: sevenDaysAgo},
{label: "letztes_jahr", from: oneYearAgo, to: thirtyDaysAgo},
{label: "aelter", from: 0, to: oneYearAgo},
}
}
func isFacetField(field string) bool {
for _, f := range FacetFields {
if f == field {
return true
}
}
return false
}
// Facets berechnet für jede Filterdimension (Akzeptanzkriterium 1) die
// Trefferzahl je Wert, mandantengetrennt (Akzeptanzkriterium 3) und unter
// Berücksichtigung bereits gewählter Filter (Akzeptanzkriterium 2: mehrere
// Filter kombinieren sich als UND-Verknüpfung in derselben bool.must-Liste
// wie der Tenant-Filter).
func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) (FacetResult, error) {
must := []map[string]any{
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
}
if queryText != "" {
must = append(must, map[string]any{"query_string": queryText})
}
for _, f := range filters {
if !isFacetField(f.Field) {
return FacetResult{}, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
}
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
}
aggs := map[string]any{}
for _, field := range FacetFields {
aggs[field] = map[string]any{"terms": map[string]any{"field": field, "size": 100}}
}
boundaries := dateRangeBoundaries(time.Now())
ranges := make([]map[string]any, 0, len(boundaries))
for _, b := range boundaries {
ranges = append(ranges, map[string]any{"from": b.from, "to": b.to})
}
aggs["sent_at"] = map[string]any{"range": map[string]any{"field": FieldSentAt, "ranges": ranges}}
payload := map[string]any{
"index": IndexName,
"query": map[string]any{"bool": map[string]any{"must": must}},
"aggs": aggs,
"limit": 0,
}
body, err := json.Marshal(payload)
if err != nil {
return FacetResult{}, fmt.Errorf("search: facettenanfrage serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body))
if err != nil {
return FacetResult{}, fmt.Errorf("search: facettenanfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return FacetResult{}, fmt.Errorf("search: facetten abrufen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return FacetResult{}, fmt.Errorf("search: facetten-antwort lesen: %w", err)
}
if resp.StatusCode != http.StatusOK {
return FacetResult{}, fmt.Errorf("search: facetten, status %d: %s", resp.StatusCode, string(respBody))
}
var parsed facetResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return FacetResult{}, fmt.Errorf("search: facetten-antwort parsen: %w", err)
}
result := FacetResult{Values: make(map[string][]FacetValue, len(FacetFields))}
for _, field := range FacetFields {
bucket := parsed.Aggregations[field]
values := make([]FacetValue, 0, len(bucket.Buckets))
for _, b := range bucket.Buckets {
if b.Key == "" {
continue
}
values = append(values, FacetValue{Value: b.Key, Count: b.DocCount})
}
result.Values[field] = values
}
sentAtBucket := parsed.Aggregations["sent_at"]
result.DateRanges = make([]DateRangeFacet, 0, len(boundaries))
for i, b := range boundaries {
count := int64(0)
if i < len(sentAtBucket.Buckets) {
count = sentAtBucket.Buckets[i].DocCount
}
result.DateRanges = append(result.DateRanges, DateRangeFacet{Label: b.label, Count: count})
}
return result, nil
}
type facetResponse struct {
Aggregations map[string]struct {
Buckets []struct {
Key string `json:"key"`
DocCount int64 `json:"doc_count"`
} `json:"buckets"`
} `json:"aggregations"`
}
-107
View File
@@ -1,107 +0,0 @@
// Integrationstest (SRC-05): echte Manticore-Instanz, TEST_MANTICORE_URL
// (gleiche Konvention wie integration_test.go/ranking_test.go).
package search
import (
"context"
"testing"
)
func indexFacetDoc(t *testing.T, client *Client, ctx context.Context, tenant string, doc Document) {
t.Helper()
doc.TenantSlug = tenant
doc.ID = DocumentID(tenant, doc.MessageID)
if err := client.Index(ctx, doc); err != nil {
t.Fatalf("index %s: %v", doc.MessageID, err)
}
}
// TestFacets_CountsMatchActualHits ist die geforderte Pflichtprüfung 1:
// Facettenzahlen stimmen mit tatsächlicher Treffermenge überein.
func TestFacets_CountsMatchActualHits(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src05-zahlen"
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-1", Subject: "a", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "pdf"})
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-2", Subject: "b", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "docx"})
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-3", Subject: "c", Sender: "bob@example.com", Mailbox: "archiv", AttachmentType: "pdf"})
result, err := client.Facets(ctx, tenant, "", nil)
if err != nil {
t.Fatalf("facets: %v", err)
}
senderCounts := toCountMap(result.Values[FieldSender])
if senderCounts["alice@example.com"] != 2 {
t.Fatalf("erwartete 2 treffer für alice@example.com, habe %d (%+v)", senderCounts["alice@example.com"], result.Values[FieldSender])
}
if senderCounts["bob@example.com"] != 1 {
t.Fatalf("erwartete 1 treffer für bob@example.com, habe %d", senderCounts["bob@example.com"])
}
attachmentCounts := toCountMap(result.Values[FieldAttachmentType])
if attachmentCounts["pdf"] != 2 {
t.Fatalf("erwartete 2 treffer für pdf, habe %d", attachmentCounts["pdf"])
}
}
// TestFacets_ThreeFiltersCombineWithAND ist die geforderte Pflichtprüfung
// 2: Kombination von drei Filtern liefert korrekt eingeschränkte Treffer.
func TestFacets_ThreeFiltersCombineWithAND(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src05-kombi"
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-treffer", Subject: "x", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "pdf", Tag: "wichtig"})
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falscher-sender", Subject: "x", Sender: "bob@example.com", Mailbox: "inbox", AttachmentType: "pdf", Tag: "wichtig"})
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falsche-mailbox", Subject: "x", Sender: "alice@example.com", Mailbox: "archiv", AttachmentType: "pdf", Tag: "wichtig"})
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falscher-typ", Subject: "x", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "docx", Tag: "wichtig"})
filters := []FacetFilter{
{Field: FieldSender, Value: "alice@example.com"},
{Field: FieldMailbox, Value: "inbox"},
{Field: FieldAttachmentType, Value: "pdf"},
}
result, err := client.Facets(ctx, tenant, "", filters)
if err != nil {
t.Fatalf("facets: %v", err)
}
tagCounts := toCountMap(result.Values[FieldTag])
if tagCounts["wichtig"] != 1 {
t.Fatalf("erwartete genau 1 verbleibenden treffer nach 3 UND-verknüpften filtern, habe %d (%+v)", tagCounts["wichtig"], result.Values[FieldTag])
}
}
// TestFacets_TenantSeparation ist die geforderte Pflichtprüfung 3:
// Facetten eines Mandanten enthalten keine Werte eines anderen.
func TestFacets_TenantSeparation(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenantA := "mandant-src05-facetten-a"
tenantB := "mandant-src05-facetten-b"
indexFacetDoc(t, client, ctx, tenantA, Document{MessageID: "msg-fa-1", Subject: "a", Sender: "nur-a@example.com", Mailbox: "inbox", AttachmentType: "pdf"})
indexFacetDoc(t, client, ctx, tenantB, Document{MessageID: "msg-fb-1", Subject: "b", Sender: "nur-b@example.com", Mailbox: "inbox", AttachmentType: "pdf"})
resultB, err := client.Facets(ctx, tenantB, "", nil)
if err != nil {
t.Fatalf("facets mandant b: %v", err)
}
senderCountsB := toCountMap(resultB.Values[FieldSender])
if _, present := senderCountsB["nur-a@example.com"]; present {
t.Fatalf("mandant b sieht facettenwert von mandant a: %+v", resultB.Values[FieldSender])
}
if senderCountsB["nur-b@example.com"] != 1 {
t.Fatalf("erwartete eigenen facettenwert bei mandant b, habe: %+v", resultB.Values[FieldSender])
}
}
func toCountMap(values []FacetValue) map[string]int64 {
m := make(map[string]int64, len(values))
for _, v := range values {
m[v.Value] = v.Count
}
return m
}
-53
View File
@@ -1,53 +0,0 @@
// Package search implementiert SRC-01: den Manticore-RT-Suchindex für
// Mail-Inhalte. Bekannter Fehler vermeiden (siehe known-issues-archivmail.md
// #11/#12): archivmail baute WHERE-Klauseln und teils Spalten-/Tabellennamen
// dynamisch über fmt.Sprintf/strings.Join zusammen. Für dieses Paket gilt
// verbindlich: Spalten- und Tabellennamen kommen AUSSCHLIESSLICH aus den
// Konstanten dieser Datei, nirgendwo sonst im Paket wird ein Feld- oder
// Tabellenname zur Laufzeit zusammengesetzt. Suchanfragen laufen über die
// strukturierte Manticore-HTTP-JSON-API (Query/Insert-Sub, kein
// String-Zusammenbau von SQL), nicht über die SQL-Schnittstelle.
package search
import "hash/fnv"
// IndexName ist der einzige Ort, an dem der Manticore-Indexname als
// Literal steht.
const IndexName = "mail_documents"
// Statische Feld-Whitelist des mail_documents-Index (muss deckungsgleich
// mit migrations/0001_mail_documents.sql bleiben).
const (
FieldTenantSlug = "tenant_slug"
FieldMessageID = "message_id"
FieldSubject = "subject"
FieldBody = "body"
FieldAttachmentText = "attachment_text"
FieldSentAt = "sent_at"
// Facettenfelder (SRC-05), nachgezogen über migrations/0002..0005.
FieldSender = "sender"
FieldMailbox = "mailbox"
FieldAttachmentType = "attachment_type"
FieldTag = "tag"
)
// FacetFields sind die je Kachel unterstützten Filterdimensionen
// (Akzeptanzkriterium 1: Absender, Postfach, Anhangstyp, Tag — Zeitraum
// läuft separat über FieldSentAt als Bereichsfacette, siehe facets.go).
// Statische Liste — Aufrufer können ausschließlich diese Feldnamen als
// Facetten-/Filterdimension angeben, kein beliebiger Client-Feldname.
var FacetFields = []string{FieldSender, FieldMailbox, FieldAttachmentType, FieldTag}
// DocumentID berechnet deterministisch die Manticore-Dokument-ID aus
// Mandant und Message-ID (FNV-1a, 64 Bit). Deterministisch statt einer
// separat vergebenen ID, damit Re-Indexierung (Index) und Löschung
// (Delete) für dieselbe Mail immer dieselbe Dokument-ID referenzieren,
// ohne dass der Aufrufer sie zwischenspeichern muss (SRC-02: Löschungen
// müssen ohne zusätzlichen Zustand nachgezogen werden können).
func DocumentID(tenantSlug, messageID string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(tenantSlug))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(messageID))
return h.Sum64()
}
-98
View File
@@ -1,98 +0,0 @@
// Integrationstest (SRC-01): echte Manticore-Instanz auf dem Testhost.
// Folgt derselben TEST_*-Env-Konvention wie mail/internal/example und
// mail/internal/dedup — TEST_MANTICORE_URL.
package search
import (
"context"
"os"
"testing"
)
func setupClient(t *testing.T) *Client {
t.Helper()
baseURL := os.Getenv("TEST_MANTICORE_URL")
if baseURL == "" {
t.Skip("TEST_MANTICORE_URL nicht gesetzt, Integrationstest übersprungen")
}
client := NewClient(baseURL)
if err := client.EnsureSchema(context.Background()); err != nil {
t.Fatalf("schema: %v", err)
}
return client
}
// TestSearch_FindsExpectedDocument ist die geforderte Pflichtprüfung 3:
// Funktionstest bestätigt, dass die Volltextsuche erwartete Treffer für
// einen Testkorpus liefert.
func TestSearch_FindsExpectedDocument(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src01-funktionstest"
if err := client.Index(ctx, Document{
ID: 910001,
TenantSlug: tenant,
MessageID: "msg-funktionstest-1",
Subject: "Quartalsbericht Q3",
Body: "Anbei der vollständige Quartalsbericht mit Umsatzzahlen.",
}); err != nil {
t.Fatalf("index: %v", err)
}
if err := client.Index(ctx, Document{
ID: 910002,
TenantSlug: tenant,
MessageID: "msg-funktionstest-2",
Subject: "Mittagessen morgen",
Body: "Wollen wir zusammen essen gehen?",
}); err != nil {
t.Fatalf("index: %v", err)
}
results, err := client.Search(ctx, tenant, "Quartalsbericht")
if err != nil {
t.Fatalf("search: %v", err)
}
found := false
for _, r := range results {
if r.MessageID == "msg-funktionstest-1" {
found = true
}
if r.MessageID == "msg-funktionstest-2" {
t.Fatal("unerwarteter treffer für nicht passende nachricht")
}
}
if !found {
t.Fatalf("erwarteten treffer msg-funktionstest-1 nicht gefunden, habe: %+v", results)
}
}
// TestSearch_TenantIsolation ist Akzeptanzkriterium 3 (mandantengetrennt
// abfragbar): identischer Inhalt bei zwei Mandanten, Suche bei Mandant A
// darf keinen Treffer bei Mandant B liefern.
func TestSearch_TenantIsolation(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenantA := "mandant-src01-iso-a"
tenantB := "mandant-src01-iso-b"
if err := client.Index(ctx, Document{
ID: 910101,
TenantSlug: tenantA,
MessageID: "msg-iso-a",
Subject: "Vertraulicher Betreff Alpha",
Body: "Inhalt nur für Mandant A.",
}); err != nil {
t.Fatalf("index mandant a: %v", err)
}
results, err := client.Search(ctx, tenantB, "Vertraulicher")
if err != nil {
t.Fatalf("search mandant b: %v", err)
}
for _, r := range results {
if r.MessageID == "msg-iso-a" {
t.Fatal("mandant b hat treffer aus mandant a gesehen — mandantentrennung verletzt")
}
}
}
@@ -1,8 +0,0 @@
CREATE TABLE IF NOT EXISTS mail_documents (
tenant_slug string attribute indexed,
message_id string attribute indexed,
subject text,
body text,
attachment_text text,
sent_at timestamp
)
@@ -1 +0,0 @@
ALTER TABLE mail_documents ADD COLUMN sender string attribute indexed
@@ -1 +0,0 @@
ALTER TABLE mail_documents ADD COLUMN mailbox string attribute indexed
@@ -1 +0,0 @@
ALTER TABLE mail_documents ADD COLUMN attachment_type string attribute indexed
@@ -1 +0,0 @@
ALTER TABLE mail_documents ADD COLUMN tag string attribute indexed
@@ -1,37 +0,0 @@
package search
import (
"os"
"strings"
"testing"
)
// TestNoDynamicSQLClauseBuilding ist die geforderte Pflichtprüfung 1:
// Codereview bestätigt automatisiert, dass client.go keine Sprintf/Join-
// basierte SQL-Klauselbildung enthält (Bekannter Fehler #11/#12 aus
// known-issues-archivmail.md). Die einzige SQL-Anfrage des Pakets
// (EnsureSchema, /sql-Endpunkt) darf ausschließlich den statisch
// eingebetteten Migrationstext verwenden — kein fmt.Sprintf, kein
// String-Concat/Join zum Bau von SQL-Text.
func TestNoDynamicSQLClauseBuilding(t *testing.T) {
src, err := os.ReadFile("client.go")
if err != nil {
t.Fatal(err)
}
code := string(src)
if strings.Contains(code, "fmt.Sprintf") {
t.Fatal("client.go darf kein fmt.Sprintf verwenden (SQL-Klauselbildung verboten, siehe known-issues #11/#12)")
}
_, after, found := strings.Cut(code, `"/sql?mode=raw"`)
if !found {
t.Fatal("erwarteter /sql-Aufruf nicht gefunden")
}
window := after
if len(window) > 200 {
window = window[:200]
}
if strings.Contains(window, "+") {
t.Fatal("kein '+'-String-Zusammenbau in der Nähe des /sql-Aufrufs erlaubt")
}
}
-167
View File
@@ -1,167 +0,0 @@
// Integrationstest (SRC-03): echte Manticore-Instanz, TEST_MANTICORE_URL
// (gleiche Konvention wie integration_test.go).
package search
import (
"context"
"fmt"
"os"
"testing"
"time"
)
// TestSearch_RankingReflectsFieldWeightAndIsTraceable ist die geforderte
// Funktionsprüfung zu Akzeptanzkriterium 1: Treffer sind nach Relevanz
// sortiert, und das Ranking ist nachvollziehbar — ein Treffer im höher
// gewichteten Betrefffeld liegt vor einem Treffer, der den Suchbegriff
// nur im niedriger gewichteten Anhangstext enthält.
func TestSearch_RankingReflectsFieldWeightAndIsTraceable(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src03-ranking"
if err := client.Index(ctx, Document{
ID: DocumentID(tenant, "msg-ranking-subject"),
TenantSlug: tenant,
MessageID: "msg-ranking-subject",
Subject: "Vertragsentwurf",
Body: "siehe Anhang",
AttachmentText: "",
SentAtUnixEpoch: 1000,
}); err != nil {
t.Fatalf("index: %v", err)
}
if err := client.Index(ctx, Document{
ID: DocumentID(tenant, "msg-ranking-attachment"),
TenantSlug: tenant,
MessageID: "msg-ranking-attachment",
Subject: "Wochenrückblick",
Body: "allgemeine Notizen",
AttachmentText: "Im Anhang findet sich ein Vertragsentwurf zur Prüfung.",
SentAtUnixEpoch: 2000,
}); err != nil {
t.Fatalf("index: %v", err)
}
results, err := client.Search(ctx, tenant, "Vertragsentwurf")
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 2 {
t.Fatalf("erwartete 2 treffer, habe %d: %+v", len(results), results)
}
if results[0].MessageID != "msg-ranking-subject" {
t.Fatalf("erwartete höher gewichteten betreff-treffer zuerst, habe: %+v", results)
}
if results[0].Score <= results[1].Score {
t.Fatalf("erwartete nachvollziehbar höheren score für betreff-treffer: %+v", results)
}
}
// TestSearch_PhraseAndExclusionOperators ist die geforderte
// Pflichtprüfung 2: Phrasensuche und Ausschlussoperator liefern erwartete
// Teilmengen.
func TestSearch_PhraseAndExclusionOperators(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src03-operatoren"
docs := []Document{
{MessageID: "msg-op-umsatz-verlust", Subject: "Umsatz Verlust", Body: "Verlust im dritten Quartal, kein Umsatzwachstum"},
{MessageID: "msg-op-umsatz-nur", Subject: "Quartalsbericht Umsatz", Body: "hoher Umsatz im dritten Quartal"},
{MessageID: "msg-op-anderes-thema", Subject: "Betriebsausflug", Body: "Planung für den nächsten Betriebsausflug"},
}
for _, d := range docs {
d.TenantSlug = tenant
d.ID = DocumentID(tenant, d.MessageID)
if err := client.Index(ctx, d); err != nil {
t.Fatalf("index %s: %v", d.MessageID, err)
}
}
phraseResults, err := client.Search(ctx, tenant, `"dritten Quartal"`)
if err != nil {
t.Fatalf("phrasensuche: %v", err)
}
phraseIDs := messageIDSet(phraseResults)
if !phraseIDs["msg-op-umsatz-verlust"] || !phraseIDs["msg-op-umsatz-nur"] {
t.Fatalf("erwartete beide 'dritten Quartal'-treffer, habe: %+v", phraseResults)
}
if phraseIDs["msg-op-anderes-thema"] {
t.Fatalf("unerwarteter treffer ohne die phrase: %+v", phraseResults)
}
exclusionResults, err := client.Search(ctx, tenant, "Umsatz -Verlust")
if err != nil {
t.Fatalf("ausschlusssuche: %v", err)
}
exclusionIDs := messageIDSet(exclusionResults)
if !exclusionIDs["msg-op-umsatz-nur"] {
t.Fatalf("erwarteter treffer ohne 'Verlust' fehlt: %+v", exclusionResults)
}
if exclusionIDs["msg-op-umsatz-verlust"] {
t.Fatalf("mit -Verlust ausgeschlossener treffer erschien dennoch: %+v", exclusionResults)
}
}
func messageIDSet(results []Result) map[string]bool {
set := make(map[string]bool, len(results))
for _, r := range results {
set[r.MessageID] = true
}
return set
}
// TestSearch_PerformanceWithLargeCorpus ist die geforderte Pflichtprüfung
// 3: Performance-Test mit großem Testkorpus bleibt innerhalb Zielzeit.
// Zielzeit 500ms für eine Suche über 1000 indexierte Dokumente — großzügig
// für die "kleinste Lösung", deckt aber real ab, dass die Suche nicht
// linear mit der Korpusgröße spürbar einbricht.
func TestSearch_PerformanceWithLargeCorpus(t *testing.T) {
if os.Getenv("TEST_MANTICORE_URL") == "" {
t.Skip("TEST_MANTICORE_URL nicht gesetzt, Integrationstest übersprungen")
}
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src03-performance"
const corpusSize = 1000
for i := 0; i < corpusSize; i++ {
messageID := fmt.Sprintf("msg-perf-%d", i)
subject := "Alltägliche Nachricht"
if i == corpusSize/2 {
subject = "Einzigartiges Suchziel Zylotharion"
}
if err := client.Index(ctx, Document{
ID: DocumentID(tenant, messageID),
TenantSlug: tenant,
MessageID: messageID,
Subject: subject,
Body: "Routinetext ohne besonderen Inhalt für Testzwecke.",
SentAtUnixEpoch: int64(i),
}); err != nil {
t.Fatalf("index %s: %v", messageID, err)
}
}
const targetLatency = 500 * time.Millisecond
start := time.Now()
results, err := client.Search(ctx, tenant, "Zylotharion")
elapsed := time.Since(start)
if err != nil {
t.Fatalf("search: %v", err)
}
if elapsed > targetLatency {
t.Fatalf("suche über %d dokumente dauerte %s, ziel war %s", corpusSize, elapsed, targetLatency)
}
found := false
for _, r := range results {
if r.MessageID == fmt.Sprintf("msg-perf-%d", corpusSize/2) {
found = true
}
}
if !found {
t.Fatal("erwartetes einzigartiges dokument im großen korpus nicht gefunden")
}
t.Logf("Suche über %d Dokumente: %s (Ziel %s)", corpusSize, elapsed, targetLatency)
}
-288
View File
@@ -1,288 +0,0 @@
// SRC-09: Suchindex-Neuaufbau/Reindexierung. Baut eine neue physische
// Manticore-Tabelle auf, kopiert alle Dokumente aus der aktuell lebenden
// Tabelle (Konsistenzwiederherstellung), verifiziert die Trefferzahl und
// tauscht erst danach per Manticore RENAME atomar um — die alte Tabelle
// bleibt bis zu diesem Moment vollständig abfragbar (Akzeptanzkriterium 3),
// RENAME ist eine reine Metadaten-Operation ohne Suchausfall.
package search
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"time"
)
// tempTableNamePattern begrenzt generierte Zwischentabellennamen auf ein
// festes Präfix + Ziffern — auch wenn der Name ausschließlich von diesem
// Paket selbst erzeugt wird (kein externer Eingabewert erreicht ihn),
// erzwingt die Prüfung strukturell, dass niemals ein beliebiger String an
// dieser Stelle landen kann (Verteidigung in der Tiefe, gleiche Haltung
// wie das Feld-Whitelist-Prinzip in fields.go).
var tempTableNamePattern = regexp.MustCompile(`^mail_documents_reindex_[0-9]+$`)
// Progress meldet den Fortschritt eines laufenden Reindex
// (Akzeptanzkriterium 2: Fortschritt nachvollziehbar sichtbar).
type Progress struct {
Copied int64
Total int64
}
// Reindexer baut den Suchindex vollständig neu auf.
type Reindexer struct {
client *Client
}
func NewReindexer(client *Client) *Reindexer {
return &Reindexer{client: client}
}
// RebuildResult fasst das Ergebnis eines abgeschlossenen Reindex zusammen.
type RebuildResult struct {
OldCount int64
NewCount int64
}
// Rebuild baut den Index vollständig neu auf: neue Tabelle anlegen, alle
// Dokumente aus der aktuell lebenden Tabelle seitenweise kopieren
// (Akzeptanzkriterium 1: kein Datenverlust im laufenden Betrieb — die
// lebende Tabelle wird dabei nur gelesen, nie verändert), Trefferzahlen
// vergleichen, dann atomar per RENAME umschalten. Schlägt ein Schritt vor
// dem Umschalten fehl (z. B. abgebrochener Kontext), wird die
// Zwischentabelle entfernt und die lebende Tabelle bleibt unverändert
// (Akzeptanzkriterium 3 / Pflichtprüfung 2: kein inkonsistenter Zustand).
func (r *Reindexer) Rebuild(ctx context.Context, onProgress func(Progress)) (RebuildResult, error) {
tempTable := fmt.Sprintf("mail_documents_reindex_%d", time.Now().UnixNano())
if !tempTableNamePattern.MatchString(tempTable) {
return RebuildResult{}, fmt.Errorf("search: erzeugter zwischentabellenname unerwartet ungültig: %q", tempTable)
}
if err := r.client.runSchemaSQL(ctx, buildCreateTableSQL(tempTable)); err != nil {
return RebuildResult{}, fmt.Errorf("search: zwischentabelle anlegen: %w", err)
}
oldTotal, err := r.copyAll(ctx, IndexName, tempTable, onProgress)
if err != nil {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: dokumente kopieren: %w", err)
}
// Manticore macht frisch eingefügte Dokumente einer neu angelegten
// RT-Tabelle für Volltext-/match_all-Zählungen erst nach einem
// expliziten FLUSH RAMCHUNK zuverlässig sichtbar (beobachtet: SELECT
// über SQL sieht die Zeile sofort, /search match_all zählt sie ohne
// Flush als 0). Vor der Konsistenzprüfung zwingend nötig.
if err := r.client.runSchemaSQL(ctx, "FLUSH RAMCHUNK "+tempTable); err != nil {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: zwischentabelle flushen: %w", err)
}
newTotal, err := r.countAll(ctx, tempTable)
if err != nil {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: neue tabelle zählen: %w", err)
}
if newTotal != oldTotal {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: trefferzahlen weichen ab (alt %d, neu %d), kein umschalten", oldTotal, newTotal)
}
retiredTable := fmt.Sprintf("mail_documents_retired_%d", time.Now().UnixNano())
if err := r.client.runSchemaSQL(ctx, fmt.Sprintf("ALTER TABLE %s RENAME %s", IndexName, retiredTable)); err != nil {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: alte tabelle umbenennen: %w", err)
}
if err := r.client.runSchemaSQL(ctx, fmt.Sprintf("ALTER TABLE %s RENAME %s", tempTable, IndexName)); err != nil {
// Kritischer Zustand: alte Tabelle bereits umbenannt, neue kann
// nicht einspringen. Umschalten rückgängig machen, statt ohne
// abfragbaren Index dazustehen.
_ = r.client.runSchemaSQL(context.Background(), fmt.Sprintf("ALTER TABLE %s RENAME %s", retiredTable, IndexName))
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: neue tabelle aktivieren: %w", err)
}
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+retiredTable)
return RebuildResult{OldCount: oldTotal, NewCount: newTotal}, nil
}
const copyPageSize = 200
func (r *Reindexer) copyAll(ctx context.Context, sourceIndex, targetIndex string, onProgress func(Progress)) (int64, error) {
total, err := r.countAll(ctx, sourceIndex)
if err != nil {
return 0, err
}
var cursor uint64
var copied int64
first := true
for {
if err := ctx.Err(); err != nil {
return 0, err
}
page, err := r.fetchPage(ctx, sourceIndex, cursor, first)
if err != nil {
return 0, err
}
first = false
if len(page) == 0 {
break
}
for _, doc := range page {
if err := r.putRaw(ctx, targetIndex, doc); err != nil {
return 0, err
}
cursor = doc.ID
copied++
}
if onProgress != nil {
onProgress(Progress{Copied: copied, Total: total})
}
}
return copied, nil
}
func (r *Reindexer) countAll(ctx context.Context, index string) (int64, error) {
payload := map[string]any{"index": index, "query": map[string]any{"match_all": map[string]any{}}, "limit": 0}
var parsed struct {
Hits struct {
Total int64 `json:"total"`
} `json:"hits"`
}
if err := r.client.postJSON(ctx, "/search", payload, &parsed); err != nil {
return 0, err
}
return parsed.Hits.Total, nil
}
type scrollHit struct {
ID uint64 `json:"_id"`
Source json.RawMessage `json:"_source"`
}
func (r *Reindexer) fetchPage(ctx context.Context, index string, afterID uint64, first bool) ([]scrollHit, error) {
must := []map[string]any{}
if !first {
must = append(must, map[string]any{"range": map[string]any{"id": map[string]any{"gt": afterID}}})
}
query := map[string]any{"match_all": map[string]any{}}
if len(must) > 0 {
query = map[string]any{"bool": map[string]any{"must": must}}
}
payload := map[string]any{
"index": index,
"query": query,
"sort": []map[string]any{{"id": "asc"}},
"limit": copyPageSize,
}
var parsed struct {
Hits struct {
Hits []scrollHit `json:"hits"`
} `json:"hits"`
}
if err := r.client.postJSON(ctx, "/search", payload, &parsed); err != nil {
return nil, err
}
return parsed.Hits.Hits, nil
}
func (r *Reindexer) putRaw(ctx context.Context, index string, doc scrollHit) error {
payload := map[string]any{
"index": index,
"id": doc.ID,
"doc": json.RawMessage(doc.Source),
}
return r.client.postJSON(ctx, "/replace", payload, nil)
}
// postJSON/buildCreateTableSQL sind bewusst hier statt in client.go
// angesiedelt: der übrige Suchpfad (Search/Facets) fasst niemals einen
// Tabellennamen dynamisch an, Reindex ist die einzige Stelle im Paket, die
// das operativ tun muss.
func (c *Client) postJSON(ctx context.Context, path string, payload any, out any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("payload serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("ausführen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("antwort lesen: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
}
if out == nil {
return nil
}
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("antwort parsen: %w", err)
}
return nil
}
// showTables listet die vorhandenen Manticore-Tabellen (Diagnose-/
// Testhilfe, um verwaiste Zwischentabellen nach einem Abbruch
// auszuschließen — Pflichtprüfung 2).
func (c *Client) showTables(ctx context.Context) ([]string, error) {
form := "query=SHOW TABLES"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", bytes.NewReader([]byte(form)))
if err != nil {
return nil, fmt.Errorf("anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("ausführen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("antwort lesen: %w", err)
}
var parsed []struct {
Data []struct {
Table string `json:"Table"`
} `json:"data"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("antwort parsen: %w", err)
}
names := []string{}
if len(parsed) > 0 {
for _, row := range parsed[0].Data {
names = append(names, row.Table)
}
}
return names, nil
}
// buildCreateTableSQL erzeugt die Schema-DDL für eine Zwischentabelle mit
// demselben Spaltensatz wie mail_documents (Basis + Facettenfelder aus
// SRC-05). tableName ist über tempTableNamePattern in Rebuild bereits
// geprüft, bevor diese Funktion aufgerufen wird.
func buildCreateTableSQL(tableName string) string {
return fmt.Sprintf(
"CREATE TABLE %s (%s string attribute indexed, %s string attribute indexed, %s text, %s text, %s text, %s string attribute indexed, %s string attribute indexed, %s string attribute indexed, %s string attribute indexed, %s timestamp)",
tableName,
FieldTenantSlug, FieldMessageID, FieldSubject, FieldBody, FieldAttachmentText,
FieldSender, FieldMailbox, FieldAttachmentType, FieldTag, FieldSentAt,
)
}
-215
View File
@@ -1,215 +0,0 @@
// Integrationstest (SRC-09): echte Manticore-Instanz, TEST_MANTICORE_URL
// (gleiche Konvention wie integration_test.go/ranking_test.go/facets_test.go).
package search
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
// TestRebuild_SearchKeepsWorkingDuringReindex ist die geforderte
// Pflichtprüfung 1: Reindex während laufender Suchanfragen unterbricht die
// Suche nicht.
func TestRebuild_SearchKeepsWorkingDuringReindex(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-parallel"
for i := 0; i < 30; i++ {
messageID := "msg-parallel-" + string(rune('a'+i))
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: messageID, Subject: "Zwiebelfisch " + messageID, Body: "Text"})
}
stop := make(chan struct{})
var searchErrors int64
var searchesDone int64
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
if _, err := client.Search(ctx, tenant, "Zwiebelfisch"); err != nil {
atomic.AddInt64(&searchErrors, 1)
t.Logf("suchfehler während reindex: %v", err)
}
atomic.AddInt64(&searchesDone, 1)
time.Sleep(2 * time.Millisecond)
}
}()
reindexer := NewReindexer(client)
result, err := reindexer.Rebuild(ctx, nil)
close(stop)
wg.Wait()
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if result.OldCount != result.NewCount {
t.Fatalf("erwartete gleiche trefferzahlen, habe alt=%d neu=%d", result.OldCount, result.NewCount)
}
if atomic.LoadInt64(&searchesDone) == 0 {
t.Fatal("keine einzige parallele suche ausgeführt — test aussagelos")
}
if errs := atomic.LoadInt64(&searchErrors); errs != 0 {
t.Fatalf("erwartete 0 fehlgeschlagene suchen während des reindex, habe %d von %d", errs, atomic.LoadInt64(&searchesDone))
}
// Suche funktioniert auch NACH dem Umschalten weiterhin real.
afterResults, err := client.Search(ctx, tenant, "Zwiebelfisch")
if err != nil {
t.Fatalf("search nach reindex: %v", err)
}
if len(afterResults) != 30 {
t.Fatalf("erwartete 30 treffer nach reindex, habe %d", len(afterResults))
}
}
// TestRebuild_AbortedReindexLeavesNoInconsistentState ist die geforderte
// Pflichtprüfung 2: abgebrochener Reindex hinterlässt keinen
// inkonsistenten Zustand.
func TestRebuild_AbortedReindexLeavesNoInconsistentState(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-abbruch"
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-abbruch-1", Subject: "Vertragsentwurf Abbruchtest", Body: "Text"})
before, err := client.Search(ctx, tenant, "Abbruchtest")
if err != nil || len(before) != 1 {
t.Fatalf("voraussetzung nicht erfüllt: %v / %d treffer", err, len(before))
}
cancelCtx, cancel := context.WithCancel(ctx)
cancel() // sofort abgebrochen, simuliert Absturz/Abbruch mitten im Kopiervorgang
reindexer := NewReindexer(client)
_, err = reindexer.Rebuild(cancelCtx, nil)
if err == nil {
t.Fatal("erwartete fehler bei abgebrochenem kontext, habe nil")
}
if !errors.Is(err, context.Canceled) {
// Manticore-Fehler durch den abgebrochenen Request sind ebenfalls
// akzeptabel, solange überhaupt ein Fehler zurückkommt.
t.Logf("fehler war nicht context.Canceled, sondern: %v (akzeptiert, solange real ein fehler zurückkommt)", err)
}
// Die lebende Tabelle muss trotz Abbruch unverändert und abfragbar sein.
after, err := client.Search(ctx, tenant, "Abbruchtest")
if err != nil {
t.Fatalf("search nach abgebrochenem reindex: %v", err)
}
if len(after) != 1 {
t.Fatalf("erwartete weiterhin 1 treffer nach abgebrochenem reindex, habe %d — inkonsistenter zustand", len(after))
}
// Keine verwaisten Zwischentabellen (kein inkonsistenter Zustand auf
// Manticore-Ebene): kurz warten, damit ein eventuell noch laufender
// CREATE-TABLE-Aufruf durchlaufen kann, dann prüfen, dass keine
// mail_documents_reindex_*-Tabelle übrig geblieben ist.
time.Sleep(200 * time.Millisecond)
orphaned := listOrphanedReindexTables(t, client)
if len(orphaned) > 0 {
t.Fatalf("verwaiste zwischentabellen nach abbruch gefunden: %v", orphaned)
}
}
func listOrphanedReindexTables(t *testing.T, client *Client) []string {
t.Helper()
// SHOW TABLES ist eine feste, unparametrisierte Anweisung ohne
// jeglichen Laufzeitwert.
rows, err := client.showTables(context.Background())
if err != nil {
t.Fatalf("show tables: %v", err)
}
names := []string{}
for _, table := range rows {
if tempTableNamePattern.MatchString(table) {
names = append(names, table)
}
}
return names
}
// TestRebuild_SampleComparisonMatchesOldAndNewIndex ist die geforderte
// Pflichtprüfung 3: Stichprobenvergleich Alt-/Neuindex bestätigt gleiche
// Trefferzahlen.
func TestRebuild_SampleComparisonMatchesOldAndNewIndex(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-stichprobe"
subjects := []string{"Quartalsbericht", "Personalplanung", "Urlaubsantrag"}
for i, s := range subjects {
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-sp-" + string(rune('a'+i)), Subject: s, Body: "Inhalt " + s})
}
beforeCounts := map[string]int{}
for _, s := range subjects {
results, err := client.Search(ctx, tenant, s)
if err != nil {
t.Fatalf("search vor reindex (%s): %v", s, err)
}
beforeCounts[s] = len(results)
}
reindexer := NewReindexer(client)
if _, err := reindexer.Rebuild(ctx, nil); err != nil {
t.Fatalf("rebuild: %v", err)
}
for _, s := range subjects {
results, err := client.Search(ctx, tenant, s)
if err != nil {
t.Fatalf("search nach reindex (%s): %v", s, err)
}
if len(results) != beforeCounts[s] {
t.Fatalf("stichprobe %q: vor reindex %d treffer, nach reindex %d treffer", s, beforeCounts[s], len(results))
}
}
}
// TestRebuild_ReportsProgress deckt Akzeptanzkriterium 2 ab (Fortschritt
// nachvollziehbar sichtbar).
func TestRebuild_ReportsProgress(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-fortschritt"
for i := 0; i < 5; i++ {
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-progress-" + string(rune('a'+i)), Subject: "x"})
}
var updates []Progress
var mu sync.Mutex
reindexer := NewReindexer(client)
_, err := reindexer.Rebuild(ctx, func(p Progress) {
mu.Lock()
updates = append(updates, p)
mu.Unlock()
})
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if len(updates) == 0 {
t.Fatal("erwartete mindestens eine fortschrittsmeldung")
}
last := updates[len(updates)-1]
if last.Copied < last.Total {
// total ist eine zu Beginn eingefrorene Momentaufnahme; die geteilte
// Manticore-Instanz kann während des Kopierens durch andere Tests
// weiter wachsen (real beobachtet) — copied darf total daher
// erreichen oder minimal überschreiten, nur ein Rückstand wäre ein
// echter Fehler.
t.Fatalf("letzte fortschrittsmeldung sollte abgeschlossen sein, habe copied=%d total=%d", last.Copied, last.Total)
}
}
-49
View File
@@ -1,49 +0,0 @@
// Package storage implementiert ARC-01: die Objekt-Speicher-Anbindung
// für archivierte Mails und Anhänge. Baut auf demselben bewährten
// Muster wie DMS FDN-03 auf (austauschbare Driver, LocalDriver für
// Entwicklung, S3Driver für Produktion) — Mail kann DMS' internal/
// nicht importieren (eigenständiges Go-Modul), daher eine bewusste,
// angepasste Neuimplementierung statt eines Cross-Modul-Imports.
//
// Erweiterung gegenüber FDN-03: ARC-01 verlangt Prüfsummenverifikation
// AN DIESER SCHICHT (Akzeptanzkriterium 2), nicht erst an einer
// späteren DB-Schicht — siehe service.go.
package storage
import (
"context"
"errors"
"io"
"strconv"
)
// ErrNotFound wird geliefert, wenn ein angefragtes Objekt nicht
// existiert.
var ErrNotFound = errors.New("storage: objekt nicht gefunden")
// Driver ist die EINE Schnittstelle, gegen die der Rest von Mail
// arbeitet (Akzeptanzkriterium 1). Zwei Implementierungen: LocalDriver
// (Entwicklung) und S3Driver (Produktion, S3-kompatibel).
type Driver interface {
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (int64, error)
Get(ctx context.Context, key string) (io.ReadCloser, error)
Delete(ctx context.Context, key string) error
}
// ObjectKey liefert das feste, dokumentierte Pfadschema für einen
// Mail-Anhang/-Teil INNERHALB des bereits mandantenspezifischen
// Buckets (Akzeptanzkriterium 1) — Bucket-Trennung selbst ist Sache
// von Core TEN-01. Lesezugriff hängt NUR von messageID+partIndex ab,
// nicht vom ursprünglichen Importpfad (IMAP/SMTP/manueller Import —
// Akzeptanzkriterium 3): derselbe Key wird unabhängig davon berechnet,
// über welchen Weg die Nachricht ins System kam.
func ObjectKey(messageID string, partIndex int) string {
return "messages/" + messageID + "/parts/" + strconv.Itoa(partIndex)
}
// checksumKey ist der Sidecar-Objektschlüssel für die beim Schreiben
// berechnete Prüfsumme (siehe service.go) — liegt bewusst im selben
// Driver/Bucket wie der Inhalt, keine separate DB-Abhängigkeit nötig.
func checksumKey(key string) string {
return key + ".sha256"
}
-63
View File
@@ -1,63 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
)
// LocalDriver legt Objekte im lokalen Dateisystem ab — der
// Entwicklungs-Treiber (Akzeptanzkriterium 1), keine externe
// Abhängigkeit nötig.
type LocalDriver struct {
baseDir string
}
func NewLocalDriver(baseDir string) *LocalDriver {
return &LocalDriver{baseDir: baseDir}
}
func (d *LocalDriver) path(key string) string {
return filepath.Join(d.baseDir, filepath.FromSlash(key))
}
func (d *LocalDriver) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) (int64, error) {
full := d.path(key)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return 0, fmt.Errorf("storage: verzeichnis anlegen: %w", err)
}
f, err := os.Create(full)
if err != nil {
return 0, fmt.Errorf("storage: datei anlegen: %w", err)
}
defer func() { _ = f.Close() }()
written, err := io.Copy(f, r)
if err != nil {
return 0, fmt.Errorf("storage: schreiben: %w", err)
}
return written, nil
}
func (d *LocalDriver) Get(_ context.Context, key string) (io.ReadCloser, error) {
f, err := os.Open(d.path(key))
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("storage: lesen: %w", err)
}
return f, nil
}
func (d *LocalDriver) Delete(_ context.Context, key string) error {
if err := os.Remove(d.path(key)); err != nil {
if os.IsNotExist(err) {
return ErrNotFound
}
return fmt.Errorf("storage: löschen: %w", err)
}
return nil
}
-110
View File
@@ -1,110 +0,0 @@
package storage
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
// S3Driver legt Objekte in einem S3-kompatiblen Objektspeicher ab — der
// Produktions-Treiber (Akzeptanzkriterium 1). Funktioniert gegen echtes
// AWS S3 UND gegen jeden S3-kompatiblen Anbieter (MinIO etc.) über
// endpointURL. Gleiches, bewährtes Muster wie DMS FDN-03s S3Driver
// (bewusste Kopie, Mail kann DMS nicht importieren).
type S3Driver struct {
client *s3.Client
bucket string
}
func NewS3Driver(ctx context.Context, bucket, region, endpointURL, accessKeyID, secretAccessKey string, usePathStyle bool) (*S3Driver, error) {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")),
)
if err != nil {
return nil, fmt.Errorf("storage: s3-konfiguration laden: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
if endpointURL != "" {
o.BaseEndpoint = aws.String(endpointURL)
}
o.UsePathStyle = usePathStyle
})
return &S3Driver{client: client, bucket: bucket}, nil
}
func (d *S3Driver) Put(ctx context.Context, key string, r io.Reader, _ int64, contentType string) (int64, error) {
buf, err := io.ReadAll(r)
if err != nil {
return 0, fmt.Errorf("storage: objekt vor upload lesen: %w", err)
}
_, err = d.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
Body: bytes.NewReader(buf),
ContentLength: aws.Int64(int64(len(buf))),
ContentType: aws.String(contentType),
})
if err != nil {
return 0, fmt.Errorf("storage: s3-upload: %w", err)
}
return int64(len(buf)), nil
}
func (d *S3Driver) Get(ctx context.Context, key string) (io.ReadCloser, error) {
out, err := d.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
})
if err != nil {
if isS3NotFound(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("storage: s3-download: %w", err)
}
return out.Body, nil
}
func (d *S3Driver) Delete(ctx context.Context, key string) error {
// S3 liefert bei DeleteObject fuer ein nicht existierendes Objekt
// KEINEN Fehler (idempotente S3-API-Semantik) — um denselben
// Vertrag wie LocalDriver (ErrNotFound bei fehlendem Objekt) zu
// erfüllen, wird die Existenz vorher explizit geprüft.
_, err := d.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(d.bucket), Key: aws.String(key)})
if err != nil {
if isS3NotFound(err) {
return ErrNotFound
}
return fmt.Errorf("storage: s3-existenzprüfung vor löschen: %w", err)
}
if _, err := d.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
}); err != nil {
return fmt.Errorf("storage: s3-löschen: %w", err)
}
return nil
}
func isS3NotFound(err error) bool {
var nsk *types.NoSuchKey
if errors.As(err, &nsk) {
return true
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NotFound" {
return true
}
return false
}
-127
View File
@@ -1,127 +0,0 @@
package storage
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
)
// ErrChecksumMismatch wird von GetVerified geliefert, wenn der beim
// Lesen berechnete Hash nicht mit der beim Schreiben gespeicherten
// Prüfsumme übereinstimmt (Akzeptanzkriterium 2 / Pflichtprüfung 2:
// ein absichtlich beschädigtes Objekt wird erkannt).
var ErrChecksumMismatch = errors.New("storage: prüfsumme stimmt nicht überein — objekt wurde verändert")
// Service verbindet einen Driver mit Prüfsummenverifikation
// (Akzeptanzkriterium 2) und der Nutzungsmeldung an Core LIC-05
// (Akzeptanzkriterium 4) — jeder Schreib-/Löschvorgang über Service
// löst GENAU EINE Meldung mit der tatsächlich geschriebenen/gelöschten
// Objektgröße aus. Aufrufer (spätere Tickets, z. B. IMP-*) rufen
// ausschließlich Service auf, nie einen Driver direkt.
type Service struct {
driver Driver
usage UsageReporter
tenantSlug string
}
func NewService(driver Driver, usage UsageReporter, tenantSlug string) *Service {
return &Service{driver: driver, usage: usage, tenantSlug: tenantSlug}
}
// Put legt den Inhalt ab UND verifiziert den Schreibvorgang durch
// Prüfsummenvergleich (Akzeptanzkriterium 2): der Inhalt wird
// geschrieben, die Prüfsumme als Sidecar-Objekt gespeichert, danach
// SOFORT zurückgelesen und erneut gehasht — weicht der Rückgelesene
// Hash vom beim Schreiben berechneten ab, meldet Put einen Fehler,
// statt eine unbemerkt fehlerhafte Ablage stehen zu lassen. Meldet die
// geschriebene Größe als positives Delta an Core LIC-05
// (Akzeptanzkriterium 4).
func (s *Service) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (checksum string, err error) {
hasher := sha256.New()
tee := io.TeeReader(r, hasher)
written, err := s.driver.Put(ctx, key, tee, size, contentType)
if err != nil {
return "", err
}
checksum = hex.EncodeToString(hasher.Sum(nil))
if _, err := s.driver.Put(ctx, checksumKey(key), bytes.NewReader([]byte(checksum)), int64(len(checksum)), "text/plain"); err != nil {
return "", fmt.Errorf("storage: prüfsumme speichern: %w", err)
}
// Sofortige Rücklese-Verifikation — beweist, dass der Schreibvorgang
// tatsächlich verifiziert wurde, nicht nur eine Prüfsumme abgelegt
// wurde, die nie geprüft wird.
if _, err := s.GetVerified(ctx, key); err != nil {
return "", fmt.Errorf("storage: schreibverifikation fehlgeschlagen: %w", err)
}
if err := s.usage.Report(ctx, s.tenantSlug, UsageMetric, written); err != nil {
return checksum, fmt.Errorf("storage: objekt gespeichert, aber nutzungsmeldung fehlgeschlagen: %w", err)
}
return checksum, nil
}
// Get liefert den Inhalt UNVERIFIZIERT (Streaming, für große Objekte).
// Für die Pflichtprüfung "beschädigtes Objekt wird erkannt" GetVerified
// verwenden.
func (s *Service) Get(ctx context.Context, key string) (io.ReadCloser, error) {
return s.driver.Get(ctx, key)
}
// GetVerified liest den vollständigen Inhalt UND vergleicht die beim
// Schreiben gespeicherte Prüfsumme gegen den beim Lesen berechneten
// Hash (Akzeptanzkriterium 2 / Pflichtprüfung 2).
func (s *Service) GetVerified(ctx context.Context, key string) ([]byte, error) {
sumReader, err := s.driver.Get(ctx, checksumKey(key))
if err != nil {
return nil, fmt.Errorf("storage: gespeicherte prüfsumme lesen: %w", err)
}
expectedRaw, err := io.ReadAll(sumReader)
_ = sumReader.Close()
if err != nil {
return nil, fmt.Errorf("storage: gespeicherte prüfsumme lesen: %w", err)
}
expected := string(expectedRaw)
contentReader, err := s.driver.Get(ctx, key)
if err != nil {
return nil, err
}
defer func() { _ = contentReader.Close() }()
hasher := sha256.New()
content, err := io.ReadAll(io.TeeReader(contentReader, hasher))
if err != nil {
return nil, fmt.Errorf("storage: objekt lesen: %w", err)
}
actual := hex.EncodeToString(hasher.Sum(nil))
if actual != expected {
return nil, ErrChecksumMismatch
}
return content, nil
}
// Delete entfernt Inhalt UND Prüfsummen-Sidecar, meldet die Größe als
// negatives Delta an Core LIC-05 (Akzeptanzkriterium 4) — der Aufrufer
// muss die Größe kennen (Delete selbst kann sie nach dem Löschen nicht
// mehr ermitteln).
func (s *Service) Delete(ctx context.Context, key string, sizeBytes int64) error {
if err := s.driver.Delete(ctx, key); err != nil {
return err
}
// Sidecar-Löschung ist best effort — ein fehlendes Sidecar (z. B.
// bei einem sehr alten Objekt) darf den eigentlichen Löschvorgang
// nicht blockieren.
_ = s.driver.Delete(ctx, checksumKey(key))
if err := s.usage.Report(ctx, s.tenantSlug, UsageMetric, -sizeBytes); err != nil {
return fmt.Errorf("storage: objekt gelöscht, aber nutzungsmeldung fehlgeschlagen: %w", err)
}
return nil
}
-134
View File
@@ -1,134 +0,0 @@
package storage
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
)
type fakeUsageReporter struct {
reports []int64
}
func (f *fakeUsageReporter) Report(_ context.Context, _, metric string, delta int64) error {
if metric != UsageMetric {
return errors.New("unerwartete metrik: " + metric)
}
f.reports = append(f.reports, delta)
return nil
}
func newTestService(t *testing.T) (*Service, *fakeUsageReporter) {
t.Helper()
driver := NewLocalDriver(t.TempDir())
usage := &fakeUsageReporter{}
return NewService(driver, usage, "acme"), usage
}
// TestPut_ReadBackIsByteIdentical ist die geforderte Pflichtprüfung 1:
// ein geschriebenes Objekt liefert beim Lesen byteidentischen Inhalt.
func TestPut_ReadBackIsByteIdentical(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
key := ObjectKey("msg-1", 0)
content := "vollständig identischer Inhalt äöü"
checksum, err := svc.Put(ctx, key, strings.NewReader(content), int64(len(content)), "text/plain")
if err != nil {
t.Fatalf("put: %v", err)
}
if checksum == "" {
t.Fatal("erwartet nicht-leere prüfsumme")
}
got, err := svc.GetVerified(ctx, key)
if err != nil {
t.Fatalf("getverified: %v", err)
}
if string(got) != content {
t.Fatalf("nicht byteidentisch: got %q, want %q", got, content)
}
}
// TestGetVerified_DetectsTamperedObject ist die geforderte
// Pflichtprüfung 2: ein absichtlich beschädigtes Objekt wird bei
// Prüfsummenvergleich erkannt.
func TestGetVerified_DetectsTamperedObject(t *testing.T) {
dir := t.TempDir()
driver := NewLocalDriver(dir)
usage := &fakeUsageReporter{}
svc := NewService(driver, usage, "acme")
ctx := context.Background()
key := ObjectKey("msg-tamper", 0)
if _, err := svc.Put(ctx, key, strings.NewReader("originaler inhalt"), 17, "text/plain"); err != nil {
t.Fatalf("put: %v", err)
}
// Objekt DIREKT am Dateisystem manipulieren — umgeht Service
// vollständig, simuliert externe Beschädigung/Manipulation.
full := driver.path(key)
if err := os.WriteFile(full, []byte("MANIPULIERTER INHALT"), 0o644); err != nil {
t.Fatalf("manipulation schreiben: %v", err)
}
_, err := svc.GetVerified(ctx, key)
if !errors.Is(err, ErrChecksumMismatch) {
t.Fatalf("erwartet ErrChecksumMismatch bei manipuliertem objekt, habe: %v", err)
}
}
// TestPut_ManySmallObjectsAcceptableLatency ist die geforderte
// Pflichtprüfung 3: Lasttest mit vielen kleinen Objekten bestätigt
// akzeptable Latenz.
func TestPut_ManySmallObjectsAcceptableLatency(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
const count = 500
start := time.Now()
for i := 0; i < count; i++ {
key := ObjectKey("msg-load", i)
if _, err := svc.Put(ctx, key, strings.NewReader("kleiner anhang inhalt"), 21, "text/plain"); err != nil {
t.Fatalf("put #%d: %v", i, err)
}
}
elapsed := time.Since(start)
avgPerObject := elapsed / count
// Großzügige Grenze (10ms/Objekt inkl. Schreiben+Sidecar+Rücklese-
// Verifikation) — Ziel ist der Nachweis, dass keine quadratische
// oder anderweitig unverhältnismäßige Verschlechterung auftritt,
// nicht ein knallhartes Performance-SLA.
if avgPerObject > 10*time.Millisecond {
t.Fatalf("erwartet akzeptable latenz (<10ms/objekt), habe %v/objekt (gesamt %v für %d objekte)", avgPerObject, elapsed, count)
}
t.Logf("Lasttest: %d Objekte in %v (%v/Objekt)", count, elapsed, avgPerObject)
}
// TestPut_ReportsUsageOnWriteAndDelete ist die geforderte
// Pflichtprüfung 4: Melde-Aufruf an Core LIC-05 bei Schreib- und
// Löschvorgang nachweislich ausgelöst, mit korrekter Größenangabe.
func TestPut_ReportsUsageOnWriteAndDelete(t *testing.T) {
svc, usage := newTestService(t)
ctx := context.Background()
key := ObjectKey("msg-usage", 0)
content := "zwölf bytes!"
if _, err := svc.Put(ctx, key, strings.NewReader(content), int64(len(content)), "text/plain"); err != nil {
t.Fatalf("put: %v", err)
}
if len(usage.reports) != 1 || usage.reports[0] != int64(len(content)) {
t.Fatalf("erwartet genau eine positive meldung mit größe %d, habe: %v", len(content), usage.reports)
}
if err := svc.Delete(ctx, key, int64(len(content))); err != nil {
t.Fatalf("delete: %v", err)
}
if len(usage.reports) != 2 || usage.reports[1] != -int64(len(content)) {
t.Fatalf("erwartet zusätzliche negative meldung mit -%d, habe: %v", len(content), usage.reports)
}
}
-75
View File
@@ -1,75 +0,0 @@
package storage
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
// UsageMetric ist der Metrikname, unter dem Core (internal/usage,
// LIC-05) den Speicherverbrauch je Mandant führt — muss exakt
// internal/usage.StorageBytesMetric aus dem NEXARCH-Core-Modul
// entsprechen (Mail kann Core nicht importieren, daher hier gespiegelt
// — identisches Muster wie DMS FDN-03).
const UsageMetric = "storage_bytes"
// UsageReporter meldet Speicherverbrauchsänderungen an Core
// (Akzeptanzkriterium 4). Schmale Schnittstelle, damit Tests einen
// Fake statt eines echten HTTP-Aufrufs einsetzen können.
type UsageReporter interface {
Report(ctx context.Context, tenantSlug, metric string, delta int64) error
}
// usageDeltaDTO entspricht Core internal/resync.usageDeltaDTO
// (JSON-Vertrag: tenant_slug/metric/delta), über den API-11
// (resync-api) real erreichbar ist.
type usageDeltaDTO struct {
TenantSlug string `json:"tenant_slug"`
Metric string `json:"metric"`
Delta int64 `json:"delta"`
}
// HTTPUsageReporter meldet über Core API-11 (resync-api,
// internal/resync.Handler.UsageHandler), authentifiziert über
// dasselbe Service-Credential-Verfahren wie jeder andere Modul-Core-
// Aufruf (API-02).
type HTTPUsageReporter struct {
endpointURL string
clientID string
clientSecret string
httpClient *http.Client
}
func NewHTTPUsageReporter(endpointURL, clientID, clientSecret string, httpClient *http.Client) *HTTPUsageReporter {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &HTTPUsageReporter{endpointURL: endpointURL, clientID: clientID, clientSecret: clientSecret, httpClient: httpClient}
}
func (r *HTTPUsageReporter) Report(ctx context.Context, tenantSlug, metric string, delta int64) error {
body, err := json.Marshal([]usageDeltaDTO{{TenantSlug: tenantSlug, Metric: metric, Delta: delta}})
if err != nil {
return fmt.Errorf("storage: nutzungsmeldung serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.endpointURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("storage: nutzungsmeldungs-anfrage aufbauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Nexarch-Client-Id", r.clientID)
req.Header.Set("X-Nexarch-Client-Secret", r.clientSecret)
resp, err := r.httpClient.Do(req)
if err != nil {
return fmt.Errorf("storage: nutzungsmeldung senden: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("storage: nutzungsmeldung von core abgelehnt: status %d", resp.StatusCode)
}
return nil
}
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS tenants;
-10
View File
@@ -1,10 +0,0 @@
-- Control-plane registry: tenant list + connection info (Modell C).
-- Core TEN-01 (siehe core-kanban).
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+14
View File
@@ -0,0 +1,14 @@
-- Control-plane registry: Tenant-Liste + Verbindungsinformationen (Modell C).
-- Enthaelt AUSSCHLIESSLICH Tenant-Metadaten, keine Geschaeftsdaten eines Mandanten.
-- Core TEN-01 (siehe core-kanban/tickets/TEN-01.md).
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS feature_flags;
+10
View File
@@ -0,0 +1,10 @@
-- Feature-Flags zentral je Mandant/Zielgruppe (LIC-02, siehe core-kanban/tickets/LIC-02.md).
-- Lebt in der Registry-DB, nicht pro Tenant-Datenbank — Flags sind eine
-- Core-weite Konfiguration, keine Mandanten-Geschaeftsdaten.
CREATE TABLE feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS module_credentials;
DROP TABLE IF EXISTS modules;
+18
View File
@@ -0,0 +1,18 @@
-- Modul-Registry & Aktivierungspruefung (API-02, siehe core-kanban/tickets/API-02.md).
CREATE TABLE modules (
name TEXT PRIMARY KEY,
version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}',
registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Service-Credential je Modul-Instanz, bei Provisionierung ausgestellt
-- (Akzeptanzkriterium 4). secret_hash enthaelt NIEMALS das Secret im
-- Klartext, nur dessen SHA-256-Hash (Timing-safe-Vergleich beim Login,
-- Referenzmuster siehe AUD-02).
CREATE TABLE module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE,
secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE status_history;
DROP TABLE status_targets;
+15
View File
@@ -0,0 +1,15 @@
-- Zentrale Statusseite (OPS-02, siehe core-kanban/tickets/OPS-02.md) —
-- ueberwachte Module und der Verlauf ihrer Statusaenderungen.
CREATE TABLE status_targets (
name TEXT PRIMARY KEY,
health_url TEXT NOT NULL
);
CREATE TABLE status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
status TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX status_history_name_idx ON status_history (name, changed_at DESC);
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
ROLE="nexarch_test"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
done
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
cd "$(dirname "$0")/.."
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
echo "== go build =="
go build ./...
echo "== go vet =="
go vet ./...
echo "== go test (-p 1) =="
go test ./... -p 1 -count=1
-80
View File
@@ -1,80 +0,0 @@
// SRC-04: Backend-for-Frontend-Route für die Such-Oberfläche. Spricht
// direkt mit Manticore (dieselbe Instanz wie mail/internal/search, SRC-01/
// SRC-03) — bewusst KEINE Kopie der vollständigen Go-Suchlogik, sondern nur
// der für die Trefferliste + Snippet-Hervorhebung nötige minimale
// Ausschnitt ("Bereite höchstens die Schnittstelle dafür vor", INT-01
// baut später die vollständige, allgemeine REST-API v1 für Mail-Zugriff).
//
// Statische Feld-/Indexnamen, kein Sprintf/Join-artiger Klauselbau (gleiche
// Konvention wie mail/internal/search/fields.go).
import { NextRequest, NextResponse } from "next/server";
import { HIGHLIGHT_AFTER, HIGHLIGHT_BEFORE } from "../../../lib/highlight";
const INDEX_NAME = "mail_documents";
const FIELD_TENANT_SLUG = "tenant_slug";
function manticoreURL(): string {
const base = process.env.MANTICORE_URL;
if (!base) {
throw new Error("MANTICORE_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base.replace(/\/$/, "");
}
interface ManticoreHit {
_score: number;
_source: { message_id: string };
highlight?: { subject?: string[]; body?: string[] };
}
interface ManticoreSearchResponse {
hits?: { hits?: ManticoreHit[] };
error?: string;
}
export async function GET(request: NextRequest) {
const tenantSlug = request.nextUrl.searchParams.get("tenant");
const query = request.nextUrl.searchParams.get("q");
if (!tenantSlug || !query) {
return NextResponse.json({ error: "'tenant' und 'q' sind erforderlich" }, { status: 400 });
}
const manticorePayload = {
index: INDEX_NAME,
query: {
bool: {
must: [{ equals: { [FIELD_TENANT_SLUG]: tenantSlug } }, { query_string: query }],
},
},
highlight: {
fields: { subject: {}, body: {} },
before_match: HIGHLIGHT_BEFORE,
after_match: HIGHLIGHT_AFTER,
},
};
let manticoreResponse: Response;
try {
manticoreResponse = await fetch(`${manticoreURL()}/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(manticorePayload),
});
} catch (err) {
return NextResponse.json({ error: `Suche nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
}
const parsed: ManticoreSearchResponse = await manticoreResponse.json().catch(() => ({}));
if (!manticoreResponse.ok || parsed.error) {
return NextResponse.json({ error: parsed.error ?? "Suche fehlgeschlagen" }, { status: 502 });
}
const hits = (parsed.hits?.hits ?? []).map((hit) => ({
messageId: hit._source.message_id,
subjectSnippet: hit.highlight?.subject?.[0] ?? "",
bodySnippet: hit.highlight?.body?.[0] ?? "",
score: hit._score,
}));
return NextResponse.json({ hits });
}
@@ -1,54 +0,0 @@
"use client";
import { useSearchParams } from "next/navigation";
import { splitHighlighted } from "../../../lib/highlight";
import { HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_LIGHT } from "../../../lib/highlightColors";
// SRC-04 Akzeptanzkriterium 2: Klick auf einen Suchtreffer öffnet die Mail
// direkt an der Fundstelle. Der vollständige Mail-Inhaltsabruf (Betreff/
// Text laden anhand messageId) ist NICHT Bestandteil dieser Kachel — dafür
// gibt es noch keine HTTP-API (folgt mit INT-01). Bis dahin trägt der
// Suchtreffer-Link Betreff-/Text-Snippet als Kontext mit, damit die
// Fundstelle bereits jetzt real anspring- und hervorhebbar ist; die
// vollständige Mail-Ansicht wird von einer späteren Kachel ergänzt.
function Highlighted({ text }: { text: string }) {
return (
<>
{splitHighlighted(text).map((segment, idx) =>
segment.matched ? (
<mark
key={idx}
id={idx === 0 ? "fundstelle" : undefined}
style={{ background: HIGHLIGHT_BG_LIGHT, color: HIGHLIGHT_FG_LIGHT, padding: "0 2px" }}
>
{segment.text}
</mark>
) : (
<span key={idx}>{segment.text}</span>
)
)}
</>
);
}
export default function MailDetailPage({ params }: { params: { messageId: string } }) {
const searchParams = useSearchParams();
const subject = searchParams.get("subject") ?? "";
const snippet = searchParams.get("snippet") ?? "";
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<p>
<a href="/"> Zurück zur Suche</a>
</p>
<h1>
{subject ? <Highlighted text={subject} /> : params.messageId}
</h1>
{snippet && (
<p>
<Highlighted text={snippet} />
</p>
)}
</main>
);
}

Some files were not shown because too many files have changed in this diff Show More