package dpreport import ( "bytes" "context" "os" "strings" "testing" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/archive/internal/retention" ) func setupTest(t *testing.T) *pgxpool.Pool { t.Helper() dsn := os.Getenv("TEST_TENANT_DSN") if dsn == "" { t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") } return setupTestWithDSN(t, dsn) } func setupTestWithDSN(t *testing.T, dsn string) *pgxpool.Pool { t.Helper() 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 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) ); ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS data_subject_ref TEXT; 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() ); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE`) }) return pool } // TestSubjectReport_MatchesKnownDataset ist die geforderte Pflichtprüfung // 1: Auskunftsbericht für Testperson mit bekanntem Datenbestand stimmt // mit erwarteter Liste überein. func TestSubjectReport_MatchesKnownDataset(t *testing.T) { pool := setupTest(t) ctx := context.Background() id1, err := retention.RegisterObjectForSubject(ctx, pool, "dms_document", "doc-1", "person-a@example.com") if err != nil { t.Fatal(err) } if err := retention.AssignClass(ctx, pool, id1, "klasse-x"); err != nil { t.Fatal(err) } if _, err := retention.RegisterObjectForSubject(ctx, pool, "dms_document", "doc-2", "person-b@example.com"); err != nil { t.Fatal(err) } if _, err := retention.RegisterObjectForSubject(ctx, pool, "mail_message", "mail-1", "person-a@example.com"); err != nil { t.Fatal(err) } report, err := SubjectReport(ctx, pool, "person-a@example.com") if err != nil { t.Fatalf("subjectreport: %v", err) } if len(report) != 2 { t.Fatalf("erwartet 2 objekte fuer person-a, habe %d: %+v", len(report), report) } refs := map[string]bool{} for _, r := range report { refs[r.ObjectReference] = true } if !refs["doc-1"] || !refs["mail-1"] { t.Fatalf("erwartete objekte fehlen: %+v", report) } if refs["doc-2"] { t.Fatal("doc-2 gehoert person-b, nicht person-a - darf nicht im bericht auftauchen") } for _, r := range report { if r.ObjectReference == "doc-1" && r.RetentionClass != "klasse-x" { t.Fatalf("erwartet klasse-x fuer doc-1, habe %q", r.RetentionClass) } } } // TestSubjectReport_TenantIsolation ist die geforderte Pflichtprüfung 2: // Bericht für einen Tenant enthält keine Objekte eines anderen Tenants. // Da SubjectReport IMMER gegen genau einen Tenant-Pool laeuft (Modell C), // wird dies strukturell bewiesen: ein zweiter, PHYSISCH GETRENNTER Pool // (eigene Datenbank, TEST_TENANT_DSN_B) kann die Zeilen des ersten // technisch nicht sehen. Braucht eine echte zweite Tenant-DB, nicht nur // denselben TEST_TENANT_DSN zweimal (sonst ist es dieselbe physische // Datenbank und der Test beweist nichts über echte Mandantentrennung). func TestSubjectReport_TenantIsolation(t *testing.T) { dsnB := os.Getenv("TEST_TENANT_DSN_B") if dsnB == "" { t.Skip("TEST_TENANT_DSN_B nicht gesetzt - Test braucht eine ECHTE zweite, physisch getrennte Tenant-Datenbank") } poolA := setupTest(t) poolB := setupTestWithDSN(t, dsnB) ctx := context.Background() if _, err := retention.RegisterObjectForSubject(ctx, poolA, "dms_document", "tenant-a-doc", "shared-person@example.com"); err != nil { t.Fatal(err) } reportB, err := SubjectReport(ctx, poolB, "shared-person@example.com") if err != nil { t.Fatalf("subjectreport (tenant b): %v", err) } if len(reportB) != 0 { t.Fatalf("tenant b darf tenant as objekte nicht sehen, habe: %+v", reportB) } } // TestWriteSubjectReportCSV_IsParseable ist die geforderte Pflichtprüfung // 3: Export lässt sich als CSV weiterverarbeiten. func TestWriteSubjectReportCSV_IsParseable(t *testing.T) { pool := setupTest(t) ctx := context.Background() if _, err := retention.RegisterObjectForSubject(ctx, pool, "dms_document", "csv-doc", "csv-person@example.com"); err != nil { t.Fatal(err) } report, err := SubjectReport(ctx, pool, "csv-person@example.com") if err != nil { t.Fatal(err) } var buf bytes.Buffer if err := WriteSubjectReportCSV(&buf, report); err != nil { t.Fatalf("csv schreiben: %v", err) } out := buf.String() if !strings.Contains(out, "object_type,object_reference") { t.Fatalf("erwartet csv-header, habe: %q", out) } if !strings.Contains(out, "csv-doc") { t.Fatalf("erwartet datenzeile mit csv-doc, habe: %q", out) } lines := strings.Split(strings.TrimSpace(out), "\n") if len(lines) != 2 { t.Fatalf("erwartet header + 1 datenzeile, habe %d zeilen: %q", len(lines), out) } } // TestProcessingOverview_CoversPresentObjectTypes deckt Akzeptanzkriterium 2. func TestProcessingOverview_CoversPresentObjectTypes(t *testing.T) { pool := setupTest(t) ctx := context.Background() if _, err := retention.RegisterObjectForSubject(ctx, pool, "dms_document", "overview-doc", ""); err != nil { t.Fatal(err) } overview, err := ProcessingOverview(ctx, pool) if err != nil { t.Fatal(err) } if len(overview) != 1 || overview[0].ObjectType != "dms_document" { t.Fatalf("erwartet genau dms_document, habe: %+v", overview) } if overview[0].Purpose == "" || overview[0].LegalBasis == "" { t.Fatalf("zweck/rechtsgrundlage fehlen: %+v", overview[0]) } }