package policy import ( "context" "errors" "os" "testing" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/rbac" ) func setupTest(t *testing.T) (*Store, *Enforcer, func()) { t.Helper() adminDSN := os.Getenv("TEST_ADMIN_DSN") if adminDSN == "" { t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") } ctx := context.Background() pool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("pool: %v", err) } if _, err := pool.Exec(ctx, ` CREATE TABLE IF NOT EXISTS policy_rules ( role TEXT NOT NULL, permission TEXT NOT NULL, granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (role, permission) ); CREATE TABLE IF NOT EXISTS policy_rule_changes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), role TEXT NOT NULL, permission TEXT NOT NULL, action TEXT NOT NULL CHECK (action IN ('grant','revoke')), actor TEXT NOT NULL, version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); err != nil { t.Fatalf("schema: %v", err) } store := NewStore(pool) cleanup := func() { _, _ = pool.Exec(ctx, `DELETE FROM policy_rule_changes WHERE role LIKE 'test\_%' ESCAPE '\'`) _, _ = pool.Exec(ctx, `DELETE FROM policy_rules WHERE role LIKE 'test\_%' ESCAPE '\'`) pool.Close() } return store, NewEnforcer(store), cleanup } // Akzeptanzkriterium 2 + Pruefung 2: Anfrage ohne passende Policy wird // zuverlaessig abgewiesen (Default-Deny), nicht standardmaessig erlaubt. func TestAuthorize_DefaultDeny(t *testing.T) { _, enforcer, cleanup := setupTest(t) defer cleanup() ctx := context.Background() err := enforcer.Authorize(ctx, rbac.Role("test_niemand"), rbac.Permission("test_irgendwas")) if !errors.Is(err, ErrDenied) { t.Fatalf("erwartet ErrDenied ohne konfigurierte regel, habe %v", err) } } // Akzeptanzkriterium 1 + Pruefung 1: Guard ruft query NUR bei Autorisierung // auf — kein Datenzugriffs-Pfad umgeht die Enforcement-Schicht. func TestGuard_NeverCallsQueryWithoutAuthorization(t *testing.T) { store, enforcer, cleanup := setupTest(t) defer cleanup() ctx := context.Background() role := rbac.Role("test_rolle_guard") perm := rbac.Permission("test_lesen") queryCalled := false _, err := Guard(ctx, enforcer, role, perm, func(ctx context.Context) (string, error) { queryCalled = true return "geheime-daten", nil }) if !errors.Is(err, ErrDenied) { t.Fatalf("erwartet ErrDenied, habe %v", err) } if queryCalled { t.Fatal("query haette bei fehlender autorisierung NICHT aufgerufen werden duerfen") } if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil { t.Fatalf("grant: %v", err) } result, err := Guard(ctx, enforcer, role, perm, func(ctx context.Context) (string, error) { queryCalled = true return "geheime-daten", nil }) if err != nil { t.Fatalf("guard nach grant: %v", err) } if !queryCalled || result != "geheime-daten" { t.Fatalf("erwartet query-aufruf mit ergebnis nach autorisierung, habe queryCalled=%v result=%q", queryCalled, result) } } // Akzeptanzkriterium 2 + 3: Policy-Entscheidungen deklarativ und unabhaengig // von Anwendungslogik testbar — Regelwechsel wirkt ohne Codeaenderung, // Historie ist versioniert nachvollziehbar. func TestGrantRevoke_ChangesBehaviorWithoutCodeChange(t *testing.T) { store, enforcer, cleanup := setupTest(t) defer cleanup() ctx := context.Background() role := rbac.Role("test_rolle_history") perm := rbac.Permission("test_schreiben") allowed, err := store.IsAllowed(ctx, role, perm) if err != nil { t.Fatalf("is allowed (vorher): %v", err) } if allowed { t.Fatal("erwartet nicht erlaubt vor grant") } if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil { t.Fatalf("grant: %v", err) } if err := enforcer.Authorize(ctx, role, perm); err != nil { t.Fatalf("authorize nach grant sollte erlauben: %v", err) } if err := store.Revoke(ctx, role, perm, "admin@example.com"); err != nil { t.Fatalf("revoke: %v", err) } if err := enforcer.Authorize(ctx, role, perm); !errors.Is(err, ErrDenied) { t.Fatalf("authorize nach revoke sollte verweigern, habe %v", err) } history, err := store.History(ctx, role, perm) if err != nil { t.Fatalf("history: %v", err) } if len(history) != 2 { t.Fatalf("erwartet 2 historieneintraege (grant, revoke), habe %d", len(history)) } if history[0].Action != "grant" || history[0].Version != 1 { t.Fatalf("history[0] unerwartet: %+v", history[0]) } if history[1].Action != "revoke" || history[1].Version != 2 { t.Fatalf("history[1] unerwartet: %+v", history[1]) } } // Akzeptanzkriterium 3 + Pruefung 3 (Tenant-Scoping): GuardTenantScoped // zwingt tenantSlug in die Query-Funktion selbst, kein Post-Filter moeglich. func TestGuardTenantScoped_IsolatesDataBetweenTenants(t *testing.T) { _, enforcer, cleanup := setupTest(t) defer cleanup() ctx := context.Background() // Simuliertes Repository: die WHERE-Klausel (hier: Map-Lookup) liegt // INNERHALB der Query-Funktion, nicht als nachgelagerter Filter. data := map[string][]string{ "acme": {"dokument-a1", "dokument-a2"}, "globex": {"dokument-b1"}, } repoQuery := func(ctx context.Context, tenantSlug string) ([]string, error) { return data[tenantSlug], nil } role := rbac.Role("test_tenant_rolle") perm := rbac.Permission("test_dokumente_lesen") if err := enforcer.store.Grant(ctx, role, perm, "admin@example.com"); err != nil { t.Fatalf("grant: %v", err) } gotAcme, err := GuardTenantScoped(ctx, enforcer, role, perm, "acme", repoQuery) if err != nil { t.Fatalf("guard tenant scoped (acme): %v", err) } if len(gotAcme) != 2 { t.Fatalf("erwartet 2 dokumente fuer acme, habe %d", len(gotAcme)) } gotGlobex, err := GuardTenantScoped(ctx, enforcer, role, perm, "globex", repoQuery) if err != nil { t.Fatalf("guard tenant scoped (globex): %v", err) } if len(gotGlobex) != 1 { t.Fatalf("erwartet 1 dokument fuer globex, habe %d", len(gotGlobex)) } }