CMP-02: dsgvo-datenschutz-berichte
- migrations/0009_data_subject_ref: additive nullable Spalte auf retention_objects, schliesst die Luecke fuer 'betroffene Person' in RET-01 - retention.RegisterObjectForSubject: NEUE additive Funktion, RegisterObject selbst unveraendert (kein Diff, keine Produktionsaufrufer betroffen) - archive/internal/dpreport: SubjectReport (Auskunftsbericht), ProcessingOverview (Verarbeitungsuebersicht, statisch gepflegte Zweck/Rechtsgrundlage je Objekttyp), WriteSubjectReportCSV - Mandantentrennung strukturell durch Modell C (ein Pool pro Tenant), real mit zwei physisch getrennten Tenant-DBs bewiesen (tenant_acme/ tenant_globex), nicht nur behauptet - 4 Tests, alle Pflichtpruefungen real bestanden - Migration real auf dms_tenant_test angewendet Pruefungen siehe archive/docs/CMP-02-PRUEFPROTOKOLL.md
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user