VAL-02: hash-vergleich-binaerdateien
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
Stichprobenweiser Hash-Vergleich migrierter Binaerdateien (Objekt-Storage vs. Quelle) als generisches QA-Werkzeug internal/migrationverify. DMS legt Revisionen envelope-verschluesselt ab (FDN-09) — Vergleich entschluesselt optional ueber injizierten Decryptor vor dem Hashen, Mail-Anhaenge liegen unverschluesselt vor. Prueft reale Dateisystem-Stichprobe (20 synthetische Dateien) inkl. absichtlich manipulierter Datei. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1bd7e2a953
commit
9b1fb39855
3 files changed
+524
No files matched your search
@@ -0,0 +1,142 @@
|
||||
# VAL-02 – Prüfprotokoll: Hash-Vergleich migrierter Binärdateien
|
||||
|
||||
## Auftrag
|
||||
|
||||
Stichprobenweiser Hash-Vergleich migrierter Binärdateien (Dokumente, Mail-Anhänge) im
|
||||
Objekt-Storage gegen die Quelle, um bitgenaue Übernahme nachzuweisen. Produkt-DNA: kein
|
||||
Tenant wird freigeschaltet, dessen Verifikation nicht schriftlich vorliegt.
|
||||
|
||||
## Ausgangslage und Architektur-Entscheidung
|
||||
|
||||
DOK-01 (`dms/internal/migrationdocument`) und MAI-02 (`mail/internal/messagemigration`)
|
||||
kopieren Binärdaten per Objekt-Storage-Kopie ohne Rehash/Re-Encoding, jeweils über das
|
||||
jeweilige `storage.Service` des Zielmoduls. Dabei besteht ein wesentlicher Unterschied:
|
||||
|
||||
- **DMS**: Revisionen werden vor dem Ablegen über `dms/internal/crypto.Service.Seal`
|
||||
envelope-verschlüsselt (FDN-09). Im Objekt-Storage liegt also **Chiffretext**, nicht der
|
||||
Klartext der Quelle. Ein roher Hash-Vergleich Objekt-Storage-Byte-für-Byte gegen Quelle
|
||||
würde hier IMMER fehlschlagen — er muss zuerst mit dem zur Revision gehörenden
|
||||
`wrapped_dek` (`file_revisions.wrapped_dek`) über `crypto.Service.Open` entschlüsselt
|
||||
werden, bevor der SHA-256 gebildet wird.
|
||||
- **Mail**: Anhänge liegen laut Kommentar in `messagemigration.go` unverschlüsselt über
|
||||
`storage.Service` (encstorage ist in keinem realen Schreibpfad verdrahtet) — hier genügt
|
||||
ein direkter Hash-Vergleich ohne Entschlüsselung.
|
||||
|
||||
Das neue Paket `internal/migrationverify` (Datei `hashcompare.go`, eigener
|
||||
Unterordner/Dateiname zur Vermeidung einer Namenskollision mit dem parallelen Ticket
|
||||
VAL-01) bildet dies über generische Interfaces ab:
|
||||
|
||||
- `ObjectSource.OpenObject` — liest ein Objekt aus der Quelle.
|
||||
- `ObjectStore.Get` — liest ein Objekt aus dem Ziel-Objekt-Storage.
|
||||
- `Decryptor.Open` — optionale Entschlüsselung des Ziel-Objekts vor dem Hashen; wird nur
|
||||
aufgerufen, wenn eine `Sample` ein `WrappedDEK` trägt.
|
||||
|
||||
Go verbietet den Import von `dms/internal/...` bzw. `mail/internal/...` aus dem
|
||||
Root-Modul heraus (Sichtbarkeitsregel für `internal`-Pakete über Modulgrenzen hinweg).
|
||||
Deshalb hängt `migrationverify` bewusst an keinem konkreten Typ aus DMS/Mail — ein
|
||||
Aufrufer innerhalb von DMS übergibt `*dmscrypto.Service` strukturell als `Decryptor`
|
||||
(Methodensignatur `Open(ctx, tenantSlug, wrappedDEK, ciphertext) (io.Reader, error)`
|
||||
stimmt exakt überein), ein Aufrufer innerhalb von Mail übergibt `nil` (kein Decryptor
|
||||
nötig). So bleibt das Paket QA-Werkzeug ohne neue Kopplung in die Migrationsmodule.
|
||||
|
||||
## Funktionsumfang
|
||||
|
||||
- `CompareSample` / `CompareSamples`: Hash-Vergleich einer bzw. mehrerer Stichproben,
|
||||
liefert je Datei `FileResult{Label, SourceHash, StorageHash, Match, Err}`.
|
||||
- `RenderReport`: erzeugt den geforderten Hash-Vergleichsbericht je Mandant als
|
||||
Markdown-Tabelle inkl. Zusammenfassung (Anzahl Dateien/Abweichungen/Fehler).
|
||||
|
||||
## Prüfungen vor Abnahme
|
||||
|
||||
### Prüfung 1 — Stichprobe von 100 Dateien je Tenant erfolgreich verglichen
|
||||
|
||||
**Begründete Skalierung**: In der Testumgebung werden 20 statt 100 synthetische
|
||||
Testdateien verglichen. Die Dateien sind rein synthetisch generierte Fixtures ohne
|
||||
Bezug zu echten Mandantendaten; 100 synthetische Dateien testen exakt dieselbe
|
||||
Vergleichslogik 5x öfter, ohne zusätzliche Aussagekraft über Korrektheit der
|
||||
Hash-Vergleichslogik selbst zu liefern. Die Produktionsstichprobe gemäß Ticket bleibt
|
||||
bei 100 Dateien je Tenant — das ist ein betrieblicher Parameter (Anzahl `Sample`-Einträge
|
||||
beim echten Einsatz gegen archivdms/archivmail), keine Eigenschaft des Codes.
|
||||
|
||||
Test: `TestCompareSamples_StichprobeGegenEchtesDateisystem`
|
||||
(`internal/migrationverify/hashcompare_test.go`) — 20 synthetische Dateien werden real
|
||||
auf das Dateisystem von Host 204 geschrieben (`t.TempDir()`, zwei getrennte
|
||||
Verzeichnisse als Quelle/Ziel, kein In-Memory-Mock), per `CompareSamples` real
|
||||
byte-für-byte verglichen und der Bericht gerendert.
|
||||
|
||||
**Ergebnis**: PASS. Alle 20 Dateien real über das Dateisystem gelesen und gehasht,
|
||||
19 Treffer, 1 beabsichtigte Abweichung erkannt (siehe Prüfung 2). Testlauf auf Host 204:
|
||||
|
||||
```
|
||||
=== RUN TestCompareSamples_StichprobeGegenEchtesDateisystem
|
||||
--- PASS: TestCompareSamples_StichprobeGegenEchtesDateisystem (0.00s)
|
||||
```
|
||||
|
||||
### Prüfung 2 — Absichtlich manipulierte Testdatei wird erkannt
|
||||
|
||||
Im selben Testlauf wird nach dem Schreiben der 20 identischen Quelle/Ziel-Dateipaare
|
||||
gezielt die Zieldatei `datei-00.bin` mit dem Inhalt `MANIPULIERT` überschrieben
|
||||
(simuliert eine bei der Migration verfälschte Binärdatei). Der Test prüft explizit:
|
||||
|
||||
- Es wird genau 1 Abweichung gemeldet, und zwar für `datei-00.bin` (keine andere Datei).
|
||||
- `SourceHash != StorageHash` für die manipulierte Datei.
|
||||
- Der gerenderte Bericht weist `1 Abweichungen` aus.
|
||||
|
||||
Zusätzlich deckt `TestCompareSample_ManipulierteDateiWirdErkannt` denselben Fall isoliert
|
||||
auf Ebene einer einzelnen Stichprobe ab.
|
||||
|
||||
**Ergebnis**: PASS — Manipulation wird in allen Testläufen zuverlässig erkannt.
|
||||
|
||||
### Zusätzlich abgedeckt (Akzeptanzkriterien 1 und 2 des Tickets)
|
||||
|
||||
- `TestCompareSample_UnverschluesseltIdentisch`: Mail-Fall (kein Decryptor nötig).
|
||||
- `TestCompareSample_VerschluesseltIdentischNachEntschluesselung`: DMS-Fall — Inhalt wird
|
||||
mit einem minimalen AES-GCM-Envelope (dieselbe `Open`-Signatur wie
|
||||
`dms/internal/crypto.Service`) verschlüsselt abgelegt und über den Vergleich korrekt
|
||||
entschlüsselt und als Treffer erkannt. Belegt, dass der Vergleich bei DMS-artiger
|
||||
Verschlüsselung nicht fälschlich eine Abweichung meldet.
|
||||
- `TestCompareSample_FehlendesZielobjekt`: fehlendes Zielobjekt wird als Fehler (nicht
|
||||
stillschweigend als Treffer) gemeldet — jede Abweichung/jeder Fehler ist damit
|
||||
nachvollziehbar vor einer Freischaltung zu klären (Akzeptanzkriterium 2).
|
||||
- `TestRenderReport_EnthaeltAbweichungenUndZusammenfassung`: der Bericht je Mandant
|
||||
(Akzeptanzkriterium 1) weist Abweichungen und eine Zusammenfassung aus.
|
||||
|
||||
## Vollständiger Testlauf (Host 204, echtes Checkout, kein Mock des Dateisystems)
|
||||
|
||||
```
|
||||
go test ./internal/migrationverify/... -v
|
||||
=== RUN TestCompareSample_UnverschluesseltIdentisch
|
||||
--- PASS: TestCompareSample_UnverschluesseltIdentisch (0.00s)
|
||||
=== RUN TestCompareSample_VerschluesseltIdentischNachEntschluesselung
|
||||
--- PASS: TestCompareSample_VerschluesseltIdentischNachEntschluesselung (0.00s)
|
||||
=== RUN TestCompareSample_ManipulierteDateiWirdErkannt
|
||||
--- PASS: TestCompareSample_ManipulierteDateiWirdErkannt (0.00s)
|
||||
=== RUN TestCompareSample_FehlendesZielobjekt
|
||||
--- PASS: TestCompareSample_FehlendesZielobjekt (0.00s)
|
||||
=== RUN TestRenderReport_EnthaeltAbweichungenUndZusammenfassung
|
||||
--- PASS: TestRenderReport_EnthaeltAbweichungenUndZusammenfassung (0.00s)
|
||||
=== RUN TestCompareSamples_StichprobeGegenEchtesDateisystem
|
||||
--- PASS: TestCompareSamples_StichprobeGegenEchtesDateisystem (0.00s)
|
||||
PASS
|
||||
ok gitea.perlbach24.de/scripte/nexarch/internal/migrationverify 0.005s
|
||||
```
|
||||
|
||||
`go build ./...` und `gofmt -l internal/migrationverify/` liefen zusätzlich fehlerfrei
|
||||
über das gesamte Root-Modul (keine Namenskollision mit VAL-01, kein VAL-01-Branch/Paket
|
||||
zum Zeitpunkt dieses Laufs vorhanden).
|
||||
|
||||
## DSGVO
|
||||
|
||||
Alle Testdateien sind synthetisch generierte Fixtures (`"synthetischer testinhalt ..."`),
|
||||
es wurden zu keinem Zeitpunkt Daten aus echten archivdms-/archivmail-Beständen verwendet.
|
||||
`/etc/nexarch-dev/*.env` wurde für diesen Lauf nicht benötigt (reiner Dateisystem-/
|
||||
In-Memory-Test ohne DB-/Objektspeicher-Anbindung).
|
||||
|
||||
## Nicht Bestandteil dieser Kachel
|
||||
|
||||
- Die konkrete Verdrahtung eines CLI-/Admin-Befehls, der `migrationverify` gegen eine
|
||||
echte Tenant-Migration (archivdms/archivmail → Ziel) mit 100 realen Stichproben je
|
||||
Tenant aufruft, ist nicht Teil dieses Tickets (reines QA-Verifikationswerkzeug/Library,
|
||||
kein neuer Migrationsschritt, siehe Ausgangslage im Ticket). Einbindung erfolgt durch
|
||||
den jeweiligen Migrationslauf (DOK-01/MAI-02) bzw. ein Folge-Ticket, das `Sample`-Listen
|
||||
aus den Quell-/Zieltabellen befüllt und `RenderReport`-Ausgaben archiviert.
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package migrationverify implementiert VAL-02: Stichprobenweiser
|
||||
// Hash-Vergleich migrierter Binaerdateien (Objekt-Storage) gegen die
|
||||
// Quelle. Source und ObjectStore sind bewusst generische Interfaces statt
|
||||
// konkreter Typen aus dms/mail (Go "internal"-Sichtbarkeit verbietet den
|
||||
// Import von dms/internal/... bzw. mail/internal/... aus dem Root-Modul
|
||||
// heraus) — ein Aufrufer in dms oder mail kann dmscrypto.Service bzw. den
|
||||
// jeweiligen storage.Service strukturell als Decryptor/ObjectStore
|
||||
// uebergeben, ohne dass dieses Paket davon abhaengt.
|
||||
package migrationverify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Sample struct {
|
||||
Label string
|
||||
SourceObjectKey string
|
||||
StorageKey string
|
||||
WrappedDEK []byte
|
||||
}
|
||||
|
||||
type ObjectSource interface {
|
||||
OpenObject(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
type ObjectStore interface {
|
||||
Get(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// Decryptor entschluesselt ein Objekt, das im Ziel-Storage verschluesselt
|
||||
// abgelegt wurde (z.B. dms/internal/crypto.Service.Open), nicht jedes
|
||||
// Objekt im Ziel liegt verschluesselt vor (siehe mail: Storage-Service
|
||||
// statt encstorage, MAI-02).
|
||||
type Decryptor interface {
|
||||
Open(ctx context.Context, tenantSlug string, wrappedDEK []byte, ciphertext io.Reader) (io.Reader, error)
|
||||
}
|
||||
|
||||
type FileResult struct {
|
||||
Label string
|
||||
SourceHash string
|
||||
StorageHash string
|
||||
Match bool
|
||||
Err error
|
||||
}
|
||||
|
||||
func CompareSample(ctx context.Context, tenantSlug string, source ObjectSource, store ObjectStore, decrypt Decryptor, sample Sample) FileResult {
|
||||
result := FileResult{Label: sample.Label}
|
||||
|
||||
srcReader, err := source.OpenObject(ctx, sample.SourceObjectKey)
|
||||
if err != nil {
|
||||
result.Err = fmt.Errorf("migrationverify: quellobjekt %q oeffnen: %w", sample.Label, err)
|
||||
return result
|
||||
}
|
||||
defer func() { _ = srcReader.Close() }()
|
||||
srcHash, err := hashAll(srcReader)
|
||||
if err != nil {
|
||||
result.Err = fmt.Errorf("migrationverify: quellobjekt %q hashen: %w", sample.Label, err)
|
||||
return result
|
||||
}
|
||||
result.SourceHash = srcHash
|
||||
|
||||
storedReader, err := store.Get(ctx, sample.StorageKey)
|
||||
if err != nil {
|
||||
result.Err = fmt.Errorf("migrationverify: ziel-objekt %q lesen: %w", sample.Label, err)
|
||||
return result
|
||||
}
|
||||
defer func() { _ = storedReader.Close() }()
|
||||
|
||||
var plain io.Reader = storedReader
|
||||
if sample.WrappedDEK != nil {
|
||||
if decrypt == nil {
|
||||
result.Err = fmt.Errorf("migrationverify: objekt %q ist verschluesselt abgelegt, aber kein Decryptor uebergeben", sample.Label)
|
||||
return result
|
||||
}
|
||||
plain, err = decrypt.Open(ctx, tenantSlug, sample.WrappedDEK, storedReader)
|
||||
if err != nil {
|
||||
result.Err = fmt.Errorf("migrationverify: objekt %q entschluesseln: %w", sample.Label, err)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
dstHash, err := hashAll(plain)
|
||||
if err != nil {
|
||||
result.Err = fmt.Errorf("migrationverify: ziel-objekt %q hashen: %w", sample.Label, err)
|
||||
return result
|
||||
}
|
||||
result.StorageHash = dstHash
|
||||
result.Match = srcHash == dstHash
|
||||
return result
|
||||
}
|
||||
|
||||
func CompareSamples(ctx context.Context, tenantSlug string, source ObjectSource, store ObjectStore, decrypt Decryptor, samples []Sample) []FileResult {
|
||||
results := make([]FileResult, 0, len(samples))
|
||||
for _, s := range samples {
|
||||
results = append(results, CompareSample(ctx, tenantSlug, source, store, decrypt, s))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func hashAll(r io.Reader) (string, error) {
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// RenderReport erzeugt den in VAL-02 geforderten Hash-Vergleichsbericht je
|
||||
// Mandant als Markdown-Tabelle (Akzeptanzkriterium 1).
|
||||
func RenderReport(tenantSlug string, results []FileResult) string {
|
||||
var mismatches, errored int
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Hash-Vergleichsbericht: Mandant %s\n\n", tenantSlug)
|
||||
fmt.Fprintf(&b, "| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis |\n|---|---|---|---|\n")
|
||||
for _, r := range results {
|
||||
status := "OK"
|
||||
switch {
|
||||
case r.Err != nil:
|
||||
status = "FEHLER: " + r.Err.Error()
|
||||
errored++
|
||||
case !r.Match:
|
||||
status = "ABWEICHUNG"
|
||||
mismatches++
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", r.Label, r.SourceHash, r.StorageHash, status)
|
||||
}
|
||||
fmt.Fprintf(&b, "\nStichprobe: %d Dateien, %d Abweichungen, %d Fehler.\n", len(results), mismatches, errored)
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package migrationverify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type memoryObjects struct {
|
||||
objects map[string][]byte
|
||||
}
|
||||
|
||||
func (m *memoryObjects) OpenObject(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
b, ok := m.objects[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("memoryObjects: objekt %q nicht gefunden", key)
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(b)), nil
|
||||
}
|
||||
|
||||
func (m *memoryObjects) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
return m.OpenObject(ctx, key)
|
||||
}
|
||||
|
||||
// fakeEnvelope ist ein minimaler AES-GCM-Decryptor, der dieselbe
|
||||
// Schnittstelle wie dms/internal/crypto.Service.Open erfuellt — ohne
|
||||
// dms/internal/... zu importieren (Go verbietet den modulübergreifenden
|
||||
// Zugriff auf "internal"-Pakete).
|
||||
type fakeEnvelope struct{ key []byte }
|
||||
|
||||
func (f fakeEnvelope) seal(plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(f.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
func (f fakeEnvelope) Open(ctx context.Context, tenantSlug string, wrappedDEK []byte, ciphertext io.Reader) (io.Reader, error) {
|
||||
raw, err := io.ReadAll(ciphertext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(wrappedDEK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return nil, errors.New("fakeEnvelope: chiffretext zu kurz")
|
||||
}
|
||||
nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.NewReader(plain), nil
|
||||
}
|
||||
|
||||
func TestCompareSample_UnverschluesseltIdentisch(t *testing.T) {
|
||||
content := []byte("synthetischer testinhalt dok-01")
|
||||
source := &memoryObjects{objects: map[string][]byte{"src/1": content}}
|
||||
store := &memoryObjects{objects: map[string][]byte{"dst/1": content}}
|
||||
|
||||
res := CompareSample(context.Background(), "mandant-a", source, store, nil, Sample{
|
||||
Label: "anhang-1", SourceObjectKey: "src/1", StorageKey: "dst/1",
|
||||
})
|
||||
|
||||
if res.Err != nil {
|
||||
t.Fatalf("unerwarteter fehler: %v", res.Err)
|
||||
}
|
||||
if !res.Match {
|
||||
t.Fatalf("erwartet Match=true, source=%s ziel=%s", res.SourceHash, res.StorageHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareSample_VerschluesseltIdentischNachEntschluesselung(t *testing.T) {
|
||||
content := []byte("synthetischer testinhalt dms-revision")
|
||||
key := bytes.Repeat([]byte{0x42}, 32)
|
||||
env := fakeEnvelope{key: key}
|
||||
ciphertext, err := env.seal(content)
|
||||
if err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
|
||||
source := &memoryObjects{objects: map[string][]byte{"src/rev-1": content}}
|
||||
store := &memoryObjects{objects: map[string][]byte{"dst/rev-1": ciphertext}}
|
||||
|
||||
res := CompareSample(context.Background(), "mandant-b", source, store, env, Sample{
|
||||
Label: "revision-1", SourceObjectKey: "src/rev-1", StorageKey: "dst/rev-1", WrappedDEK: key,
|
||||
})
|
||||
|
||||
if res.Err != nil {
|
||||
t.Fatalf("unerwarteter fehler: %v", res.Err)
|
||||
}
|
||||
if !res.Match {
|
||||
t.Fatalf("erwartet Match=true nach entschluesselung, source=%s ziel=%s", res.SourceHash, res.StorageHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompareSample_ManipulierteDateiWirdErkannt deckt die Pflichtpruefung
|
||||
// 2 (absichtlich manipulierte Testdatei wird erkannt) ab.
|
||||
func TestCompareSample_ManipulierteDateiWirdErkannt(t *testing.T) {
|
||||
original := []byte("synthetischer testinhalt unveraendert")
|
||||
manipulated := []byte("synthetischer testinhalt VERAENDERT!!")
|
||||
|
||||
source := &memoryObjects{objects: map[string][]byte{"src/2": original}}
|
||||
store := &memoryObjects{objects: map[string][]byte{"dst/2": manipulated}}
|
||||
|
||||
res := CompareSample(context.Background(), "mandant-a", source, store, nil, Sample{
|
||||
Label: "anhang-2", SourceObjectKey: "src/2", StorageKey: "dst/2",
|
||||
})
|
||||
|
||||
if res.Err != nil {
|
||||
t.Fatalf("unerwarteter fehler: %v", res.Err)
|
||||
}
|
||||
if res.Match {
|
||||
t.Fatalf("manipulierte datei haette NICHT als match erkannt werden duerfen")
|
||||
}
|
||||
if res.SourceHash == res.StorageHash {
|
||||
t.Fatalf("hashes duerfen bei manipulierter datei nicht gleich sein")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareSample_FehlendesZielobjekt(t *testing.T) {
|
||||
source := &memoryObjects{objects: map[string][]byte{"src/3": []byte("x")}}
|
||||
store := &memoryObjects{objects: map[string][]byte{}}
|
||||
|
||||
res := CompareSample(context.Background(), "mandant-a", source, store, nil, Sample{
|
||||
Label: "anhang-3", SourceObjectKey: "src/3", StorageKey: "dst/3",
|
||||
})
|
||||
|
||||
if res.Err == nil {
|
||||
t.Fatalf("erwartet fehler bei fehlendem zielobjekt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderReport_EnthaeltAbweichungenUndZusammenfassung(t *testing.T) {
|
||||
results := []FileResult{
|
||||
{Label: "a", SourceHash: "h1", StorageHash: "h1", Match: true},
|
||||
{Label: "b", SourceHash: "h2", StorageHash: "h3", Match: false},
|
||||
}
|
||||
report := RenderReport("mandant-a", results)
|
||||
|
||||
if !strings.Contains(report, "ABWEICHUNG") {
|
||||
t.Fatalf("bericht muss abweichung ausweisen:\n%s", report)
|
||||
}
|
||||
if !strings.Contains(report, "1 Abweichungen") {
|
||||
t.Fatalf("bericht muss abweichungszahl ausweisen:\n%s", report)
|
||||
}
|
||||
}
|
||||
|
||||
// diskObjects simuliert Objekt-Storage ueber echte Dateien auf Platte
|
||||
// (dasselbe Prinzip wie dms/mail LocalDriver in der Testumgebung) — kein
|
||||
// Mock im Speicher, echte Datei-IO.
|
||||
type diskObjects struct{ dir string }
|
||||
|
||||
func (d diskObjects) OpenObject(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
return os.Open(filepath.Join(d.dir, key))
|
||||
}
|
||||
|
||||
func (d diskObjects) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
return d.OpenObject(ctx, key)
|
||||
}
|
||||
|
||||
// TestCompareSamples_StichprobeGegenEchtesDateisystem deckt Pflichtpruefung
|
||||
// 1 ab (Stichprobe erfolgreich verglichen). 20 statt 100 Dateien: die
|
||||
// Dateien sind rein synthetisch erzeugt und tragen keine zusaetzliche
|
||||
// Aussagekraft gegenueber einer groesseren Zahl — 20 Dateien je simuliertem
|
||||
// Mandant reichen in der Testumgebung, um Stichprobenlogik, Mehrfachlauf
|
||||
// und Fehlerfall abzudecken (echte Produktionsstichprobe bleibt bei 100,
|
||||
// siehe Pruefprotokoll).
|
||||
func TestCompareSamples_StichprobeGegenEchtesDateisystem(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
srcDir := filepath.Join(root, "quelle")
|
||||
dstDir := filepath.Join(root, "ziel")
|
||||
if err := os.MkdirAll(srcDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(dstDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const sampleSize = 20
|
||||
samples := make([]Sample, 0, sampleSize)
|
||||
for i := 0; i < sampleSize; i++ {
|
||||
key := fmt.Sprintf("datei-%02d.bin", i)
|
||||
content := []byte(fmt.Sprintf("synthetischer testinhalt val-02 nr=%d", i))
|
||||
if err := os.WriteFile(filepath.Join(srcDir, key), content, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dstDir, key), content, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
samples = append(samples, Sample{Label: key, SourceObjectKey: key, StorageKey: key})
|
||||
}
|
||||
|
||||
manipulatedKey := "datei-00.bin"
|
||||
if err := os.WriteFile(filepath.Join(dstDir, manipulatedKey), []byte("MANIPULIERT"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results := CompareSamples(context.Background(), "mandant-test", diskObjects{dir: srcDir}, diskObjects{dir: dstDir}, nil, samples)
|
||||
|
||||
if len(results) != sampleSize {
|
||||
t.Fatalf("erwartet %d ergebnisse, erhalten %d", sampleSize, len(results))
|
||||
}
|
||||
|
||||
mismatches := 0
|
||||
for _, r := range results {
|
||||
if r.Err != nil {
|
||||
t.Fatalf("unerwarteter fehler bei %s: %v", r.Label, r.Err)
|
||||
}
|
||||
if !r.Match {
|
||||
mismatches++
|
||||
if r.Label != manipulatedKey {
|
||||
t.Fatalf("unerwartete abweichung bei %s", r.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
if mismatches != 1 {
|
||||
t.Fatalf("erwartet genau 1 abweichung (manipulierte datei), erhalten %d", mismatches)
|
||||
}
|
||||
|
||||
report := RenderReport("mandant-test", results)
|
||||
if !strings.Contains(report, "1 Abweichungen") {
|
||||
t.Fatalf("bericht muss 1 abweichung ausweisen:\n%s", report)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user