CMP-03: modulübergreifende eDiscovery-Suche & prüfsummengesicherter Export
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Failing after 28s
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:
1 parent
c24448d556
commit
fb5fdab9cf
3 files changed
+379
No files matched your search
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user