diff --git a/archive/docs/RET-05-PRUEFPROTOKOLL.md b/archive/docs/RET-05-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..46198bc --- /dev/null +++ b/archive/docs/RET-05-PRUEFPROTOKOLL.md @@ -0,0 +1,79 @@ +# RET-05 – Prüfprotokoll: Modul-Adapter-Schnittstelle + +Voraussetzung RET-01 – erledigt, siehe eigenes Protokoll. + +## Grundsatzentscheidung: Interface-Freeze, keine Modul-Implementierung + +Nutzervorgabe: RET-05 als reines INTERFACE definieren (Registrierung, +Rückruf für Löschbestätigung, Fehlerverhalten) — NICHT schon +implementieren, damit spätere DMS-/Mail-Kacheln gegen ein bereits +feststehendes, nicht nachträglich verändertes Interface bauen. Dieses +Ticket liefert daher NUR Archives eigene Seite: + +- Registrierungs-API (`internal/moduleadapter.Register` + + `RegisterHandler`, REST-Schnittstelle laut Ticket-Technikvorgabe). +- Rückruf-Auslöser (`NotifyDestruction`) mit feststehendem + Payload-Vertrag (`DestructionNotice`: `object_type`, + `object_reference`, `destroyed_at`). + +**Bewusst NICHT Teil dieses Tickets**: die eigentlichen Rückruf- +EMPFÄNGER (DMS'/Mails Löschbestätigungs-Endpunkte) — die tatsächliche +Vernichtungslogik, die `NotifyDestruction` aufruft (kommt mit RET-02 +und späteren Vernichtungs-Tickets), sowie Wiederholungslogik bei +fehlgeschlagenem Rückruf (Interface-Vertrag ist klar: Erfolg = HTTP +2xx, sonst Fehler — WIE mit einem Fehler umgegangen wird, ist +Aufgabe des aufrufenden Vernichtungs-Jobs, nicht dieses Pakets). + +## Umsetzung + +- `migrations/0003_module_registrations.up.sql`/`.down.sql` — + `module_registrations` (module_name, object_type, callback_url, + UNIQUE-Constraint). +- `internal/moduleadapter.Register` — `ON CONFLICT DO NOTHING` + Nachlese + der bestehenden Zeile, damit eine erneute Registrierung NIE die + bestehende `callback_url` überschreibt (Akzeptanzkriterium 3). +- `internal/moduleadapter.ListRegistrations`. +- `internal/moduleadapter.NotifyDestruction` — echter HTTP-POST mit dem + festen `DestructionNotice`-Vertrag. +- `internal/moduleadapter.RegisterHandler` — REST-Endpunkt + (`POST /register`). + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Zwei fiktive Module (DMS, Mail) parallel registriert ohne Kollision | **bestanden** — `TestRegister_TwoModulesNoCollision`: unterschiedliche IDs, `ListRegistrations` zeigt beide | +| 2 | Rückruf bei Vernichtung erfolgreich gegen einen Testendpunkt ausgeführt | **bestanden** — `TestNotifyDestruction_CallsRealTestEndpoint`: echter `httptest.Server`, echter POST, Payload real empfangen und geprüft (`object_reference` korrekt) | +| 3 | Erneute Registrierung desselben Objekttyps ändert nichts am bestehenden Zustand | **bestanden** — `TestRegister_IsIdempotent_UnchangedExistingState` (Go-Funktion, mit absichtlich ABWEICHENDER `callback_url` im zweiten Aufruf) UND `TestRegisterHandler_RealHTTPRoundTrip` (dieselbe Prüfung nochmal über die HTTP-Schicht, nicht nur direkt gegen die Funktion) | + +Zusätzlich: `TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus` +(Fehlerverhalten), `TestRegisterHandler_RejectsMissingFields` +(REST-Schicht weist unvollständige Registrierungen ab). + +## Echte Verdrahtung auf 192.168.1.131 + +- Migration real gegen `dms_tenant_test` angewendet — `module_registrations` + bestätigt vorhanden +- Kein systemd-Dienst — `RegisterHandler` ist ein `http.HandlerFunc`, + wird in einen künftigen Core-/Archive-HTTP-Server eingehängt, sobald + ein solcher für Archive existiert (aktuell kein eigener Archive- + API-Server, nur die bisherigen CLI/Metrics-Prozesse) — dokumentierter, + kein stiller Gap, entspricht dem Interface-Freeze-Charakter dieses + Tickets + +## Build/Test-Ergebnis (192.168.1.131, `make check`) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -count=1 -> 9/9 Pakete mit Tests ok, 0 Fehlschläge +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt — Idempotenz sowohl auf Go- als auch auf +HTTP-Ebene bewiesen, Rückruf-Vertrag gegen einen echten Testendpunkt +verifiziert. Bewusst als reiner Interface-Freeze umgesetzt, keine +DMS-/Mail-seitige Implementierung — wie vom Nutzer vorgegeben. diff --git a/archive/internal/moduleadapter/handler.go b/archive/internal/moduleadapter/handler.go new file mode 100644 index 0000000..27d087d --- /dev/null +++ b/archive/internal/moduleadapter/handler.go @@ -0,0 +1,45 @@ +package moduleadapter + +import ( + "encoding/json" + "net/http" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type registerRequest struct { + ModuleName string `json:"module_name"` + ObjectType string `json:"object_type"` + CallbackURL string `json:"callback_url"` +} + +// RegisterHandler ist die REST-Schnittstelle (Ticket-Technikvorgabe), über +// die ein Modul einen Objekttyp registriert (Akzeptanzkriterium 1). +// POST /register mit JSON-Body {module_name, object_type, callback_url}. +func RegisterHandler(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req registerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest) + return + } + if req.ModuleName == "" || req.ObjectType == "" || req.CallbackURL == "" { + http.Error(w, "module_name, object_type und callback_url sind pflichtfelder", http.StatusBadRequest) + return + } + + reg, err := Register(r.Context(), pool, req.ModuleName, req.ObjectType, req.CallbackURL) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(reg) + } +} diff --git a/archive/internal/moduleadapter/handler_test.go b/archive/internal/moduleadapter/handler_test.go new file mode 100644 index 0000000..99e8194 --- /dev/null +++ b/archive/internal/moduleadapter/handler_test.go @@ -0,0 +1,67 @@ +package moduleadapter + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// TestRegisterHandler_RealHTTPRoundTrip beweist die REST-Schnittstelle +// (Ticket-Technikvorgabe) end-to-end: echter HTTP-Server, echter POST, +// zweiter POST mit abweichender callback_url aendert nichts (Pruefung 3 +// auch ueber die HTTP-Schicht, nicht nur die Go-Funktion direkt). +func TestRegisterHandler_RealHTTPRoundTrip(t *testing.T) { + pool := requireTestPool(t) + server := httptest.NewServer(RegisterHandler(pool)) + defer server.Close() + + post := func(body registerRequest) (int, Registration) { + t.Helper() + data, _ := json.Marshal(body) + resp, err := http.Post(server.URL, "application/json", bytes.NewReader(data)) + if err != nil { + t.Fatalf("post: %v", err) + } + defer func() { _ = resp.Body.Close() }() + var reg Registration + if resp.StatusCode == http.StatusOK { + if err := json.NewDecoder(resp.Body).Decode(®); err != nil { + t.Fatalf("antwort dekodieren: %v", err) + } + } + return resp.StatusCode, reg + } + + status1, reg1 := post(registerRequest{ModuleName: "dms", ObjectType: "document", CallbackURL: "https://dms.example.test/original"}) + if status1 != http.StatusOK { + t.Fatalf("erster post: status = %d, want 200", status1) + } + + status2, reg2 := post(registerRequest{ModuleName: "dms", ObjectType: "document", CallbackURL: "https://dms.example.test/andere"}) + if status2 != http.StatusOK { + t.Fatalf("zweiter post: status = %d, want 200", status2) + } + if reg2.ID != reg1.ID || reg2.CallbackURL != "https://dms.example.test/original" { + t.Fatalf("zweiter post veraenderte bestehenden zustand: %+v, erster war %+v", reg2, reg1) + } +} + +// TestRegisterHandler_RejectsMissingFields ist Nachweis des +// Fehlerverhaltens auf der REST-Schicht. +func TestRegisterHandler_RejectsMissingFields(t *testing.T) { + pool := requireTestPool(t) + server := httptest.NewServer(RegisterHandler(pool)) + defer server.Close() + + data, _ := json.Marshal(registerRequest{ModuleName: "dms"}) + resp, err := http.Post(server.URL, "application/json", bytes.NewReader(data)) + if err != nil { + t.Fatalf("post: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 bei fehlenden pflichtfeldern", resp.StatusCode) + } +} diff --git a/archive/internal/moduleadapter/moduleadapter.go b/archive/internal/moduleadapter/moduleadapter.go new file mode 100644 index 0000000..f5a3788 --- /dev/null +++ b/archive/internal/moduleadapter/moduleadapter.go @@ -0,0 +1,120 @@ +// Package moduleadapter implementiert RET-05: die Schnittstelle, über +// die DMS und Mail ihre Objekttypen bei Archive registrieren, statt +// eigene Retention-Logik zu bauen. BEWUSST NUR DAS INTERFACE UND +// ARCHIVES EIGENE SEITE (Registrierungs-API + Rückruf-Auslöser) — die +// eigentlichen Rückruf-EMPFÄNGER (DMS'/Mails Löschbestätigungs-Endpunkte) +// sind NICHT Teil dieses Tickets, damit spätere DMS-/Mail-Kacheln +// gegen ein bereits feststehendes, nicht nachträglich verändertes +// Interface bauen (Nutzervorgabe). +package moduleadapter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Registration ist EIN registrierter Objekttyp eines Moduls. +type Registration struct { + ID string + ModuleName string + ObjectType string + CallbackURL string +} + +// Register registriert einen Objekttyp eines Moduls mit Rückruf-Adresse +// für Löschbestätigung — mandantenfähig durch Modell C (physisch +// isolierte Tenant-DB, kein tenant_id-Feld nötig). Idempotent im Sinne +// von Akzeptanzkriterium 3: eine erneute Registrierung DESSELBEN +// Objekttyps ändert NICHTS am bestehenden Zustand (kein Überschreiben +// der callback_url) — anders als RET-01s RegisterObject (dort +// UPSERT-artig), weil ein bereits registrierter Rückruf nicht +// stillschweigend durch eine zweite, möglicherweise abweichende +// Registrierung ersetzt werden darf. +func Register(ctx context.Context, pool *pgxpool.Pool, moduleName, objectType, callbackURL string) (Registration, error) { + var reg Registration + err := pool.QueryRow(ctx, ` + INSERT INTO module_registrations (module_name, object_type, callback_url) + VALUES ($1, $2, $3) + ON CONFLICT (module_name, object_type) DO NOTHING + RETURNING id, module_name, object_type, callback_url + `, moduleName, objectType, callbackURL).Scan(®.ID, ®.ModuleName, ®.ObjectType, ®.CallbackURL) + if err == nil { + return reg, nil + } + // ON CONFLICT DO NOTHING liefert keine Zeile zurueck (pgx: ErrNoRows) - + // bestehende Registrierung unveraendert nachlesen und zurueckgeben. + err = pool.QueryRow(ctx, ` + SELECT id, module_name, object_type, callback_url FROM module_registrations + WHERE module_name = $1 AND object_type = $2 + `, moduleName, objectType).Scan(®.ID, ®.ModuleName, ®.ObjectType, ®.CallbackURL) + if err != nil { + return Registration{}, fmt.Errorf("moduleadapter: registrierung lesen/anlegen: %w", err) + } + return reg, nil +} + +// ListRegistrations liefert alle registrierten Objekttypen — Grundlage +// für Statusübersichten und Tests (Pflichtprüfung 1: zwei Module +// parallel registriert ohne Kollision). +func ListRegistrations(ctx context.Context, pool *pgxpool.Pool) ([]Registration, error) { + rows, err := pool.Query(ctx, `SELECT id, module_name, object_type, callback_url FROM module_registrations ORDER BY module_name, object_type`) + if err != nil { + return nil, fmt.Errorf("moduleadapter: registrierungen auflisten: %w", err) + } + defer rows.Close() + + var regs []Registration + for rows.Next() { + var r Registration + if err := rows.Scan(&r.ID, &r.ModuleName, &r.ObjectType, &r.CallbackURL); err != nil { + return nil, fmt.Errorf("moduleadapter: registrierungs-zeile lesen: %w", err) + } + regs = append(regs, r) + } + return regs, rows.Err() +} + +// DestructionNotice ist der Rückruf-Payload bei Vernichtung eines +// Objekts (Akzeptanzkriterium 2) — das feststehende Vertragsformat, das +// jeder Modul-Rückruf-Empfänger erwarten muss. +type DestructionNotice struct { + ObjectType string `json:"object_type"` + ObjectReference string `json:"object_reference"` + DestroyedAt time.Time `json:"destroyed_at"` +} + +// NotifyDestruction ruft das registrierte Modul beim Vernichten eines +// Objekts zurück, statt dass Archive selbst Modul-Interna kennen müsste +// (Akzeptanzkriterium 2). Fehlerverhalten: liefert den Fehler an den +// Aufrufer zurück, statt ihn zu verschlucken — ein fehlgeschlagener +// Rückruf ist ein Fehlerzustand, der behandelt/wiederholt werden muss +// (Wiederholungslogik ist NICHT Teil dieses Tickets, nur der +// Interface-Vertrag: Erfolg = HTTP 2xx, sonst Fehler). +func NotifyDestruction(ctx context.Context, client *http.Client, callbackURL string, notice DestructionNotice) error { + body, err := json.Marshal(notice) + if err != nil { + return fmt.Errorf("moduleadapter: rückruf-payload kodieren: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, callbackURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("moduleadapter: rückruf-request erstellen: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("moduleadapter: rückruf fehlgeschlagen: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("moduleadapter: rückruf-endpunkt antwortete mit status %d", resp.StatusCode) + } + return nil +} diff --git a/archive/internal/moduleadapter/moduleadapter_test.go b/archive/internal/moduleadapter/moduleadapter_test.go new file mode 100644 index 0000000..5253dac --- /dev/null +++ b/archive/internal/moduleadapter/moduleadapter_test.go @@ -0,0 +1,151 @@ +package moduleadapter + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func jsonDecode(r *http.Request, v interface{}) error { + defer func() { _ = r.Body.Close() }() + return json.NewDecoder(r.Body).Decode(v) +} + +func requireTestPool(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(), module_name TEXT NOT NULL, + object_type 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 +} + +// TestRegister_TwoModulesNoCollision ist Pruefung 1: zwei fiktive Module +// (DMS, Mail) parallel registriert ohne Kollision. +func TestRegister_TwoModulesNoCollision(t *testing.T) { + pool := requireTestPool(t) + ctx := context.Background() + + dmsReg, err := Register(ctx, pool, "dms", "document", "https://dms.example.test/callback") + if err != nil { + t.Fatalf("dms registrieren: %v", err) + } + mailReg, err := Register(ctx, pool, "mail", "message", "https://mail.example.test/callback") + if err != nil { + t.Fatalf("mail registrieren: %v", err) + } + if dmsReg.ID == mailReg.ID { + t.Fatal("dms und mail erhielten dieselbe id - kollision") + } + + all, err := ListRegistrations(ctx, pool) + if err != nil { + t.Fatalf("listregistrations: %v", err) + } + if len(all) != 2 { + t.Fatalf("erwartet 2 registrierungen, habe %d", len(all)) + } +} + +// TestRegister_IsIdempotent_UnchangedExistingState ist Pruefung 3: +// erneute Registrierung desselben Objekttyps aendert NICHTS am +// bestehenden Zustand - auch nicht bei abweichender callback_url im +// zweiten Aufruf. +func TestRegister_IsIdempotent_UnchangedExistingState(t *testing.T) { + pool := requireTestPool(t) + ctx := context.Background() + + first, err := Register(ctx, pool, "dms", "document", "https://dms.example.test/original") + if err != nil { + t.Fatalf("erste registrierung: %v", err) + } + second, err := Register(ctx, pool, "dms", "document", "https://dms.example.test/ANDERE-url") + if err != nil { + t.Fatalf("zweite registrierung: %v", err) + } + + if second.ID != first.ID { + t.Fatalf("erneute registrierung erzeugte neue id: %q, want %q", second.ID, first.ID) + } + if second.CallbackURL != "https://dms.example.test/original" { + t.Fatalf("callback_url wurde ueberschrieben: %q, want unveraendert %q", second.CallbackURL, first.CallbackURL) + } + + all, err := ListRegistrations(ctx, pool) + if err != nil { + t.Fatalf("listregistrations: %v", err) + } + if len(all) != 1 { + t.Fatalf("erwartet weiterhin genau 1 registrierung, habe %d", len(all)) + } +} + +// TestNotifyDestruction_CallsRealTestEndpoint ist Pruefung 2: Rueckruf +// bei Vernichtung erfolgreich gegen einen Testendpunkt ausgefuehrt - +// echter HTTP-Server, echter Request, echte Payload-Pruefung. +func TestNotifyDestruction_CallsRealTestEndpoint(t *testing.T) { + var receivedNotice DestructionNotice + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + if r.Method != http.MethodPost { + t.Errorf("erwartet POST, habe %s", r.Method) + } + if err := jsonDecode(r, &receivedNotice); err != nil { + t.Errorf("payload dekodieren: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + notice := DestructionNotice{ObjectType: "document", ObjectReference: "doc-789", DestroyedAt: time.Now().UTC()} + if err := NotifyDestruction(context.Background(), server.Client(), server.URL, notice); err != nil { + t.Fatalf("notifydestruction: %v", err) + } + if !called { + t.Fatal("testendpunkt wurde nie aufgerufen") + } + if receivedNotice.ObjectReference != "doc-789" { + t.Fatalf("empfangene objekt-referenz = %q, want doc-789", receivedNotice.ObjectReference) + } +} + +// TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus ist Nachweis des +// Fehlerverhaltens: ein fehlschlagender Rueckruf wird als Fehler +// gemeldet, nicht verschluckt. +func TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + err := NotifyDestruction(context.Background(), server.Client(), server.URL, DestructionNotice{}) + if err == nil { + t.Fatal("erwartet fehler bei 500-antwort des rueckruf-endpunkts") + } +} diff --git a/archive/migrations/0003_module_registrations.down.sql b/archive/migrations/0003_module_registrations.down.sql new file mode 100644 index 0000000..9934016 --- /dev/null +++ b/archive/migrations/0003_module_registrations.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS module_registrations; diff --git a/archive/migrations/0003_module_registrations.up.sql b/archive/migrations/0003_module_registrations.up.sql new file mode 100644 index 0000000..dcb4f09 --- /dev/null +++ b/archive/migrations/0003_module_registrations.up.sql @@ -0,0 +1,14 @@ +-- RET-05: Modul-Adapter-Schnittstelle. Ein Modul (DMS, Mail, ...) +-- registriert je Objekttyp EINE Rueckruf-Adresse fuer Loeschbestaetigung +-- - Archive kennt danach nur noch module_name/object_type/callback_url, +-- keine Modul-Interna. Mandantenfaehig durch Modell C (physisch +-- isolierte Tenant-DB, TEN-01) - kein tenant_id-Feld noetig, dieselbe +-- Begruendung wie RET-01s retention_objects. +CREATE TABLE IF NOT EXISTS module_registrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + module_name TEXT NOT NULL, + object_type TEXT NOT NULL, + callback_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (module_name, object_type) +);