diff --git a/archive/docs/QA-02-PRUEFPROTOKOLL.md b/archive/docs/QA-02-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..b570f70a --- /dev/null +++ b/archive/docs/QA-02-PRUEFPROTOKOLL.md @@ -0,0 +1,100 @@ +# QA-02 – Prüfprotokoll: Prüfgate Retention & WORM + +Stand: 2026-10-16, Testhost 192.168.1.204 (echte `TEST_TENANT_DSN`-PostgreSQL-Testdatenbank, kein +Mocking). Voraussetzung RET-03 (Löschworkflow & Legal Hold) und RET-04 (WORM-Speicher-Garantie) – +beide Fertig, siehe `RET-03-PRUEFPROTOKOLL.md`/`RET-04-PRUEFPROTOKOLL.md`. Dieses Gate wiederholt +deren Pflichtprüfungen unter Prüfgate-Bedingungen und bindet sie zusätzlich an den in QA-01 +definierten Referenzdatensatz (`internal/referenceset`). + +## Umsetzung dieses Gates + +- `archive/internal/deletionworkflow/deletionworkflow_test.go`: neuer Test + `TestPruefgate02_LegalHoldResistsDeletionForReferenceSetObjects` – durchläuft alle drei + `referenceset.RetentionObjects()` (`dms_document`/`steuerlich_10j`, + `mail_message`/`handelsrechtlich_6j`, `audit_event`/`sofort_loeschbar`), setzt je Objekt eine + Aufbewahrungssperre, prüft, dass `ReleaseExpired` das gesperrte, fällige Referenzobjekt NICHT + freigibt, und dass `Destroy` mit `ErrOnLegalHold` scheitert. +- `archive/internal/wormstore/wormstore_test.go`: neuer Test + `TestPruefgate02_OverwriteAndTamperDetectionForReferenceSetPayloads` – schreibt je + Größenklasse (`SizeSmall`/`SizeMedium`/`SizeLarge`) die deterministischen + `referenceset.WormPayload`-Nutzdaten, vergleicht die Prüfsumme mit + `referenceset.WormPayloadChecksum`, prüft den Überschreibversuch über die Go-API + (`ErrAlreadyExists`) und den direkten Schreibzugriff am Dateisystem. +- Kein Produktivcode in `deletionworkflow`/`wormstore` geändert – nur Testdateien, RET-03/RET-04 + bleiben unverändert (Pflichttestgate aus `TESTSTRATEGIE-ARCHIVE.md` Abschnitt 3 bleibt erfüllt). + +## Prüfungen vor der Abnahme + +| # | Prüfung | Zielwert | Messwert | Bewertung | +|---|---|---|---|---| +| 1 | Löschversuch an gesperrtem Objekt schlägt fehl (RET-03, Referenzdatensatz) | `Destroy` liefert `ErrOnLegalHold` für jedes der drei Referenzobjekte, Status bleibt unverändert, `ReleaseExpired` gibt das gesperrte Objekt trotz Fälligkeit nicht frei | `go test ./internal/deletionworkflow/... -v -count=1` gegen echte `TEST_TENANT_DSN`-DB, 2026-10-16: **7/7 PASS**, davon `TestDestroy_ObjectWithActiveHoldResistsDeletion` (Alt-Fixture) und `TestPruefgate02_LegalHoldResistsDeletionForReferenceSetObjects` (Referenzdatensatz, alle 3 Objekttypen) beide grün; zusätzlich `TestDestructionLog_IsItselfLogged`/`TestDestructionLog_IsImmutable`-Familie unverändert grün | **bestanden** | +| 2 | Überschreibversuch am WORM-Speicher schlägt fehl (RET-04, Referenzdatensatz) | Zweiter `Put` auf denselben Schlüssel liefert `ErrAlreadyExists`; direkter Schreibzugriff am Dateisystem scheitert; Prüfsumme der gelesenen Daten entspricht `referenceset.WormPayloadChecksum` | **Teilweise bestanden, Teilergebnis offen** – siehe Restrisiko 1. Automatisierter Store-Testlauf (`TEST_WORMSTORE_LOCK_DIR` auf ZFS-Wurzel des Testhosts) übersprang 7 Tests (inkl. des neuen `TestPruefgate02_OverwriteAndTamperDetectionForReferenceSetPayloads`) real mit der Meldung "chattr +i wird ... nicht durchgesetzt" – reproduziert exakt den in `RET-11-PRUEFPROTOKOLL.md` für Host 131 dokumentierten Befund, jetzt auch für Host 204 bestätigt. Als Ersatznachweis für die ursprüngliche RET-04-Garantie (chmod 0400, ohne chattr) wurde **manuell** als echter Betriebsnutzer geprüft: Verzeichnis `nexarch:nexarch`-Eigentümer (realistische Produktionslage), Datei von `nexarch` geschrieben und anschließend `chmod 0400` gesetzt; `sudo -u nexarch` Schreibversuch auf die Datei → **„Permission denied", Exit 1**, Inhalt unverändert. Reflection-Test `TestStore_OnlyDestroyCanRemoveObjects` (kein Lösch-Pfad außer `Destroy`) lief real und **PASS** | **teilweise bestanden** (Schreibschutz real nachgewiesen, Storage-Object-Lock-Ebene auf diesem Host nicht prüfbar) | +| 3 | Offene Restrisiken sind schriftlich benannt | Vollständige, ehrliche Liste | 3 Punkte identifiziert und dokumentiert (unten) | **bestanden** | + +## Build/Test-Ergebnis (192.168.1.204) + +``` +cd archive && go build ./... -> clean +cd archive && go vet ./... -> clean +golangci-lint -> auf Host 204 nicht installiert (anders als 131 in frueheren + Protokollen; keine Installation vorgenommen, siehe Vorgabe + "keine Toolchain-Installationen auf 204"); go vet als + verfuegbare statische Pruefstufe genutzt +go test ./internal/deletionworkflow/... -v -count=1 + -> 7/7 PASS (TEST_TENANT_DSN, echte Postgres-Testdatenbank) +go test ./internal/wormstore/... -v -count=1 + -> 1 PASS (TestStore_OnlyDestroyCanRemoveObjects), 7 SKIP (chattr +i auf + ZFS/unprivilegiertem LXC-Host strukturell nicht durchsetzbar) +``` + +Manueller Zusatznachweis (nicht Teil der Go-Testsuite, siehe Prüfung 2): Verzeichnis-/Datei-Setup +und `sudo -u nexarch`-Schreibversuch, durchgeführt 2026-10-16 ca. 12:11 UTC, Testartefakte danach +entfernt (`/tmp/qa02-worm-manual2`). + +## Offene Restrisiken (Akzeptanzkriterium/Pflichtprüfung 3) + +1. **Storage-Object-Lock (RET-11, `chattr +i`) auf keinem bekannten Host dieses Projekts real + nachweisbar – potenziell auch in Produktion nicht funktionsfähig.** `wormstore.Store.Put` ist + seit RET-11 fail-closed: schlägt das Setzen von `chattr +i` fehl, wird die bereits geschriebene + Datei wieder entfernt und `Put` insgesamt mit Fehler beendet. Sowohl Host 131 + (`RET-11-PRUEFPROTOKOLL.md`) als auch – in diesem Gate neu bestätigt – Host 204 sind + unprivilegierte Proxmox-LXC-Container auf ZFS-Root, auf denen `chattr +i` strukturell mit + `EPERM` scheitert. `docs/STORAGE-KONZEPT.md` Abschnitt 10 benennt "Stufe 1 (Einzel-LXC)" mit + "lokales Dateisystem oder ZFS" ausdrücklich als Ziel-Produktionsarchitektur – also exakt dieselbe + Kombination, an der `chattr +i` auf beiden bisher geprüften Hosts scheitert. **Ungeklärt, ob + `wormstore.Store.Put` in der aktuellen, dokumentierten Zielarchitektur überhaupt jemals + erfolgreich schreiben kann.** Das ist kein neuer Befund dieses Gates (RET-11 selbst stufte seine + Prüfungen 1/2 bereits als "offen" ein), aber die erneute Bestätigung auf einem zweiten, + unabhängigen Host erhöht das Gewicht dieses Risikos von "Testumgebungs-Lücke" zu + "möglicherweise produktionsrelevant". **Empfehlung:** eigenes Folgeticket, das (a) `Put` entweder + mit einem privilegierten Container/VM mit ext4/xfs/btrfs-Backing real verifiziert, oder (b) einen + Fallback-Modus für Hosts ohne `CAP_LINUX_IMMUTABLE`-Durchsetzung definiert, statt stillschweigend + jeden Schreibvorgang scheitern zu lassen. +2. **Referenzdatensatz-Bindung deckt nur RET-03/RET-04 ab, nicht RET-06/RET-07.** Die + Ausgangslage dieses Tickets nennt RET-06 (Fristen-Konfigurationsoberfläche) und RET-07 + (Fristablauf-Benachrichtigungen) als bereits fertiggestellt, QA-02s Akzeptanzkriterien verlangen + aber ausdrücklich nur den Nachweis für RET-03/RET-04 – dieses Gate prüft RET-06/RET-07 bewusst + nicht erneut (kein Scope-Drift). Ein eigenes Prüfgate für RET-06/RET-07 existiert nach aktuellem + Stand nicht. +3. **`golangci-lint` auf Host 204 nicht verfügbar.** Frühere Archive-Prüfprotokolle (RET-03, RET-04, + RET-11) liefen auf Host 131 inklusive `golangci-lint run ./...`. Auf 204 steht das Werkzeug nicht + zur Verfügung und wurde gemäß Vorgabe (keine Toolchain-Installation auf 204) nicht nachinstalliert + – `go build`/`go vet` liefen stattdessen clean, ersetzen aber nicht den vollen Lint-Umfang. + +## Gesamtergebnis + +**Teilweise bestanden.** RET-03 (Löschworkflow & Legal Hold) ist vollständig und real gegen den +Referenzdatensatz nachgewiesen (Prüfung 1: bestanden). RET-04 (WORM-Speicher) ist in seinem +ursprünglichen, eigenen Geltungsbereich (Anwendungs-API ohne Lösch-Pfad, Schreibschutz gegen den +realen Betriebsnutzer `nexarch`) real bestätigt, die seit RET-11 zusätzliche +Storage-Object-Lock-Ebene bleibt auf diesem Host technisch nicht prüfbar (Prüfung 2: teilweise +bestanden). Drei Restrisiken benannt, keines davon ist ein neu eingeführter Fehler dieses Gates – +Restrisiko 1 verdient wegen der Übereinstimmung mit der dokumentierten Zielarchitektur +(`STORAGE-KONZEPT.md`) besondere Priorität vor einer produktiven Freigabe der WORM-Funktion. + +## Unterschriften + +- **Umsetzung:** Claude (Agent), 2026-10-16 — alle Prüfungen durchgeführt, Protokoll erstellt. +- **Gegenzeichnung:** aussteht (wie bei früheren QA-Gates wird das Gegenlesen im Pull Request + nachgetragen, sobald eine zweite Person verfügbar ist; blockiert nicht das technische + Inkrafttreten dieses Protokolls). diff --git a/archive/internal/deletionworkflow/deletionworkflow_test.go b/archive/internal/deletionworkflow/deletionworkflow_test.go index 8410f317..8b570efd 100644 --- a/archive/internal/deletionworkflow/deletionworkflow_test.go +++ b/archive/internal/deletionworkflow/deletionworkflow_test.go @@ -8,6 +8,8 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/referenceset" ) func setupTest(t *testing.T) *pgxpool.Pool { @@ -267,3 +269,45 @@ func TestDestroy_RequiresPriorRelease(t *testing.T) { t.Fatalf("erwartet ErrNotReleased, habe: %v", err) } } + +// TestPruefgate02_LegalHoldResistsDeletionForReferenceSetObjects ist QA-02 +// Pflichtpruefung 1, ausgefuehrt gegen den Referenzdatensatz +// (referenceset.RetentionObjects) statt ad-hoc-Fixtures. +func TestPruefgate02_LegalHoldResistsDeletionForReferenceSetObjects(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + + for _, class := range []string{"steuerlich_10j", "handelsrechtlich_6j", "sofort_loeschbar"} { + if _, err := pool.Exec(ctx, `INSERT INTO retention_class_rules (retention_class, duration) VALUES ($1, '1 second')`, class); err != nil { + t.Fatal(err) + } + } + past := time.Now().UTC().Add(-24 * time.Hour) + + for _, obj := range referenceset.RetentionObjects() { + var objID string + if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ($1, $2) RETURNING id`, obj.ObjectType, obj.ObjectReference).Scan(&objID); err != nil { + t.Fatalf("%s: objekt anlegen: %v", obj.ObjectType, err) + } + if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, $2, $3)`, objID, obj.RetentionClass, past); err != nil { + t.Fatalf("%s: klassenzuordnung: %v", obj.ObjectType, err) + } + if err := SetLegalHold(ctx, pool, objID, "qa-02 pruefgate referenzdatensatz", "qa02@acme.example"); err != nil { + t.Fatalf("%s: sperre setzen: %v", obj.ObjectType, err) + } + + released, err := ReleaseExpired(ctx, pool, time.Now().UTC()) + if err != nil { + t.Fatalf("%s: releaseexpired: %v", obj.ObjectType, err) + } + for _, r := range released { + if r == objID { + t.Fatalf("%s: gesperrtes, faelliges referenzobjekt wurde trotzdem freigegeben", obj.ObjectType) + } + } + + if err := Destroy(ctx, pool, objID, "qa02-worker"); !errors.Is(err, ErrOnLegalHold) { + t.Fatalf("%s: erwartet ErrOnLegalHold, habe: %v", obj.ObjectType, err) + } + } +} diff --git a/archive/internal/wormstore/wormstore_test.go b/archive/internal/wormstore/wormstore_test.go index 98c03e35..533005af 100644 --- a/archive/internal/wormstore/wormstore_test.go +++ b/archive/internal/wormstore/wormstore_test.go @@ -1,8 +1,10 @@ package wormstore import ( + "bytes" "context" "errors" + "fmt" "os" "reflect" "strings" @@ -10,6 +12,8 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/referenceset" ) // wormLockDir liefert ein Basisverzeichnis, in dem chattr +i tatsächlich @@ -256,3 +260,47 @@ func TestStore_OnlyDestroyCanRemoveObjects(t *testing.T) { } } } + +// TestPruefgate02_OverwriteAndTamperDetectionForReferenceSetPayloads ist +// QA-02 Pflichtpruefung 2, ausgefuehrt gegen den Referenzdatensatz +// (referenceset.WormPayload/WormPayloadChecksum) statt ad-hoc-Testbytes, +// ueber alle drei Groessenklassen. +func TestPruefgate02_OverwriteAndTamperDetectionForReferenceSetPayloads(t *testing.T) { + store, _ := setupTest(t) + ctx := context.Background() + + for _, size := range []referenceset.WormPayloadSize{referenceset.SizeSmall, referenceset.SizeMedium, referenceset.SizeLarge} { + key := fmt.Sprintf("wormtest_refset_%d", size) + payload := referenceset.WormPayload(size) + wantSum := referenceset.WormPayloadChecksum(size) + + sum, written, err := store.Put(ctx, key, bytes.NewReader(payload), time.Now().Add(time.Hour), "test_tenant", "qa02-pruefgate") + if err != nil { + t.Fatalf("groessenklasse %d: put: %v", size, err) + } + if sum != wantSum { + t.Fatalf("groessenklasse %d: pruefsumme weicht vom referenzdatensatz ab: habe %q, will %q", size, sum, wantSum) + } + if written != int64(size) { + t.Fatalf("groessenklasse %d: groesse weicht ab: habe %d, will %d", size, written, int64(size)) + } + + if _, _, err := store.Put(ctx, key, bytes.NewReader([]byte("manipulierter ueberschreibversuch")), time.Now().Add(time.Hour), "test_tenant", "qa02-pruefgate"); !errors.Is(err, ErrAlreadyExists) { + t.Fatalf("groessenklasse %d: erwartet ErrAlreadyExists beim ueberschreibversuch, habe: %v", size, err) + } + + full := store.path(key) + if f, openErr := os.OpenFile(full, os.O_WRONLY, 0); openErr == nil { + _ = f.Close() + t.Fatalf("groessenklasse %d: direkter schreibzugriff haette an der storage-object-lock scheitern muessen", size) + } + + data, err := store.GetVerified(ctx, key) + if err != nil { + t.Fatalf("groessenklasse %d: getverified: %v", size, err) + } + if !bytes.Equal(data, payload) { + t.Fatalf("groessenklasse %d: gelesener inhalt weicht vom referenzdatensatz ab", size) + } + } +}