Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 bd37de529f SRC-07: ocr-fuer-anhaenge
OCR-Pipeline für Bild-/PDF-Anhänge, eigener Mail-OCR-Pfad unabhängig vom
DMS-Board. Direkte Vorbedingung für SRC-10.

- ocr.go: zustandsloses Paket (wie crypto/dedup), kennt weder Mandant
  noch Speicher. ExtractTextFromImage ruft tesseract (deu+eng) mit fester
  Argumentliste auf. HasTextLayer/ExtractTextFromPDF nutzen pdftotext zur
  Erkennung einer vorhandenen Textebene (direkt übernommen, kein
  unnötiges OCR) und rastern nur bei fehlender Textebene über pdftoppm
  (300dpi) jede Seite für Tesseract.
- Bekannten Fehler vermieden (archivmail-Sprintf-WHERE-Muster): keine
  SQL-Klauselbildung, ausschließlich exec.CommandContext mit fester
  Argumentliste, keine Shell.
- testpdf_test.go: Testfixtures (Vektor-Text-PDF, Bild-only-PDF mit
  eingebettetem JPEG) vollständig in Go erzeugt, keine externe
  Bibliothek, keine Testdateien im Repo.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-07-PRUEFPROTOKOLL.md):
1. TestExtractTextFromImage_KnownTextRecognized: reales gerastertes Bild,
   Text real korrekt erkannt.
2. TestExtractTextFromPDF_SkipsOCRWhenTextLayerPresent: echtes Vektor-
   Text-PDF, OCR real übersprungen.
3. TestExtractTextFromPDF_ThroughputIsAcceptable: 3,48s/Anhang real
   gemessen (Ziel 8s/Anhang).
Zusätzlich TestExtractTextFromPDF_PerformsOCRWhenNoTextLayer für
Akzeptanzkriterium 1 (gescannte PDFs) end-zu-Ende.

Kein Umbau: search/dedup/indexworker/storage/crypto/encstorage/
savedsearch unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 22:10:16 +02:00
sysopsandClaude Sonnet 5 ff4d716b94 SRC-08: gespeicherte-suchanfragen
Gespeicherte Suchanfragen: Suchkriterien inklusive aktiver Filter
benannt speichern und live wiederausführen.

- savedsearch/store.go: Postgres-Store, Save (Upsert über tenant_slug/
  user_id/name), List/Get streng auf Mandant+Benutzer beschränkt,
  Delete entfernt genau eine Zeile. Execute führt jede Ausführung LIVE
  gegen search.Client aus, kein eingefrorener Snapshot.
- search/facets.go: kleinste nötige Erweiterung — Client.SearchWithFilters
  (gemeinsame buildFilteredMust-Hilfsfunktion mit Facets extrahiert)
  liefert tatsächlich gefilterte Treffer statt nur Zählungen, sonst gäbe
  es keinen echten Weg, gespeicherte Filter beim Wiederausführen
  anzuwenden.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-08-PRUEFPROTOKOLL.md):
1. TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly: 2
   kombinierte Filter liefern real genau das eine passende Dokument.
2. TestList_UserSeesNoOtherTenantsSavedSearches: Mandant B sieht real
   keine gespeicherten Suchen von Mandant A.
3. TestDelete_RemovesOnlyThatSavedSearch: Löschen entfernt real nur die
   eine gespeicherte Suche, die andere bleibt unverändert.

Kein Umbau: Search/Index/Delete-Verhalten sonst unverändert,
dedup/indexworker/storage/crypto/encstorage unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 13:31:19 +02:00
sysopsandClaude Sonnet 5 6b5cefc20f ARC-08: verschluesselungsschluessel-rotation
Tenant-KEK-Rotation ohne Neuverschlüsselung des Archivbestands
(Envelope-Encryption bleibt aus ARC-02 unverändert, Objekt-DEKs werden
nicht angefasst).

Core (API-10, RotateTenantKEK) ersetzt den Tenant-KEK durch einen neuen
Wert und hält keine Historie vor — TenantKEKHandler liefert immer nur den
aktuellen Schlüssel. Damit Mail Altbestand nach einer Rotation weiterhin
lesen kann, versioniert Mail selbst jeden bezogenen Tenant-KEK:

- crypto/kekversions.go: KEKVersionStore, lokal verschlüsselt mit
  eigenem Wrap-Schlüssel (nur über Umgebungsvariable), erkennt Rotation
  automatisch (RecordIfNew), erlaubt gezieltes Sperren einer Version
  (Revoke).
- crypto/service.go: Service.WithVersionStore (optional, Open bleibt für
  Rückwärtskompatibilität unverändert), Seal zeichnet die verwendete
  KEK-Version auf, neue Methode OpenAtVersion liest mit historischer
  statt aktueller Version.
- encstorage.go: neuer .dek.version-Sidecar (gleiches Muster wie der
  bestehende .dek-Sidecar), GetDecrypted nutzt OpenAtVersion; fehlender
  Sidecar (Altobjekte vor ARC-08) fällt auf Version 0 zurück, identisches
  Verhalten wie vorher.

