diff --git a/.changelog/pr-134.txt b/.changelog/pr-134.txt new file mode 100644 index 0000000..26d8ca3 --- /dev/null +++ b/.changelog/pr-134.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +`resource/pingone_group`: Added export support for PingOne groups, including `name`, `description`, `population_id` (referencing `pingone_population`), `user_filter`, `external_id`, and `custom_data`. +``` diff --git a/cmd/export_outputs_test.go b/cmd/export_outputs_test.go index b340390..3522e29 100644 --- a/cmd/export_outputs_test.go +++ b/cmd/export_outputs_test.go @@ -16,12 +16,12 @@ import ( func TestParseOutputPath(t *testing.T) { tests := []struct { - name string - raw string - wantOk bool - wantResType string - wantLabel string - wantAttrPath string + name string + raw string + wantOk bool + wantResType string + wantLabel string + wantAttrPath string }{ { name: "valid 3-segment path", diff --git a/contributing/RESOURCE_COVERAGE.md b/contributing/RESOURCE_COVERAGE.md index d847a62..8321945 100644 --- a/contributing/RESOURCE_COVERAGE.md +++ b/contributing/RESOURCE_COVERAGE.md @@ -64,7 +64,7 @@ Legend: ✅ Supported · ❌ Not yet supported | `pingone_application_role_assignment` | ❌ | | `pingone_application_secret` | ❌ | | `pingone_application_sign_on_policy_assignment` | ❌ | -| `pingone_group` | ❌ | +| `pingone_group` | ✅ | | `pingone_group_nesting` | ❌ | | `pingone_group_role_assignment` | ❌ | | `pingone_identity_provider` | ❌ | diff --git a/definitions/embed.go b/definitions/embed.go index 975ee4c..cf64ec5 100644 --- a/definitions/embed.go +++ b/definitions/embed.go @@ -4,8 +4,10 @@ package definitions import "embed" // FS contains all embedded resource definition YAML files. -// The embed directive includes all .yaml files under pingone subdirectories. -// Subdirectories (base/, davinci/, etc.) are organizational only. +// The embed directive includes all .yaml files under any pingone category +// subdirectory (base/, davinci/, sso/, etc.) — subdirectories are +// organizational only. New category directories do not require editing this +// file. // -//go:embed pingone/base/*.yaml pingone/davinci/*.yaml +//go:embed pingone/*/*.yaml var FS embed.FS diff --git a/definitions/pingone/sso/group.yaml b/definitions/pingone/sso/group.yaml new file mode 100644 index 0000000..8ad4ad5 --- /dev/null +++ b/definitions/pingone/sso/group.yaml @@ -0,0 +1,92 @@ +# PingOne Group Resource Definition +# +# SDK: github.com/patrickcping/pingone-go-sdk-v2/management, type Group. +# Schema-driven definition: no custom handlers are needed beyond the +# list/get dispatch (see resource_group.go), which is required because this +# resource uses the management SDK (list-then-iterate pagination, no +# per-resource typed HAL links). + +metadata: + platform: pingone + resource_type: pingone_group + api_type: Group + name: PingOne Group + short_name: group + version: "1.0" + +api: + sdk_package: github.com/patrickcping/pingone-go-sdk-v2/management + sdk_type: Group + list_method: GroupsApi.ReadAllGroups + get_method: GroupsApi.ReadOneGroup + id_field: id + name_field: name + label_fields: [name] + pagination_type: cursor + +attributes: + # ID - computed only + - name: ID + terraform_name: id + type: string + source_path: Id + computed: true + + # Environment ID - required, references environment. No source_path: the + # orchestrator injects the exported environment's ID directly (see + # pingone_davinci_flow for the same pattern). + - name: EnvironmentID + terraform_name: environment_id + type: string + required: true + references_type: pingone_environment + reference_field: id + + # Name - required string + - name: Name + terraform_name: name + type: string + source_path: Name + required: true + + # Description - optional string + - name: Description + terraform_name: description + type: string + source_path: Description + + # Population ID - optional, references population. Immutable in the + # provider (replace-on-change), but that is a provider-side plan detail + # that does not affect export. + - name: PopulationID + terraform_name: population_id + type: string + source_path: Population.Id + references_type: pingone_population + reference_field: id + + # User Filter - optional string (SCIM filter for dynamic membership) + - name: UserFilter + terraform_name: user_filter + type: string + source_path: UserFilter + + # External ID - optional string + - name: ExternalID + terraform_name: external_id + type: string + source_path: ExternalId + + # Custom Data - optional, arbitrary user-defined JSON object + - name: CustomData + terraform_name: custom_data + type: object + source_path: CustomData + transform: jsonencode_raw + +dependencies: + depends_on: [] + import_id_format: "{env_id}/{resource_id}" + +variables: + eligible_attributes: [] diff --git a/go.mod b/go.mod index 19cadee..d5f232a 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,13 @@ module github.com/pingidentity/pingcli-plugin-terraformer -go 1.25.1 +go 1.25.6 require ( github.com/google/uuid v1.6.0 github.com/hashicorp/go-plugin v1.7.0 github.com/hashicorp/hcl/v2 v2.24.0 + github.com/patrickcping/pingone-go-sdk-v2 v0.14.14 + github.com/patrickcping/pingone-go-sdk-v2/management v0.70.0 github.com/pingidentity/pingcli v0.8.0 github.com/pingidentity/pingone-go-client v0.12.0 github.com/spf13/pflag v1.0.10 @@ -35,6 +37,11 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/oklog/run v1.2.0 // indirect + github.com/patrickcping/pingone-go-sdk-v2/authorize v0.8.3 // indirect + github.com/patrickcping/pingone-go-sdk-v2/credentials v0.12.1 // indirect + github.com/patrickcping/pingone-go-sdk-v2/mfa v0.25.1 // indirect + github.com/patrickcping/pingone-go-sdk-v2/risk v0.21.1 // indirect + github.com/patrickcping/pingone-go-sdk-v2/verify v0.11.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/zalando/go-keyring v0.2.8 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/go.sum b/go.sum index 10b09ae..61cd8c1 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,20 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/patrickcping/pingone-go-sdk-v2 v0.14.14 h1:illFMogbGgYoZUr+kW0OQjbLyTfGEemLmTcX6oIIdx8= +github.com/patrickcping/pingone-go-sdk-v2 v0.14.14/go.mod h1:DhdLzztyNuH03k4h/AvmLjiYdvxHf1EViZBwDMan6BM= +github.com/patrickcping/pingone-go-sdk-v2/authorize v0.8.3 h1:rB2g0Ry+AlvCxN0gxXNb2AQm/1kyB3G47g2H3KRPHVQ= +github.com/patrickcping/pingone-go-sdk-v2/authorize v0.8.3/go.mod h1:Tb8FQPxveYj4QOc4VJa42QccMv2hSLNpTKDUSdWXtVk= +github.com/patrickcping/pingone-go-sdk-v2/credentials v0.12.1 h1:7g4f1UWz85C4Onwg5hfqZPvyWrbqXJ5HlYNeVDRD8Sk= +github.com/patrickcping/pingone-go-sdk-v2/credentials v0.12.1/go.mod h1:SaqdGPTxYXaeVgOCJXDDCK40Avr+zGujG0YJGQTwyJg= +github.com/patrickcping/pingone-go-sdk-v2/management v0.70.0 h1:O+tBQC6AreSSoFAproARMxaxcPL3hDJKJmb7GkVm7Sg= +github.com/patrickcping/pingone-go-sdk-v2/management v0.70.0/go.mod h1:Ld0egUhvuv9SRvTjTgQrzZur6tB+qBTxm4kSfX6uj6Y= +github.com/patrickcping/pingone-go-sdk-v2/mfa v0.25.1 h1:qLwEQfXJwKsStn935tEUyt9L6r7rJRuukmOfVwRX4H0= +github.com/patrickcping/pingone-go-sdk-v2/mfa v0.25.1/go.mod h1:U5xu9EHj1Wvgl2an+qoq8QgScAPYB02bF0CKQmpo08Y= +github.com/patrickcping/pingone-go-sdk-v2/risk v0.21.1 h1:hjXbUFYkKuQEIyu/m8+yDXOkJRIuIB4/LIy1WZ0Zf+U= +github.com/patrickcping/pingone-go-sdk-v2/risk v0.21.1/go.mod h1:3emXaUBwjjZd6znlR4l9z52j8mryoCuzXOajlQH5Biw= +github.com/patrickcping/pingone-go-sdk-v2/verify v0.11.2 h1:BfJGv6gmHzUDNYI/ZyqvdBIQVPlkAKwnjYs/kevD/PE= +github.com/patrickcping/pingone-go-sdk-v2/verify v0.11.2/go.mod h1:qbJfZ1MXPDeouQBsWelraoNfmguOT1/RBJFuAnQvrSA= github.com/pingidentity/pingcli v0.8.0 h1:KmUuzGliAtNlLz/okMRtT0vdRoiSAz3Kb+/FGti6rpA= github.com/pingidentity/pingcli v0.8.0/go.mod h1:3fAj8w0kZtLU6DBLKdCLQnPCN+gbVjysnwGrgsJoSYY= github.com/pingidentity/pingone-go-client v0.12.0 h1:t9L18Qul3UXqqvi0578R7bzys54cEEl+oSJDT6rMdyM= diff --git a/internal/platform/pingone/client.go b/internal/platform/pingone/client.go index 5d846b2..f966e80 100644 --- a/internal/platform/pingone/client.go +++ b/internal/platform/pingone/client.go @@ -12,6 +12,8 @@ import ( "fmt" "github.com/google/uuid" + "github.com/patrickcping/pingone-go-sdk-v2/management" + pingonesdkv2 "github.com/patrickcping/pingone-go-sdk-v2/pingone" "github.com/pingidentity/pingone-go-client/config" "github.com/pingidentity/pingone-go-client/oauth2" "github.com/pingidentity/pingone-go-client/pingone" @@ -28,9 +30,11 @@ var _ clients.APIClient = (*Client)(nil) // Resource-specific list/get logic and custom handlers live in resource_*.go // files. Each file registers everything for its resource via init(). type Client struct { - apiClient *pingone.APIClient - environmentID uuid.UUID - warnings []string + apiClient *pingone.APIClient + managementCfg *pingonesdkv2.Config + managementClient *management.APIClient + environmentID uuid.UUID + warnings []string } // New creates a DaVinci APIClient from a pre-built SDK client and environment ID. @@ -38,6 +42,34 @@ func New(apiClient *pingone.APIClient, environmentID uuid.UUID) *Client { return &Client{apiClient: apiClient, environmentID: environmentID} } +// NewWithManagementClient creates a Client from a pre-built DaVinci SDK +// client and a pre-built management SDK client. Used by tests that need to +// inject a fake management.APIClient without performing a real OAuth +// exchange. +func NewWithManagementClient(apiClient *pingone.APIClient, managementClient *management.APIClient, environmentID uuid.UUID) *Client { + return &Client{apiClient: apiClient, managementClient: managementClient, environmentID: environmentID} +} + +// management lazily builds and caches the management SDK API client. The +// management SDK performs an OAuth token exchange as soon as the client is +// built (unlike the DaVinci SDK, which defers auth to the first request), so +// construction is deferred until a management resource handler actually +// needs it. +func (c *Client) management(ctx context.Context) (*management.APIClient, error) { + if c.managementClient != nil { + return c.managementClient, nil + } + if c.managementCfg == nil { + return nil, fmt.Errorf("management API client not configured") + } + client, err := c.managementCfg.ManagementAPIClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to initialize management API client: %w", err) + } + c.managementClient = client + return client, nil +} + // Platform returns the platform identifier. func (c *Client) Platform() string { return "pingone" } @@ -106,12 +138,20 @@ func NewFromCredentials(ctx context.Context, workerEnvID, exportEnvID, region, c return nil, fmt.Errorf("failed to initialize API client: %w", err) } + regionCode := management.EnumRegionCode(region) + managementCfg := &pingonesdkv2.Config{ + ClientID: &clientID, + ClientSecret: &clientSecret, + EnvironmentID: &workerEnvID, + RegionCode: ®ionCode, + } + envUUID, err := uuid.Parse(exportEnvID) if err != nil { return nil, fmt.Errorf("invalid export environment ID format: %w", err) } - return &Client{apiClient: apiClient, environmentID: envUUID}, nil + return &Client{apiClient: apiClient, managementCfg: managementCfg, environmentID: envUUID}, nil } // ValidRegions returns the list of valid PingOne region codes. diff --git a/internal/platform/pingone/client_test.go b/internal/platform/pingone/client_test.go index b3241cf..6c1fe14 100644 --- a/internal/platform/pingone/client_test.go +++ b/internal/platform/pingone/client_test.go @@ -30,8 +30,12 @@ func TestNewClient(t *testing.T) { func TestNewFromCredentials(t *testing.T) { ctx := context.Background() - // A valid UUID to use as exportEnvID in success cases. + // Valid UUIDs — real PingOne client/environment IDs are UUIDs, and the + // management SDK's Config.Validate() rejects non-UUID-shaped values. validExportEnvID := "00000000-0000-0000-0000-000000000001" + validWorkerEnvID := "00000000-0000-0000-0000-000000000010" + validClientID := "00000000-0000-0000-0000-000000000020" + validClientSecret := "secret-123" tests := []struct { name string @@ -48,96 +52,96 @@ func TestNewFromCredentials(t *testing.T) { workerEnvID: "", exportEnvID: validExportEnvID, region: "NA", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: true, errorContains: "auth environment ID is required", }, { name: "missing target environment ID", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: "", region: "NA", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: true, errorContains: "target environment ID is required", }, { name: "missing region", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: validExportEnvID, region: "", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: true, errorContains: "region is required", }, { name: "invalid region", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: validExportEnvID, region: "XX", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: true, errorContains: "invalid region: XX", }, { name: "missing client ID", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: validExportEnvID, region: "NA", clientID: "", - clientSecret: "secret-123", + clientSecret: validClientSecret, expectError: true, errorContains: "client ID is required", }, { name: "missing client secret", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: validExportEnvID, region: "NA", - clientID: "client-123", + clientID: validClientID, clientSecret: "", expectError: true, errorContains: "client secret is required", }, { name: "invalid export environment ID format", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: "not-a-uuid", region: "NA", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: true, errorContains: "invalid export environment ID format", }, { name: "valid credentials", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: validExportEnvID, region: "NA", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: false, }, { name: "valid credentials AU region", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: "00000000-0000-0000-0000-000000000002", region: "AU", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: false, }, { name: "valid credentials SG region", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: "00000000-0000-0000-0000-000000000003", region: "SG", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: false, }, } diff --git a/internal/platform/pingone/dispatch_test.go b/internal/platform/pingone/dispatch_test.go index 8bdf6f5..da1565b 100644 --- a/internal/platform/pingone/dispatch_test.go +++ b/internal/platform/pingone/dispatch_test.go @@ -21,6 +21,7 @@ func TestSupportedResourceTypes(t *testing.T) { "pingone_davinci_flow_enable", "pingone_davinci_variable", "pingone_environment", + "pingone_group", } assert.Equal(t, expected, SupportedResourceTypes()) } diff --git a/internal/platform/pingone/resource_group.go b/internal/platform/pingone/resource_group.go new file mode 100644 index 0000000..5a0a488 --- /dev/null +++ b/internal/platform/pingone/resource_group.go @@ -0,0 +1,59 @@ +package pingone + +import ( + "context" + "fmt" +) + +func init() { + // API client dispatch. + registerResource("pingone_group", resourceHandler{ + list: listGroups, + get: getGroup, + }) +} + +// listGroups lists all groups in the environment using the management SDK's +// paginated iterator over ReadAllGroups. +func listGroups(ctx context.Context, c *Client, _ string) ([]interface{}, error) { + mgmt, err := c.management(ctx) + if err != nil { + return nil, err + } + + var result []interface{} + iterator := mgmt.GroupsApi.ReadAllGroups(ctx, c.environmentID.String()).Execute() + for cursor, err := range iterator { + if err != nil { + return nil, fmt.Errorf("list groups: %w", err) + } + if cursor.EntityArray == nil { + continue + } + embedded, ok := cursor.EntityArray.GetEmbeddedOk() + if !ok || embedded == nil { + continue + } + groups, ok := embedded.GetGroupsOk() + if !ok { + continue + } + for i := range groups { + result = append(result, &groups[i]) + } + } + return result, nil +} + +// getGroup retrieves a single group by ID using the management SDK. +func getGroup(ctx context.Context, c *Client, _ string, resourceID string) (interface{}, error) { + mgmt, err := c.management(ctx) + if err != nil { + return nil, err + } + group, _, err := mgmt.GroupsApi.ReadOneGroup(ctx, c.environmentID.String(), resourceID).Execute() + if err != nil { + return nil, fmt.Errorf("get group: %w", err) + } + return group, nil +} diff --git a/internal/platform/pingone/resource_group_test.go b/internal/platform/pingone/resource_group_test.go new file mode 100644 index 0000000..d73ecdf --- /dev/null +++ b/internal/platform/pingone/resource_group_test.go @@ -0,0 +1,207 @@ +package pingone + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/patrickcping/pingone-go-sdk-v2/management" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGroupResourceRegistered verifies the group handler is in the dispatch table. +func TestGroupResourceRegistered(t *testing.T) { + assert.True(t, isSupported("pingone_group")) +} + +// TestGroupResourceHandlerFunctions verifies list and get functions are set. +func TestGroupResourceHandlerFunctions(t *testing.T) { + h, ok := resourceHandlers["pingone_group"] + assert.True(t, ok) + assert.NotNil(t, h.list) + assert.NotNil(t, h.get) +} + +// newTestManagementClient builds a management.APIClient pointed at the given +// httptest.Server so listGroups/getGroup can be exercised without a real +// OAuth exchange or network call. +func newTestManagementClient(serverURL string) *management.APIClient { + cfg := management.NewConfiguration() + cfg.Servers = management.ServerConfigurations{ + {URL: serverURL, Variables: map[string]management.ServerVariable{}}, + } + return management.NewAPIClient(cfg) +} + +func TestListGroups(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + wantErr string + wantGroupIDs []string + }{ + { + name: "single page of groups", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "_embedded": map[string]interface{}{ + "groups": []map[string]interface{}{ + {"id": "grp-1", "name": "Group One"}, + {"id": "grp-2", "name": "Group Two"}, + }, + }, + }) + }, + wantGroupIDs: []string{"grp-1", "grp-2"}, + }, + { + name: "empty embedded returns nil result", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{}) + }, + wantGroupIDs: nil, + }, + { + name: "embedded present but no groups key", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "_embedded": map[string]interface{}{ + "populations": []map[string]interface{}{ + {"id": "pop-1"}, + }, + }, + }) + }, + wantGroupIDs: nil, + }, + { + // 400 is not in the SDK's retryable status code list (unlike 5xx/429), + // so this fails fast instead of exercising the exponential backoff loop. + name: "client error surfaces as list error", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"boom"}`)) + }, + wantErr: "list groups", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(tt.handler) + defer srv.Close() + + c := NewWithManagementClient(nil, newTestManagementClient(srv.URL), uuid.New()) + + result, err := listGroups(context.Background(), c, "") + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + + var gotIDs []string + for _, item := range result { + grp, ok := item.(*management.Group) + require.True(t, ok, "expected *management.Group, got %T", item) + gotIDs = append(gotIDs, grp.GetId()) + } + assert.Equal(t, tt.wantGroupIDs, gotIDs) + }) + } +} + +func TestGetGroup(t *testing.T) { + tests := []struct { + name string + resourceID string + handler http.HandlerFunc + wantErr string + wantName string + }{ + { + name: "successful get", + resourceID: "grp-1", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "grp-1", + "name": "Group One", + }) + }, + wantName: "Group One", + }, + { + // 404 is not in the SDK's retryable status code list, so this + // fails fast instead of exercising the exponential backoff loop. + name: "not found returns error", + resourceID: "missing", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + }, + wantErr: "get group", + }, + { + name: "empty resource ID still dispatches to API", + resourceID: "", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "", + "name": "", + }) + }, + wantName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(tt.handler) + defer srv.Close() + + c := NewWithManagementClient(nil, newTestManagementClient(srv.URL), uuid.New()) + + result, err := getGroup(context.Background(), c, "", tt.resourceID) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + + grp, ok := result.(*management.Group) + require.True(t, ok, "expected *management.Group, got %T", result) + assert.Equal(t, tt.wantName, grp.GetName()) + }) + } +} + +func TestListGroups_ManagementClientError(t *testing.T) { + // A Client with neither managementClient nor managementCfg set should + // surface the "not configured" error from c.management(), not panic. + c := &Client{environmentID: uuid.New()} + + _, err := listGroups(context.Background(), c, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "management API client not configured") +} + +func TestGetGroup_ManagementClientError(t *testing.T) { + c := &Client{environmentID: uuid.New()} + + _, err := getGroup(context.Background(), c, "", "grp-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "management API client not configured") +} diff --git a/internal/schema/integration_test.go b/internal/schema/integration_test.go index e3b5282..0f11a2f 100644 --- a/internal/schema/integration_test.go +++ b/internal/schema/integration_test.go @@ -73,8 +73,8 @@ func TestRegistryLoadPlatform(t *testing.T) { err := registry.LoadPlatform("../../definitions", "pingone") require.NoError(t, err) - // Should have loaded all definitions (7 enabled ones, 1 environment is disabled) - assert.Equal(t, 7, registry.Count()) + // Should have loaded all definitions (8 enabled ones, 1 environment is disabled) + assert.Equal(t, 8, registry.Count()) // Get the variable definition def, err := registry.Get("pingone_davinci_variable") @@ -89,8 +89,8 @@ func TestRegistryLoadFromFS(t *testing.T) { err := registry.LoadFromFS(definitions.FS, "pingone") require.NoError(t, err) - // Should have loaded all definitions (7 enabled ones, 1 environment is disabled). - assert.Equal(t, 7, registry.Count()) + // Should have loaded all definitions (8 enabled ones, 1 environment is disabled). + assert.Equal(t, 8, registry.Count()) // Verify a specific definition loaded correctly. def, err := registry.Get("pingone_davinci_variable") @@ -105,7 +105,7 @@ func TestLoaderLoadFromFS(t *testing.T) { // Load from embedded FS. defs, err := loader.LoadFromFS(definitions.FS, "pingone") require.NoError(t, err) - assert.Len(t, defs, 7) + assert.Len(t, defs, 8) // Verify each definition has valid metadata. for _, def := range defs {