internal/retention: object_type/object_reference als reine Textfelder (Adapter-Muster, keine Fremdschluessel auf DMS-/Mail-Tabellen). Aufbewahrungsklassen-Zuordnung historisiert (jede Zuordnung eigene, unveraenderliche Zeile). Migration real vorwaerts+rueckwaerts gegen die tatsaechlichen .sql-Dateien getestet, Mandantentrennung gegen echtes zweites Tenant-DB bewiesen. Grundlage fuer RET-05 (Adapter- Interface) und RET-02.
138 lines
4.6 KiB
Go
138 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|