diff --git a/.gitea/workflows/archive-contract-tests.yml b/.gitea/workflows/archive-contract-tests.yml new file mode 100644 index 00000000..68c454d2 --- /dev/null +++ b/.gitea/workflows/archive-contract-tests.yml @@ -0,0 +1,23 @@ +name: Archive-Schnittstellen-Vertragstests + +on: + push: + paths: + - "archive/internal/contracttest/**" + - "archive/internal/moduleadapter/**" + pull_request: + paths: + - "archive/internal/contracttest/**" + - "archive/internal/moduleadapter/**" + +jobs: + contract-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - name: Vertragstests ausfuehren + working-directory: archive + run: go test ./internal/contracttest/... -v diff --git a/archive/docs/QA-06-PRUEFPROTOKOLL.md b/archive/docs/QA-06-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..6142cd3a --- /dev/null +++ b/archive/docs/QA-06-PRUEFPROTOKOLL.md @@ -0,0 +1,26 @@ +# QA-06 – Prüfprotokoll + +Stand: 2026-09-17. Testhost 192.168.1.131 (Gitea-Actions-Runner `nexarch-131-runner`). + +## Prüfung 1: Testlauf gegen aktuellen Stand grün + +`go test ./internal/contracttest/... -v` — alle 4 Tests PASS: +- `TestContract_RET05_RegisterResponse` +- `TestContract_RET05_DetectsBreakingChange_RemovedField` +- `TestContract_RET05_DestructionNoticePayload` +- `TestContract_RET05_DetectsBreakingChange_RenamedField` + +## Prüfung 2: Simulierte Breaking Change erkannt + +`TestContract_RET05_DetectsBreakingChange_RemovedField` (Feld `CallbackURL` in der Registrierungs- +Antwort absichtlich entfernt) und `TestContract_RET05_DetectsBreakingChange_RenamedField` (Feld +`object_reference` im Vernichtungs-Rückruf zu `objectReference` umbenannt) — beide Fälle werden +zuverlässig erkannt (`missing`-Liste enthält genau das betroffene Feld). + +## Prüfung 3: CI-Integration nachgewiesen + +`.gitea/workflows/archive-contract-tests.yml` läuft bei jedem Push/PR mit Pfad-Filter auf +`archive/internal/contracttest/**` bzw. `archive/internal/moduleadapter/**`. Echter Gitea-Actions- +Runner (`nexarch-131-runner`, Host-Modus ohne Docker) auf 192.168.1.131 registriert und läuft +bereits seit QA-07 (Mail). Nach diesem Commit tatsächlich in der Gitea-Weboberfläche unter +Actions überprüfbar (nicht nur eine behauptete Workflow-Datei). diff --git a/archive/internal/contracttest/contracttest.go b/archive/internal/contracttest/contracttest.go new file mode 100644 index 00000000..1ff8067b --- /dev/null +++ b/archive/internal/contracttest/contracttest.go @@ -0,0 +1,31 @@ +// Package contracttest implementiert Archive QA-06: Vertragstests für die +// Modul-Adapter-Schnittstelle (RET-05), über die DMS und Mail Objekte bei +// der Retention-Engine registrieren. Jeder Test prüft die TATSÄCHLICHE +// Antwort einer echten Archive-Komponente gegen ihr dokumentiertes Schema +// — eine entfernte oder umbenannte Pflichteigenschaft lässt den jeweiligen +// Test fehlschlagen, BEVOR sie ein konsumierendes Modul bricht +// (Akzeptanzkriterium 2). Gleiches Muster wie Core QA-07 +// (internal/contracttest), hier für Archive gespiegelt (physisch +// getrenntes Go-Modul). +package contracttest + +import ( + "encoding/json" + "fmt" +) + +// RequireJSONFields dekodiert data als JSON-Objekt und prüft, dass ALLE +// angegebenen Top-Level-Schlüssel vorhanden sind. Liefert die fehlenden +// Schlüssel zurück — leer bedeutet: Vertrag eingehalten. +func RequireJSONFields(data []byte, required []string) (missing []string, err error) { + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, fmt.Errorf("contracttest: antwort ist kein json-objekt: %w", err) + } + for _, field := range required { + if _, ok := decoded[field]; !ok { + missing = append(missing, field) + } + } + return missing, nil +} diff --git a/archive/internal/contracttest/contracttest_test.go b/archive/internal/contracttest/contracttest_test.go new file mode 100644 index 00000000..e92a56dd --- /dev/null +++ b/archive/internal/contracttest/contracttest_test.go @@ -0,0 +1,150 @@ +package contracttest + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/moduleadapter" +) + +// --- Vertrag 1: RET-05 Registrierungs-Antwort (POST /register) --- +// Akzeptanzkriterium 1 + Prüfung 1. + +func setupModuleAdapterTest(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS module_registrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_slug TEXT NOT NULL, module_name TEXT NOT NULL, + object_type TEXT NOT NULL, retention_class TEXT NOT NULL, callback_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (module_name, object_type) + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE module_registrations`) }) + return pool +} + +type alwaysValidVerifier struct{} + +func (alwaysValidVerifier) VerifyTenant(_ context.Context, _ string) (bool, error) { return true, nil } + +// TestContract_RET05_RegisterResponse prüft die TATSÄCHLICHE HTTP-Antwort +// des echten RegisterHandler gegen das dokumentierte Schema (Feldnamen, +// die DMS/Mail beim Registrieren als Rückgabewert erwarten dürfen). +func TestContract_RET05_RegisterResponse(t *testing.T) { + pool := setupModuleAdapterTest(t) + server := httptest.NewServer(moduleadapter.RegisterHandler(pool, alwaysValidVerifier{})) + defer server.Close() + + body, _ := json.Marshal(map[string]string{ + "tenant_slug": "mandant-vertragstest", "module_name": "contracttest-dms", + "object_type": "document", "retention_class": "klasse-standard", + "callback_url": "https://dms.example.test/callback", + }) + resp, err := http.Post(server.URL, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("post: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var raw bytes.Buffer + if _, err := raw.ReadFrom(resp.Body); err != nil { + t.Fatalf("antwort lesen: %v", err) + } + + required := []string{"ID", "TenantSlug", "ModuleName", "ObjectType", "RetentionClass", "CallbackURL"} + missing, err := RequireJSONFields(raw.Bytes(), required) + if err != nil { + t.Fatalf("antwort nicht lesbar: %v", err) + } + if len(missing) != 0 { + t.Fatalf("registrierungs-antwort fehlen pflichtfelder: %v", missing) + } +} + +// Prüfung 2: absichtlich simulierter Breaking Change (Feld "CallbackURL" +// entfernt/umbenannt) lässt den Vertragstest fehlschlagen. +func TestContract_RET05_DetectsBreakingChange_RemovedField(t *testing.T) { + brokenResponse := []byte(`{"ID":"abc","TenantSlug":"mandant-a","ModuleName":"dms","ObjectType":"document","RetentionClass":"klasse-standard"}`) // "CallbackURL" fehlt absichtlich + required := []string{"ID", "TenantSlug", "ModuleName", "ObjectType", "RetentionClass", "CallbackURL"} + + missing, err := RequireJSONFields(brokenResponse, required) + if err != nil { + t.Fatalf("pruefung selbst fehlgeschlagen: %v", err) + } + if len(missing) != 1 || missing[0] != "CallbackURL" { + t.Fatalf("erwartet erkanntes fehlendes feld 'CallbackURL', habe: %v", missing) + } +} + +// --- Vertrag 2: RET-05 Vernichtungs-Rückruf-Payload (NotifyDestruction) --- +// Akzeptanzkriterium 1 + Prüfung 1. + +// TestContract_RET05_DestructionNoticePayload prüft die TATSÄCHLICHE, von +// NotifyDestruction gesendete Payload gegen das dokumentierte Schema, das +// DMS/Mail als Rückruf-Empfänger erwarten müssen. +func TestContract_RET05_DestructionNoticePayload(t *testing.T) { + var received []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + _, _ = buf.ReadFrom(r.Body) + received = buf.Bytes() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + notice := moduleadapter.DestructionNotice{ + ObjectType: "document", ObjectReference: "doc-vertragstest", DestroyedAt: time.Now().UTC(), + } + if err := moduleadapter.NotifyDestruction(context.Background(), server.Client(), server.URL, notice); err != nil { + t.Fatalf("notifydestruction: %v", err) + } + + required := []string{"object_type", "object_reference", "destroyed_at"} + missing, err := RequireJSONFields(received, required) + if err != nil { + t.Fatalf("payload nicht lesbar: %v", err) + } + if len(missing) != 0 { + t.Fatalf("vernichtungs-payload fehlen pflichtfelder: %v", missing) + } +} + +// Prüfung 2: simulierter Breaking Change — Feld "object_reference" +// umbenannt (z. B. fälschlich zu "objectReference"). +func TestContract_RET05_DetectsBreakingChange_RenamedField(t *testing.T) { + brokenPayload := []byte(`{"object_type":"document","objectReference":"doc-1","destroyed_at":"2026-09-17T12:00:00Z"}`) + required := []string{"object_type", "object_reference", "destroyed_at"} + + missing, err := RequireJSONFields(brokenPayload, required) + if err != nil { + t.Fatalf("pruefung selbst fehlgeschlagen: %v", err) + } + if len(missing) != 1 || missing[0] != "object_reference" { + t.Fatalf("erwartet erkanntes umbenanntes feld 'object_reference', habe: %v", missing) + } +}