Files
nexarch/internal/migration/verify/hashcompare_test.go
T
patrickandClaude Sonnet 5.5 5b8c20777d refactor: Pruefgate-Pakete nach Funktion benannt, migration* gruppiert
- dms/ai/workflow: qa0x-Pakete nach internal/gates/<thema> verschoben, Paketname = Thema
- internal/migration{acl,audit,...} -> internal/migration/{acl,audit,...}
- Importpfade, Gate-Skripte und zwei relative Migrationspfade angepasst
- keine Logikaenderung; mail-Gates bewusst unveraendert (pfadabhaengig)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 12:44:21 +01:00

250 lines
7.8 KiB
Go

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)
}
}