package keycloak import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" ) // stubUsersKC is a users+organizations KC look-alike; stubKC (client_test.go) // covers the org-create/invite paths and doesn't register these reads. func stubUsersKC( t *testing.T, users map[string]userRepresentation, memberOrgs map[string][]memberOrgRepresentation, ) *httptest.Server { t.Helper() mux := http.NewServeMux() mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "test-token", "expires_in": 60}) }) mux.HandleFunc("GET /admin/realms/test-realm/users/{id}", func(w http.ResponseWriter, r *http.Request) { u, ok := users[r.PathValue("id")] if !ok { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"User not found"}`)) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(u) }) mux.HandleFunc("GET /admin/realms/test-realm/organizations/members/{id}/organizations", func(w http.ResponseWriter, r *http.Request) { orgs, ok := memberOrgs[r.PathValue("id")] if !ok { orgs = []memberOrgRepresentation{} } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(orgs) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv } func usersAdapter(srv *httptest.Server) *HTTPAdapter { return NewHTTPAdapter(HTTPConfig{ BaseURL: srv.URL, Realm: "test-realm", ClientID: "svc", ClientSecret: "secret", }) } func TestHTTPAdapter_Memberships(t *testing.T) { users := map[string]userRepresentation{ "u-1": {ID: "u-1", Username: "test@breakpilot.com", Enabled: true, Attributes: map[string][]string{ // legacy projection attrs — MUST NOT grant membership on their own "tenant_id": {"stale-legacy-001"}, "tenant_slug": {"stale"}, "tenant_status": {"active"}, "plan": {"Scale"}, "org_roles": {"IT_ADMIN", "FINANCE"}, // the KC admin console writes multivalued attrs "##"-joined "products": {"compliance##certifai"}, }}, "u-2": {ID: "u-2", Username: "bare@breakpilot.com", Enabled: true}, "u-3": {ID: "u-3", Username: "multi@breakpilot.com", Enabled: true}, "u-4": {ID: "u-4", Username: "attrs-only@breakpilot.com", Enabled: true, Attributes: map[string][]string{ "tenant_id": {"acme-001"}, "tenant_slug": {"acme"}, }}, } memberOrgs := map[string][]memberOrgRepresentation{ "u-1": {{ ID: "org-1", Alias: "acme", Enabled: true, Attributes: map[string][]string{"tenant_id": {"2f875d6a-1d94-433a-b2ec-8529451a2d89"}}, }}, "u-3": { {ID: "org-1", Alias: "acme", Enabled: true, Attributes: map[string][]string{"tenant_id": {"2f875d6a-1d94-433a-b2ec-8529451a2d89"}}}, {ID: "org-2", Alias: "globex", Enabled: true, Attributes: map[string][]string{"tenant_id": {"7c3f2b10-0000-4000-8000-000000000042"}}}, {ID: "org-3", Alias: "disabled-co", Enabled: false, Attributes: map[string][]string{"tenant_id": {"9e9e9e9e-0000-4000-8000-000000000099"}}}, }, } srv := stubUsersKC(t, users, memberOrgs) a := usersAdapter(srv) t.Run("org membership becomes the claim, org attrs are authoritative", func(t *testing.T) { got, err := a.Memberships(context.Background(), "u-1") if err != nil { t.Fatalf("memberships: %v", err) } if len(got) != 1 { t.Fatalf("want 1 membership, got %d", len(got)) } c := got[0] // tenant identity comes from the ORG (alias + tenant_id attribute), // never from the user's legacy projection attributes if c.TenantID != "2f875d6a-1d94-433a-b2ec-8529451a2d89" || c.TenantSlug != "acme" { t.Errorf("org identity wrong: %+v", c) } // per-user claim attrs still ride along if c.Plan != "Scale" || c.TenantStatus != "active" { t.Errorf("user claim attrs wrong: %+v", c) } if len(c.OrgRoles) != 2 || c.OrgRoles[0] != "IT_ADMIN" || c.OrgRoles[1] != "FINANCE" { t.Errorf("org_roles wrong: %v", c.OrgRoles) } if len(c.Products) != 2 || c.Products[0] != "compliance" || c.Products[1] != "certifai" { t.Errorf("## split failed: %v", c.Products) } }) t.Run("user in no org has zero memberships", func(t *testing.T) { got, err := a.Memberships(context.Background(), "u-2") if err != nil { t.Fatalf("memberships: %v", err) } if len(got) != 0 { t.Fatalf("want 0 memberships, got %+v", got) } }) t.Run("legacy tenant attributes alone grant NO membership", func(t *testing.T) { got, err := a.Memberships(context.Background(), "u-4") if err != nil { t.Fatalf("memberships: %v", err) } if len(got) != 0 { t.Fatalf("attribute projection must not grant membership, got %+v", got) } }) t.Run("multiple orgs give multiple memberships, disabled org skipped", func(t *testing.T) { got, err := a.Memberships(context.Background(), "u-3") if err != nil { t.Fatalf("memberships: %v", err) } if len(got) != 2 { t.Fatalf("want 2 memberships (disabled org skipped), got %d", len(got)) } if got[0].TenantSlug != "acme" || got[1].TenantSlug != "globex" { t.Errorf("slugs wrong: %+v", got) } }) t.Run("unknown user is ErrUserNotFound", func(t *testing.T) { _, err := a.Memberships(context.Background(), "nope") if !errors.Is(err, ErrUserNotFound) { t.Fatalf("want ErrUserNotFound, got %v", err) } }) } func TestHTTPAdapter_Memberships_OrgQueryFailure(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "t", "expires_in": 60}) }) mux.HandleFunc("GET /admin/realms/test-realm/users/{id}", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(userRepresentation{ID: "u-1", Enabled: true}) }) mux.HandleFunc("GET /admin/realms/test-realm/organizations/members/{id}/organizations", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) _, err := usersAdapter(srv).Memberships(context.Background(), "u-1") if err == nil { t.Fatal("want error when the org query fails, got nil") } }