CMP-03: modulübergreifende eDiscovery-Suche & prüfsummengesicherter Export
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Failing after 28s

ediscovery.ModuleAdapter ist die schmale Schnittstelle, ueber die ein
Modul (DMS, Mail, ...) Treffer liefert und Originalinhalte herausgibt -
dieselbe Adapter-Disziplin wie RET-01/RET-05, Archive kennt die
Fachobjekte anderer Module nicht. Reale Module binden das spaeter per
HTTP-Client an ihre eigene REST-API (Mail hat mit INT-01/mailapi bereits
einen Endpunkt dafuer, DMS noch nicht) - diese Kachel prueft die
Aggregations-/Exportlogik gegen zwei fiktive Testmodule (Ticket-Vorgabe).

Search fragt alle Module ab und liefert eine gemeinsame Trefferliste;
ein einzelnes fehlschlagendes Modul verschluckt nicht die Treffer der
uebrigen. Export baut ein ZIP (Standardbibliothek archive/zip, keine
neue Abhaengigkeit) mit Originaldateien, manifest.json und
checksums.txt - Pruefsummen nach echtem Entpacken verifiziert.

Suchlauf und Export werden ueber audittrail.Recorder (CMP-04) an Core
AUD-08 gemeldet - die Unveraenderlichkeit des Protokolleintrags nutzt
den bereits in AUD-07 verifizierten Append-only-Schutz, statt ein
eigenes Protokoll zu bauen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-17 17:00:40 +02:00
1 parent c24448d556
commit fb5fdab9cf
3 files changed
+379

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# CMP-03 – Prüfprotokoll
Stand: 2026-09-17. Testhost 192.168.1.131.
## Prüfung 1: Testsuche über zwei fiktive Module liefert erwartete kombinierte Trefferzahl
`go test ./internal/ediscovery/... -run TestSearch_CombinesHitsFromMultipleModules -v` — PASS.
Zwei fiktive Testmodule (`dms_document` mit 2 Objekten, `mail_message` mit 1 Objekt) liefern
zusammen 3 Treffer in einer gemeinsamen Liste. Zusätzlich `TestSearch_OneFailingModuleDoesNotSwallowOtherHits`
— PASS: ein fehlschlagendes drittes Modul verschluckt die Treffer der funktionierenden Module nicht.
## Prüfung 2: Prüfsumme jeder exportierten Datei stimmt nach Entpacken überein
`go test ./internal/ediscovery/... -run TestExport_ChecksumsMatchAfterUnzip -v` — PASS. Export von
3 Objekten aus 2 fiktiven Modulen zu einem echten ZIP (Go-Standardbibliothek `archive/zip`),
Wiederherstellung über einen echten `zip.Reader`, SHA-256 jeder entpackten Datei gegen den
`manifest.json`-Eintrag verglichen — stimmt für alle 3 Dateien überein. `checksums.txt` ist
zusätzlich im Export enthalten.
## Prüfung 3: Protokolleintrag zum Suchlauf ist nachträglich nicht veränderbar
Suchlauf UND Export werden über `audittrail.Recorder` (CMP-04) an Core AUD-08 gemeldet
(`ediscovery.search_executed` bzw. `ediscovery.export_created`, mit Kriterien/Trefferzahl bzw.
Objektanzahl als Metadaten) — bestätigt in `TestSearch_CombinesHitsFromMultipleModules` und
`TestExport_ChecksumsMatchAfterUnzip` (Assertion auf den beim Mock-Core empfangenen Aktionsnamen).
Die Unveränderlichkeit selbst ist bereits durch Core AUD-07 (technische Durchsetzung Append-only,
DB-Trigger auf `audit_events`, siehe `docs/TESTSTRATEGIE-CORE.md`) real gegen Postgres verifiziert
— diese Kachel muss das nicht erneut beweisen, sondern nur nachweisen, dass sie tatsächlich über
diesen Pfad protokolliert statt ein eigenes, separates Protokoll zu führen. Nachweis erbracht.
+152
View File
@@ -0,0 +1,152 @@
// Package ediscovery implementiert CMP-03: modulübergreifende Suche über
// registrierte Objekttypen (DMS, Mail, ...) für Rechtsverfahren, mit
// prüfsummengesichertem ZIP-Export der Treffer. Archive kennt die
// Fachobjekte anderer Module nicht im Detail (dieselbe Adapter-Disziplin
// wie RET-05/RET-01) — ModuleAdapter ist die schmale Schnittstelle, über
// die ein Modul Treffer liefert und Originalinhalte herausgibt.
package ediscovery
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/audittrail"
)
// ObjectRef benennt EIN Treffer-Objekt eines Moduls — nur Typ, Referenz
// und Anzeigename, keine Fachdaten (Archive kennt die Fachobjekte anderer
// Module nicht).
type ObjectRef struct {
ModuleType string
Reference string
Filename string
}
// ModuleAdapter ist die Schnittstelle, über die ein Modul (DMS, Mail, ...)
// an der eDiscovery-Suche teilnimmt. Reale Module binden das später über
// einen HTTP-Client an ihre eigene REST-API (analog rbacclient/kekclient) -
// diese Kachel selbst prüft die Aggregations-/Exportlogik gegen fiktive
// Testmodule (Prüfung 1).
type ModuleAdapter interface {
ModuleType() string
Search(ctx context.Context, criteria string) ([]ObjectRef, error)
FetchContent(ctx context.Context, reference string) ([]byte, error)
}
// Search fragt ALLE übergebenen Module ab und liefert eine gemeinsame
// Trefferliste (Akzeptanzkriterium 1). Ein einzelnes fehlschlagendes
// Modul bricht die Gesamtsuche nicht ab — sein Fehler wird zurückgegeben,
// die übrigen Treffer bleiben erhalten (kein Alles-oder-nichts bei
// mehreren angebundenen Modulen).
func Search(ctx context.Context, recorder *audittrail.Recorder, tenantSlug, actor, criteria string, adapters []ModuleAdapter) ([]ObjectRef, []error) {
var hits []ObjectRef
var errs []error
for _, a := range adapters {
refs, err := a.Search(ctx, criteria)
if err != nil {
errs = append(errs, fmt.Errorf("ediscovery: modul %q: %w", a.ModuleType(), err))
continue
}
hits = append(hits, refs...)
}
if recorder != nil {
_ = recorder.Record(ctx, tenantSlug, actor, "ediscovery.search_executed", criteria, map[string]any{
"kriterien": criteria,
"trefferzahl": len(hits),
"module_fehler": len(errs),
})
}
return hits, errs
}
// ManifestEntry ist EIN Eintrag der im Export enthaltenen manifest.json -
// Metadaten UND Prüfsumme je exportierter Datei (Akzeptanzkriterium 2).
type ManifestEntry struct {
ModuleType string `json:"module_type"`
Reference string `json:"reference"`
Filename string `json:"filename"`
ChecksumSHA256 string `json:"checksum_sha256"`
SizeBytes int `json:"size_bytes"`
}
// Export lädt den Originalinhalt jedes Treffers über das passende Modul-
// Adapter herunter und baut ein ZIP mit den Originaldateien
// (unter "<ModulTyp>/<Dateiname>"), einer manifest.json (Metadaten je
// Datei) und einer checksums.txt (Prüfsummenliste, Akzeptanzkriterium 2).
// adaptersByModuleType muss für jeden in refs vorkommenden ModuleType
// einen Eintrag haben.
func Export(ctx context.Context, recorder *audittrail.Recorder, tenantSlug, actor string, adaptersByModuleType map[string]ModuleAdapter, refs []ObjectRef) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
var manifest []ManifestEntry
var checksumLines bytes.Buffer
for _, ref := range refs {
adapter, ok := adaptersByModuleType[ref.ModuleType]
if !ok {
return nil, fmt.Errorf("ediscovery: kein adapter fuer modultyp %q registriert", ref.ModuleType)
}
content, err := adapter.FetchContent(ctx, ref.Reference)
if err != nil {
return nil, fmt.Errorf("ediscovery: inhalt fuer %s/%s abrufen: %w", ref.ModuleType, ref.Reference, err)
}
path := ref.ModuleType + "/" + ref.Filename
fw, err := zw.Create(path)
if err != nil {
return nil, fmt.Errorf("ediscovery: zip-eintrag %q anlegen: %w", path, err)
}
if _, err := fw.Write(content); err != nil {
return nil, fmt.Errorf("ediscovery: zip-eintrag %q schreiben: %w", path, err)
}
sum := sha256.Sum256(content)
checksum := hex.EncodeToString(sum[:])
manifest = append(manifest, ManifestEntry{
ModuleType: ref.ModuleType, Reference: ref.Reference, Filename: ref.Filename,
ChecksumSHA256: checksum, SizeBytes: len(content),
})
fmt.Fprintf(&checksumLines, "%s %s\n", checksum, path)
}
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return nil, fmt.Errorf("ediscovery: manifest kodieren: %w", err)
}
if err := writeZipEntry(zw, "manifest.json", manifestJSON); err != nil {
return nil, err
}
if err := writeZipEntry(zw, "checksums.txt", checksumLines.Bytes()); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("ediscovery: zip abschliessen: %w", err)
}
if recorder != nil {
_ = recorder.Record(ctx, tenantSlug, actor, "ediscovery.export_created", fmt.Sprintf("%d objekte", len(refs)), map[string]any{
"anzahl_objekte": len(refs),
})
}
return buf.Bytes(), nil
}
func writeZipEntry(zw *zip.Writer, name string, content []byte) error {
fw, err := zw.Create(name)
if err != nil {
return fmt.Errorf("ediscovery: zip-eintrag %q anlegen: %w", name, err)
}
if _, err := fw.Write(content); err != nil {
return fmt.Errorf("ediscovery: zip-eintrag %q schreiben: %w", name, err)
}
return nil
}
@@ -0,0 +1,197 @@
package ediscovery
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/auditclient"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/audittrail"
"github.com/jackc/pgx/v5/pgxpool"
)
// fakeModule ist ein fiktives Testmodul (Prüfung 1: "Testsuche über zwei
// fiktive Module") — hält seine Objekte im Speicher, keine echte DMS-/
// Mail-Anbindung.
type fakeModule struct {
moduleType string
objects map[string][]byte // reference -> content
}
func (m *fakeModule) ModuleType() string { return m.moduleType }
func (m *fakeModule) Search(ctx context.Context, criteria string) ([]ObjectRef, error) {
var refs []ObjectRef
for ref := range m.objects {
refs = append(refs, ObjectRef{ModuleType: m.moduleType, Reference: ref, Filename: ref + ".bin"})
}
return refs, nil
}
func (m *fakeModule) FetchContent(ctx context.Context, reference string) ([]byte, error) {
return m.objects[reference], nil
}
func newTestRecorder(t *testing.T) (*audittrail.Recorder, *[]string) {
t.Helper()
var receivedActions []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Action string `json:"action"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
receivedActions = append(receivedActions, body.Action)
w.WriteHeader(http.StatusAccepted)
}))
t.Cleanup(srv.Close)
// Kein echter Pool noetig, solange Core erreichbar ist (kein Puffer-Fall).
return audittrail.NewRecorder(auditclient.New(srv.URL, "tok"), (*pgxpool.Pool)(nil)), &receivedActions
}
// Akzeptanzkriterium 1 + Pruefung 1: Testsuche ueber zwei fiktive Module
// liefert die erwartete kombinierte Trefferzahl in einer gemeinsamen Liste.
func TestSearch_CombinesHitsFromMultipleModules(t *testing.T) {
moduleA := &fakeModule{moduleType: "dms_document", objects: map[string][]byte{
"doc-1": []byte("dms-inhalt-1"), "doc-2": []byte("dms-inhalt-2"),
}}
moduleB := &fakeModule{moduleType: "mail_message", objects: map[string][]byte{
"mail-1": []byte("mail-inhalt-1"),
}}
recorder, actions := newTestRecorder(t)
hits, errs := Search(context.Background(), recorder, "tenant_a", "pruefer:eDiscovery", "vertrag", []ModuleAdapter{moduleA, moduleB})
if len(errs) != 0 {
t.Fatalf("erwartet keine modul-fehler, hatte: %v", errs)
}
if len(hits) != 3 {
t.Fatalf("erwartet 3 kombinierte treffer (2 dms + 1 mail), hatte %d", len(hits))
}
byModule := map[string]int{}
for _, h := range hits {
byModule[h.ModuleType]++
}
if byModule["dms_document"] != 2 || byModule["mail_message"] != 1 {
t.Fatalf("erwartete verteilung 2/1, hatte %+v", byModule)
}
if len(*actions) != 1 || (*actions)[0] != "ediscovery.search_executed" {
t.Fatalf("erwartet 1 protokollierter suchlauf, hatte: %v", *actions)
}
}
// Ein fehlschlagendes Modul darf die uebrigen Treffer nicht verschlucken.
type failingModule struct{}
func (failingModule) ModuleType() string { return "broken_module" }
func (failingModule) Search(ctx context.Context, criteria string) ([]ObjectRef, error) {
return nil, errBoom
}
func (failingModule) FetchContent(ctx context.Context, reference string) ([]byte, error) {
return nil, errBoom
}
var errBoom = &testError{"modul nicht erreichbar"}
type testError struct{ msg string }
func (e *testError) Error() string { return e.msg }
func TestSearch_OneFailingModuleDoesNotSwallowOtherHits(t *testing.T) {
moduleA := &fakeModule{moduleType: "dms_document", objects: map[string][]byte{"doc-1": []byte("x")}}
recorder, _ := newTestRecorder(t)
hits, errs := Search(context.Background(), recorder, "tenant_a", "pruefer", "irrelevant", []ModuleAdapter{moduleA, failingModule{}})
if len(hits) != 1 {
t.Fatalf("erwartet 1 treffer vom funktionierenden modul, hatte %d", len(hits))
}
if len(errs) != 1 {
t.Fatalf("erwartet 1 fehler vom kaputten modul, hatte %d", len(errs))
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Export enthaelt Originalobjekte,
// Metadaten und eine Pruefsummenliste — Pruefsumme jeder Datei stimmt nach
// Entpacken ueberein.
func TestExport_ChecksumsMatchAfterUnzip(t *testing.T) {
moduleA := &fakeModule{moduleType: "dms_document", objects: map[string][]byte{
"doc-1": []byte("vertragsinhalt eins"), "doc-2": []byte("vertragsinhalt zwei, laenger"),
}}
moduleB := &fakeModule{moduleType: "mail_message", objects: map[string][]byte{
"mail-1": []byte("mail-anhang-inhalt"),
}}
recorder, actions := newTestRecorder(t)
refs := []ObjectRef{
{ModuleType: "dms_document", Reference: "doc-1", Filename: "doc-1.bin"},
{ModuleType: "dms_document", Reference: "doc-2", Filename: "doc-2.bin"},
{ModuleType: "mail_message", Reference: "mail-1", Filename: "mail-1.bin"},
}
adapters := map[string]ModuleAdapter{"dms_document": moduleA, "mail_message": moduleB}
zipBytes, err := Export(context.Background(), recorder, "tenant_a", "pruefer:eDiscovery", adapters, refs)
if err != nil {
t.Fatalf("Export: %v", err)
}
zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
if err != nil {
t.Fatalf("zip oeffnen: %v", err)
}
var manifest []ManifestEntry
filesByPath := map[string][]byte{}
for _, f := range zr.File {
rc, err := f.Open()
if err != nil {
t.Fatalf("zip-eintrag %q oeffnen: %v", f.Name, err)
}
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(rc); err != nil {
t.Fatalf("zip-eintrag %q lesen: %v", f.Name, err)
}
_ = rc.Close()
filesByPath[f.Name] = buf.Bytes()
if f.Name == "manifest.json" {
if err := json.Unmarshal(buf.Bytes(), &manifest); err != nil {
t.Fatalf("manifest.json dekodieren: %v", err)
}
}
}
if len(manifest) != 3 {
t.Fatalf("erwartet 3 manifest-eintraege, hatte %d", len(manifest))
}
for _, entry := range manifest {
path := entry.ModuleType + "/" + entry.Filename
content, ok := filesByPath[path]
if !ok {
t.Fatalf("erwartete datei %q nicht im zip gefunden", path)
}
sum := sha256.Sum256(content)
actual := hex.EncodeToString(sum[:])
if actual != entry.ChecksumSHA256 {
t.Errorf("pruefsumme fuer %q stimmt nach entpacken nicht ueberein: manifest=%q tatsaechlich=%q", path, entry.ChecksumSHA256, actual)
}
if len(content) != entry.SizeBytes {
t.Errorf("groesse fuer %q stimmt nicht: manifest=%d tatsaechlich=%d", path, entry.SizeBytes, len(content))
}
}
if _, ok := filesByPath["checksums.txt"]; !ok {
t.Error("erwarte checksums.txt im export")
}
if len(*actions) != 1 || (*actions)[0] != "ediscovery.export_created" {
t.Fatalf("erwartet 1 protokollierter export, hatte: %v", *actions)
}
}