package policyapi import ( "bytes" "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "testing" "time" "gitea.perlbach24.de/scripte/nexarch/internal/flag" "gitea.perlbach24.de/scripte/nexarch/internal/policy" "gitea.perlbach24.de/scripte/nexarch/internal/rbac" "gitea.perlbach24.de/scripte/nexarch/internal/testdb" ) const testToken = "test-service-token" func setupTest(t *testing.T) (*policy.Enforcer, *flag.Service) { e, f, _, _ := setupTestFull(t) return e, f } func setupTestFull(t *testing.T) (*policy.Enforcer, *flag.Service, *policy.Store, *flag.Store) { t.Helper() dsn := os.Getenv("TEST_ADMIN_DSN") if dsn == "" { t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") } ctx := context.Background() pool := testdb.Pool(t, dsn) if _, err := pool.Exec(ctx, ` CREATE EXTENSION IF NOT EXISTS pgcrypto; 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() ); CREATE TABLE IF NOT EXISTS policy_module_scopes ( role TEXT NOT NULL, permission TEXT NOT NULL, module TEXT NOT NULL, flag_key TEXT NOT NULL, PRIMARY KEY (role, permission) ); CREATE TABLE IF NOT EXISTS feature_flags ( key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false, rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}', updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS object_acl ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_slug TEXT NOT NULL, object_type TEXT NOT NULL CHECK (object_type IN ('folder','document')), object_id TEXT NOT NULL, principal_type TEXT NOT NULL CHECK (principal_type IN ('user','group')), principal_id TEXT NOT NULL, object_right TEXT NOT NULL CHECK (object_right IN ('read','write','manage')), effect TEXT NOT NULL CHECK (effect IN ('allow','deny')), created_by TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_slug, object_type, object_id, principal_type, principal_id, object_right) ); CREATE TABLE IF NOT EXISTS object_acl_breaks ( tenant_slug TEXT NOT NULL, folder_id TEXT NOT NULL, created_by TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_slug, folder_id) ); CREATE TABLE IF NOT EXISTS object_acl_changes ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, tenant_slug TEXT NOT NULL, object_type TEXT NOT NULL, object_id TEXT NOT NULL, action TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', actor TEXT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM object_acl_changes WHERE tenant_slug LIKE 'rbac11\_%' ESCAPE '\'`) _, _ = pool.Exec(context.Background(), `DELETE FROM object_acl_breaks WHERE tenant_slug LIKE 'rbac11\_%' ESCAPE '\'`) _, _ = pool.Exec(context.Background(), `DELETE FROM object_acl WHERE tenant_slug LIKE 'rbac11\_%' ESCAPE '\'`) _, _ = pool.Exec(context.Background(), `DELETE FROM policy_module_scopes WHERE role LIKE 'rbac06\_%' ESCAPE '\'`) _, _ = pool.Exec(context.Background(), `DELETE FROM feature_flags WHERE key LIKE 'rbac06\_%' ESCAPE '\'`) _, _ = pool.Exec(context.Background(), `DELETE FROM policy_rule_changes WHERE role LIKE 'rbac06\_%' ESCAPE '\'`) _, _ = pool.Exec(context.Background(), `DELETE FROM policy_rules WHERE role LIKE 'rbac06\_%' ESCAPE '\'`) }) store := policy.NewStore(pool) if err := store.Grant(ctx, rbac.Role("rbac06_erlaubte_rolle"), rbac.Permission("rbac06_recht"), "test"); err != nil { t.Fatalf("grant: %v", err) } flagStore := flag.NewStore(pool) return policy.NewEnforcer(store), flag.NewService(flagStore, 10*time.Millisecond), store, flagStore } func authorize(t *testing.T, server *httptest.Server, role, permission string) (int, bool) { t.Helper() body, _ := json.Marshal(authorizeRequest{Role: role, Permission: permission}) req, _ := http.NewRequest(http.MethodPost, server.URL+"/authorize", bytes.NewReader(body)) req.Header.Set("X-Service-Token", testToken) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("post: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return resp.StatusCode, false } var out authorizeResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("antwort dekodieren: %v", err) } return resp.StatusCode, out.Allowed } // TestAuthorizeHandler_MatchesDirectEnforcerResult ist die geforderte // Pflichtpruefung: Endpunkt-Antwort stimmt in 100 Stichproben exakt mit // dem direkten Enforcer.Authorize-Ergebnis ueberein - real fuer sowohl // erlaubte als auch verbotene role+permission-Kombinationen. func TestAuthorizeHandler_MatchesDirectEnforcerResult(t *testing.T) { enforcer, flags := setupTest(t) mux := http.NewServeMux() Mount(mux, enforcer, flags, testToken) server := httptest.NewServer(mux) defer server.Close() cases := []struct { role, permission string }{ {"rbac06_erlaubte_rolle", "rbac06_recht"}, // erlaubt {"rbac06_unbekannte_rolle", "rbac06_recht"}, // verboten (default-deny) {"rbac06_erlaubte_rolle", "rbac06_anderes"}, // verboten (falsches recht) } for i := 0; i < 100; i++ { c := cases[i%len(cases)] directErr := enforcer.Authorize(context.Background(), rbac.Role(c.role), rbac.Permission(c.permission)) directAllowed := directErr == nil if directErr != nil && !errors.Is(directErr, policy.ErrDenied) { t.Fatalf("stichprobe %d: unerwarteter enforcer-fehler: %v", i, directErr) } status, httpAllowed := authorize(t, server, c.role, c.permission) if status != http.StatusOK { t.Fatalf("stichprobe %d: unerwarteter status %d", i, status) } if httpAllowed != directAllowed { t.Fatalf("stichprobe %d (%s/%s): http=%t, direkt=%t - weichen ab", i, c.role, c.permission, httpAllowed, directAllowed) } } }