Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0db32007ba | ||
|
|
19dca43012 | ||
|
|
b37b790792 |
@@ -0,0 +1,64 @@
|
||||
# RET-01 – Prüfprotokoll: Generisches Retention-Objektmodell
|
||||
|
||||
Keine Vorbedingungen (Welle 1 des RET-Epics).
|
||||
|
||||
## Grundsatzentscheidung: Adapter-Muster, keine Fremdschlüssel auf Modul-Tabellen
|
||||
|
||||
`retention_objects.object_type`/`object_reference` sind reine
|
||||
Textfelder — Archive importiert weder DMS- noch Mail-Code (eigene
|
||||
Go-Module, physisch getrennte Verantwortung, dieselbe Disziplin wie
|
||||
BAK-05s `reconcile`-Paket gegenüber `file_revisions`). Ein neues Modul
|
||||
kann retention-pflichtige Objekte einbinden, ohne dieses Paket zu
|
||||
ändern — nur `RegisterObject(objectType, objectReference)` aufrufen.
|
||||
|
||||
Aufbewahrungsklassen-Zuordnung ist historisiert: `AssignClass` fügt
|
||||
IMMER eine neue Zeile hinzu, ändert nie eine bestehende — die "aktuelle"
|
||||
Klasse ist die mit dem jüngsten `assigned_at`. Damit bleibt
|
||||
nachvollziehbar, wann und wie oft sich die Klasse eines Objekts
|
||||
geändert hat, ohne eigene Audit-Tabelle.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `migrations/0002_retention.up.sql`/`.down.sql` — `retention_objects`,
|
||||
`retention_class_assignments`.
|
||||
- `internal/retention.RegisterObject` — idempotent (`ON CONFLICT`).
|
||||
- `internal/retention.AssignClass`/`CurrentClass`/`ClassHistory`.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Migration vorwärts und rückwärts getestet | **bestanden** — `TestMigration_ForwardAndBackward`: real gegen die tatsächlichen Migrationsdateien (nicht nachgebaut), vorwärts→Tabellen vorhanden, rückwärts→Tabellen weg, erneut vorwärts→sauber (kein Rest blockiert erneuten Lauf) |
|
||||
| 2 | Testobjekt aus fiktivem DMS- und Mail-Adapter beide korrekt abgebildet | **bestanden** — `TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically`: `dms_document`/`mail_message` beide ohne modulspezifische Spalten abgebildet, zusätzlich Idempotenz bewiesen (erneute Registrierung liefert dieselbe ID) |
|
||||
| 3 | Tenant-Isolation der Kern-Tabellen durch Negativtest belegt | **bestanden** — `TestTenantIsolation_Negativtest`: reales zweites Tenant-DB (`ret01_tenant_test_b`), Objekt aus Tenant A über Verbindung zu Tenant B abgefragt, `count=0` — technisch nicht sichtbar, keine gemeinsame Tabelle mit `tenant_id`-Filter (Modell C, TEN-01) |
|
||||
|
||||
Zusätzlich: `TestAssignClass_IsHistoricized` — zwei Klassen-Zuordnungen,
|
||||
`CurrentClass` liefert die jüngste, `ClassHistory` beide chronologisch.
|
||||
|
||||
## Echte Verdrahtung auf 192.168.1.131
|
||||
|
||||
- Migration real gegen `dms_tenant_test` angewendet (`psql -f
|
||||
migrations/0002_retention.up.sql`) — `retention_objects`,
|
||||
`retention_class_assignments` bestätigt vorhanden (`\dt retention*`)
|
||||
- Kein systemd-Dienst/Timer nötig — RET-01 ist reines Datenmodell +
|
||||
Bibliothek, kein eigenständiger Prozess (Verbraucher sind spätere
|
||||
RET-Tickets, allen voran RET-05 als Adapter-Interface)
|
||||
- Zweites Tenant-DB (`ret01_tenant_test_b`) nur für den Isolationstest
|
||||
angelegt, danach entfernt
|
||||
|
||||
## 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 -> 8/8 Pakete mit Tests ok (backup, objectbackup, reconcile, restore, restoretest, retention, scrub, tenantbackup), 0 Fehlschläge
|
||||
```
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||
Pflichtprüfungen real erfüllt — Migration gegen die tatsächlichen
|
||||
`.sql`-Dateien (nicht nachgebaut) getestet, Mandantentrennung gegen ein
|
||||
echtes zweites Tenant-DB bewiesen. Grundlage für RET-05 (Adapter-
|
||||
Interface, als Nächstes) und RET-02 gelegt.
|
||||
@@ -0,0 +1,94 @@
|
||||
# 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).
|
||||
|
||||
## Korrektur vor Abschluss: `retention_class` fehlte im ersten Entwurf
|
||||
|
||||
Akzeptanzkriterium 1 verlangt "Objekttyp MIT Aufbewahrungsklasse UND
|
||||
Rückruf-Adresse" — der erste Entwurf von `module_registrations` und
|
||||
`Register` hatte nur `callback_url`, `retention_class` fehlte komplett.
|
||||
Vor dem Board-Flip auf „Fertig" bemerkt und korrigiert: Migration,
|
||||
`Registration`-Struct, `Register`, `ListRegistrations` und
|
||||
`RegisterHandler` um `retention_class` ergänzt, alle Tests entsprechend
|
||||
angepasst (inkl. Idempotenz-Nachweis auch für `retention_class`, nicht
|
||||
nur `callback_url`). Reale, bereits angewendete Migration auf
|
||||
`dms_tenant_test` musste dafür gedroppt und neu angewendet werden (kein
|
||||
Produktivbestand betroffen, Testsystem).
|
||||
|
||||
## 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
|
||||
(nach Korrektur; internal/retention und internal/moduleadapter brauchen
|
||||
TEST_TENANT_DSN/TEST_TENANT_DSN_B bzw. TEST_TENANT_DSN)
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,46 @@
|
||||
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"`
|
||||
RetentionClass string `json:"retention_class"`
|
||||
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.RetentionClass == "" || req.CallbackURL == "" {
|
||||
http.Error(w, "module_name, object_type, retention_class und callback_url sind pflichtfelder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reg, err := Register(r.Context(), pool, req.ModuleName, req.ObjectType, req.RetentionClass, 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)
|
||||
}
|
||||
}
|
||||
@@ -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", RetentionClass: "klasse-original", 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", RetentionClass: "klasse-ANDERE", 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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
|
||||
RetentionClass 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, retentionClass, callbackURL string) (Registration, error) {
|
||||
var reg Registration
|
||||
err := pool.QueryRow(ctx, `
|
||||
INSERT INTO module_registrations (module_name, object_type, retention_class, callback_url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (module_name, object_type) DO NOTHING
|
||||
RETURNING id, module_name, object_type, retention_class, callback_url
|
||||
`, moduleName, objectType, retentionClass, callbackURL).Scan(®.ID, ®.ModuleName, ®.ObjectType, ®.RetentionClass, ®.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, retention_class, callback_url FROM module_registrations
|
||||
WHERE module_name = $1 AND object_type = $2
|
||||
`, moduleName, objectType).Scan(®.ID, ®.ModuleName, ®.ObjectType, ®.RetentionClass, ®.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, retention_class, 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.RetentionClass, &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
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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, 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
|
||||
}
|
||||
|
||||
// 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", "klasse-B2B-standard", "https://dms.example.test/callback")
|
||||
if err != nil {
|
||||
t.Fatalf("dms registrieren: %v", err)
|
||||
}
|
||||
mailReg, err := Register(ctx, pool, "mail", "message", "klasse-korrespondenz", "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", "klasse-original", "https://dms.example.test/original")
|
||||
if err != nil {
|
||||
t.Fatalf("erste registrierung: %v", err)
|
||||
}
|
||||
second, err := Register(ctx, pool, "dms", "document", "klasse-ANDERE", "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)
|
||||
}
|
||||
if second.RetentionClass != "klasse-original" {
|
||||
t.Fatalf("retention_class wurde ueberschrieben: %q, want unveraendert %q", second.RetentionClass, "klasse-original")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TestMigration_ForwardAndBackward ist Pruefung 3 fuer Akzeptanzkriterium
|
||||
// 3: Migration laeuft gegen leere Datenbank durch UND ist rueckrollbar -
|
||||
// real gegen die TATSAECHLICHEN Migrationsdateien, kein Nachbau.
|
||||
func TestMigration_ForwardAndBackward(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
if _, err := exec.LookPath("psql"); err != nil {
|
||||
t.Skip("psql nicht installiert, Integrationstest uebersprungen")
|
||||
}
|
||||
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "migrations")
|
||||
upSQL := filepath.Join(migrationsDir, "0002_retention.up.sql")
|
||||
downSQL := filepath.Join(migrationsDir, "0002_retention.down.sql")
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
// sauberer Ausgangszustand, falls von einem frueheren Testlauf uebrig.
|
||||
_, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS retention_class_assignments, retention_objects CASCADE`)
|
||||
|
||||
runPsql := func(sqlFile string) []byte {
|
||||
t.Helper()
|
||||
cmd := exec.CommandContext(ctx, "psql", dsn, "-v", "ON_ERROR_STOP=1", "-f", sqlFile)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("psql -f %s: %v (ausgabe: %s)", sqlFile, err, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
tableExists := func(name string) bool {
|
||||
var exists bool
|
||||
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)`, name).Scan(&exists); err != nil {
|
||||
t.Fatalf("tabellenexistenz pruefen: %v", err)
|
||||
}
|
||||
return exists
|
||||
}
|
||||
|
||||
// vorwaerts
|
||||
runPsql(upSQL)
|
||||
if !tableExists("retention_objects") || !tableExists("retention_class_assignments") {
|
||||
t.Fatal("migration vorwaerts: erwartete tabellen fehlen")
|
||||
}
|
||||
|
||||
// rueckwaerts
|
||||
runPsql(downSQL)
|
||||
if tableExists("retention_objects") || tableExists("retention_class_assignments") {
|
||||
t.Fatal("migration rueckwaerts: tabellen haetten entfernt sein muessen")
|
||||
}
|
||||
|
||||
// erneut vorwaerts (beweist: rueckwaerts hat wirklich sauber
|
||||
// aufgeraeumt, kein Rest, der einen zweiten Vorwaertslauf bloeckieren wuerde)
|
||||
runPsql(upSQL)
|
||||
if !tableExists("retention_objects") {
|
||||
t.Fatal("zweiter vorwaertslauf nach rollback fehlgeschlagen")
|
||||
}
|
||||
// aufraeumen
|
||||
_, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS retention_class_assignments, retention_objects CASCADE`)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Package retention implementiert RET-01: ein generisches Datenmodell
|
||||
// für aufbewahrungspflichtige Objekte, modulübergreifend über Adapter
|
||||
// (Objekttyp + Objekt-Referenz als reine Textfelder) — Archive kennt die
|
||||
// Fachobjekte anderer Module (DMS, Mail) nicht im Detail, nur ihren Typ
|
||||
// und ihre Referenz. Keine Fremdschlüssel auf modulspezifische Tabellen,
|
||||
// damit ein neues Modul retention-pflichtige Objekte einbinden kann,
|
||||
// ohne dieses Paket zu ändern.
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Status eines Retention-Objekts.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
StatusExpired Status = "expired"
|
||||
StatusDeleted Status = "deleted"
|
||||
)
|
||||
|
||||
// RegisterObject registriert ein Objekt eines beliebigen Moduls unter
|
||||
// seinem Typ+Referenz — idempotent (ON CONFLICT), ein Adapter kann ein
|
||||
// bereits bekanntes Objekt gefahrlos erneut registrieren
|
||||
// (Akzeptanzkriterium 1: bildet beliebige Objekttypen ab, ohne
|
||||
// modulspezifische Spalten).
|
||||
func RegisterObject(ctx context.Context, pool *pgxpool.Pool, objectType, objectReference string) (string, error) {
|
||||
var id string
|
||||
err := pool.QueryRow(ctx, `
|
||||
INSERT INTO retention_objects (object_type, object_reference)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (object_type, object_reference) DO UPDATE SET object_type = EXCLUDED.object_type
|
||||
RETURNING id
|
||||
`, objectType, objectReference).Scan(&id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("retention: objekt registrieren: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Assignment ist EINE historische Zuordnung einer Aufbewahrungsklasse.
|
||||
type Assignment struct {
|
||||
RetentionClass string
|
||||
AssignedAt time.Time
|
||||
}
|
||||
|
||||
// AssignClass ordnet einem Retention-Objekt eine neue Aufbewahrungsklasse
|
||||
// zu — fügt IMMER eine neue Zeile hinzu, ändert nie eine bestehende
|
||||
// (Akzeptanzkriterium 2: historisierbar).
|
||||
func AssignClass(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, retentionClass string) error {
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO retention_class_assignments (retention_object_id, retention_class)
|
||||
VALUES ($1, $2)
|
||||
`, retentionObjectID, retentionClass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retention: aufbewahrungsklasse zuordnen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurrentClass liefert die AKTUELLE Aufbewahrungsklasse (jüngste
|
||||
// Zuordnung) eines Retention-Objekts.
|
||||
func CurrentClass(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) (Assignment, error) {
|
||||
var a Assignment
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT retention_class, assigned_at FROM retention_class_assignments
|
||||
WHERE retention_object_id = $1
|
||||
ORDER BY assigned_at DESC LIMIT 1
|
||||
`, retentionObjectID).Scan(&a.RetentionClass, &a.AssignedAt)
|
||||
if err != nil {
|
||||
return Assignment{}, fmt.Errorf("retention: aktuelle aufbewahrungsklasse lesen: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ClassHistory liefert ALLE Zuordnungen eines Retention-Objekts,
|
||||
// chronologisch aufsteigend — voller Nachvollzug der Historie.
|
||||
func ClassHistory(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) ([]Assignment, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT retention_class, assigned_at FROM retention_class_assignments
|
||||
WHERE retention_object_id = $1
|
||||
ORDER BY assigned_at ASC
|
||||
`, retentionObjectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("retention: klassenhistorie lesen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var history []Assignment
|
||||
for rows.Next() {
|
||||
var a Assignment
|
||||
if err := rows.Scan(&a.RetentionClass, &a.AssignedAt); err != nil {
|
||||
return nil, fmt.Errorf("retention: historien-zeile lesen: %w", err)
|
||||
}
|
||||
history = append(history, a)
|
||||
}
|
||||
return history, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const schemaSQL = `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS retention_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (object_type, object_reference)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`
|
||||
|
||||
func requireTestPool(t *testing.T, dsn string) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
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, schemaSQL); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE`)
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
// TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically ist
|
||||
// Pruefung 2: Testobjekt aus fiktivem DMS- und Mail-Adapter beide
|
||||
// korrekt abgebildet - KEINE modulspezifischen Spalten noetig, nur
|
||||
// object_type/object_reference.
|
||||
func TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically(t *testing.T) {
|
||||
pool := requireTestPool(t, os.Getenv("TEST_TENANT_DSN"))
|
||||
ctx := context.Background()
|
||||
|
||||
dmsID, err := RegisterObject(ctx, pool, "dms_document", "doc-123")
|
||||
if err != nil {
|
||||
t.Fatalf("dms-adapter registrieren: %v", err)
|
||||
}
|
||||
mailID, err := RegisterObject(ctx, pool, "mail_message", "msg-456")
|
||||
if err != nil {
|
||||
t.Fatalf("mail-adapter registrieren: %v", err)
|
||||
}
|
||||
if dmsID == "" || mailID == "" || dmsID == mailID {
|
||||
t.Fatalf("erwartet zwei unterschiedliche, gueltige ids, habe dms=%q mail=%q", dmsID, mailID)
|
||||
}
|
||||
|
||||
// Idempotenz: erneute Registrierung desselben Objekts liefert dieselbe id.
|
||||
dmsIDAgain, err := RegisterObject(ctx, pool, "dms_document", "doc-123")
|
||||
if err != nil {
|
||||
t.Fatalf("erneute registrierung: %v", err)
|
||||
}
|
||||
if dmsIDAgain != dmsID {
|
||||
t.Fatalf("erneute registrierung lieferte andere id: %q, want %q", dmsIDAgain, dmsID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignClass_IsHistoricized ist Pruefung fuer Akzeptanzkriterium 2:
|
||||
// Aufbewahrungsklasse ist eindeutig zugeordnet UND historisierbar.
|
||||
func TestAssignClass_IsHistoricized(t *testing.T) {
|
||||
pool := requireTestPool(t, os.Getenv("TEST_TENANT_DSN"))
|
||||
ctx := context.Background()
|
||||
|
||||
objID, err := RegisterObject(ctx, pool, "dms_document", "doc-hist")
|
||||
if err != nil {
|
||||
t.Fatalf("registrieren: %v", err)
|
||||
}
|
||||
if err := AssignClass(ctx, pool, objID, "klasse-A"); err != nil {
|
||||
t.Fatalf("erste zuordnung: %v", err)
|
||||
}
|
||||
if err := AssignClass(ctx, pool, objID, "klasse-B"); err != nil {
|
||||
t.Fatalf("zweite zuordnung: %v", err)
|
||||
}
|
||||
|
||||
current, err := CurrentClass(ctx, pool, objID)
|
||||
if err != nil {
|
||||
t.Fatalf("currentclass: %v", err)
|
||||
}
|
||||
if current.RetentionClass != "klasse-B" {
|
||||
t.Fatalf("aktuelle klasse = %q, want klasse-B", current.RetentionClass)
|
||||
}
|
||||
|
||||
history, err := ClassHistory(ctx, pool, objID)
|
||||
if err != nil {
|
||||
t.Fatalf("classhistory: %v", err)
|
||||
}
|
||||
if len(history) != 2 || history[0].RetentionClass != "klasse-A" || history[1].RetentionClass != "klasse-B" {
|
||||
t.Fatalf("erwartet [klasse-A, klasse-B] chronologisch, habe %+v", history)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenantIsolation_Negativtest ist Pruefung 3: ein in Tenant-DB A
|
||||
// registriertes Objekt ist ueber eine Verbindung zu Tenant-DB B technisch
|
||||
// nicht sichtbar - real gegen zwei unabhaengige Datenbanken (Modell C,
|
||||
// TEN-01), keine gemeinsame Tabelle mit tenant_id-Filter.
|
||||
func TestTenantIsolation_Negativtest(t *testing.T) {
|
||||
dsnA := os.Getenv("TEST_TENANT_DSN")
|
||||
dsnB := os.Getenv("TEST_TENANT_DSN_B")
|
||||
if dsnA == "" || dsnB == "" {
|
||||
t.Skip("TEST_TENANT_DSN und TEST_TENANT_DSN_B nicht beide gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
poolA := requireTestPool(t, dsnA)
|
||||
poolB := requireTestPool(t, dsnB)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := RegisterObject(ctx, poolA, "dms_document", "nur-in-tenant-a"); err != nil {
|
||||
t.Fatalf("registrieren in tenant a: %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := poolB.QueryRow(ctx, `SELECT count(*) FROM retention_objects WHERE object_reference = 'nur-in-tenant-a'`).Scan(&count); err != nil {
|
||||
t.Fatalf("tenant b abfragen: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("objekt aus tenant a in tenant b sichtbar (count=%d) - mandantentrennung verletzt", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS retention_class_assignments;
|
||||
DROP TABLE IF EXISTS retention_objects;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- RET-01: generisches Retention-Objektmodell. Modulübergreifend über
|
||||
-- Adapter (object_type/object_reference als reine Textfelder, KEINE
|
||||
-- Fremdschlüssel auf DMS-/Mail-Tabellen) — Archive kennt die
|
||||
-- Fachobjekte anderer Module nicht, nur deren Typ+Referenz.
|
||||
CREATE TABLE IF NOT EXISTS retention_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (object_type, object_reference)
|
||||
);
|
||||
|
||||
-- Historisiert: JEDE Zuordnung ist eine eigene, unveränderliche Zeile
|
||||
-- (nie UPDATE) — "aktuelle" Aufbewahrungsklasse ist die mit dem
|
||||
-- jüngsten assigned_at je retention_object_id. So bleibt nachvollziehbar,
|
||||
-- wann sich die Klasse eines Objekts geändert hat (Akzeptanzkriterium 2).
|
||||
CREATE TABLE IF NOT EXISTS retention_class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
retention_class TEXT NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_retention_class_assignments_object
|
||||
ON retention_class_assignments (retention_object_id, assigned_at DESC);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS module_registrations;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 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,
|
||||
retention_class TEXT NOT NULL,
|
||||
callback_url TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (module_name, object_type)
|
||||
);
|
||||
Reference in New Issue
Block a user