QA-01: Teststrategie Archive (Pflichttest-Gate, Referenzdatensatz, CI-Workflow)
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Failing after 31s

Gespiegelt von Core QA-01: Testpyramide (Unit/Integration/Wiederherstellungs-
test/Extern), Pflichttests fuer sicherheitskritischen Code als technisch
erzwungenes Merge-Gate statt Dokumentationsvorgabe.

Sicherheitskritische Bereiche fuer Archive (docs/TESTSTRATEGIE-ARCHIVE.md
Abschnitt 3): wormstore (WORM-Durchsetzung selbst), rbacclient/retentionapi
(Zugriffspruefung), tenantregistryclient/kekclient/backupcrypto (Tenant-/
Schluessel-Bezug). internal/pflichttestgate + cmd/pflichttestgate pruefen
das automatisiert gegen den PR-Diff, .gitea/workflows/archive-pflichttest-
gate.yml fuehrt es in CI aus (Pfad-Filter auf archive/**, da Monorepo mit
mehreren physisch getrennten Go-Modulen).

internal/referenceset liefert den in Abschnitt 4 definierten, synthetischen
Referenzdatensatz (Retention-Objekte je Fristklasse, WORM-Nutzdaten in drei
Groessenklassen, Backup-Generationen mit geteilten/eindeutigen Bloecken) -
keine echten Kunden-/Mandantendaten (DSGVO-Vorgabe), reproduzierbar per
festem Seed, Nachweis in referenceset_test.go.

Offen: Gegenlesen durch zweite Person (Akzeptanzkriterium 3) - wie bei
Core QA-01 im PR nachzutragen, blockiert nicht das technische Inkrafttreten
des Gates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-17 16:49:30 +02:00
1 parent a5c0bd6c89
commit 81dca7779e
7 files changed
+464

No files matched your search

@@ -0,0 +1,23 @@
name: Archive-Pflichttest-Gate
on:
pull_request:
paths:
- "archive/**"
jobs:
pflichttest-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Gate bauen
working-directory: archive
run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate
- name: Geänderte Dateien gegen Pflichttest-Regel pruefen
run: |
git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- archive/ | /tmp/pflichttestgate
+41
View File
@@ -0,0 +1,41 @@
// Command pflichttestgate ist das CI-Gate aus docs/TESTSTRATEGIE-ARCHIVE.md Abschnitt 5.
// Aufruf: pflichttestgate <geänderte-Dateien-getrennt-durch-Zeilenumbruch-via-stdin>
// Beendet sich mit Exit-Code 1 und einer Liste der Verstöße, wenn ein sicherheitskritisches
// Package ohne begleitende Testdatei geändert wurde.
package main
import (
"bufio"
"fmt"
"os"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/pflichttestgate"
)
func main() {
var changedFiles []string
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Text()
if line != "" {
changedFiles = append(changedFiles, line)
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "pflichttestgate: Eingabe konnte nicht gelesen werden: %v\n", err)
os.Exit(2)
}
violations := pflichttestgate.CheckDiff(changedFiles)
if len(violations) == 0 {
fmt.Println("pflichttestgate: bestanden — alle sicherheitskritischen Änderungen haben begleitende Tests.")
return
}
fmt.Fprintln(os.Stderr, "pflichttestgate: FEHLGESCHLAGEN — Pflichttest fehlt für:")
for _, v := range violations {
fmt.Fprintf(os.Stderr, " - Package %q (Datei %q hat keine begleitende _test.go-Änderung)\n", v.Package, v.ChangedFile)
}
fmt.Fprintln(os.Stderr, "\nSiehe docs/TESTSTRATEGIE-ARCHIVE.md Abschnitt 3.")
os.Exit(1)
}
+86
View File
@@ -0,0 +1,86 @@
# NEXARCH Archive – Teststrategie
Stand: 2026-09-17. Ticket: QA-01.
## 1. Warum dieses Dokument existiert
Core übernimmt aus archivdms/archivmail den Grundsatz, dass Testpflicht für sicherheitskritischen
Code ein Merge-Gate ist, keine Nachrüstung (siehe `docs/TESTSTRATEGIE-CORE.md`, Core QA-01). Archive
trägt zusätzliches, eigenes Risiko: Es ist die letzte Verteidigungslinie gegen Datenverlust
(Backup/Restore) und die technische Durchsetzung gesetzlicher Aufbewahrungspflichten (WORM,
Löschsperren). Ein ungetesteter Regressionsfehler hier bedeutet im schlechtesten Fall ein
unwiederherstellbares oder ein vorzeitig gelöschtes, aufbewahrungspflichtiges Dokument.
## 2. Testpyramide
| Ebene | Werkzeug | Umfang | Verantwortlich |
|---|---|---|---|
| Unit | `go test` (Standardbibliothek) | Einzelne Funktionen/Typen, keine externe Abhängigkeit (DB, Dateisystem, Netzwerk) | Autor der Änderung |
| Integration | `go test` gegen echte PostgreSQL-Instanz (`nexarch_test`-Rolle) + echtes Dateisystem für WORM-Locks | Retention-Engine, WORM-Speicher, Backup/Restore-Zyklen, Client-Pakete gegen Core (RBAC, KEK, Tenant-Registry) | Autor der Änderung |
| Wiederherstellungstest | `go test` mit echtem `restic`/`pg_combinebackup` gegen den Testhost | Vollständiger Backup→Restore-Zyklus inkl. Envelope-Verschlüsselung (BAK-01 bis BAK-09) | Autor der Änderung, Abnahme durch QA-05 |
| Extern | Archive `QA-05`/`QA-07` (Abnahme-/Vertragstests) | Modulübergreifende Verträge (Retention-Adapter, Audit-Ingest) | Siehe jeweiliges Ticket |
Diese Kachel (QA-01) legt die Pflichtebenen fest und erzwingt sie technisch für die
sicherheitskritischsten Bereiche; sie ersetzt nicht QA-02 bis QA-06, die eigene Prüfgates haben.
## 3. Pflichttests als Merge-Gate
Verbindlich für jeden Pull Request, der Dateien in einem der folgenden Bereiche ändert:
- **WORM-Durchsetzung** (`archive/internal/wormstore/`) — die Storage-Object-Lock-Garantie selbst.
- **Zugriffsprüfung** (`archive/internal/rbacclient/`, `archive/internal/retentionapi/`) — RBAC-Anbindung
und die APIs, die sie durchsetzen.
- **Tenant-/Schlüssel-Bezug** (`archive/internal/tenantregistryclient/`, `archive/internal/kekclient/`,
`archive/internal/backupcrypto/`) — unabhängige Tenant-Validierung und Verschlüsselungspfade.
Regel: **jede geänderte `.go`-Datei in einem dieser Bereiche muss von einer geänderten oder neuen
`_test.go`-Datei im selben Package begleitet sein.** Das CI-Gate (Abschnitt 5) prüft das
automatisiert und blockiert den Merge, wenn die Regel verletzt ist.
Diese Regel gilt projektweit für alle sieben Boards, nicht nur Core — siehe `SICHERHEITSKONZEPT.md`
Abschnitt 11, Punkt 8, sowie die jeweiligen `QA-01`-Tickets der anderen Boards.
## 4. Referenzdatensatz für Retention-/Backup-Tests
Ein reproduzierbarer, synthetischer Referenzdatensatz (keine echten Kunden-/Mandantendaten,
siehe `CLAUDE.md` DSGVO-Vorgabe) für Retention- und Backup-Integrationstests:
- **Retention-Objekte**: drei synthetische Objekttypen (`dms_document`, `mail_message`,
`audit_event`) mit je einer Aufbewahrungsklasse (`steuerlich_10j`, `handelsrechtlich_6j`,
`sofort_loeschbar`) — deckt kurze, mittlere und lange Fristen sowie Sofort-Ablauf ab.
- **WORM-Nutzdaten**: kleine, deterministisch erzeugte Dateien (`crypto/rand`-Seed fest verdrahtet
im Testcode, keine externen Fixtures) in drei Größenklassen (1 KiB, 1 MiB, 10 MiB) — deckt
Prüfsummenbildung und Object-Lock unabhängig von der Dateigröße ab.
- **Backup-Generationen**: eine Voll- und zwei Inkrement-Generationen mit überlappenden und
eindeutigen Blöcken — deckt Deduplizierung (BAK-01/02) und Envelope-Verschlüsselung (BAK-09) ab.
- **Erzeugung**: jeder Testfall erzeugt seinen Anteil des Referenzdatensatzes selbst zur Laufzeit
(`t.TempDir()`, deterministische Testdaten-Generatoren in den jeweiligen `_test.go`-Dateien) und
räumt ihn über `t.Cleanup()` wieder ab — keine geteilten Fixture-Dateien zwischen Testfällen, aus
demselben Grund wie Core Abschnitt 4 (Isolation zwischen parallelen Testläufen auf demselben
physischen Testhost).
- **Nachweis der Reproduzierbarkeit**: `internal/referenceset/referenceset_test.go` (dieses Ticket)
baut den vollständigen Referenzdatensatz zweimal unabhängig voneinander auf und vergleicht die
Ergebnisse (Prüfsummen, Objekt-/Klassenzuordnungen) — Prüfung 1 dieses Tickets.
## 5. CI-Gate
`.gitea/workflows/archive-pflichttest-gate.yml` führt `archive/cmd/pflichttestgate` gegen den
PR-Diff aus (`git diff --name-only origin/<base>...HEAD -- archive/`). Das Programm:
1. Filtert die geänderten Dateien auf die in Abschnitt 3 genannten Pfad-Muster.
2. Prüft je betroffenem Go-Package, ob mindestens eine `_test.go`-Datei desselben Packages ebenfalls
im Diff enthalten ist.
3. Beendet sich mit Exit-Code 1 und einer Liste der betroffenen Packages ohne Teständerung, wenn die
Regel verletzt ist — der CI-Job schlägt dann fehl, der Merge ist blockiert.
Negativtest des Gates selbst: `internal/pflichttestgate/gate_test.go` enthält einen Testfall, der
einen Diff mit geänderter `archive/internal/wormstore/wormstore.go` ohne begleitende Testdatei
simuliert und erwartet, dass das Gate das als Verstoß erkennt (Prüfung 1 dieses Tickets — gespiegelt
zur Referenzdatensatz-Prüfung in Abschnitt 4, beide sind Teil derselben Abnahme).
## 6. Dokumentation & Gegenlesen
Dieses Dokument ist von einer zweiten Person gegenzulesen, bevor die Kachel als abgeschlossen gilt
(Akzeptanzkriterium 3 / Prüfung 2). **Offen zum Zeitpunkt dieses Commits** — wie bei Core QA-01 wird
das Gegenlesen im Pull Request nachgetragen, sobald eine zweite Person verfügbar ist; es blockiert
nicht das technische Inkrafttreten des Gates (Abschnitt 5 ist bereits automatisiert scharf).
+70
View File
@@ -0,0 +1,70 @@
// Package pflichttestgate erzwingt die in docs/TESTSTRATEGIE-ARCHIVE.md Abschnitt 3 festgelegte
// Regel: jede geänderte Go-Datei in einem sicherheitskritischen Bereich (WORM-Durchsetzung,
// Zugriffsprüfung, Tenant-/Schlüssel-Bezug) muss von einer geänderten oder neuen _test.go-Datei
// im selben Package begleitet sein. Selbe Logik wie Core QA-01 (internal/pflichttestgate),
// hier für Archive gespiegelt statt gemeinsam genutzt (physisch getrenntes Go-Modul).
package pflichttestgate
import (
"path"
"regexp"
"strings"
)
// sensitivePathPatterns beschreibt die Bereiche aus TESTSTRATEGIE-ARCHIVE.md Abschnitt 3.
var sensitivePathPatterns = []*regexp.Regexp{
regexp.MustCompile(`(^|/)archive/internal/wormstore/`),
regexp.MustCompile(`(^|/)archive/internal/rbacclient/`),
regexp.MustCompile(`(^|/)archive/internal/tenantregistryclient/`),
regexp.MustCompile(`(^|/)archive/internal/retentionapi/`),
regexp.MustCompile(`(^|/)archive/internal/kekclient/`),
regexp.MustCompile(`(^|/)archive/internal/backupcrypto/`),
}
// Violation beschreibt ein Package mit sicherheitskritischer Änderung ohne begleitende Testdatei.
type Violation struct {
Package string
ChangedFile string
}
// isSensitive meldet, ob eine geänderte Datei in einen der Pflichttest-Bereiche fällt.
func isSensitive(file string) bool {
if !strings.HasSuffix(file, ".go") || strings.HasSuffix(file, "_test.go") {
return false
}
for _, re := range sensitivePathPatterns {
if re.MatchString(file) {
return true
}
}
return false
}
// CheckDiff prüft eine Liste geänderter Dateipfade (z. B. aus `git diff --name-only`) gegen die
// Pflichttest-Regel und gibt für jedes betroffene Package ohne begleitende Testdatei eine Violation
// zurück. Ein leeres Ergebnis bedeutet: Gate besteht.
func CheckDiff(changedFiles []string) []Violation {
sensitiveByPkg := map[string]string{}
testTouchedPkgs := map[string]bool{}
for _, f := range changedFiles {
pkg := path.Dir(f)
if strings.HasSuffix(f, "_test.go") {
testTouchedPkgs[pkg] = true
continue
}
if isSensitive(f) {
if _, seen := sensitiveByPkg[pkg]; !seen {
sensitiveByPkg[pkg] = f
}
}
}
var violations []Violation
for pkg, file := range sensitiveByPkg {
if !testTouchedPkgs[pkg] {
violations = append(violations, Violation{Package: pkg, ChangedFile: file})
}
}
return violations
}
@@ -0,0 +1,84 @@
package pflichttestgate
import "testing"
// Prüfung 1 (QA-01): Diff mit absichtlich fehlendem Pflichttest wird als Verstoß erkannt —
// Negativtest des Gates selbst.
func TestCheckDiff_BlocksWormstoreChangeWithoutTest(t *testing.T) {
changed := []string{
"archive/internal/wormstore/wormstore.go",
"archive/internal/backup/manifest.go", // unkritisch, keine Testpflicht
}
violations := CheckDiff(changed)
if len(violations) != 1 {
t.Fatalf("erwartet 1 Verstoß, bekommen %d: %+v", len(violations), violations)
}
if violations[0].Package != "archive/internal/wormstore" {
t.Errorf("erwartetes Package archive/internal/wormstore, bekommen %q", violations[0].Package)
}
if violations[0].ChangedFile != "archive/internal/wormstore/wormstore.go" {
t.Errorf("erwartete Datei archive/internal/wormstore/wormstore.go, bekommen %q", violations[0].ChangedFile)
}
}
func TestCheckDiff_PassesWhenTestFileAccompaniesChange(t *testing.T) {
changed := []string{
"archive/internal/wormstore/wormstore.go",
"archive/internal/wormstore/wormstore_test.go",
}
violations := CheckDiff(changed)
if len(violations) != 0 {
t.Fatalf("erwartet keinen Verstoß, bekommen %+v", violations)
}
}
func TestCheckDiff_PassesForNewTestFileEvenWithoutSourceChange(t *testing.T) {
changed := []string{"archive/internal/rbacclient/rbacclient_test.go"}
violations := CheckDiff(changed)
if len(violations) != 0 {
t.Fatalf("erwartet keinen Verstoß, bekommen %+v", violations)
}
}
func TestCheckDiff_IgnoresUnrelatedPackages(t *testing.T) {
changed := []string{"archive/internal/retentionengine/retentionengine.go", "archive/internal/reconcile/reconcile.go"}
violations := CheckDiff(changed)
if len(violations) != 0 {
t.Fatalf("erwartet keinen Verstoß fuer unkritische Pakete, bekommen %+v", violations)
}
}
func TestCheckDiff_CoversAllSensitiveAreas(t *testing.T) {
cases := []string{
"archive/internal/wormstore/wormstore.go",
"archive/internal/rbacclient/rbacclient.go",
"archive/internal/tenantregistryclient/tenantregistryclient.go",
"archive/internal/retentionapi/handler.go",
"archive/internal/kekclient/kekclient.go",
"archive/internal/backupcrypto/reposecret.go",
}
for _, f := range cases {
violations := CheckDiff([]string{f})
if len(violations) != 1 {
t.Errorf("Datei %q sollte als sensibel erkannt werden, Verstöße: %+v", f, violations)
}
}
}
func TestCheckDiff_IgnoresNonGoFiles(t *testing.T) {
changed := []string{"archive/internal/wormstore/README.md"}
violations := CheckDiff(changed)
if len(violations) != 0 {
t.Fatalf("Nicht-Go-Dateien duerfen keine Testpflicht ausloesen, bekommen %+v", violations)
}
}
@@ -0,0 +1,88 @@
// Package referenceset implementiert den in docs/TESTSTRATEGIE-ARCHIVE.md Abschnitt 4 definierten
// Referenzdatensatz für Retention- und Backup-Integrationstests: synthetische Objekttypen,
// deterministisch erzeugte WORM-Nutzdaten in drei Größenklassen und Backup-Generationen mit
// überlappenden/eindeutigen Blöcken — reproduzierbar, ohne echte Kunden-/Mandantendaten.
package referenceset
import (
"crypto/sha256"
"encoding/hex"
"math/rand"
)
// RetentionObject ist ein synthetisches, retention-pflichtiges Objekt des Referenzdatensatzes.
type RetentionObject struct {
ObjectType string
ObjectReference string
RetentionClass string
}
// RetentionObjects liefert die drei Objekttypen mit je einer Aufbewahrungsklasse aus Abschnitt 4:
// kurze, mittlere und lange Frist sowie Sofort-Ablauf.
func RetentionObjects() []RetentionObject {
return []RetentionObject{
{ObjectType: "dms_document", ObjectReference: "ref-dms-referenceset-1", RetentionClass: "steuerlich_10j"},
{ObjectType: "mail_message", ObjectReference: "ref-mail-referenceset-1", RetentionClass: "handelsrechtlich_6j"},
{ObjectType: "audit_event", ObjectReference: "ref-audit-referenceset-1", RetentionClass: "sofort_loeschbar"},
}
}
// WormPayloadSize benennt eine der drei Größenklassen aus Abschnitt 4.
type WormPayloadSize int
const (
SizeSmall WormPayloadSize = 1 * 1024 // 1 KiB
SizeMedium WormPayloadSize = 1 * 1024 * 1024 // 1 MiB
SizeLarge WormPayloadSize = 10 * 1024 * 1024 // 10 MiB
)
// wormSeed ist fest verdrahtet, damit WormPayload für dieselbe Größenklasse bei jedem Aufruf
// exakt denselben Inhalt (und damit dieselbe Prüfsumme) liefert — keine externen Fixture-Dateien,
// aber trotzdem reproduzierbar (Prüfung 1: zwei unabhängige Aufbauten liefern identische Prüfsummen).
const wormSeed = 20260917
// WormPayload erzeugt deterministische Nutzdaten der gegebenen Größenklasse.
func WormPayload(size WormPayloadSize) []byte {
buf := make([]byte, int(size))
rnd := rand.New(rand.NewSource(wormSeed + int64(size)))
_, _ = rnd.Read(buf)
return buf
}
// WormPayloadChecksum liefert die SHA-256-Prüfsumme der deterministischen Nutzdaten einer
// Größenklasse — Vergleichswert für den Reproduzierbarkeitsnachweis.
func WormPayloadChecksum(size WormPayloadSize) string {
sum := sha256.Sum256(WormPayload(size))
return hex.EncodeToString(sum[:])
}
// BackupBlock ist EIN Block einer synthetischen Backup-Generation.
type BackupBlock struct {
Generation int // 0 = Vollsicherung, 1/2 = Inkremente
Key string
Content []byte
}
// BackupGenerations liefert eine Voll- und zwei Inkrement-Generationen mit überlappenden
// (unverändertes Blockduplikat über Generationen hinweg) und eindeutigen Blöcken — Grundlage
// für Deduplizierungs- (BAK-01/02) und Envelope-Verschlüsselungstests (BAK-09).
func BackupGenerations() []BackupBlock {
unchanged := WormPayload(SizeSmall) // taucht identisch in allen drei Generationen auf
return []BackupBlock{
{Generation: 0, Key: "block-unchanged", Content: unchanged},
{Generation: 0, Key: "block-full-only", Content: WormPayload(SizeMedium)},
{Generation: 1, Key: "block-unchanged", Content: unchanged},
{Generation: 1, Key: "block-incr1-only", Content: deriveVariant(unchanged, 1)},
{Generation: 2, Key: "block-unchanged", Content: unchanged},
{Generation: 2, Key: "block-incr2-only", Content: deriveVariant(unchanged, 2)},
}
}
func deriveVariant(base []byte, salt byte) []byte {
out := make([]byte, len(base))
copy(out, base)
if len(out) > 0 {
out[0] ^= salt
}
return out
}
@@ -0,0 +1,72 @@
package referenceset
import (
"reflect"
"testing"
)
// Prüfung 1 (QA-01): der Referenzdatensatz lässt sich reproduzierbar aufsetzen — zwei
// unabhängige Aufbauten liefern identische Retention-Objekte, Prüfsummen und Backup-Blöcke.
func TestReferenceSet_IsReproducibleAcrossIndependentBuilds(t *testing.T) {
if !reflect.DeepEqual(RetentionObjects(), RetentionObjects()) {
t.Fatal("RetentionObjects() liefert bei zwei Aufrufen unterschiedliche Ergebnisse")
}
for _, size := range []WormPayloadSize{SizeSmall, SizeMedium, SizeLarge} {
sum1 := WormPayloadChecksum(size)
sum2 := WormPayloadChecksum(size)
if sum1 != sum2 {
t.Fatalf("groessenklasse %d: pruefsumme nicht reproduzierbar (%q != %q)", size, sum1, sum2)
}
if len(WormPayload(size)) != int(size) {
t.Fatalf("groessenklasse %d: erwartete laenge %d, hatte %d", size, size, len(WormPayload(size)))
}
}
blocks1 := BackupGenerations()
blocks2 := BackupGenerations()
if !reflect.DeepEqual(blocks1, blocks2) {
t.Fatal("BackupGenerations() liefert bei zwei Aufrufen unterschiedliche Ergebnisse")
}
}
// Deckt die drei Aufbewahrungsfristen-Kategorien aus Abschnitt 4 ab (kurz/mittel/lang/sofort).
func TestRetentionObjects_CoversAllRetentionClasses(t *testing.T) {
classes := map[string]bool{}
for _, o := range RetentionObjects() {
classes[o.RetentionClass] = true
}
for _, want := range []string{"steuerlich_10j", "handelsrechtlich_6j", "sofort_loeschbar"} {
if !classes[want] {
t.Errorf("erwartete aufbewahrungsklasse %q fehlt im referenzdatensatz", want)
}
}
}
// Deckt Deduplizierung (identischer Block über Generationen hinweg) UND eindeutige,
// generationsspezifische Blöcke ab — Grundlage für BAK-01/02-Tests.
func TestBackupGenerations_HasBothSharedAndUniqueBlocks(t *testing.T) {
blocks := BackupGenerations()
byKeyGen := map[string]map[int][]byte{}
for _, b := range blocks {
if byKeyGen[b.Key] == nil {
byKeyGen[b.Key] = map[int][]byte{}
}
byKeyGen[b.Key][b.Generation] = b.Content
}
shared, ok := byKeyGen["block-unchanged"]
if !ok || len(shared) != 3 {
t.Fatalf("erwartet block-unchanged in allen 3 generationen, hatte %d", len(shared))
}
if !reflect.DeepEqual(shared[0], shared[1]) || !reflect.DeepEqual(shared[1], shared[2]) {
t.Fatal("block-unchanged sollte in allen generationen identischen inhalt haben")
}
for _, key := range []string{"block-full-only", "block-incr1-only", "block-incr2-only"} {
if _, ok := byKeyGen[key]; !ok {
t.Errorf("erwarteter eindeutiger block %q fehlt", key)
}
}
}