Prüfungen (alle real durchgeführt, siehe mail/docs/ARC-08-PRUEFPROTOKOLL.md):
1. TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation: Altbestand
   nach realer Rotation weiterhin lesbar über OpenAtVersion, naives Open
   mit dem neuen Schlüssel schlägt für das alte Objekt real fehl.
2. TestRotation_CompromisedOldKeyCanBeRevoked: gesperrte Version blockiert
   Lesezugriff real, andere Versionen bleiben unberührt.
3. Rotationsvorgang vollständig durchgespielt (siehe Prüfprotokoll).

Kein Umbau: storage/dedup/indexworker/search unverändert, bestehende
ARC-02-Tests (encstorage_test.go) unverändert weiterhin grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 11:17:23 +02:00
sysopsandClaude Sonnet 5 86c4223855 SRC-09: suchindex-neuaufbau-reindexierung
Werkzeug für vollständigen Suchindex-Neuaufbau: neue Tabelle anlegen,
Dokumente aus der lebenden Tabelle kopieren, Trefferzahlen verifizieren,
erst dann per Manticore RENAME atomar umschalten.

- reindex.go: Reindexer.Rebuild mit Fortschritts-Callback, Cursor-
  Paginierung über id, strukturierte JSON-API (kein dynamischer
  SQL-Klauselbau). Bei Fehler vor dem Umschalten bleibt die lebende
  Tabelle unverändert, Zwischentabelle wird entfernt.
- Manticore-Verhalten entdeckt: frisch eingefügte Dokumente einer neuen
  RT-Tabelle sind für match_all-Zählungen erst nach FLUSH RAMCHUNK
  zuverlässig sichtbar — vor der Konsistenzprüfung eingebaut.
- Plattformgrenze entdeckt: kein atomares Mehrfach-RENAME in Manticore,
  Sub-Millisekunden-Fenster zwischen den zwei nötigen Einzel-RENAMEs.
  Client.Search bekam einen begrenzten Retry auf "unknown local table".
- Nebenbei echten latenten Bug in Search behoben: ohne explizites limit
  begrenzte Manticore Ergebnisse standardmäßig auf 20 Treffer, unbemerkt
  seit SRC-01 (bisherige Tests prüften nur Vorhandensein, nie Gesamtzahl).

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-09-PRUEFPROTOKOLL.md):
1. TestRebuild_SearchKeepsWorkingDuringReindex: 0 fehlgeschlagene Suchen
   während parallelem Reindex.
2. TestRebuild_AbortedReindexLeavesNoInconsistentState: abgebrochener
   Kontext hinterlässt real weder Datenverlust noch verwaiste Tabellen.
3. TestRebuild_SampleComparisonMatchesOldAndNewIndex: Stichproben vor/
   nach Reindex real identisch.

Kein Umbau: Index/Delete/Facets-Verhalten sonst unverändert,
dedup/indexworker/storage/crypto/encstorage unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 11:01:45 +02:00
sysopsandClaude Sonnet 5 db73aab0de SRC-05: facetten-filter-api
Facetten- und Filter-API für Suche (Absender, Zeitraum, Postfach,
Anhangstyp, Tag), mandantengetrennt, mit UND-Verknüpfung mehrerer Filter.

- migrations/0002..0005: vier nummerierte ALTER-Migrationen für die neuen
  Facettenfelder (Manticore erlaubt nur eine Spalte je ALTER-Anweisung),
  EnsureSchema wendet sie idempotent nach.
- fields.go: FacetFields-Whitelist, einzige zulässige Facettendimensionen.
- facets.go: Client.Facets nutzt Manticores strukturierte aggs.terms/
  aggs.range-API, kein dynamischer SQL-Klauselbau. Filter kombinieren als
  zusätzliche equals-Klauseln in derselben bool.must-Liste wie der
  Tenant-Filter. Zeitraum-Facette über feste Buckets via aggs.range.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-05-PRUEFPROTOKOLL.md):
1. TestFacets_CountsMatchActualHits: Facettenzahlen stimmen real mit der
   tatsächlichen Treffermenge überein.
2. TestFacets_ThreeFiltersCombineWithAND: 3 kombinierte Filter schränken
   4 Dokumente real auf genau 1 verbleibenden Treffer ein.
3. TestFacets_TenantSeparation: Facetten eines Mandanten enthalten real
   keine Werte eines anderen.

