Compare commits

..
Author SHA1 Message Date
sysops da80643564 OPS-03: dev-server fuer live-scrape-verifikation; expfmt-namensvalidierung fixen 2026-08-28 08:43:18 +02:00
sysops 814a7fda0a OPS-03: metrics-aggregation-ueber-module-hinweg (prometheus-textformat, dynamische quellen) 2026-08-28 08:39:54 +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
134 changed files with 2422 additions and 7772 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
+85
View File
@@ -44,3 +44,88 @@ 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
### 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 ++++++++++
---
+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 {
+43
View File
@@ -0,0 +1,43 @@
// metrics-devserver stellt den OPS-03-Metrics-Aggregator (internal/metrics)
// unter /metrics bereit, damit ein echter Prometheus-Scrape-Vorgang gegen
// den Core-Dienst geprueft werden kann (Pruefung 3). Getrennt von cmd/core
// aus demselben Grund wie die anderen *-devserver.
package main
import (
"context"
"log"
"net/http"
"os"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/metrics"
)
func main() {
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
if dsn == "" {
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
}
addr := os.Getenv("NEXARCH_METRICS_LISTEN_ADDR")
if addr == "" {
addr = ":8085"
}
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
sourceStore := metrics.NewSourceStore(pool)
agg := metrics.NewAggregator(metrics.NewCoreRegistry(), sourceStore.Provide)
mux := http.NewServeMux()
mux.HandleFunc("/metrics", agg.Handler())
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("metrics-devserver listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
+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"])
}
}
+19
View File
@@ -0,0 +1,19 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
// NewCoreRegistry liefert das Prometheus-Registry fuer die EIGENEN
// Kennzahlen des Core-Dienstes (Akzeptanzkriterium 1) — alle Namen tragen
// das Praefix "nexarch_core_" gemaess der im Paketkommentar dokumentierten
// Namenskonvention (Akzeptanzkriterium 3). Ein eigenes Registry statt des
// globalen DefaultRegisterer, damit Tests unabhaengig voneinander sind.
func NewCoreRegistry() *prometheus.Registry {
reg := prometheus.NewRegistry()
reg.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "nexarch_core_up",
Help: "1, solange der Core-Dienst laeuft und Metriken liefern kann.",
}, func() float64 { return 1 }),
)
return reg
}
+168
View File
@@ -0,0 +1,168 @@
// Package metrics implementiert Core OPS-03: einen zentralen /metrics-
// Endpunkt im Prometheus-Textformat, der Kennzahlen des Core-Dienstes UND
// aggregierte Kennzahlen aller registrierten Module bereitstellt — offenes
// Pull-Modell nach Prometheus-Vorbild, kein proprietaerer Push-Mechanismus.
//
// Namenskonvention (Akzeptanzkriterium 3, modulübergreifend konsistent):
//
// nexarch_core_<name> — Kennzahlen des Core-Dienstes selbst
// nexarch_module_<modul>_<name> — von einem Modul gescrapte Kennzahl
// <name>, umbenannt mit dem
// Modulnamen als Praefix
//
// Ein Modul liefert seine eigenen Kennzahlen unter EIGENEM Namen (z.B.
// "requests_total") unter seinem eigenen /metrics-Endpunkt — dieses Paket
// benennt sie beim Einsammeln konsistent um, damit im aggregierten Core-
// Endpunkt niemals zwei Module denselben Metrik-Namen kollidieren lassen.
package metrics
import (
"context"
"fmt"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
)
// init erzwingt das klassische Prometheus-Namensschema (a-z, A-Z, 0-9, _)
// fuer die Namensvalidierung von expfmt/model — ohne diese explizite
// Festlegung liefert die Bibliothek "Invalid name validation scheme
// requested: unset" beim Parsen/Kodieren, da sie den globalen Default in
// dieser Version nicht mehr implizit setzt.
func init() {
model.NameValidationScheme = model.LegacyValidation
}
// Source ist EIN registriertes Modul mit seinem eigenen /metrics-Endpunkt
// (siehe internal/health fuer das analoge Muster bei Readiness-Checks).
type Source struct {
ModuleName string
MetricsURL string
}
// SourceProvider liefert die aktuell registrierten Module — typischerweise
// rueckgebunden an internal/moduleregistry.Registry.List (API-02) ueber
// einen kleinen Adapter im aufrufenden Code, damit dieses Paket
// internal/moduleregistry nicht direkt importieren muss (Kein Umbau
// angrenzender Bereiche). Ein NEU registriertes Modul erscheint automatisch
// beim naechsten Aufruf von Aggregator.Handler, OHNE Codeaenderung an diesem
// Paket (Akzeptanzkriterium 2 / Pruefung 2).
type SourceProvider func(ctx context.Context) ([]Source, error)
// FetchTimeout begrenzt, wie lange EIN Modul-Scrape maximal dauern darf —
// ein haengendes Modul darf den gesamten Aggregations-Request nicht
// verzoegern (Pruefung 1: Antwort unter Last innerhalb definierter Zeit).
const FetchTimeout = 2 * time.Second
// Aggregator sammelt Core-eigene Metriken (coreGatherer) und die Metriken
// aller ueber sourceProvider gemeldeten Module in EINER Antwort ein.
type Aggregator struct {
coreGatherer prometheus.Gatherer
sourceProvider SourceProvider
client *http.Client
}
func NewAggregator(coreGatherer prometheus.Gatherer, sourceProvider SourceProvider) *Aggregator {
return &Aggregator{
coreGatherer: coreGatherer,
sourceProvider: sourceProvider,
client: &http.Client{Timeout: FetchTimeout},
}
}
// Gather implementiert prometheus.Gatherer: liefert Core-Metriken PLUS alle
// erreichbaren Modul-Metriken (umbenannt gemaess Namenskonvention) in einer
// gemeinsamen Liste von MetricFamilies.
func (a *Aggregator) Gather(ctx context.Context) ([]*dto.MetricFamily, error) {
families, err := a.coreGatherer.Gather()
if err != nil {
return nil, fmt.Errorf("core-metriken einsammeln: %w", err)
}
sources, err := a.sourceProvider(ctx)
if err != nil {
return nil, fmt.Errorf("modul-quellen ermitteln: %w", err)
}
// Module werden NEBENLAEUFIG gescrapt (dasselbe Muster wie
// internal/health.Registry.CheckAll) — ein langsames/nicht erreichbares
// Modul haelt weder andere Module noch den Gesamt-Request auf.
type fetchResult struct {
families []*dto.MetricFamily
}
resultCh := make(chan fetchResult, len(sources))
for _, src := range sources {
go func(src Source) {
fetchCtx, cancel := context.WithTimeout(ctx, FetchTimeout)
defer cancel()
mf, err := a.fetchAndRename(fetchCtx, src)
if err != nil {
resultCh <- fetchResult{} // Fehlerfall: einfach nichts beitragen, Aggregation laeuft weiter
return
}
resultCh <- fetchResult{families: mf}
}(src)
}
for range sources {
r := <-resultCh
families = append(families, r.families...)
}
return families, nil
}
// fetchAndRename ruft die /metrics-URL eines Moduls ab, parst das
// Prometheus-Textformat und benennt jede Metrik gemaess der
// Namenskonvention um (Akzeptanzkriterium 2 + 3).
func (a *Aggregator) fetchAndRename(ctx context.Context, src Source) ([]*dto.MetricFamily, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, src.MetricsURL, nil)
if err != nil {
return nil, err
}
resp, err := a.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("modul %s: unerwarteter status %d", src.ModuleName, resp.StatusCode)
}
parser := expfmt.NewTextParser(model.LegacyValidation)
parsed, err := parser.TextToMetricFamilies(resp.Body)
if err != nil {
return nil, fmt.Errorf("modul %s: metrik-text nicht parsebar: %w", src.ModuleName, err)
}
out := make([]*dto.MetricFamily, 0, len(parsed))
for name, mf := range parsed {
renamed := fmt.Sprintf("nexarch_module_%s_%s", src.ModuleName, name)
mf.Name = &renamed
out = append(out, mf)
}
return out, nil
}
// Handler liefert einen HTTP-Handler, der Gather aufruft und das Ergebnis im
// Prometheus-Textformat ausgibt (Akzeptanzkriterium 1).
func (a *Aggregator) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
families, err := a.Gather(r.Context())
if err != nil {
http.Error(w, "metriken konnten nicht eingesammelt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", string(expfmt.NewFormat(expfmt.TypeTextPlain)))
enc := expfmt.NewEncoder(w, expfmt.NewFormat(expfmt.TypeTextPlain))
for _, mf := range families {
if err := enc.Encode(mf); err != nil {
return
}
}
}
}
+179
View File
@@ -0,0 +1,179 @@
package metrics
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
)
func fakeModuleServer(metricName string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
fmt.Fprintf(w, "# HELP %s ein test-zaehler\n# TYPE %s counter\n%s 42\n", metricName, metricName, metricName)
}))
}
// Akzeptanzkriterium 1: Core liefert unter dem Handler valides
// Prometheus-Textformat mit den eigenen Kennzahlen.
func TestHandler_ServesCoreMetricsInPrometheusFormat(t *testing.T) {
agg := NewAggregator(NewCoreRegistry(), func(ctx context.Context) ([]Source, error) { return nil, nil })
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rec := httptest.NewRecorder()
agg.Handler()(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
parser := expfmt.NewTextParser(model.LegacyValidation)
families, err := parser.TextToMetricFamilies(strings.NewReader(rec.Body.String()))
if err != nil {
t.Fatalf("antwort ist kein valides prometheus-textformat: %v", err)
}
if _, ok := families["nexarch_core_up"]; !ok {
t.Fatalf("erwartet 'nexarch_core_up' unter den core-metriken, habe: %v", keysOf(families))
}
}
func keysOf[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// Akzeptanzkriterium 2 + Pruefung 2: ein NEU registriertes Modul erscheint
// in der Aggregation, OHNE dass dieses Paket oder der Aufrufer Code
// aendern muss — die Quelle kommt ausschliesslich aus sourceProvider.
func TestHandler_NewlyRegisteredModuleAppearsWithoutCodeChange(t *testing.T) {
moduleServer := fakeModuleServer("requests_total")
defer moduleServer.Close()
// Simuliert eine sich zur Laufzeit aendernde Modul-Liste (z.B. aus
// SourceStore.Provide) — zunaechst LEER, dann mit einem Eintrag.
var sources []Source
var mu sync.Mutex
provider := func(ctx context.Context) ([]Source, error) {
mu.Lock()
defer mu.Unlock()
out := make([]Source, len(sources))
copy(out, sources)
return out, nil
}
agg := NewAggregator(NewCoreRegistry(), provider)
// Vor der Registrierung: Modul-Metrik nicht vorhanden.
rec1 := httptest.NewRecorder()
agg.Handler()(rec1, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if strings.Contains(rec1.Body.String(), "requests_total") {
t.Fatal("modul-metrik haette vor registrierung nicht erscheinen duerfen")
}
// Modul wird "registriert" (kein Code hier oder in metrics.go aendert sich).
mu.Lock()
sources = append(sources, Source{ModuleName: "dms", MetricsURL: moduleServer.URL})
mu.Unlock()
rec2 := httptest.NewRecorder()
agg.Handler()(rec2, httptest.NewRequest(http.MethodGet, "/metrics", nil))
body := rec2.Body.String()
if !strings.Contains(body, "nexarch_module_dms_requests_total") {
t.Fatalf("erwartet umbenannte modul-metrik 'nexarch_module_dms_requests_total' nach registrierung, body:\n%s", body)
}
}
// Akzeptanzkriterium 3: Namenskonvention "nexarch_module_<modul>_<name>"
// wird tatsaechlich angewendet.
func TestFetchAndRename_AppliesNamingConvention(t *testing.T) {
moduleServer := fakeModuleServer("queue_depth")
defer moduleServer.Close()
agg := NewAggregator(NewCoreRegistry(), nil)
families, err := agg.fetchAndRename(context.Background(), Source{ModuleName: "mail", MetricsURL: moduleServer.URL})
if err != nil {
t.Fatalf("fetchAndRename: %v", err)
}
if len(families) != 1 || families[0].GetName() != "nexarch_module_mail_queue_depth" {
t.Fatalf("erwartet genau 1 metrik 'nexarch_module_mail_queue_depth', habe: %+v", families)
}
}
// Ein nicht erreichbares Modul darf die Aggregation der uebrigen und die
// Gesamtantwort nicht verhindern (dieselbe Resilienz wie OPS-02).
func TestHandler_UnreachableModuleDoesNotBreakAggregation(t *testing.T) {
reachable := fakeModuleServer("healthy_metric")
defer reachable.Close()
unreachable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
unreachableURL := unreachable.URL
unreachable.Close() // sofort schliessen -> Verbindung schlaegt fehl
provider := func(ctx context.Context) ([]Source, error) {
return []Source{
{ModuleName: "ok", MetricsURL: reachable.URL},
{ModuleName: "kaputt", MetricsURL: unreachableURL},
}, nil
}
agg := NewAggregator(NewCoreRegistry(), provider)
rec := httptest.NewRecorder()
agg.Handler()(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 trotz einem nicht erreichbaren modul", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "nexarch_module_ok_healthy_metric") {
t.Fatal("erreichbares modul haette trotz ausfall des anderen aggregiert werden sollen")
}
if strings.Contains(body, "kaputt") {
t.Fatal("nicht erreichbares modul haette keine metrik beitragen duerfen")
}
}
// Pruefung 1: Endpunkt antwortet unter mehreren gleichzeitigen Anfragen
// innerhalb definierter Zeit — kein unbeschraenktes Blockieren durch
// langsame Module (FetchTimeout begrenzt jeden Scrape).
func TestHandler_RespondsWithinBoundedTimeUnderLoad(t *testing.T) {
hangingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(10 * time.Second) // wuerde ohne timeout jede anfrage blockieren
}))
defer hangingServer.Close()
provider := func(ctx context.Context) ([]Source, error) {
return []Source{{ModuleName: "haengend", MetricsURL: hangingServer.URL}}, nil
}
agg := NewAggregator(NewCoreRegistry(), provider)
const concurrentRequests = 10
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < concurrentRequests; i++ {
wg.Add(1)
go func() {
defer wg.Done()
rec := httptest.NewRecorder()
agg.Handler()(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}()
}
wg.Wait()
elapsed := time.Since(start)
if elapsed > FetchTimeout+3*time.Second {
t.Fatalf("%d gleichzeitige anfragen brauchten %s, erwartet deutlich unter %s durch FetchTimeout",
concurrentRequests, elapsed, FetchTimeout+3*time.Second)
}
}
+52
View File
@@ -0,0 +1,52 @@
package metrics
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// SourceStore persistiert, welche Module ihre Metriken unter welcher URL
// bereitstellen — dieselbe Postgres-basierte "kein Code-Deploy noetig"-
// Konvention wie internal/statuspage.Store.RegisterTarget (OPS-02): ein neu
// registriertes Modul erscheint automatisch in der Aggregation, sobald es
// hier eingetragen ist (Akzeptanzkriterium 2 / Pruefung 2).
type SourceStore struct {
pool *pgxpool.Pool
}
func NewSourceStore(pool *pgxpool.Pool) *SourceStore {
return &SourceStore{pool: pool}
}
func (s *SourceStore) RegisterSource(ctx context.Context, moduleName, metricsURL string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO metrics_sources (module_name, metrics_url)
VALUES ($1, $2)
ON CONFLICT (module_name) DO UPDATE SET metrics_url = $2
`, moduleName, metricsURL)
if err != nil {
return fmt.Errorf("metrik-quelle speichern: %w", err)
}
return nil
}
// Provide implementiert SourceProvider direkt aus der Datenbank.
func (s *SourceStore) Provide(ctx context.Context) ([]Source, error) {
rows, err := s.pool.Query(ctx, `SELECT module_name, metrics_url FROM metrics_sources ORDER BY module_name`)
if err != nil {
return nil, fmt.Errorf("metrik-quellen auflisten: %w", err)
}
defer rows.Close()
var out []Source
for rows.Next() {
var src Source
if err := rows.Scan(&src.ModuleName, &src.MetricsURL); err != nil {
return nil, fmt.Errorf("metrik-quelle lesen: %w", err)
}
out = append(out, src)
}
return out, rows.Err()
}
+60
View File
@@ -0,0 +1,60 @@
package metrics
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupSourceStoreTest(t *testing.T) (*SourceStore, 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 metrics_sources (module_name TEXT PRIMARY KEY, metrics_url TEXT NOT NULL)
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewSourceStore(pool), cleanup
}
// Akzeptanzkriterium 2 / Pruefung 2 auf Persistenz-Ebene: eine ueber die
// Datenbank registrierte Quelle ist sofort ueber Provide() sichtbar — genau
// der Mechanismus, der ein neues Modul ohne Core-Codeaenderung erscheinen
// laesst.
func TestSourceStore_RegisterSourceAppearsInProvide(t *testing.T) {
store, cleanup := setupSourceStoreTest(t)
defer cleanup()
ctx := context.Background()
name := fmt.Sprintf("modul-%d", time.Now().UnixNano())
if err := store.RegisterSource(ctx, name, "http://example.invalid/metrics"); err != nil {
t.Fatalf("registersource: %v", err)
}
sources, err := store.Provide(ctx)
if err != nil {
t.Fatalf("provide: %v", err)
}
found := false
for _, s := range sources {
if s.ModuleName == name {
found = true
}
}
if !found {
t.Fatalf("erwartet %s in provide()-ergebnis, habe: %+v", name, sources)
}
}
+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)
}
}
+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.
-87
View File
@@ -1,87 +0,0 @@
# ARC-08 Prüfprotokoll: Verschlüsselungsschlüssel-Rotation
Voraussetzung ARC-02 (Fertig).
## Architektur-Ausgangslage (real geprüft)
Core (API-10, `internal/kek.Store.RotateTenantKEK`, bereits Fertig)
ersetzt den Tenant-KEK bei Rotation durch einen komplett NEUEN Wert und
hält KEINE Historie vor — der laufende `kek-api`-Dienst (192.168.1.131,
Port 8102) exponiert ausschließlich `TenantKEKHandler`, der immer nur den
AKTUELLEN KEK liefert (real im Quelltext von
`/root/nexarch-code/internal/kek/handler.go` und `cmd/kek-api/main.go`
auf 131 verifiziert). Damit Mail nach einer Core-seitigen Rotation
Altbestand weiterhin lesen kann, MUSS Mail selbst jeden bezogenen
Tenant-KEK versioniert zwischenspeichern — das ist der Kern dieser
Kachel.
## Umsetzung
- `mail/internal/crypto/kekversions.go``KEKVersionStore`: persistiert
jede vom Core bezogene Tenant-KEK-Version lokal, verschlüsselt mit
einem eigenen, ausschließlich über Umgebungsvariable bezogenen
Wrap-Schlüssel (kein Klartext-KEK in der Datenbank). `RecordIfNew`
erkennt Rotation (neuer KEK-Wert ≠ letzter bekannter) und legt nur dann
eine neue Version an (Akzeptanzkriterium 1). `Revoke` sperrt gezielt
eine einzelne Version (Pflichtprüfung 2).
- `mail/internal/crypto/service.go``Service.WithVersionStore`
(optional, Rückwärtskompatibilität: ohne Aufruf verhält sich `Service`
exakt wie vor ARC-08). `Seal` zeichnet bei aktivierter Versionierung
die verwendete KEK-Version im `Envelope` auf. Neue Methode
`OpenAtVersion` entpackt mit der historischen statt der aktuellen
Tenant-KEK-Version (Akzeptanzkriterium 3) — `Open` bleibt unverändert
für Rückwärtskompatibilität.
- `mail/internal/encstorage/encstorage.go` — neuer Sidecar
`<key>.dek.version` (gleiches Muster wie der bestehende `.dek`-Sidecar
aus ARC-02) speichert die KEK-Version je Objekt. `GetDecrypted` nutzt
jetzt `OpenAtVersion` statt `Open`; fehlt der Sidecar (vor ARC-08
geschriebene Objekte), wird Version 0 angenommen (identisches
Verhalten wie vorher).
- Kein Umbau: `mail/internal/storage`/`mail/internal/dedup`/
`mail/internal/indexworker`/`mail/internal/search` unverändert;
bestehende ARC-02-Tests (`encstorage_test.go`) unverändert lauffähig
ohne Codeänderung an ihnen.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Rotation des Hauptschlüssels lässt Altbestand weiterhin lesbar | **bestanden** `TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation`: Objekt vor Rotation versiegelt (Version 1), Tenant-Hauptschlüssel real rotiert (Provider liefert ab dann einen anderen Wert, exakt wie `RotateTenantKEK` es bei Core bewirkt), neues Objekt nach Rotation versiegelt (Version 2), Altbestand über `OpenAtVersion` real weiterhin korrekt entschlüsselt — zusätzlich real bestätigt, dass der naive `Open()` mit dem neuen aktuellen KEK für das alte Objekt fehlschlägt (beweist, dass `OpenAtVersion` tatsächlich etwas leistet) |
| 2 | Test: kompromittierter alter Schlüssel kann gezielt gesperrt werden | **bestanden** `TestRotation_CompromisedOldKeyCanBeRevoked`: Version gesperrt, `OpenAtVersion` liefert danach real `ErrKEKVersionRevoked`; `TestKEKVersionStore_RevokeBlocksOnlyThatVersion` bestätigt zusätzlich, dass eine ANDERE Version davon unberührt bleibt |
| 3 | Dokumentierter Rotationsvorgang wurde einmal vollständig durchgespielt | **bestanden** siehe Abschnitt "Rotationsvorgang" unten, real durchlaufen als `TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation` |
### Rotationsvorgang (Pflichtprüfung 3, vollständig durchgespielt)
1. Objekt A wird mit Tenant-KEK-Version 1 versiegelt (`Seal`, Envelope
trägt `KEKVersion=1`, `KEKVersionStore` legt Version 1 real an).
2. Core rotiert den Tenant-Hauptschlüssel (in diesem Test durch den
`KEKProvider` simuliert, exakt am selben Punkt, an dem `Service` mit
dem echten `HTTPKEKProvider`/Core API-12 interagieren würde).
3. Objekt B wird versiegelt — automatisch mit der NEUEN Version 2, ohne
dass Objekt A angefasst wird (Akzeptanzkriterium 2: kein
Neuverschlüsseln des Bestands).
4. Objekt A wird über `OpenAtVersion(..., kekVersion=1, ...)` gelesen —
real erfolgreich, Klartext identisch zum Original.
5. Ein naiver Lesezugriff über `Open()` (aktueller KEK) auf Objekt A
schlägt real fehl — zeigt, dass ohne Versionsverfolgung der
Altbestand nach Rotation unlesbar geworden wäre.
## 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/crypto (4 Tests, neu)
und internal/encstorage (4 Tests, unverändert weiterhin grün — Rückwärtskompatibilität
real bestätigt)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Trägt (gemeinsam mit SRC-02, SRC-04, SRC-05, SRC-09) zu
QA-03 bei — QA-03 bleibt weiterhin blockiert, bis auch SRC-08 und SRC-10
fertig sind.
-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.
-69
View File
@@ -1,69 +0,0 @@
# SRC-07 Prüfprotokoll: OCR für Anhänge
Voraussetzung ARC-01, ARC-03 (beide Fertig). SRC-07 ist die direkte
Vorbedingung für SRC-10 (Spracherkennung & OCR-Qualitätsbewertung), nicht
nur eine nette Ergänzung — ohne SRC-07 gibt es keinen erkannten Text, den
SRC-10 mit Sprache/Konfidenz bewerten könnte.
## Umsetzung
- `mail/internal/ocr/ocr.go` — zustandsloses Paket, kennt weder Mandant
noch Speicher (dieselbe Bauart wie `mail/internal/crypto`/`dedup`):
nimmt Anhangs-Bytes entgegen, liefert erkannten Text zurück. Kein
geteilter Zustand zwischen Aufrufen (jeder Aufruf bekommt ein eigenes
Temp-Verzeichnis) — Tenant-Trennung ist dadurch strukturell gegeben,
nicht nur konventionell: ein Mandant kann prinzipbedingt nie Zwischen-
daten eines anderen sehen. Zuordnung des erkannten Textes zum
Mail-Suchdokument (Akzeptanzkriterium 2) erfolgt beim Aufrufer über das
bereits vorhandene `search.Document.AttachmentText`-Feld (SRC-01) — kein
neues Feld nötig.
- `ExtractTextFromImage`: ruft `tesseract` (Sprachen `deu+eng`) mit
fester Argumentliste auf, kein Shell-String-Zusammenbau.
- `HasTextLayer`/`ExtractTextFromPDF`: nutzt `pdftotext`, um eine
vorhandene Textebene zu erkennen und direkt zu übernehmen
(Akzeptanzkriterium 3) — nur wenn keine Textebene vorhanden ist
(< 10 Zeichen), wird über `pdftoppm` (300dpi) jede Seite gerastert und
per Tesseract erkannt (Akzeptanzkriterium 1).
- Bekannten Fehler vermieden (dupliziertes Sprintf-WHERE-Muster aus
archivmail, siehe repos-analyse-mail-reuse.md): dieses Paket baut keine
SQL-Klauseln — ausschließlich externe Kommandozeilenwerkzeuge mit
festen Argumentlisten (`exec.CommandContext`, keine Shell).
- Kein Umbau: `mail/internal/search`/`dedup`/`indexworker`/`storage`/
`crypto`/`encstorage`/`savedsearch` unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Bildanhang mit bekanntem Text liefert erwartete Texterkennung | **bestanden** `TestExtractTextFromImage_KnownTextRecognized`: reales, über `pdftoppm` gerastertes Bild mit dem Text "Rechnungsnummer 4711", `ExtractTextFromImage` erkennt real beide Wortbestandteile |
| 2 | Test: PDF mit vorhandener Textebene wird korrekt übersprungen | **bestanden** `TestExtractTextFromPDF_SkipsOCRWhenTextLayerPresent`: real erzeugtes Vektor-Text-PDF (echte PDF-Textebene, kein Bild), `ExtractTextFromPDF` liefert `OCRPerformed=false` und den Text direkt aus der Textebene |
| 3 | Durchsatztest bestätigt akzeptable Verarbeitungszeit je Anhang | **bestanden** `TestExtractTextFromPDF_ThroughputIsAcceptable`: 3 reale Anhänge (Rasterung 300dpi + OCR) in durchschnittlich 3,48s/Anhang (Ziel 8s/Anhang) |
Zusätzlich (Akzeptanzkriterium 1, gescannte PDFs end-zu-Ende):
`TestExtractTextFromPDF_PerformsOCRWhenNoTextLayer` — ein reales,
ausschließlich rasterbildbasiertes PDF (kein Textelement, JPEG-Bild via
`/DCTDecode` eingebettet) wird real per OCR erkannt, `OCRPerformed=true`.
Testfixtures (`testpdf_test.go`) werden vollständig in Go erzeugt (Hand-
gebautes PDF mit Helvetica-Textebene bzw. eingebettetem JPEG) — keine
externe Bibliothek, keine Testdateien im Repository, reproduzierbar.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./internal/ocr/... -v -> 4/4 bestanden (14,99s gesamt)
TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1
-> alle Pakete bestanden, keine Regression
```
Werkzeugversionen auf 192.168.1.131: `tesseract 5.5.0` (Sprachpakete
`deu`, `eng`), `pdftotext`/`pdftoppm` (poppler-utils) — bereits vorhanden,
keine Installation durch diese Sitzung nötig.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Entsperrt SRC-10.
-59
View File
@@ -1,59 +0,0 @@
# SRC-08 Prüfprotokoll: Gespeicherte Suchanfragen
Voraussetzung SRC-03 (Fertig).
## Umsetzung
- `mail/internal/savedsearch/store.go``Store` (Postgres,
`mail_saved_searches`): `Save` (Upsert über `UNIQUE(tenant_slug,
user_id, name)`, Akzeptanzkriterium 1), `List`/`Get` streng auf
Mandant UND Benutzer beschränkt (Akzeptanzkriterium 3), `Delete`
löscht genau eine Zeile über `tenant_slug + user_id + id`.
„Benutzer" ist bis zu einer zentralen Session-/IAM-Anbindung
(Core-Board-Scope) ein vom Aufrufer mitgegebener opaker
`userID`-String — dieselbe Konvention wie der Tenant-Kontext in
`web/mail-search` (SRC-04).
- `Execute(ctx, client, saved)` führt die gespeicherte Suche LIVE gegen
`search.Client` aus — speichert selbst keine Treffer, jeder Aufruf
fragt Manticore neu ab (Akzeptanzkriterium 2).
- `mail/internal/search/facets.go` — kleinste nötige Erweiterung: neue
Methode `Client.SearchWithFilters` (gemeinsame `buildFilteredMust`-
Hilfsfunktion mit `Facets` extrahiert) liefert TATSÄCHLICH gefilterte
Treffer statt nur Facettenzählungen — ohne dies gäbe es keinen echten
Weg, gespeicherte Filter beim Wiederausführen anzuwenden.
- Kein Umbau: `Search`/`Facets`/`Index`/`Delete`-Verhalten sonst
unverändert, `mail/internal/dedup`/`indexworker`/`storage`/`crypto`/
`encstorage` unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: gespeicherte Suche mit mehreren Filtern wird korrekt reproduziert | **bestanden** `TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly`: 3 Dokumente, 2 Filter (Sender+Postfach) gespeichert, `Execute` liefert real genau das eine Dokument, das beide Filter erfüllt |
| 2 | Test: Benutzer sieht keine gespeicherten Suchen anderer Mandanten | **bestanden** `TestList_UserSeesNoOtherTenantsSavedSearches`: zwei Mandanten mit je einer gespeicherten Suche, `List` bei Mandant B liefert real nur die eigene, nicht die von Mandant A |
| 3 | Test: Löschen einer gespeicherten Suche entfernt nur diese | **bestanden** `TestDelete_RemovesOnlyThatSavedSearch`: zwei gespeicherte Suchen, eine gelöscht, `Get` liefert für die gelöschte real `ErrNotFound`, die andere bleibt real unverändert abrufbar |
Zusätzlich (Akzeptanzkriterium 2, kein eingefrorener Snapshot):
`TestExecute_ReturnsCurrentResultsNotFrozenSnapshot` — Ausführung vor
einer neuen Indexierung liefert real 0 Treffer, danach real 1 Treffer,
ohne dass die gespeicherte Suche selbst verändert wurde.
## 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/savedsearch (4 Tests, neu),
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate/
crypto/search
```
## 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-09) zu QA-03 bei — QA-03 bleibt weiterhin blockiert, bis auch
SRC-10 fertig ist.
-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.
-57
View File
@@ -1,57 +0,0 @@
# SRC-10 Prüfprotokoll: Spracherkennung & OCR-Qualitätsbewertung
Voraussetzung SRC-07 (Fertig).
## Umsetzung
- `mail/internal/ocr/language.go``RecognizeWithLanguageAndConfidence`:
Tesseract erkennt bei kombinierten Sprachpaketen (`deu+eng`) nicht,
WELCHE Sprache vorlag — daher wird das Bild bewusst EINZELN mit jedem
Kandidaten (`deu`, `eng`) im TSV-Ausgabemodus erkannt; die Sprache mit
dem höheren durchschnittlichen Worterkennungs-Konfidenzwert gewinnt
(Akzeptanzkriterium 1). Derselbe Tesseract-TSV-Lauf liefert den
Konfidenzwert direkt mit (Akzeptanzkriterium 2, 0100, Mittelwert über
alle erkannten Wörter) — keine zweite externe Bibliothek nötig.
- `mail/internal/search`: neue Felder `ocr_language`/`ocr_confidence`
(Migrationen 0006/0007, gleiches ALTER-Muster wie SRC-05), in
`Document`/`Result` gespiegelt (Akzeptanzkriterium 1: für Anzeige
nutzbar). Neue Methode `Client.AttachmentsBelowConfidence(ctx,
tenantSlug, threshold)` (Akzeptanzkriterium 3: gezielt für manuelle
Nachbearbeitung auffindbar) — filtert `ocr_confidence < threshold`,
schließt Dokumente ohne OCR-Anhang (`ocr_confidence` bleibt 0) explizit
aus.
- Echten Regressionsbug beim eigenen Testlauf gefunden und behoben:
`reindex.go`s `buildCreateTableSQL` (SRC-09) kannte die neuen
OCR-Spalten nicht — ein Reindex nach dieser Kachel wäre mit "unknown
column" fehlgeschlagen. Jetzt ergänzt, mit Wartungshinweis im
Quelltext für künftige Schema-Erweiterungen.
- Kein Umbau: `Search`/`Facets`/`SearchWithFilters`/`Index`/`Delete`-
Verhalten sonst unverändert, `mail/internal/dedup`/`indexworker`/
`storage`/`crypto`/`encstorage`/`savedsearch` unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test mit mehrsprachigem Testkorpus bestätigt korrekte Spracherkennung | **bestanden** `TestRecognizeWithLanguageAndConfidence_MultilingualCorpus`: reales deutsches Testbild ("Rechnung ueber Lieferung...") real als `deu` erkannt, reales englisches Testbild ("Invoice for delivery...") real als `eng` erkannt |
| 2 | Test: künstlich verschlechtertes Bild erzeugt niedrigeren Konfidenzwert | **bestanden** `TestRecognizeWithLanguageAndConfidence_DegradedImageLowersConfidence`: reproduzierbare Pixelierung + Kontrastreduktion (reiner Go-Standardbibliothekscode, kein externes Werkzeug) senkt den real gemessenen Konfidenzwert von 91,76 auf 28,21 |
| 3 | Abfrage aller Anhänge unterhalb einer Konfidenzschwelle liefert erwartete Treffer | **bestanden** `TestAttachmentsBelowConfidence_QueryReturnsExpectedResults`: 4 Dokumente (2 niedrig-, 1 hoch-konfident, 1 ohne OCR-Anhang), Abfrage mit Schwelle 50 liefert real genau die 2 niedrig-konfidenten, weder den hoch-konfidenten noch den ohne OCR-Anhang |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./internal/ocr/... -v -run 'Language|Degraded' -> 2/2 bestanden
TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1
-> alle Pakete bestanden (Regressionsbug in reindex.go vor diesem
Protokoll gefunden und behoben, danach vollständig grün)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Entsperrt QA-03 (gemeinsam mit ARC-08, SRC-02, SRC-04,
SRC-05, SRC-08, SRC-09 — alle jetzt Fertig, letzte fehlende
Abhängigkeit).
-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
}
-144
View File
@@ -1,144 +0,0 @@
// ARC-08: Tenant-KEK-Rotation. Core (API-10, `internal/kek.Store.
// RotateTenantKEK`) ersetzt den Tenant-KEK durch einen komplett neuen
// Wert — Core selbst hält KEINE Historie vor, `TenantKEKHandler` liefert
// immer nur den AKTUELLEN Schlüssel (siehe kekprovider.go). Damit ARC-08s
// Akzeptanzkriterium 3 ("alte Schlüsselversionen bleiben für
// Lesezugriff kontrolliert verfügbar") erfüllbar ist, muss Mail selbst
// jeden von Core bezogenen Tenant-KEK versioniert zwischenspeichern —
// KEKVersionStore übernimmt genau das, lokal mit einem eigenen,
// ausschließlich über Umgebungsvariable bezogenen Wrap-Schlüssel
// verschlüsselt (kein Klartext-KEK in der Datenbank).
package crypto
import (
"bytes"
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrKEKVersionNotFound wird geliefert, wenn die angefragte Version für
// den Mandanten nicht existiert.
var ErrKEKVersionNotFound = errors.New("crypto: kek-version nicht gefunden")
// ErrKEKVersionRevoked wird geliefert, wenn die angefragte Version gezielt
// gesperrt wurde (Pflichtprüfung 2: kompromittierter alter Schlüssel kann
// gezielt gesperrt werden) — der Lesezugriff auf mit dieser Version
// verschlüsselte Altobjekte ist dann bewusst blockiert.
var ErrKEKVersionRevoked = errors.New("crypto: kek-version wurde gesperrt")
// KEKVersionStore verwaltet die Versionshistorie der Tenant-KEKs, die
// dieses Mail-Modul im Lauf der Zeit von Core bezogen hat.
type KEKVersionStore struct {
pool *pgxpool.Pool
localWrapKey []byte
}
// NewKEKVersionStore erzeugt einen Store. localWrapKey verschlüsselt die
// zwischengespeicherten Tenant-KEKs lokal at rest (KEKSize Bytes,
// ausschließlich über Umgebungsvariable bezogen — nie im Code).
func NewKEKVersionStore(pool *pgxpool.Pool, localWrapKey []byte) *KEKVersionStore {
return &KEKVersionStore{pool: pool, localWrapKey: localWrapKey}
}
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert —
// gleiches Muster wie mail/internal/dedup/indexworker (kein zentraler
// Migrationsläufer für Mandanten-Datenbanken im Mail-Modul vorhanden).
func (s *KEKVersionStore) EnsureSchema(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS mail_kek_versions (
tenant_slug TEXT NOT NULL,
version INT NOT NULL,
wrapped_kek BYTEA NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_slug, version)
)
`); err != nil {
return fmt.Errorf("crypto: kek-versionsschema anlegen: %w", err)
}
return nil
}
// RecordIfNew merkt sich plainKEK als neue Version für tenantSlug, FALLS
// er sich vom zuletzt gespeicherten Wert unterscheidet (Rotation
// erkannt) — bei unverändertem KEK wird keine neue Version angelegt,
// sondern die bestehende Versionsnummer zurückgegeben (Akzeptanzkriterium
// 1: rotierbar verwaltet, nicht bei jedem Aufruf eine neue Version).
func (s *KEKVersionStore) RecordIfNew(ctx context.Context, tenantSlug string, plainKEK []byte) (version int, err error) {
var latestVersion int
var latestWrapped []byte
err = s.pool.QueryRow(ctx, `
SELECT version, wrapped_kek FROM mail_kek_versions
WHERE tenant_slug = $1 ORDER BY version DESC LIMIT 1
`, tenantSlug).Scan(&latestVersion, &latestWrapped)
switch {
case errors.Is(err, pgx.ErrNoRows):
return s.insertVersion(ctx, tenantSlug, 1, plainKEK)
case err != nil:
return 0, fmt.Errorf("crypto: letzte kek-version lesen: %w", err)
}
latestPlain, err := open(s.localWrapKey, latestWrapped)
if err != nil {
return 0, fmt.Errorf("crypto: zwischengespeicherten kek entpacken: %w", err)
}
if bytes.Equal(latestPlain, plainKEK) {
return latestVersion, nil
}
return s.insertVersion(ctx, tenantSlug, latestVersion+1, plainKEK)
}
func (s *KEKVersionStore) insertVersion(ctx context.Context, tenantSlug string, version int, plainKEK []byte) (int, error) {
wrapped, err := seal(s.localWrapKey, plainKEK)
if err != nil {
return 0, fmt.Errorf("crypto: kek für zwischenspeicherung verpacken: %w", err)
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_kek_versions (tenant_slug, version, wrapped_kek) VALUES ($1, $2, $3)
`, tenantSlug, version, wrapped); err != nil {
return 0, fmt.Errorf("crypto: kek-version speichern: %w", err)
}
return version, nil
}
// Get liefert den entschlüsselten historischen Tenant-KEK einer
// bestimmten Version. Liefert ErrKEKVersionRevoked, wenn die Version
// gezielt gesperrt wurde (Pflichtprüfung 2).
func (s *KEKVersionStore) Get(ctx context.Context, tenantSlug string, version int) ([]byte, error) {
var wrapped []byte
var revoked bool
err := s.pool.QueryRow(ctx, `
SELECT wrapped_kek, revoked FROM mail_kek_versions
WHERE tenant_slug = $1 AND version = $2
`, tenantSlug, version).Scan(&wrapped, &revoked)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrKEKVersionNotFound
}
return nil, fmt.Errorf("crypto: kek-version lesen: %w", err)
}
if revoked {
return nil, ErrKEKVersionRevoked
}
return open(s.localWrapKey, wrapped)
}
// Revoke sperrt eine Tenant-KEK-Version gezielt (Pflichtprüfung 2):
// nachfolgende Get-Aufrufe für genau diese Version schlagen mit
// ErrKEKVersionRevoked fehl, andere Versionen bleiben unberührt.
func (s *KEKVersionStore) Revoke(ctx context.Context, tenantSlug string, version int) error {
tag, err := s.pool.Exec(ctx, `
UPDATE mail_kek_versions SET revoked = true WHERE tenant_slug = $1 AND version = $2
`, tenantSlug, version)
if err != nil {
return fmt.Errorf("crypto: kek-version sperren: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrKEKVersionNotFound
}
return nil
}
-111
View File
@@ -1,111 +0,0 @@
// Integrationstest (ARC-08): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/dedup/indexworker — TEST_TENANT_DSN.
package crypto
import (
"bytes"
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
var testLocalWrapKey = bytes.Repeat([]byte{0x7a}, KEKSize)
func setupKEKVersionStore(t *testing.T, tenantSlug string) *KEKVersionStore {
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 := NewKEKVersionStore(pool, testLocalWrapKey)
if err := store.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_kek_versions WHERE tenant_slug = $1`, tenantSlug)
})
return store
}
func TestKEKVersionStore_RecordIfNewDetectsRotationOnly(t *testing.T) {
tenant := "mandant-arc08-recordifnew"
store := setupKEKVersionStore(t, tenant)
ctx := context.Background()
kekV1 := bytes.Repeat([]byte{0x01}, KEKSize)
v1, err := store.RecordIfNew(ctx, tenant, kekV1)
if err != nil {
t.Fatalf("erste erfassung: %v", err)
}
if v1 != 1 {
t.Fatalf("erwartete version 1, habe %d", v1)
}
// Erneuter Aufruf mit UNVERÄNDERTEM KEK darf keine neue Version anlegen.
vAgain, err := store.RecordIfNew(ctx, tenant, kekV1)
if err != nil {
t.Fatalf("zweite erfassung (unverändert): %v", err)
}
if vAgain != 1 {
t.Fatalf("erwartete weiterhin version 1 bei unverändertem kek, habe %d", vAgain)
}
kekV2 := bytes.Repeat([]byte{0x02}, KEKSize)
v2, err := store.RecordIfNew(ctx, tenant, kekV2)
if err != nil {
t.Fatalf("dritte erfassung (rotiert): %v", err)
}
if v2 != 2 {
t.Fatalf("erwartete version 2 nach rotation, habe %d", v2)
}
gotV1, err := store.Get(ctx, tenant, 1)
if err != nil {
t.Fatalf("get v1: %v", err)
}
if !bytes.Equal(gotV1, kekV1) {
t.Fatal("v1 liefert nicht den ursprünglichen kek zurück")
}
gotV2, err := store.Get(ctx, tenant, 2)
if err != nil {
t.Fatalf("get v2: %v", err)
}
if !bytes.Equal(gotV2, kekV2) {
t.Fatal("v2 liefert nicht den rotierten kek zurück")
}
}
func TestKEKVersionStore_RevokeBlocksOnlyThatVersion(t *testing.T) {
tenant := "mandant-arc08-revoke"
store := setupKEKVersionStore(t, tenant)
ctx := context.Background()
kekV1 := bytes.Repeat([]byte{0x11}, KEKSize)
kekV2 := bytes.Repeat([]byte{0x22}, KEKSize)
if _, err := store.RecordIfNew(ctx, tenant, kekV1); err != nil {
t.Fatalf("v1 erfassen: %v", err)
}
if _, err := store.RecordIfNew(ctx, tenant, kekV2); err != nil {
t.Fatalf("v2 erfassen: %v", err)
}
if err := store.Revoke(ctx, tenant, 1); err != nil {
t.Fatalf("v1 sperren: %v", err)
}
if _, err := store.Get(ctx, tenant, 1); err != ErrKEKVersionRevoked {
t.Fatalf("erwartete ErrKEKVersionRevoked für gesperrte version 1, habe: %v", err)
}
if _, err := store.Get(ctx, tenant, 2); err != nil {
t.Fatalf("version 2 sollte unberührt bleiben: %v", err)
}
}
-131
View File
@@ -1,131 +0,0 @@
// ARC-08: End-zu-Ende-Rotationstest. Nutzt einen fake KEKProvider (echter
// Testkonvention aus ARC-02, siehe encstorage_test.go) statt eines echten
// HTTP-Aufrufs an Core API-12 — Core selbst hat keine rotierbare
// Testschnittstelle über HTTP exponiert (nur der aktuelle KEK ist
// abrufbar), die Rotation wird hier auf Höhe der KEKProvider-Schnittstelle
// simuliert, exakt wie ARC-02 es für Fehlerfälle bereits tut.
package crypto
import (
"bytes"
"context"
"io"
"strings"
"sync"
"testing"
)
// rotatableKEKProvider liefert für einen Mandanten einen aktuell
// gesetzten KEK, der zur Laufzeit "rotiert" werden kann (simuliert Core
// API-10s RotateTenantKEK, dessen Effekt auf API-12 exakt darin besteht,
// dass TenantKEK ab dann einen anderen Wert liefert).
type rotatableKEKProvider struct {
mu sync.Mutex
current []byte
}
func (p *rotatableKEKProvider) TenantKEK(_ context.Context, _ string) ([]byte, error) {
p.mu.Lock()
defer p.mu.Unlock()
return p.current, nil
}
func (p *rotatableKEKProvider) rotate(newKEK []byte) {
p.mu.Lock()
defer p.mu.Unlock()
p.current = newKEK
}
// TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation ist die
// geforderte Pflichtprüfung 1: Rotation des Hauptschlüssels lässt
// Altbestand weiterhin lesbar.
func TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation(t *testing.T) {
tenant := "mandant-arc08-rotation-lesbar"
store := setupKEKVersionStore(t, tenant)
ctx := context.Background()
provider := &rotatableKEKProvider{current: bytes.Repeat([]byte{0x51}, KEKSize)}
svc := NewService(provider).WithVersionStore(store)
// Objekt VOR der Rotation versiegeln.
oldEnvelope, err := svc.Seal(ctx, tenant, strings.NewReader("altbestand vor rotation"))
if err != nil {
t.Fatalf("seal (alt): %v", err)
}
if oldEnvelope.KEKVersion != 1 {
t.Fatalf("erwartete kek-version 1 vor rotation, habe %d", oldEnvelope.KEKVersion)
}
oldCiphertext, err := io.ReadAll(oldEnvelope.Ciphertext)
if err != nil {
t.Fatalf("chiffretext (alt) lesen: %v", err)
}
// Core rotiert den Tenant-Hauptschlüssel — TenantKEK liefert ab jetzt
// einen komplett anderen Wert, exakt wie internal/kek.Store.
// RotateTenantKEK es real bei Core bewirkt.
provider.rotate(bytes.Repeat([]byte{0x52}, KEKSize))
// Objekt NACH der Rotation versiegeln (Akzeptanzkriterium 2: kein
// Neuverschlüsseln des Altbestands nötig, nur neue Objekte nutzen den
// neuen Schlüssel).
newEnvelope, err := svc.Seal(ctx, tenant, strings.NewReader("neuer inhalt nach rotation"))
if err != nil {
t.Fatalf("seal (neu): %v", err)
}
if newEnvelope.KEKVersion != 2 {
t.Fatalf("erwartete kek-version 2 nach rotation, habe %d", newEnvelope.KEKVersion)
}
// Altbestand bleibt über die aufgezeichnete Version lesbar
// (Akzeptanzkriterium 3).
openedOld, err := svc.OpenAtVersion(ctx, tenant, oldEnvelope.KEKVersion, oldEnvelope.WrappedDEK, bytes.NewReader(oldCiphertext))
if err != nil {
t.Fatalf("openatversion (alt, nach rotation): %v", err)
}
plainOld, err := io.ReadAll(openedOld)
if err != nil {
t.Fatalf("altbestand lesen: %v", err)
}
if string(plainOld) != "altbestand vor rotation" {
t.Fatalf("altbestand-inhalt stimmt nicht, habe %q", string(plainOld))
}
// Der naive Open() (aktueller KEK) darf für das ALTE Objekt inzwischen
// NICHT mehr funktionieren — das beweist, dass OpenAtVersion die
// Rotation tatsächlich überbrückt, statt zufällig auch so zu klappen.
if _, err := svc.Open(ctx, tenant, oldEnvelope.WrappedDEK, bytes.NewReader(oldCiphertext)); err == nil {
t.Fatal("erwartete fehler bei Open() des altbestands mit dem NEUEN aktuellen kek, habe nil")
}
}
// TestRotation_CompromisedOldKeyCanBeRevoked ist die geforderte
// Pflichtprüfung 2: kompromittierter alter Schlüssel kann gezielt
// gesperrt werden.
func TestRotation_CompromisedOldKeyCanBeRevoked(t *testing.T) {
tenant := "mandant-arc08-revoke-e2e"
store := setupKEKVersionStore(t, tenant)
ctx := context.Background()
provider := &rotatableKEKProvider{current: bytes.Repeat([]byte{0x61}, KEKSize)}
svc := NewService(provider).WithVersionStore(store)
compromisedEnvelope, err := svc.Seal(ctx, tenant, strings.NewReader("mit kompromittiertem schlüssel versiegelt"))
if err != nil {
t.Fatalf("seal: %v", err)
}
compromisedCiphertext, err := io.ReadAll(compromisedEnvelope.Ciphertext)
if err != nil {
t.Fatalf("chiffretext lesen: %v", err)
}
provider.rotate(bytes.Repeat([]byte{0x62}, KEKSize))
if err := store.Revoke(ctx, tenant, compromisedEnvelope.KEKVersion); err != nil {
t.Fatalf("kompromittierte version sperren: %v", err)
}
_, err = svc.OpenAtVersion(ctx, tenant, compromisedEnvelope.KEKVersion, compromisedEnvelope.WrappedDEK, bytes.NewReader(compromisedCiphertext))
if err == nil {
t.Fatal("erwartete fehler beim lesen mit gesperrter kek-version, habe nil")
}
}
-114
View File
@@ -1,114 +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).
// KEKVersion identifiziert (ARC-08), MIT welcher Tenant-KEK-Version der
// DEK verpackt wurde — 0, solange kein KEKVersionStore konfiguriert ist
// (Rückwärtskompatibilität, siehe WithVersionStore).
type Envelope struct {
Ciphertext io.Reader
WrappedDEK []byte
KEKVersion int
}
// 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
versions *KEKVersionStore
}
func NewService(kek KEKProvider) *Service {
return &Service{kek: kek}
}
// WithVersionStore aktiviert die Tenant-KEK-Versionsverfolgung (ARC-08).
// Ohne aufgerufenes WithVersionStore verhält sich Service exakt wie vor
// ARC-08 (KEKVersion bleibt 0, OpenAtVersion fällt auf Open zurück) —
// bestehende Aufrufer (z. B. encstorage) sind unverändert lauffähig.
func (s *Service) WithVersionStore(store *KEKVersionStore) *Service {
s.versions = store
return s
}
// 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 außer in der optionalen
// KEK-Versionshistorie für spätere Altbestands-Lesezugriffe).
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
}
var kekVersion int
if s.versions != nil {
kekVersion, err = s.versions.RecordIfNew(ctx, tenantSlug, kek)
if err != nil {
return nil, fmt.Errorf("crypto: kek-version erfassen: %w", err)
}
}
return &Envelope{Ciphertext: ciphertext, WrappedDEK: wrappedDEK, KEKVersion: kekVersion}, 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. Nach einer Tenant-KEK-Rotation bei
// Core funktioniert Open nur noch für Objekte, die mit dem NEUEN KEK
// versiegelt wurden — für Altbestand siehe OpenAtVersion.
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)
}
// OpenAtVersion entpackt den DEK mit der beim Seal aufgezeichneten
// historischen Tenant-KEK-Version statt mit dem aktuellen Core-KEK
// (ARC-08 Akzeptanzkriterium 3: Altbestand bleibt nach einer
// Hauptschlüssel-Rotation lesbar). Ist kekVersion 0 oder kein
// KEKVersionStore konfiguriert, verhält es sich wie Open (Rückwärts-
// kompatibilität für vor ARC-08 versiegelte Objekte).
func (s *Service) OpenAtVersion(ctx context.Context, tenantSlug string, kekVersion int, wrappedDEK []byte, ciphertext io.Reader) (io.Reader, error) {
if kekVersion == 0 || s.versions == nil {
return s.Open(ctx, tenantSlug, wrappedDEK, ciphertext)
}
kek, err := s.versions.Get(ctx, tenantSlug, kekVersion)
if err != nil {
return nil, fmt.Errorf("crypto: historischen 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)
}
}
-138
View File
@@ -1,138 +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"
"errors"
"fmt"
"io"
"strconv"
"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"
}
// kekVersionKey ist der Sidecar-Objektschlüssel für die Tenant-KEK-
// Version, mit der der DEK verpackt wurde (ARC-08 Akzeptanzkriterium 3).
// Fehlt dieser Sidecar (vor ARC-08 geschriebene Objekte), wird Version 0
// angenommen — GetDecrypted verhält sich dann wie vor ARC-08.
func kekVersionKey(key string) string {
return key + ".dek.version"
}
// 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)
}
if env.KEKVersion != 0 {
versionBytes := []byte(strconv.Itoa(env.KEKVersion))
if _, err := s.storage.Put(ctx, kekVersionKey(key), bytes.NewReader(versionBytes), int64(len(versionBytes)), "text/plain"); err != nil {
return fmt.Errorf("encstorage: kek-version 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)
}
kekVersion, err := s.readKEKVersion(ctx, key)
if err != nil {
return nil, err
}
plaintextReader, err := s.crypto.OpenAtVersion(ctx, tenantSlug, kekVersion, wrappedDEK, bytes.NewReader(ciphertext))
if err != nil {
return nil, err
}
return io.ReadAll(plaintextReader)
}
// readKEKVersion liest den Versions-Sidecar (ARC-08). Fehlt er (vor
// ARC-08 geschriebene Objekte, oder ein Seal ohne konfigurierten
// KEKVersionStore), gilt Version 0 — crypto.Service.OpenAtVersion fällt
// dafür auf das unveränderte Open-Verhalten zurück.
func (s *Service) readKEKVersion(ctx context.Context, key string) (int, error) {
reader, err := s.storage.Get(ctx, kekVersionKey(key))
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
return 0, nil
}
return 0, fmt.Errorf("encstorage: kek-version lesen: %w", err)
}
defer func() { _ = reader.Close() }()
raw, err := io.ReadAll(reader)
if err != nil {
return 0, fmt.Errorf("encstorage: kek-version lesen: %w", err)
}
version, err := strconv.Atoi(string(raw))
if err != nil {
return 0, fmt.Errorf("encstorage: kek-version parsen: %w", err)
}
return version, nil
}
-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)
})
}
-124
View File
@@ -1,124 +0,0 @@
// SRC-10: Spracherkennung & OCR-Qualitätsbewertung. Tesseract erkennt bei
// kombinierten Sprachpaketen (Languages = "deu+eng") nicht, WELCHE Sprache
// tatsächlich vorlag — daher wird das Bild hier bewusst EINZELN mit jedem
// Kandidaten aus candidateLanguages erkannt, und die Sprache mit dem
// höheren durchschnittlichen Worterkennungs-Konfidenzwert gewinnt
// (Akzeptanzkriterium 1). Derselbe Tesseract-TSV-Lauf liefert den
// Konfidenzwert direkt mit (Akzeptanzkriterium 2) — keine zweite externe
// Bibliothek für Sprach- ODER Konfidenzerkennung nötig.
package ocr
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// candidateLanguages sind die unterstützten Einzelsprachen — muss eine
// Teilmenge der in Languages kombinierten Sprachpakete sein.
var candidateLanguages = []string{"deu", "eng"}
// RecognizedText ist das Ergebnis einer sprach- und konfidenzbewussten
// Texterkennung.
type RecognizedText struct {
Text string
// Language ist der ISO-639-2-Sprachcode aus candidateLanguages mit dem
// höchsten Konfidenzwert (Akzeptanzkriterium 1).
Language string
// Confidence ist der durchschnittliche Tesseract-Worterkennungs-
// Konfidenzwert (0100) der gewählten Sprache (Akzeptanzkriterium 2).
Confidence float64
}
// RecognizeWithLanguageAndConfidence erkennt Sprache und Text eines Bildes
// und liefert einen auswertbaren Konfidenzwert.
func RecognizeWithLanguageAndConfidence(ctx context.Context, imageBytes []byte) (RecognizedText, error) {
var best RecognizedText
haveResult := false
for _, lang := range candidateLanguages {
text, confidence, err := recognizeWithTSV(ctx, imageBytes, lang)
if err != nil {
return RecognizedText{}, err
}
if !haveResult || confidence > best.Confidence {
best = RecognizedText{Text: text, Language: lang, Confidence: confidence}
haveResult = true
}
}
return best, nil
}
// recognizeWithTSV führt Tesseract mit GENAU einer Sprache im TSV-Ausgabe-
// modus aus und liefert den zusammengesetzten Text sowie den
// durchschnittlichen Konfidenzwert über alle erkannten Wörter (Tesseract-
// TSV-Zeilen mit level=5, conf>=0 — negative Konfidenzwerte markieren
// Struktur-/Layoutzeilen ohne eigenen Text, siehe Tesseract-TSV-Format).
func recognizeWithTSV(ctx context.Context, imageBytes []byte, lang string) (text string, confidence float64, err error) {
workDir, err := os.MkdirTemp("", "mail-ocr-lang-*")
if err != nil {
return "", 0, fmt.Errorf("ocr: arbeitsverzeichnis anlegen: %w", err)
}
defer func() { _ = os.RemoveAll(workDir) }()
imagePath := filepath.Join(workDir, "input")
if err := os.WriteFile(imagePath, imageBytes, 0o600); err != nil {
return "", 0, fmt.Errorf("ocr: bild schreiben: %w", err)
}
outBase := filepath.Join(workDir, "output")
cmd := exec.CommandContext(ctx, "tesseract", imagePath, outBase, "-l", lang, "tsv")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", 0, fmt.Errorf("ocr: tesseract (tsv, %s) fehlgeschlagen: %w (%s)", lang, err, stderr.String())
}
tsvBytes, err := os.ReadFile(outBase + ".tsv")
if err != nil {
return "", 0, fmt.Errorf("ocr: tsv-ausgabe lesen: %w", err)
}
return parseTSV(string(tsvBytes))
}
// tsvWordLevel ist der Tesseract-TSV-"level"-Wert für einzelne Wörter
// (1=Seite, 2=Block, 3=Absatz, 4=Zeile, 5=Wort).
const tsvWordLevel = "5"
func parseTSV(tsv string) (text string, confidence float64, err error) {
lines := strings.Split(tsv, "\n")
var words []string
var confSum float64
var confCount int
for i, line := range lines {
if i == 0 || strings.TrimSpace(line) == "" {
continue // Kopfzeile bzw. Leerzeile überspringen
}
cols := strings.Split(line, "\t")
if len(cols) < 12 || cols[0] != tsvWordLevel {
continue
}
conf, convErr := strconv.ParseFloat(cols[10], 64)
if convErr != nil || conf < 0 {
continue
}
word := cols[11]
if word == "" {
continue
}
words = append(words, word)
confSum += conf
confCount++
}
if confCount == 0 {
return "", 0, nil
}
return strings.Join(words, " "), confSum / float64(confCount), nil
}
-101
View File
@@ -1,101 +0,0 @@
package ocr
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"testing"
)
// TestRecognizeWithLanguageAndConfidence_MultilingualCorpus ist die
// geforderte Pflichtprüfung 1: Test mit mehrsprachigem Testkorpus
// bestätigt korrekte Spracherkennung.
func TestRecognizeWithLanguageAndConfidence_MultilingualCorpus(t *testing.T) {
requireTools(t)
ctx := context.Background()
germanImage := knownTextImage(t, ctx, "Rechnung ueber Lieferung Nummer Zwei")
englishImage := knownTextImage(t, ctx, "Invoice for delivery number two")
germanResult, err := RecognizeWithLanguageAndConfidence(ctx, germanImage)
if err != nil {
t.Fatalf("deutsches bild: %v", err)
}
if germanResult.Language != "deu" {
t.Fatalf("erwartete sprache 'deu' für deutschen text, habe %q (text: %q)", germanResult.Language, germanResult.Text)
}
englishResult, err := RecognizeWithLanguageAndConfidence(ctx, englishImage)
if err != nil {
t.Fatalf("englisches bild: %v", err)
}
if englishResult.Language != "eng" {
t.Fatalf("erwartete sprache 'eng' für englischen text, habe %q (text: %q)", englishResult.Language, englishResult.Text)
}
}
// TestRecognizeWithLanguageAndConfidence_DegradedImageLowersConfidence ist
// die geforderte Pflichtprüfung 2: künstlich verschlechtertes Bild erzeugt
// niedrigeren Konfidenzwert.
func TestRecognizeWithLanguageAndConfidence_DegradedImageLowersConfidence(t *testing.T) {
requireTools(t)
ctx := context.Background()
original := knownTextImage(t, ctx, "Qualitaetsvergleich Testbild Konfidenz")
originalImg, err := png.Decode(bytes.NewReader(original))
if err != nil {
t.Fatalf("original dekodieren: %v", err)
}
degradedImg := pixelate(originalImg, 12)
var degradedBuf bytes.Buffer
if err := png.Encode(&degradedBuf, degradedImg); err != nil {
t.Fatalf("verschlechtertes bild kodieren: %v", err)
}
originalResult, err := RecognizeWithLanguageAndConfidence(ctx, original)
if err != nil {
t.Fatalf("original erkennen: %v", err)
}
degradedResult, err := RecognizeWithLanguageAndConfidence(ctx, degradedBuf.Bytes())
if err != nil {
t.Fatalf("verschlechtertes bild erkennen: %v", err)
}
t.Logf("original: conf=%.2f text=%q — verschlechtert: conf=%.2f text=%q",
originalResult.Confidence, originalResult.Text, degradedResult.Confidence, degradedResult.Text)
if degradedResult.Confidence >= originalResult.Confidence {
t.Fatalf("erwartete niedrigeren konfidenzwert für verschlechtertes bild, habe original=%.2f verschlechtert=%.2f",
originalResult.Confidence, degradedResult.Confidence)
}
}
// pixelate verschlechtert img künstlich, reproduzierbar und ohne externe
// Werkzeuge: Downsample um factor per Nearest-Neighbor, dann wieder auf
// Originalgröße hochskaliert — zerstört Feindetails der Textglyphen.
func pixelate(img image.Image, factor int) image.Image {
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
out := image.NewRGBA(bounds)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
blockX := (x / factor) * factor
blockY := (y / factor) * factor
c := img.At(bounds.Min.X+blockX, bounds.Min.Y+blockY)
out.Set(bounds.Min.X+x, bounds.Min.Y+y, colorToGray50(c))
}
}
return out
}
// colorToGray50 mischt zusätzlich mit 50% Grau, um den Kontrast zu
// verringern — verstärkt die künstliche Qualitätsverschlechterung über
// reine Pixelierung hinaus.
func colorToGray50(c color.Color) color.Color {
r, g, b, a := c.RGBA()
mix := func(v uint32) uint8 {
return uint8((v>>8)/2 + 128/2)
}
return color.RGBA{R: mix(r), G: mix(g), B: mix(b), A: uint8(a >> 8)}
}
-187
View File
@@ -1,187 +0,0 @@
// Package ocr implementiert SRC-07: eine OCR-Pipeline für Bild-/PDF-
// Anhänge, als eigener Mail-OCR-Pfad unabhängig vom DMS-Board (siehe
// repos-analyse-mail-reuse.md: dupliziertes Sprintf-WHERE-Muster aus
// archivmail wird NICHT übernommen — dieses Paket baut keine SQL-Klauseln,
// ruft ausschließlich externe Kommandozeilenwerkzeuge mit festen
// Argumentlisten auf, niemals über eine Shell/String-Konkatenation).
//
// Zustandslos: dieses Paket kennt weder Mandanten noch Speicher — es
// nimmt Anhangs-Bytes entgegen und liefert erkannten Text zurück, exakt
// wie mail/internal/crypto und mail/internal/dedup. Tenant-Trennung
// entsteht dadurch strukturell: es gibt keinen geteilten Zustand
// zwischen Aufrufen, den unterschiedliche Mandanten sehen könnten (jeder
// Aufruf bekommt sein eigenes Temp-Verzeichnis, siehe workDir).
//
// Bekannter Fehler vermeiden (siehe ARC-03-Ticket): OCR darf erst NACH
// abgeschlossener Duplikatsprüfung laufen — das ist Sache des Aufrufers
// (spätere Ingest-Tickets), dieses Paket kennt mail/internal/dedup nicht.
package ocr
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// Languages ist die feste Tesseract-Sprachkombination (Akzeptanzkriterium
// 1 aus SRC-10 baut hierauf auf) — Deutsch und Englisch, passend zum
// deutschsprachigen NEXARCH-Einsatzkontext mit englischsprachigen
// Geschäftskorrespondenzen.
const Languages = "deu+eng"
// ExtractTextFromImage führt Tesseract-OCR auf einem einzelnen Bild aus
// (PNG/JPEG/TIFF/BMP — von Tesseract/Leptonica unterstützte Formate).
func ExtractTextFromImage(ctx context.Context, imageBytes []byte) (string, error) {
workDir, err := os.MkdirTemp("", "mail-ocr-*")
if err != nil {
return "", fmt.Errorf("ocr: arbeitsverzeichnis anlegen: %w", err)
}
defer func() { _ = os.RemoveAll(workDir) }()
imagePath := filepath.Join(workDir, "input")
if err := os.WriteFile(imagePath, imageBytes, 0o600); err != nil {
return "", fmt.Errorf("ocr: bild schreiben: %w", err)
}
outBase := filepath.Join(workDir, "output")
cmd := exec.CommandContext(ctx, "tesseract", imagePath, outBase, "-l", Languages)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("ocr: tesseract fehlgeschlagen: %w (%s)", err, stderr.String())
}
text, err := os.ReadFile(outBase + ".txt")
if err != nil {
return "", fmt.Errorf("ocr: erkannten text lesen: %w", err)
}
return strings.TrimSpace(string(text)), nil
}
// minTextLayerLength ist die Mindestanzahl nicht-leerer Zeichen, ab der
// ein PDF als "hat bereits eine Textebene" gilt (Akzeptanzkriterium 3).
// Ein paar Steuerzeichen/Leerzeichen aus pdftotext bei einem rein
// bildbasierten PDF dürfen keine OCR-Umgehung auslösen.
const minTextLayerLength = 10
// HasTextLayer prüft über pdftotext, ob pdfBytes bereits durchsuchbaren
// Text enthält (Akzeptanzkriterium 3: bereits durchsuchbare PDFs werden
// nicht unnötig erneut per OCR verarbeitet).
func HasTextLayer(ctx context.Context, pdfBytes []byte) (bool, error) {
text, err := extractPDFText(ctx, pdfBytes)
if err != nil {
return false, err
}
return len(strings.TrimSpace(text)) >= minTextLayerLength, nil
}
func extractPDFText(ctx context.Context, pdfBytes []byte) (string, error) {
workDir, err := os.MkdirTemp("", "mail-ocr-pdf-*")
if err != nil {
return "", fmt.Errorf("ocr: arbeitsverzeichnis anlegen: %w", err)
}
defer func() { _ = os.RemoveAll(workDir) }()
pdfPath := filepath.Join(workDir, "input.pdf")
if err := os.WriteFile(pdfPath, pdfBytes, 0o600); err != nil {
return "", fmt.Errorf("ocr: pdf schreiben: %w", err)
}
cmd := exec.CommandContext(ctx, "pdftotext", pdfPath, "-")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("ocr: pdftotext fehlgeschlagen: %w (%s)", err, stderr.String())
}
return stdout.String(), nil
}
// PDFResult ist das Ergebnis von ExtractTextFromPDF.
type PDFResult struct {
Text string
// OCRPerformed ist false, wenn eine vorhandene Textebene genutzt
// wurde (Akzeptanzkriterium 3), true, wenn tatsächlich per Tesseract
// erkannt wurde (Akzeptanzkriterium 1).
OCRPerformed bool
}
// ExtractTextFromPDF liefert den Text eines PDF-Anhangs: hat das PDF
// bereits eine Textebene, wird sie direkt übernommen (schneller,
// zuverlässiger als OCR, Akzeptanzkriterium 3). Sonst wird jede Seite
// gerastert und per Tesseract erkannt (Akzeptanzkriterium 1: gescannte
// PDF-Anhänge).
func ExtractTextFromPDF(ctx context.Context, pdfBytes []byte) (PDFResult, error) {
existingText, err := extractPDFText(ctx, pdfBytes)
if err != nil {
return PDFResult{}, err
}
if len(strings.TrimSpace(existingText)) >= minTextLayerLength {
return PDFResult{Text: strings.TrimSpace(existingText), OCRPerformed: false}, nil
}
pageImages, err := rasterizePDF(ctx, pdfBytes)
if err != nil {
return PDFResult{}, err
}
var pages []string
for _, imageBytes := range pageImages {
pageText, err := ExtractTextFromImage(ctx, imageBytes)
if err != nil {
return PDFResult{}, err
}
pages = append(pages, pageText)
}
return PDFResult{Text: strings.TrimSpace(strings.Join(pages, "\n")), OCRPerformed: true}, nil
}
// rasterizePDF rastert jede Seite von pdfBytes über pdftoppm in ein PNG
// und liefert die Seiten in Reihenfolge.
func rasterizePDF(ctx context.Context, pdfBytes []byte) ([][]byte, error) {
workDir, err := os.MkdirTemp("", "mail-ocr-raster-*")
if err != nil {
return nil, fmt.Errorf("ocr: arbeitsverzeichnis anlegen: %w", err)
}
defer func() { _ = os.RemoveAll(workDir) }()
pdfPath := filepath.Join(workDir, "input.pdf")
if err := os.WriteFile(pdfPath, pdfBytes, 0o600); err != nil {
return nil, fmt.Errorf("ocr: pdf schreiben: %w", err)
}
outBase := filepath.Join(workDir, "page")
cmd := exec.CommandContext(ctx, "pdftoppm", "-png", "-r", "300", pdfPath, outBase)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ocr: pdftoppm fehlgeschlagen: %w (%s)", err, stderr.String())
}
entries, err := os.ReadDir(workDir)
if err != nil {
return nil, fmt.Errorf("ocr: gerasterte seiten auflisten: %w", err)
}
var pagePaths []string
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".png") {
pagePaths = append(pagePaths, filepath.Join(workDir, e.Name()))
}
}
sort.Strings(pagePaths)
var pages [][]byte
for _, p := range pagePaths {
data, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("ocr: gerasterte seite lesen: %w", err)
}
pages = append(pages, data)
}
return pages, nil
}
-156
View File
@@ -1,156 +0,0 @@
package ocr
import (
"bytes"
"context"
"image/png"
"os/exec"
"strings"
"testing"
"time"
)
func requireTools(t *testing.T) {
t.Helper()
for _, tool := range []string{"tesseract", "pdftotext", "pdftoppm"} {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("%s nicht im PATH, Integrationstest übersprungen", tool)
}
}
}
// knownTextImage rastert ein hand-gebautes Text-PDF (bekannter Inhalt)
// über die echte pdftoppm-Pipeline in ein reales PNG-Bild — liefert damit
// ein Bild mit tatsächlich gerenderten, OCR-fähigen Glyphen, ohne eine
// zusätzliche Font-Rendering-Bibliothek einzuführen.
func knownTextImage(t *testing.T, ctx context.Context, text string) []byte {
t.Helper()
pdfBytes := buildTextLayerPDF(text)
pages, err := rasterizePDF(ctx, pdfBytes)
if err != nil {
t.Fatalf("testbild rastern: %v", err)
}
if len(pages) != 1 {
t.Fatalf("erwartete genau 1 gerasterte seite, habe %d", len(pages))
}
return pages[0]
}
// TestExtractTextFromImage_KnownTextRecognized ist die geforderte
// Pflichtprüfung 1: Bildanhang mit bekanntem Text liefert erwartete
// Texterkennung.
func TestExtractTextFromImage_KnownTextRecognized(t *testing.T) {
requireTools(t)
ctx := context.Background()
imageBytes := knownTextImage(t, ctx, "Rechnungsnummer 4711")
got, err := ExtractTextFromImage(ctx, imageBytes)
if err != nil {
t.Fatalf("extracttextfromimage: %v", err)
}
if !strings.Contains(got, "Rechnungsnummer") || !strings.Contains(got, "4711") {
t.Fatalf("erwarteten text nicht erkannt, habe: %q", got)
}
}
// TestExtractTextFromPDF_SkipsOCRWhenTextLayerPresent ist die geforderte
// Pflichtprüfung 2: PDF mit vorhandener Textebene wird korrekt
// übersprungen.
func TestExtractTextFromPDF_SkipsOCRWhenTextLayerPresent(t *testing.T) {
requireTools(t)
ctx := context.Background()
pdfBytes := buildTextLayerPDF("Vertragsentwurf Version 3")
hasLayer, err := HasTextLayer(ctx, pdfBytes)
if err != nil {
t.Fatalf("hastextlayer: %v", err)
}
if !hasLayer {
t.Fatal("erwartete erkannte textebene im vektor-pdf")
}
result, err := ExtractTextFromPDF(ctx, pdfBytes)
if err != nil {
t.Fatalf("extracttextfrompdf: %v", err)
}
if result.OCRPerformed {
t.Fatal("ocr wurde durchgeführt, obwohl eine textebene vorhanden war")
}
if !strings.Contains(result.Text, "Vertragsentwurf") {
t.Fatalf("erwarteten text aus textebene nicht gefunden, habe: %q", result.Text)
}
}
// TestExtractTextFromPDF_PerformsOCRWhenNoTextLayer deckt
// Akzeptanzkriterium 1 (gescannte PDF-Anhänge) zusätzlich end-zu-Ende ab:
// ein PDF ohne Textebene, das nur ein Rasterbild enthält, wird real per
// OCR erkannt.
func TestExtractTextFromPDF_PerformsOCRWhenNoTextLayer(t *testing.T) {
requireTools(t)
ctx := context.Background()
pngBytes := knownTextImage(t, ctx, "Gescannter Anhang Test")
img, err := png.Decode(bytes.NewReader(pngBytes))
if err != nil {
t.Fatalf("testbild dekodieren: %v", err)
}
scannedPDF, err := buildImageOnlyPDF(img)
if err != nil {
t.Fatalf("bild-pdf bauen: %v", err)
}
hasLayer, err := HasTextLayer(ctx, scannedPDF)
if err != nil {
t.Fatalf("hastextlayer: %v", err)
}
if hasLayer {
t.Fatal("bild-only-pdf wurde fälschlich als textebene erkannt")
}
result, err := ExtractTextFromPDF(ctx, scannedPDF)
if err != nil {
t.Fatalf("extracttextfrompdf: %v", err)
}
if !result.OCRPerformed {
t.Fatal("erwartete durchgeführte ocr bei fehlender textebene")
}
if !strings.Contains(result.Text, "Gescannter") {
t.Fatalf("erwarteten text aus ocr nicht gefunden, habe: %q", result.Text)
}
}
// TestExtractTextFromPDF_ThroughputIsAcceptable ist die geforderte
// Pflichtprüfung 3: Durchsatztest bestätigt akzeptable Verarbeitungszeit
// je Anhang. Zielzeit 8s je Anhang (großzügig für Rasterung + OCR bei
// 300dpi, deckt aber real ab, dass kein pathologischer Ausreißer
// entsteht).
func TestExtractTextFromPDF_ThroughputIsAcceptable(t *testing.T) {
requireTools(t)
ctx := context.Background()
const attachments = 3
const targetPerAttachment = 8 * time.Second
pngBytes := knownTextImage(t, ctx, "Durchsatztest Anhang")
img, err := png.Decode(bytes.NewReader(pngBytes))
if err != nil {
t.Fatalf("testbild dekodieren: %v", err)
}
scannedPDF, err := buildImageOnlyPDF(img)
if err != nil {
t.Fatalf("bild-pdf bauen: %v", err)
}
start := time.Now()
for i := 0; i < attachments; i++ {
if _, err := ExtractTextFromPDF(ctx, scannedPDF); err != nil {
t.Fatalf("anhang %d: %v", i, err)
}
}
elapsed := time.Since(start)
perAttachment := elapsed / attachments
t.Logf("Durchsatz: %d Anhänge in %s (%s/Anhang, Ziel %s/Anhang)", attachments, elapsed, perAttachment, targetPerAttachment)
if perAttachment > targetPerAttachment {
t.Fatalf("verarbeitung zu langsam: %s/anhang, ziel %s/anhang", perAttachment, targetPerAttachment)
}
}
-95
View File
@@ -1,95 +0,0 @@
package ocr
import (
"bytes"
"fmt"
"image"
"image/jpeg"
)
// buildTextLayerPDF erzeugt ein minimales, aber ECHTES PDF mit einer
// vektorbasierten Textebene (Standardfont Helvetica, kein eingebettetes
// Fontprogramm nötig) — von Hand gebaut, keine externe Bibliothek/kein
// externes Werkzeug, damit der Test reproduzierbar und unabhängig von
// Systempaketen bleibt. pdftotext extrahiert text unmittelbar daraus.
func buildTextLayerPDF(text string) []byte {
var buf bytes.Buffer
offsets := make([]int, 0, 6)
buf.WriteString("%PDF-1.4\n")
writeObj := func(n int, body string) {
offsets = append(offsets, buf.Len())
fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", n, body)
}
writeObj(1, "<< /Type /Catalog /Pages 2 0 R >>")
writeObj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
writeObj(3, "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> /MediaBox [0 0 400 200] /Contents 4 0 R >>")
content := fmt.Sprintf("BT /F1 24 Tf 20 100 Td (%s) Tj ET", text)
stream := fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content)
writeObj(4, stream)
writeObj(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
xrefStart := buf.Len()
fmt.Fprintf(&buf, "xref\n0 %d\n", len(offsets)+1)
buf.WriteString("0000000000 65535 f \n")
for _, off := range offsets {
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF", len(offsets)+1, xrefStart)
return buf.Bytes()
}
// buildImageOnlyPDF erzeugt ein minimales, ECHTES PDF, dessen einzige
// Seite ausschließlich aus einem eingebetteten Rasterbild besteht (JPEG
// via /DCTDecode — von PDF nativ unterstützt, kein zusätzlicher Codec
// nötig) — simuliert ein gescanntes PDF ohne Textebene
// (Akzeptanzkriterium 1).
func buildImageOnlyPDF(img image.Image) ([]byte, error) {
var jpegBuf bytes.Buffer
if err := jpeg.Encode(&jpegBuf, img, &jpeg.Options{Quality: 90}); err != nil {
return nil, fmt.Errorf("jpeg kodieren: %w", err)
}
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
var buf bytes.Buffer
offsets := make([]int, 0, 6)
buf.WriteString("%PDF-1.4\n")
writeObj := func(n int, header string, body []byte) {
offsets = append(offsets, buf.Len())
fmt.Fprintf(&buf, "%d 0 obj\n%s\n", n, header)
if body != nil {
buf.Write(body)
buf.WriteString("\nendstream\n")
}
buf.WriteString("endobj\n")
}
writeObj(1, "<< /Type /Catalog /Pages 2 0 R >>", nil)
writeObj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", nil)
writeObj(3, fmt.Sprintf("<< /Type /Page /Parent 2 0 R /Resources << /XObject << /Im0 5 0 R >> >> /MediaBox [0 0 %d %d] /Contents 4 0 R >>", w, h), nil)
content := fmt.Sprintf("q %d 0 0 %d 0 0 cm /Im0 Do Q", w, h)
writeObj(4, fmt.Sprintf("<< /Length %d >>\nstream", len(content)), []byte(content))
imgHeader := fmt.Sprintf(
"<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream",
w, h, jpegBuf.Len(),
)
writeObj(5, imgHeader, jpegBuf.Bytes())
xrefStart := buf.Len()
fmt.Fprintf(&buf, "xref\n0 %d\n", len(offsets)+1)
buf.WriteString("0000000000 65535 f \n")
for _, off := range offsets {
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF", len(offsets)+1, xrefStart)
return buf.Bytes(), nil
}
-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)
}
}
@@ -1,11 +0,0 @@
CREATE TABLE IF NOT EXISTS mail_saved_searches (
id BIGSERIAL PRIMARY KEY,
tenant_slug TEXT NOT NULL,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
query_text TEXT NOT NULL DEFAULT '',
filters JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_slug, user_id, name)
)
-174
View File
@@ -1,174 +0,0 @@
// Package savedsearch implementiert SRC-08: benannte, wiederausführbare
// Suchanfragen inklusive aktiver Filter. Speichert ausschließlich die
// SUCHKRITERIEN (Suchtext + Filter), niemals Trefferlisten — Execute ruft
// bei jeder Ausführung real gegen mail/internal/search (SRC-01/SRC-03/
// SRC-05) auf, damit stets aktuelle Treffer geliefert werden, kein
// eingefrorener Snapshot (Akzeptanzkriterium 2). Kombiniert bewusst zwei
// bereits fertige, unveränderte Pakete (Postgres-Speicherung hier,
// Ausführung über search.Client) statt eines davon zu erweitern.
//
// "Benutzer" ist bis zu einer zentralen Session-/IAM-Anbindung (Core-
// Board-Scope, nicht Bestandteil dieser Kachel) ein vom Aufrufer
// mitgegebener opaker userID-String — dieselbe Konvention wie der
// Tenant-Kontext in web/mail-search (SRC-04).
package savedsearch
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
)
//go:embed migrations/0001_mail_saved_searches.sql
var schemaMigration string
// ErrNotFound wird geliefert, wenn keine gespeicherte Suche mit den
// angegebenen Bezugsdaten existiert.
var ErrNotFound = errors.New("savedsearch: gespeicherte suche nicht gefunden")
// SavedSearch ist eine benannte, wiederausführbare Suchanfrage.
type SavedSearch struct {
ID int64
TenantSlug string
UserID string
Name string
QueryText string
Filters []search.FacetFilter
}
// Store persistiert gespeicherte Suchen in Postgres.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert —
// gleiches Muster wie mail/internal/dedup/indexworker (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, schemaMigration); err != nil {
return fmt.Errorf("savedsearch: schema anlegen: %w", err)
}
return nil
}
// Save legt eine gespeicherte Suche unter name an oder aktualisiert sie,
// falls unter demselben Namen für denselben Benutzer/Mandanten bereits
// eine existiert (Akzeptanzkriterium 1).
func (s *Store) Save(ctx context.Context, tenantSlug, userID, name, queryText string, filters []search.FacetFilter) (int64, error) {
filtersJSON, err := json.Marshal(filters)
if err != nil {
return 0, fmt.Errorf("savedsearch: filter serialisieren: %w", err)
}
var id int64
err = s.pool.QueryRow(ctx, `
INSERT INTO mail_saved_searches (tenant_slug, user_id, name, query_text, filters)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_slug, user_id, name) DO UPDATE
SET query_text = EXCLUDED.query_text, filters = EXCLUDED.filters, updated_at = now()
RETURNING id
`, tenantSlug, userID, name, queryText, filtersJSON).Scan(&id)
if err != nil {
return 0, fmt.Errorf("savedsearch: speichern: %w", err)
}
return id, nil
}
// List liefert alle gespeicherten Suchen eines Benutzers innerhalb eines
// Mandanten (Akzeptanzkriterium 3: je Benutzer/Mandant getrennt sichtbar).
func (s *Store) List(ctx context.Context, tenantSlug, userID string) ([]SavedSearch, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_slug, user_id, name, query_text, filters
FROM mail_saved_searches
WHERE tenant_slug = $1 AND user_id = $2
ORDER BY name
`, tenantSlug, userID)
if err != nil {
return nil, fmt.Errorf("savedsearch: liste lesen: %w", err)
}
defer rows.Close()
var results []SavedSearch
for rows.Next() {
item, err := scanSavedSearch(rows)
if err != nil {
return nil, err
}
results = append(results, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("savedsearch: liste iterieren: %w", err)
}
return results, nil
}
// Get liefert eine einzelne gespeicherte Suche, streng auf Mandant und
// Benutzer beschränkt.
func (s *Store) Get(ctx context.Context, tenantSlug, userID string, id int64) (SavedSearch, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, tenant_slug, user_id, name, query_text, filters
FROM mail_saved_searches
WHERE tenant_slug = $1 AND user_id = $2 AND id = $3
`, tenantSlug, userID, id)
item, err := scanSavedSearch(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return SavedSearch{}, ErrNotFound
}
return SavedSearch{}, err
}
return item, nil
}
// Delete entfernt GENAU EINE gespeicherte Suche, streng auf Mandant und
// Benutzer beschränkt (Akzeptanzkriterium/Pflichtprüfung 3: Löschen
// entfernt nur diese eine gespeicherte Suche).
func (s *Store) Delete(ctx context.Context, tenantSlug, userID string, id int64) error {
tag, err := s.pool.Exec(ctx, `
DELETE FROM mail_saved_searches WHERE tenant_slug = $1 AND user_id = $2 AND id = $3
`, tenantSlug, userID, id)
if err != nil {
return fmt.Errorf("savedsearch: löschen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanSavedSearch(row rowScanner) (SavedSearch, error) {
var item SavedSearch
var filtersJSON []byte
if err := row.Scan(&item.ID, &item.TenantSlug, &item.UserID, &item.Name, &item.QueryText, &filtersJSON); err != nil {
return SavedSearch{}, fmt.Errorf("savedsearch: zeile lesen: %w", err)
}
if err := json.Unmarshal(filtersJSON, &item.Filters); err != nil {
return SavedSearch{}, fmt.Errorf("savedsearch: filter parsen: %w", err)
}
return item, nil
}
// Execute führt eine gespeicherte Suche LIVE gegen client aus
// (Akzeptanzkriterium 2: aktuelle Treffer, kein eingefrorener Snapshot —
// Execute speichert selbst nichts zwischen, jeder Aufruf fragt Manticore
// neu ab). Hinterlegte Filter werden über search.Client.SearchWithFilters
// tatsächlich auf die Treffermenge angewandt (UND-Verknüpfung), nicht nur
// auf ihr Vorhandensein geprüft.
func Execute(ctx context.Context, client *search.Client, saved SavedSearch) ([]search.Result, error) {
return client.SearchWithFilters(ctx, saved.TenantSlug, saved.QueryText, saved.Filters)
}
-202
View File
@@ -1,202 +0,0 @@
// Integrationstest (SRC-08): echte Postgres- UND Manticore-Instanz,
// folgt derselben TEST_*-Env-Konvention wie mail/internal/search.
package savedsearch
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
)
func setupStore(t *testing.T) *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)
if err := store.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_saved_searches WHERE tenant_slug LIKE 'mandant-src08-%'`)
})
return store
}
func setupSearchClient(t *testing.T) *search.Client {
t.Helper()
baseURL := os.Getenv("TEST_MANTICORE_URL")
if baseURL == "" {
t.Skip("TEST_MANTICORE_URL nicht gesetzt, Integrationstest übersprungen")
}
client := search.NewClient(baseURL)
if err := client.EnsureSchema(context.Background()); err != nil {
t.Fatalf("search-schema: %v", err)
}
return client
}
// TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly ist die
// geforderte Pflichtprüfung 1: gespeicherte Suche mit mehreren Filtern
// wird korrekt reproduziert.
func TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly(t *testing.T) {
store := setupStore(t)
searchClient := setupSearchClient(t)
ctx := context.Background()
tenant := "mandant-src08-filter"
docs := []search.Document{
{MessageID: "msg-sv-treffer", Subject: "Quartalsbericht", Sender: "alice@example.com", Mailbox: "inbox"},
{MessageID: "msg-sv-falscher-sender", Subject: "Quartalsbericht", Sender: "bob@example.com", Mailbox: "inbox"},
{MessageID: "msg-sv-falsche-mailbox", Subject: "Quartalsbericht", Sender: "alice@example.com", Mailbox: "archiv"},
}
for _, d := range docs {
d.TenantSlug = tenant
d.ID = search.DocumentID(tenant, d.MessageID)
if err := searchClient.Index(ctx, d); err != nil {
t.Fatalf("index %s: %v", d.MessageID, err)
}
}
filters := []search.FacetFilter{
{Field: search.FieldSender, Value: "alice@example.com"},
{Field: search.FieldMailbox, Value: "inbox"},
}
id, err := store.Save(ctx, tenant, "user-1", "Alice Inbox Quartalsbericht", "Quartalsbericht", filters)
if err != nil {
t.Fatalf("save: %v", err)
}
saved, err := store.Get(ctx, tenant, "user-1", id)
if err != nil {
t.Fatalf("get: %v", err)
}
results, err := Execute(ctx, searchClient, saved)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(results) != 1 || results[0].MessageID != "msg-sv-treffer" {
t.Fatalf("erwartete genau msg-sv-treffer, habe: %+v", results)
}
}
// TestExecute_ReturnsCurrentResultsNotFrozenSnapshot deckt
// Akzeptanzkriterium 2 ab: aktuelle Treffer, kein eingefrorener Snapshot.
func TestExecute_ReturnsCurrentResultsNotFrozenSnapshot(t *testing.T) {
store := setupStore(t)
searchClient := setupSearchClient(t)
ctx := context.Background()
tenant := "mandant-src08-live"
docID := search.DocumentID(tenant, "msg-live-1")
// Verteidigung gegen Testreste eines vorherigen Laufs (Manticore wird
// zwischen Testläufen nicht automatisch geleert, anders als Postgres
// über t.Cleanup) — Ausgangszustand muss real leer sein, damit der
// Test die Aussage "kein eingefrorener Snapshot" beweiskräftig prüft.
_ = searchClient.Delete(ctx, docID)
t.Cleanup(func() { _ = searchClient.Delete(context.Background(), docID) })
id, err := store.Save(ctx, tenant, "user-1", "Alles zu Zylotharion", "Zylotharion", nil)
if err != nil {
t.Fatalf("save: %v", err)
}
saved, err := store.Get(ctx, tenant, "user-1", id)
if err != nil {
t.Fatalf("get: %v", err)
}
before, err := Execute(ctx, searchClient, saved)
if err != nil {
t.Fatalf("execute (vorher): %v", err)
}
if len(before) != 0 {
t.Fatalf("erwartete 0 treffer vor indexierung, habe %d", len(before))
}
if err := searchClient.Index(ctx, search.Document{
ID: docID,
TenantSlug: tenant,
MessageID: "msg-live-1",
Subject: "Neuigkeiten zu Zylotharion",
}); err != nil {
t.Fatalf("index: %v", err)
}
after, err := Execute(ctx, searchClient, saved)
if err != nil {
t.Fatalf("execute (nachher): %v", err)
}
if len(after) != 1 {
t.Fatalf("erwartete 1 aktuellen treffer nach neuer indexierung (kein eingefrorener snapshot), habe %d", len(after))
}
}
// TestList_UserSeesNoOtherTenantsSavedSearches ist die geforderte
// Pflichtprüfung 2: Benutzer sieht keine gespeicherten Suchen anderer
// Mandanten.
func TestList_UserSeesNoOtherTenantsSavedSearches(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenantA := "mandant-src08-iso-a"
tenantB := "mandant-src08-iso-b"
if _, err := store.Save(ctx, tenantA, "user-1", "Suche A", "irgendwas", nil); err != nil {
t.Fatalf("save mandant a: %v", err)
}
if _, err := store.Save(ctx, tenantB, "user-1", "Suche B", "irgendwas", nil); err != nil {
t.Fatalf("save mandant b: %v", err)
}
listB, err := store.List(ctx, tenantB, "user-1")
if err != nil {
t.Fatalf("list mandant b: %v", err)
}
if len(listB) != 1 || listB[0].Name != "Suche B" {
t.Fatalf("mandant b sieht fremde/fehlende suchen: %+v", listB)
}
}
// TestDelete_RemovesOnlyThatSavedSearch ist die geforderte Pflichtprüfung
// 3: Löschen einer gespeicherten Suche entfernt nur diese.
func TestDelete_RemovesOnlyThatSavedSearch(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenant := "mandant-src08-delete"
idA, err := store.Save(ctx, tenant, "user-1", "Suche A", "a", nil)
if err != nil {
t.Fatalf("save a: %v", err)
}
idB, err := store.Save(ctx, tenant, "user-1", "Suche B", "b", nil)
if err != nil {
t.Fatalf("save b: %v", err)
}
if err := store.Delete(ctx, tenant, "user-1", idA); err != nil {
t.Fatalf("delete a: %v", err)
}
if _, err := store.Get(ctx, tenant, "user-1", idA); err != ErrNotFound {
t.Fatalf("erwartete ErrNotFound für gelöschte suche a, habe: %v", err)
}
stillThere, err := store.Get(ctx, tenant, "user-1", idB)
if err != nil {
t.Fatalf("suche b sollte weiterhin existieren: %v", err)
}
if stillThere.Name != "Suche B" {
t.Fatalf("unerwarteter inhalt für suche b: %+v", stillThere)
}
}
-350
View File
@@ -1,350 +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
// SRC-10: OCR-Sprach-/Qualitätsfelder, gleiches Muster wie die
// Facettenfelder aus SRC-05.
//
//go:embed migrations/0006_mail_documents_ocr_language.sql
var migrationAddOCRLanguage string
//go:embed migrations/0007_mail_documents_ocr_confidence.sql
var migrationAddOCRConfidence string
var facetMigrations = []string{
migrationAddSender,
migrationAddMailbox,
migrationAddAttachmentType,
migrationAddTag,
migrationAddOCRLanguage,
migrationAddOCRConfidence,
}
// 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"`
// OCR-Sprach-/Qualitätsfelder (SRC-10), optional — leerer String/0
// bedeutet "kein OCR-Anhang bzw. kein Konfidenzwert vorhanden".
OCRLanguage string `json:"ocr_language"`
OCRConfidence float64 `json:"ocr_confidence"`
}
// 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
// OCRLanguage/OCRConfidence (SRC-10 Akzeptanzkriterium 1: erkannte
// Sprache für Anzeige nutzbar) — leer/0, wenn das Dokument keinen
// OCR-Anhang hat.
OCRLanguage string
OCRConfidence float64
}
// 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,
OCRLanguage: hit.Source.OCRLanguage,
OCRConfidence: hit.Source.OCRConfidence,
})
}
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"`
OCRLanguage string `json:"ocr_language"`
OCRConfidence float64 `json:"ocr_confidence"`
} `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)
}
}
-292
View File
@@ -1,292 +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
}
// buildFilteredMust baut die gemeinsame bool.must-Liste für Facets und
// SearchWithFilters: Tenant-Filter zwingend, optionaler Suchtext, dann je
// Filter eine zusätzliche equals-Klausel (UND-Verknüpfung) — einzige
// Stelle, an der Filter-Feldnamen gegen FacetFields geprüft werden.
func buildFilteredMust(tenantSlug, queryText string, filters []FacetFilter) ([]map[string]any, 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 nil, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
}
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
}
return must, nil
}
// SearchWithFilters ist Search, zusätzlich beschränkt auf Dokumente, die
// ALLE angegebenen Filter erfüllen (UND-Verknüpfung, dieselbe Semantik wie
// Facets) — Grundlage für SRC-08s Wiederausführung gespeicherter Suchen
// mit Filtern.
func (c *Client) SearchWithFilters(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) ([]Result, error) {
if len(filters) == 0 {
return c.Search(ctx, tenantSlug, queryText)
}
must, err := buildFilteredMust(tenantSlug, queryText, filters)
if err != nil {
return nil, err
}
payload := map[string]any{
"index": IndexName,
"query": map[string]any{"bool": map[string]any{"must": must}},
"options": map[string]any{
"field_weights": fieldWeights,
},
"limit": searchResultLimit,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: gefilterte 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,
OCRLanguage: hit.Source.OCRLanguage,
OCRConfidence: hit.Source.OCRConfidence,
})
}
return results, nil
}
// 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, err := buildFilteredMust(tenantSlug, queryText, filters)
if err != nil {
return FacetResult{}, err
}
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
}
// AttachmentsBelowConfidence liefert alle Dokumente eines Mandanten, deren
// OCR-Konfidenzwert UNTER threshold liegt (SRC-10 Akzeptanzkriterium 3:
// gezielt für manuelle Nachbearbeitung auffindbar). Dokumente ohne
// OCR-Anhang (ocr_confidence bleibt 0) tauchen hier NICHT auf — 0 ist
// "kein Wert", nicht "schlechtester Wert" (siehe Document-Feldkommentar) —
// daher zusätzlicher Filter ocr_confidence > 0.
func (c *Client) AttachmentsBelowConfidence(ctx context.Context, tenantSlug string, threshold float64) ([]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}},
{"range": map[string]any{FieldOCRConfidence: map[string]any{"gt": 0}}},
{"range": map[string]any{FieldOCRConfidence: map[string]any{"lt": threshold}}},
},
},
},
"sort": []map[string]any{{FieldOCRConfidence: "asc"}},
"limit": searchResultLimit,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: konfidenzanfrage 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,
OCRLanguage: hit.Source.OCRLanguage,
OCRConfidence: hit.Source.OCRConfidence,
})
}
return results, 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
}
-57
View File
@@ -1,57 +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"
// OCR-Sprach-/Qualitätsfelder (SRC-10), nachgezogen über
// migrations/0006..0007.
FieldOCRLanguage = "ocr_language"
FieldOCRConfidence = "ocr_confidence"
)
// 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 +0,0 @@
ALTER TABLE mail_documents ADD COLUMN ocr_language string attribute indexed
@@ -1 +0,0 @@
ALTER TABLE mail_documents ADD COLUMN ocr_confidence float
@@ -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")
}
}
@@ -1,53 +0,0 @@
// Integrationstest (SRC-10): echte Manticore-Instanz, TEST_MANTICORE_URL
// (gleiche Konvention wie facets_test.go/ranking_test.go).
package search
import (
"context"
"testing"
)
// TestAttachmentsBelowConfidence_QueryReturnsExpectedResults ist die
// geforderte Pflichtprüfung 3: Abfrage aller Anhänge unterhalb einer
// Konfidenzschwelle liefert erwartete Treffer.
func TestAttachmentsBelowConfidence_QueryReturnsExpectedResults(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src10-konfidenz"
docs := []Document{
{MessageID: "msg-conf-niedrig-1", Subject: "a", OCRLanguage: "deu", OCRConfidence: 22.5},
{MessageID: "msg-conf-niedrig-2", Subject: "b", OCRLanguage: "eng", OCRConfidence: 41.0},
{MessageID: "msg-conf-hoch", Subject: "c", OCRLanguage: "deu", OCRConfidence: 93.2},
{MessageID: "msg-conf-kein-ocr", Subject: "d"}, // kein OCR-Anhang, OCRConfidence bleibt 0
}
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)
}
}
results, err := client.AttachmentsBelowConfidence(ctx, tenant, 50.0)
if err != nil {
t.Fatalf("attachmentsbelowconfidence: %v", err)
}
ids := make(map[string]bool, len(results))
for _, r := range results {
ids[r.MessageID] = true
}
if !ids["msg-conf-niedrig-1"] || !ids["msg-conf-niedrig-2"] {
t.Fatalf("erwartete beide niedrig-konfidenten anhänge, habe: %+v", results)
}
if ids["msg-conf-hoch"] {
t.Fatalf("hoch-konfidenter anhang hätte nicht auftauchen dürfen: %+v", results)
}
if ids["msg-conf-kein-ocr"] {
t.Fatalf("dokument ohne ocr-anhang (confidence=0) hätte nicht auftauchen dürfen: %+v", results)
}
if len(results) != 2 {
t.Fatalf("erwartete genau 2 treffer, habe %d: %+v", len(results), results)
}
}
-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)
}
-292
View File
@@ -1,292 +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 + OCR-Felder aus SRC-10). tableName ist über
// tempTableNamePattern in Rebuild bereits geprüft, bevor diese Funktion
// aufgerufen wird. MUSS bei jeder neuen Spalte in mail_documents
// (migrations/000N_*.sql) mitgepflegt werden — sonst schlägt Reindex mit
// "unknown column" fehl (siehe SRC-10, real so aufgetreten und behoben).
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, %s string attribute indexed, %s float)",
tableName,
FieldTenantSlug, FieldMessageID, FieldSubject, FieldBody, FieldAttachmentText,
FieldSender, FieldMailbox, FieldAttachmentType, FieldTag, FieldSentAt,
FieldOCRLanguage, FieldOCRConfidence,
)
}

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