Kein Umbau: Search/Delete/Index-Verhalten aus SRC-01/SRC-03 unverändert,
dedup/indexworker/storage/crypto/encstorage unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 10:49:52 +02:00
26 changed files with 2628 additions and 28 deletions
+87
View File
@@ -0,0 +1,87 @@
# 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.
+54
View File
@@ -0,0 +1,54 @@
# 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
@@ -0,0 +1,69 @@
# 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
@@ -0,0 +1,59 @@
# 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
@@ -0,0 +1,72 @@
# 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.
+144
View File
@@ -0,0 +1,144 @@
// 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
@@ -0,0 +1,111 @@
// 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
@@ -0,0 +1,131 @@
// 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")
}
}
+52 -5
View File
@@ -9,26 +9,41 @@ import (
// Envelope ist das Ergebnis einer Seal-Operation: der Chiffretext- // Envelope ist das Ergebnis einer Seal-Operation: der Chiffretext-
// Stream plus der mit dem Tenant-KEK verpackte DEK, der zusammen mit // Stream plus der mit dem Tenant-KEK verpackte DEK, der zusammen mit
// dem Objekt persistiert werden muss (siehe mail/internal/encstorage). // 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 { type Envelope struct {
Ciphertext io.Reader Ciphertext io.Reader
WrappedDEK []byte WrappedDEK []byte
KEKVersion int
} }
// Service verbindet KEKProvider mit den Envelope-Operationen — Aufrufer // Service verbindet KEKProvider mit den Envelope-Operationen — Aufrufer
// (mail/internal/encstorage) rufen ausschließlich Service auf, nie die // (mail/internal/encstorage) rufen ausschließlich Service auf, nie die
// Einzelfunktionen aus envelope.go direkt. // Einzelfunktionen aus envelope.go direkt.
type Service struct { type Service struct {
kek KEKProvider kek KEKProvider
versions *KEKVersionStore
} }
func NewService(kek KEKProvider) *Service { func NewService(kek KEKProvider) *Service {
return &Service{kek: kek} 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 // Seal erzeugt einen neuen DEK (Akzeptanzkriterium 1), verschlüsselt
// plaintext damit und verpackt den DEK mit dem aktuellen Tenant-KEK // plaintext damit und verpackt den DEK mit dem aktuellen Tenant-KEK
// (Akzeptanzkriterium 2 — der KEK wird bei JEDEM Aufruf frisch von Core // (Akzeptanzkriterium 2 — der KEK wird bei JEDEM Aufruf frisch von Core
// bezogen, nie zwischengespeichert). // 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) { func (s *Service) Seal(ctx context.Context, tenantSlug string, plaintext io.Reader) (*Envelope, error) {
dek, err := GenerateDEK() dek, err := GenerateDEK()
if err != nil { if err != nil {
@@ -46,14 +61,25 @@ func (s *Service) Seal(ctx context.Context, tenantSlug string, plaintext io.Read
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Envelope{Ciphertext: ciphertext, WrappedDEK: wrappedDEK}, nil
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 // Open entpackt den DEK mit dem AKTUELLEN Tenant-KEK (Akzeptanzkriterium
// 3: nur mit gültigem, mandantenbezogenem Schlüssel möglich — ein // 3: nur mit gültigem, mandantenbezogenem Schlüssel möglich — ein
// falscher Tenant-Slug liefert entweder einen falschen KEK von Core // falscher Tenant-Slug liefert entweder einen falschen KEK von Core
// [dann schlägt UnwrapDEK fehl] oder Core verweigert den Zugriff direkt) // [dann schlägt UnwrapDEK fehl] oder Core verweigert den Zugriff direkt)
// und entschlüsselt ciphertext damit. // 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) { func (s *Service) Open(ctx context.Context, tenantSlug string, wrappedDEK []byte, ciphertext io.Reader) (io.Reader, error) {
kek, err := s.kek.TenantKEK(ctx, tenantSlug) kek, err := s.kek.TenantKEK(ctx, tenantSlug)
if err != nil { if err != nil {
@@ -65,3 +91,24 @@ func (s *Service) Open(ctx context.Context, tenantSlug string, wrappedDEK []byte
} }
return DecryptStream(dek, ciphertext) 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)
}
+46 -1
View File
@@ -16,8 +16,10 @@ package encstorage
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"strconv"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto" "gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
@@ -30,6 +32,14 @@ func wrappedDEKKey(key string) string {
return key + ".dek" 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 // Service verbindet Storage (ARC-01) und Crypto (ARC-02): der Rest von
// Mail ruft AUSSCHLIESSLICH diesen Service auf, nie storage.Service // Mail ruft AUSSCHLIESSLICH diesen Service auf, nie storage.Service
// direkt mit Klartext — das verhindert einen Schreibpfad, der die // direkt mit Klartext — das verhindert einen Schreibpfad, der die
@@ -62,6 +72,12 @@ func (s *Service) Put(ctx context.Context, tenantSlug, key string, plaintext io.
if _, err := s.storage.Put(ctx, wrappedDEKKey(key), bytes.NewReader(env.WrappedDEK), int64(len(env.WrappedDEK)), "application/octet-stream"); err != nil { if _, err := s.storage.Put(ctx, wrappedDEKKey(key), bytes.NewReader(env.WrappedDEK), int64(len(env.WrappedDEK)), "application/octet-stream"); err != nil {
return fmt.Errorf("encstorage: verpackten dek speichern: %w", err) return 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 return nil
} }
@@ -85,9 +101,38 @@ func (s *Service) GetDecrypted(ctx context.Context, tenantSlug, key string) ([]b
return nil, fmt.Errorf("encstorage: verpackten dek lesen: %w", err) return nil, fmt.Errorf("encstorage: verpackten dek lesen: %w", err)
} }
plaintextReader, err := s.crypto.Open(ctx, tenantSlug, wrappedDEK, bytes.NewReader(ciphertext)) 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 { if err != nil {
return nil, err return nil, err
} }
return io.ReadAll(plaintextReader) 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
}
+187
View File
@@ -0,0 +1,187 @@
// 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
@@ -0,0 +1,156 @@
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
@@ -0,0 +1,95 @@
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
}
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,174 @@
// 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
@@ -0,0 +1,202 @@
// 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)
}
}
+112 -22
View File
@@ -22,6 +22,29 @@ import (
//go:embed migrations/0001_mail_documents.sql //go:embed migrations/0001_mail_documents.sql
var schemaMigration string var schemaMigration string
// SRC-05: Facettenfelder als eigene, nummerierte ALTER-Migrationen
// nachgezogen (Manticore erlaubt nur eine Spalte je ALTER TABLE ADD
// COLUMN-Anweisung). Reihenfolge ist die Anwendungsreihenfolge.
//
//go:embed migrations/0002_mail_documents_facets.sql
var migrationAddSender string
//go:embed migrations/0003_mail_documents_mailbox.sql
var migrationAddMailbox string
//go:embed migrations/0004_mail_documents_attachment_type.sql
var migrationAddAttachmentType string
//go:embed migrations/0005_mail_documents_tag.sql
var migrationAddTag string
var facetMigrations = []string{
migrationAddSender,
migrationAddMailbox,
migrationAddAttachmentType,
migrationAddTag,
}
// Client spricht ausschließlich über die strukturierte Manticore-HTTP- // Client spricht ausschließlich über die strukturierte Manticore-HTTP-
// JSON-API (kein String-Zusammenbau von SQL-Klauseln, siehe fields.go). // JSON-API (kein String-Zusammenbau von SQL-Klauseln, siehe fields.go).
// Die SQL-Schnittstelle wird nur für EnsureSchema verwendet, und dort // Die SQL-Schnittstelle wird nur für EnsureSchema verwendet, und dort
@@ -40,24 +63,46 @@ func NewClient(baseURL string) *Client {
} }
// EnsureSchema legt den Index gemäß dem versionierten, statischen // EnsureSchema legt den Index gemäß dem versionierten, statischen
// Migrationstext an (Akzeptanzkriterium 1). Idempotent (CREATE TABLE // Migrationstext an (Akzeptanzkriterium 1) und zieht die Facettenfelder
// IF NOT EXISTS im Migrationstext). // (SRC-05) idempotent nach.
func (c *Client) EnsureSchema(ctx context.Context) error { func (c *Client) EnsureSchema(ctx context.Context) error {
form := "query=" + schemaMigration 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)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", strings.NewReader(form))
if err != nil { if err != nil {
return fmt.Errorf("search: schema-anfrage bauen: %w", err) return fmt.Errorf("anfrage bauen: %w", err)
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("search: schema anlegen: %w", err) return fmt.Errorf("ausführen: %w", err)
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("search: schema anlegen, status %d: %s", resp.StatusCode, string(body)) 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 return nil
} }
@@ -72,6 +117,11 @@ type Document struct {
Body string `json:"body"` Body string `json:"body"`
AttachmentText string `json:"attachment_text"` AttachmentText string `json:"attachment_text"`
SentAtUnixEpoch int64 `json:"sent_at"` SentAtUnixEpoch int64 `json:"sent_at"`
// Facettenfelder (SRC-05), optional — leerer String bedeutet "kein Wert".
Sender string `json:"sender"`
Mailbox string `json:"mailbox"`
AttachmentType string `json:"attachment_type"`
Tag string `json:"tag"`
} }
// Index legt/ersetzt ein Suchdokument (Akzeptanzkriterium 2: Schreibzugriff // Index legt/ersetzt ein Suchdokument (Akzeptanzkriterium 2: Schreibzugriff
@@ -154,6 +204,52 @@ var fieldWeights = map[string]any{
FieldAttachmentText: 1, 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 // Search sucht queryText innerhalb der Volltextfelder, strikt begrenzt auf
// den Mandanten tenantSlug (Akzeptanzkriterium 2: mandantengetrennt // den Mandanten tenantSlug (Akzeptanzkriterium 2: mandantengetrennt
// abfragbar) — der Tenant-Filter läuft über ein strukturiertes "equals"- // abfragbar) — der Tenant-Filter läuft über ein strukturiertes "equals"-
@@ -166,6 +262,8 @@ var fieldWeights = map[string]any{
// Feld-/Tabellennamen, der beeinflusst werden könnte. Ergebnisse kommen // Feld-/Tabellennamen, der beeinflusst werden könnte. Ergebnisse kommen
// von Manticore bereits nach Relevanz (BM25, gewichtet über fieldWeights) // von Manticore bereits nach Relevanz (BM25, gewichtet über fieldWeights)
// absteigend sortiert zurück (Akzeptanzkriterium 1). // absteigend sortiert zurück (Akzeptanzkriterium 1).
const searchResultLimit = 1000
func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Result, error) { func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Result, error) {
payload := map[string]any{ payload := map[string]any{
"index": IndexName, "index": IndexName,
@@ -180,29 +278,21 @@ func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Re
"options": map[string]any{ "options": map[string]any{
"field_weights": fieldWeights, "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) body, err := json.Marshal(payload)
if err != nil { if err != nil {
return nil, fmt.Errorf("search: suchanfrage serialisieren: %w", err) return nil, fmt.Errorf("search: suchanfrage serialisieren: %w", err)
} }
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body)) respBody, err := c.doSearchWithSwapRetry(ctx, body)
if err != nil { if err != nil {
return nil, fmt.Errorf("search: suchanfrage bauen: %w", err) return nil, 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)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("search: antwort lesen: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("search: suche, status %d: %s", resp.StatusCode, string(respBody))
} }
var parsed searchResponse var parsed searchResponse
+240
View File
@@ -0,0 +1,240 @@
// 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,
})
}
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
}
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
@@ -0,0 +1,107 @@
// 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
}
+12
View File
@@ -24,8 +24,20 @@ const (
FieldBody = "body" FieldBody = "body"
FieldAttachmentText = "attachment_text" FieldAttachmentText = "attachment_text"
FieldSentAt = "sent_at" FieldSentAt = "sent_at"
// Facettenfelder (SRC-05), nachgezogen über migrations/0002..0005.
FieldSender = "sender"
FieldMailbox = "mailbox"
FieldAttachmentType = "attachment_type"
FieldTag = "tag"
) )
// FacetFields sind die je Kachel unterstützten Filterdimensionen
// (Akzeptanzkriterium 1: Absender, Postfach, Anhangstyp, Tag — Zeitraum
// läuft separat über FieldSentAt als Bereichsfacette, siehe facets.go).
// Statische Liste — Aufrufer können ausschließlich diese Feldnamen als
// Facetten-/Filterdimension angeben, kein beliebiger Client-Feldname.
var FacetFields = []string{FieldSender, FieldMailbox, FieldAttachmentType, FieldTag}
// DocumentID berechnet deterministisch die Manticore-Dokument-ID aus // DocumentID berechnet deterministisch die Manticore-Dokument-ID aus
// Mandant und Message-ID (FNV-1a, 64 Bit). Deterministisch statt einer // Mandant und Message-ID (FNV-1a, 64 Bit). Deterministisch statt einer
// separat vergebenen ID, damit Re-Indexierung (Index) und Löschung // separat vergebenen ID, damit Re-Indexierung (Index) und Löschung
@@ -0,0 +1 @@
ALTER TABLE mail_documents ADD COLUMN sender string attribute indexed
@@ -0,0 +1 @@
ALTER TABLE mail_documents ADD COLUMN mailbox string attribute indexed
@@ -0,0 +1 @@
ALTER TABLE mail_documents ADD COLUMN attachment_type string attribute indexed
@@ -0,0 +1 @@
ALTER TABLE mail_documents ADD COLUMN tag string attribute indexed
+288
View File
@@ -0,0 +1,288 @@
// SRC-09: Suchindex-Neuaufbau/Reindexierung. Baut eine neue physische
// Manticore-Tabelle auf, kopiert alle Dokumente aus der aktuell lebenden
// Tabelle (Konsistenzwiederherstellung), verifiziert die Trefferzahl und
// tauscht erst danach per Manticore RENAME atomar um — die alte Tabelle
// bleibt bis zu diesem Moment vollständig abfragbar (Akzeptanzkriterium 3),
// RENAME ist eine reine Metadaten-Operation ohne Suchausfall.
package search
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"time"
)
// tempTableNamePattern begrenzt generierte Zwischentabellennamen auf ein
// festes Präfix + Ziffern — auch wenn der Name ausschließlich von diesem
// Paket selbst erzeugt wird (kein externer Eingabewert erreicht ihn),
// erzwingt die Prüfung strukturell, dass niemals ein beliebiger String an
// dieser Stelle landen kann (Verteidigung in der Tiefe, gleiche Haltung
// wie das Feld-Whitelist-Prinzip in fields.go).
var tempTableNamePattern = regexp.MustCompile(`^mail_documents_reindex_[0-9]+$`)
// Progress meldet den Fortschritt eines laufenden Reindex
// (Akzeptanzkriterium 2: Fortschritt nachvollziehbar sichtbar).
type Progress struct {
Copied int64
Total int64
}
// Reindexer baut den Suchindex vollständig neu auf.
type Reindexer struct {
client *Client
}
func NewReindexer(client *Client) *Reindexer {
return &Reindexer{client: client}
}
// RebuildResult fasst das Ergebnis eines abgeschlossenen Reindex zusammen.
type RebuildResult struct {
OldCount int64
NewCount int64
}
// Rebuild baut den Index vollständig neu auf: neue Tabelle anlegen, alle
// Dokumente aus der aktuell lebenden Tabelle seitenweise kopieren
// (Akzeptanzkriterium 1: kein Datenverlust im laufenden Betrieb — die
// lebende Tabelle wird dabei nur gelesen, nie verändert), Trefferzahlen
// vergleichen, dann atomar per RENAME umschalten. Schlägt ein Schritt vor
// dem Umschalten fehl (z. B. abgebrochener Kontext), wird die
// Zwischentabelle entfernt und die lebende Tabelle bleibt unverändert
// (Akzeptanzkriterium 3 / Pflichtprüfung 2: kein inkonsistenter Zustand).
func (r *Reindexer) Rebuild(ctx context.Context, onProgress func(Progress)) (RebuildResult, error) {
tempTable := fmt.Sprintf("mail_documents_reindex_%d", time.Now().UnixNano())
if !tempTableNamePattern.MatchString(tempTable) {
return RebuildResult{}, fmt.Errorf("search: erzeugter zwischentabellenname unerwartet ungültig: %q", tempTable)
}
if err := r.client.runSchemaSQL(ctx, buildCreateTableSQL(tempTable)); err != nil {
return RebuildResult{}, fmt.Errorf("search: zwischentabelle anlegen: %w", err)
}
oldTotal, err := r.copyAll(ctx, IndexName, tempTable, onProgress)
if err != nil {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: dokumente kopieren: %w", err)
}
// Manticore macht frisch eingefügte Dokumente einer neu angelegten
// RT-Tabelle für Volltext-/match_all-Zählungen erst nach einem
// expliziten FLUSH RAMCHUNK zuverlässig sichtbar (beobachtet: SELECT
// über SQL sieht die Zeile sofort, /search match_all zählt sie ohne
// Flush als 0). Vor der Konsistenzprüfung zwingend nötig.
if err := r.client.runSchemaSQL(ctx, "FLUSH RAMCHUNK "+tempTable); err != nil {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: zwischentabelle flushen: %w", err)
}
newTotal, err := r.countAll(ctx, tempTable)
if err != nil {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: neue tabelle zählen: %w", err)
}
if newTotal != oldTotal {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: trefferzahlen weichen ab (alt %d, neu %d), kein umschalten", oldTotal, newTotal)
}
retiredTable := fmt.Sprintf("mail_documents_retired_%d", time.Now().UnixNano())
if err := r.client.runSchemaSQL(ctx, fmt.Sprintf("ALTER TABLE %s RENAME %s", IndexName, retiredTable)); err != nil {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: alte tabelle umbenennen: %w", err)
}
if err := r.client.runSchemaSQL(ctx, fmt.Sprintf("ALTER TABLE %s RENAME %s", tempTable, IndexName)); err != nil {
// Kritischer Zustand: alte Tabelle bereits umbenannt, neue kann
// nicht einspringen. Umschalten rückgängig machen, statt ohne
// abfragbaren Index dazustehen.
_ = r.client.runSchemaSQL(context.Background(), fmt.Sprintf("ALTER TABLE %s RENAME %s", retiredTable, IndexName))
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: neue tabelle aktivieren: %w", err)
}
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+retiredTable)
return RebuildResult{OldCount: oldTotal, NewCount: newTotal}, nil
}
const copyPageSize = 200
func (r *Reindexer) copyAll(ctx context.Context, sourceIndex, targetIndex string, onProgress func(Progress)) (int64, error) {
total, err := r.countAll(ctx, sourceIndex)
if err != nil {
return 0, err
}
var cursor uint64
var copied int64
first := true
for {
if err := ctx.Err(); err != nil {
return 0, err
}
page, err := r.fetchPage(ctx, sourceIndex, cursor, first)
if err != nil {
return 0, err
}
first = false
if len(page) == 0 {
break
}
for _, doc := range page {
if err := r.putRaw(ctx, targetIndex, doc); err != nil {
return 0, err
}
cursor = doc.ID
copied++
}
if onProgress != nil {
onProgress(Progress{Copied: copied, Total: total})
}
}
return copied, nil
}
func (r *Reindexer) countAll(ctx context.Context, index string) (int64, error) {
payload := map[string]any{"index": index, "query": map[string]any{"match_all": map[string]any{}}, "limit": 0}
var parsed struct {
Hits struct {
Total int64 `json:"total"`
} `json:"hits"`
}
if err := r.client.postJSON(ctx, "/search", payload, &parsed); err != nil {
return 0, err
}
return parsed.Hits.Total, nil
}
type scrollHit struct {
ID uint64 `json:"_id"`
Source json.RawMessage `json:"_source"`
}
func (r *Reindexer) fetchPage(ctx context.Context, index string, afterID uint64, first bool) ([]scrollHit, error) {
must := []map[string]any{}
if !first {
must = append(must, map[string]any{"range": map[string]any{"id": map[string]any{"gt": afterID}}})
}
query := map[string]any{"match_all": map[string]any{}}
if len(must) > 0 {
query = map[string]any{"bool": map[string]any{"must": must}}
}
payload := map[string]any{
"index": index,
"query": query,
"sort": []map[string]any{{"id": "asc"}},
"limit": copyPageSize,
}
var parsed struct {
Hits struct {
Hits []scrollHit `json:"hits"`
} `json:"hits"`
}
if err := r.client.postJSON(ctx, "/search", payload, &parsed); err != nil {
return nil, err
}
return parsed.Hits.Hits, nil
}
func (r *Reindexer) putRaw(ctx context.Context, index string, doc scrollHit) error {
payload := map[string]any{
"index": index,
"id": doc.ID,
"doc": json.RawMessage(doc.Source),
}
return r.client.postJSON(ctx, "/replace", payload, nil)
}
// postJSON/buildCreateTableSQL sind bewusst hier statt in client.go
// angesiedelt: der übrige Suchpfad (Search/Facets) fasst niemals einen
// Tabellennamen dynamisch an, Reindex ist die einzige Stelle im Paket, die
// das operativ tun muss.
func (c *Client) postJSON(ctx context.Context, path string, payload any, out any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("payload serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("ausführen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("antwort lesen: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
}
if out == nil {
return nil
}
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("antwort parsen: %w", err)
}
return nil
}
// showTables listet die vorhandenen Manticore-Tabellen (Diagnose-/
// Testhilfe, um verwaiste Zwischentabellen nach einem Abbruch
// auszuschließen — Pflichtprüfung 2).
func (c *Client) showTables(ctx context.Context) ([]string, error) {
form := "query=SHOW TABLES"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", bytes.NewReader([]byte(form)))
if err != nil {
return nil, fmt.Errorf("anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("ausführen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("antwort lesen: %w", err)
}
var parsed []struct {
Data []struct {
Table string `json:"Table"`
} `json:"data"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("antwort parsen: %w", err)
}
names := []string{}
if len(parsed) > 0 {
for _, row := range parsed[0].Data {
names = append(names, row.Table)
}
}
return names, nil
}
// buildCreateTableSQL erzeugt die Schema-DDL für eine Zwischentabelle mit
// demselben Spaltensatz wie mail_documents (Basis + Facettenfelder aus
// SRC-05). tableName ist über tempTableNamePattern in Rebuild bereits
// geprüft, bevor diese Funktion aufgerufen wird.
func buildCreateTableSQL(tableName string) string {
return fmt.Sprintf(
"CREATE TABLE %s (%s string attribute indexed, %s string attribute indexed, %s text, %s text, %s text, %s string attribute indexed, %s string attribute indexed, %s string attribute indexed, %s string attribute indexed, %s timestamp)",
tableName,
FieldTenantSlug, FieldMessageID, FieldSubject, FieldBody, FieldAttachmentText,
FieldSender, FieldMailbox, FieldAttachmentType, FieldTag, FieldSentAt,
)
}
+215
View File
@@ -0,0 +1,215 @@
// Integrationstest (SRC-09): echte Manticore-Instanz, TEST_MANTICORE_URL
// (gleiche Konvention wie integration_test.go/ranking_test.go/facets_test.go).
package search
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
// TestRebuild_SearchKeepsWorkingDuringReindex ist die geforderte
// Pflichtprüfung 1: Reindex während laufender Suchanfragen unterbricht die
// Suche nicht.
func TestRebuild_SearchKeepsWorkingDuringReindex(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-parallel"
for i := 0; i < 30; i++ {
messageID := "msg-parallel-" + string(rune('a'+i))
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: messageID, Subject: "Zwiebelfisch " + messageID, Body: "Text"})
}
stop := make(chan struct{})
var searchErrors int64
var searchesDone int64
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
if _, err := client.Search(ctx, tenant, "Zwiebelfisch"); err != nil {
atomic.AddInt64(&searchErrors, 1)
t.Logf("suchfehler während reindex: %v", err)
}
atomic.AddInt64(&searchesDone, 1)
time.Sleep(2 * time.Millisecond)
}
}()
reindexer := NewReindexer(client)
result, err := reindexer.Rebuild(ctx, nil)
close(stop)
wg.Wait()
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if result.OldCount != result.NewCount {
t.Fatalf("erwartete gleiche trefferzahlen, habe alt=%d neu=%d", result.OldCount, result.NewCount)
}
if atomic.LoadInt64(&searchesDone) == 0 {
t.Fatal("keine einzige parallele suche ausgeführt — test aussagelos")
}
if errs := atomic.LoadInt64(&searchErrors); errs != 0 {
t.Fatalf("erwartete 0 fehlgeschlagene suchen während des reindex, habe %d von %d", errs, atomic.LoadInt64(&searchesDone))
}
// Suche funktioniert auch NACH dem Umschalten weiterhin real.
afterResults, err := client.Search(ctx, tenant, "Zwiebelfisch")
if err != nil {
t.Fatalf("search nach reindex: %v", err)
}
if len(afterResults) != 30 {
t.Fatalf("erwartete 30 treffer nach reindex, habe %d", len(afterResults))
}
}
// TestRebuild_AbortedReindexLeavesNoInconsistentState ist die geforderte
// Pflichtprüfung 2: abgebrochener Reindex hinterlässt keinen
// inkonsistenten Zustand.
func TestRebuild_AbortedReindexLeavesNoInconsistentState(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-abbruch"
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-abbruch-1", Subject: "Vertragsentwurf Abbruchtest", Body: "Text"})
before, err := client.Search(ctx, tenant, "Abbruchtest")
if err != nil || len(before) != 1 {
t.Fatalf("voraussetzung nicht erfüllt: %v / %d treffer", err, len(before))
}
cancelCtx, cancel := context.WithCancel(ctx)
cancel() // sofort abgebrochen, simuliert Absturz/Abbruch mitten im Kopiervorgang
reindexer := NewReindexer(client)
_, err = reindexer.Rebuild(cancelCtx, nil)
if err == nil {
t.Fatal("erwartete fehler bei abgebrochenem kontext, habe nil")
}
if !errors.Is(err, context.Canceled) {
// Manticore-Fehler durch den abgebrochenen Request sind ebenfalls
// akzeptabel, solange überhaupt ein Fehler zurückkommt.
t.Logf("fehler war nicht context.Canceled, sondern: %v (akzeptiert, solange real ein fehler zurückkommt)", err)
}
// Die lebende Tabelle muss trotz Abbruch unverändert und abfragbar sein.
after, err := client.Search(ctx, tenant, "Abbruchtest")
if err != nil {
t.Fatalf("search nach abgebrochenem reindex: %v", err)
}
if len(after) != 1 {
t.Fatalf("erwartete weiterhin 1 treffer nach abgebrochenem reindex, habe %d — inkonsistenter zustand", len(after))
}
// Keine verwaisten Zwischentabellen (kein inkonsistenter Zustand auf
// Manticore-Ebene): kurz warten, damit ein eventuell noch laufender
// CREATE-TABLE-Aufruf durchlaufen kann, dann prüfen, dass keine
// mail_documents_reindex_*-Tabelle übrig geblieben ist.
time.Sleep(200 * time.Millisecond)
orphaned := listOrphanedReindexTables(t, client)
if len(orphaned) > 0 {
t.Fatalf("verwaiste zwischentabellen nach abbruch gefunden: %v", orphaned)
}
}
func listOrphanedReindexTables(t *testing.T, client *Client) []string {
t.Helper()
// SHOW TABLES ist eine feste, unparametrisierte Anweisung ohne
// jeglichen Laufzeitwert.
rows, err := client.showTables(context.Background())
if err != nil {
t.Fatalf("show tables: %v", err)
}
names := []string{}
for _, table := range rows {
if tempTableNamePattern.MatchString(table) {
names = append(names, table)
}
}
return names
}
// TestRebuild_SampleComparisonMatchesOldAndNewIndex ist die geforderte
// Pflichtprüfung 3: Stichprobenvergleich Alt-/Neuindex bestätigt gleiche
// Trefferzahlen.
func TestRebuild_SampleComparisonMatchesOldAndNewIndex(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-stichprobe"
subjects := []string{"Quartalsbericht", "Personalplanung", "Urlaubsantrag"}
for i, s := range subjects {
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-sp-" + string(rune('a'+i)), Subject: s, Body: "Inhalt " + s})
}
beforeCounts := map[string]int{}
for _, s := range subjects {
results, err := client.Search(ctx, tenant, s)
if err != nil {
t.Fatalf("search vor reindex (%s): %v", s, err)
}
beforeCounts[s] = len(results)
}
reindexer := NewReindexer(client)
if _, err := reindexer.Rebuild(ctx, nil); err != nil {
t.Fatalf("rebuild: %v", err)
}
for _, s := range subjects {
results, err := client.Search(ctx, tenant, s)
if err != nil {
t.Fatalf("search nach reindex (%s): %v", s, err)
}
if len(results) != beforeCounts[s] {
t.Fatalf("stichprobe %q: vor reindex %d treffer, nach reindex %d treffer", s, beforeCounts[s], len(results))
}
}
}
// TestRebuild_ReportsProgress deckt Akzeptanzkriterium 2 ab (Fortschritt
// nachvollziehbar sichtbar).
func TestRebuild_ReportsProgress(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-fortschritt"
for i := 0; i < 5; i++ {
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-progress-" + string(rune('a'+i)), Subject: "x"})
}
var updates []Progress
var mu sync.Mutex
reindexer := NewReindexer(client)
_, err := reindexer.Rebuild(ctx, func(p Progress) {
mu.Lock()
updates = append(updates, p)
mu.Unlock()
})
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if len(updates) == 0 {
t.Fatal("erwartete mindestens eine fortschrittsmeldung")
}
last := updates[len(updates)-1]
if last.Copied < last.Total {
// total ist eine zu Beginn eingefrorene Momentaufnahme; die geteilte
// Manticore-Instanz kann während des Kopierens durch andere Tests
// weiter wachsen (real beobachtet) — copied darf total daher
// erreichen oder minimal überschreiten, nur ein Rückstand wäre ein
// echter Fehler.
t.Fatalf("letzte fortschrittsmeldung sollte abgeschlossen sein, habe copied=%d total=%d", last.Copied, last.Total)
}
}