diff --git a/.changelog/pr-135.txt b/.changelog/pr-135.txt new file mode 100644 index 0000000..8402bc4 --- /dev/null +++ b/.changelog/pr-135.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +`resource/pingone_population`: Added export support. +``` 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..d1a1944 100644 --- a/contributing/RESOURCE_COVERAGE.md +++ b/contributing/RESOURCE_COVERAGE.md @@ -70,7 +70,7 @@ Legend: ✅ Supported · ❌ Not yet supported | `pingone_identity_provider` | ❌ | | `pingone_identity_provider_attribute` | ❌ | | `pingone_password_policy` | ❌ | -| `pingone_population` | ❌ | +| `pingone_population` | ✅ | | `pingone_population_default` | ❌ | | `pingone_population_default_identity_provider` | ❌ | | `pingone_resource` | ❌ | 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/population.yaml b/definitions/pingone/sso/population.yaml new file mode 100644 index 0000000..5bdb2ae --- /dev/null +++ b/definitions/pingone/sso/population.yaml @@ -0,0 +1,120 @@ +# PingOne Population Resource Definition + +metadata: + platform: pingone + resource_type: pingone_population + api_type: Population + name: PingOne Population + short_name: population + version: "1.0" + +api: + sdk_package: github.com/patrickcping/pingone-go-sdk-v2/management + sdk_type: Population + list_method: PopulationsApi.ReadAllPopulations + get_method: PopulationsApi.ReadOnePopulation + id_field: id + name_field: name + pagination_type: cursor + +attributes: + # ID - computed only (resource identifier, not written to HCL as an argument) + - name: ID + terraform_name: id + type: string + source_path: Id + computed: true + + # Environment ID - required, references environment + - name: EnvironmentID + terraform_name: environment_id + type: string + source_path: Environment.Id + 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 + + # AlternativeIdentifiers - optional set of strings + - name: AlternativeIdentifiers + terraform_name: alternative_identifiers + type: set + source_path: AlternativeIdentifiers + + # PreferredLanguage - optional string + - name: PreferredLanguage + terraform_name: preferred_language + type: string + source_path: PreferredLanguage + + # PasswordPolicy - optional nested object. References a password policy + # resource. pingone_password_policy is not yet exported by this tool (no + # YAML definition/handler exists), so the reference resolver falls back to + # a Terraform variable (var.pingone_password_policy_id) until that resource + # type is added, at which point resolution is automatically promoted to a + # resource reference with no changes needed here. + - name: PasswordPolicy + terraform_name: password_policy + type: object + source_path: PasswordPolicy + nested_attributes: + - name: ID + terraform_name: id + type: string + source_path: Id + required: true + references_type: pingone_password_policy + reference_field: id + + # Theme - optional nested object. References a branding theme resource. + # pingone_branding_theme is not yet exported by this tool either (see + # resource_flow.go's identical fallback note for the DaVinci showForm theme + # reference), so this also falls back to a Terraform variable until that + # resource type is added. + - name: Theme + terraform_name: theme + type: object + source_path: Theme + nested_attributes: + - name: ID + terraform_name: id + type: string + source_path: Id + references_type: pingone_branding_theme + reference_field: id + +dependencies: + depends_on: + - resource_type: pingone_environment + field_path: Environment.Id + lookup_field: id + reference_format: "var.pingone_environment_id" + + - resource_type: pingone_password_policy + field_path: PasswordPolicy.ID + lookup_field: id + reference_format: "pingone_password_policy.{resource_name}.id" + optional: true + + - resource_type: pingone_branding_theme + field_path: Theme.ID + lookup_field: id + reference_format: "pingone_branding_theme.{resource_name}.id" + optional: true + + 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..dced31a 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_population", } assert.Equal(t, expected, SupportedResourceTypes()) } diff --git a/internal/platform/pingone/resource_population.go b/internal/platform/pingone/resource_population.go new file mode 100644 index 0000000..162532b --- /dev/null +++ b/internal/platform/pingone/resource_population.go @@ -0,0 +1,58 @@ +package pingone + +import ( + "context" + "fmt" +) + +func init() { + // API client dispatch. + registerResource("pingone_population", resourceHandler{ + list: listPopulations, + get: getPopulation, + }) +} + +// listPopulations lists all populations in the environment using the +// management SDK's paginated iterator. Unlike the DaVinci SDK's cursor +// pattern (pageCursor.Data.Embedded), the management SDK exposes the page's +// entity array embedded struct via cursor.EntityArray.GetEmbeddedOk(). +func listPopulations(ctx context.Context, c *Client, _ string) ([]interface{}, error) { + mgmt, err := c.management(ctx) + if err != nil { + return nil, err + } + + var result []interface{} + iterator := mgmt.PopulationsApi.ReadAllPopulations(ctx, c.environmentID.String()).Execute() + for cursor, err := range iterator { + if err != nil { + return nil, fmt.Errorf("list populations: %w", err) + } + embedded, ok := cursor.EntityArray.GetEmbeddedOk() + if !ok || embedded == nil { + continue + } + populations, ok := embedded.GetPopulationsOk() + if !ok { + continue + } + for i := range populations { + result = append(result, &populations[i]) + } + } + return result, nil +} + +// getPopulation retrieves a single population by ID via the management SDK. +func getPopulation(ctx context.Context, c *Client, _ string, resourceID string) (interface{}, error) { + mgmt, err := c.management(ctx) + if err != nil { + return nil, err + } + population, _, err := mgmt.PopulationsApi.ReadOnePopulation(ctx, c.environmentID.String(), resourceID).Execute() + if err != nil { + return nil, fmt.Errorf("get population: %w", err) + } + return population, nil +} diff --git a/internal/platform/pingone/resource_population_test.go b/internal/platform/pingone/resource_population_test.go new file mode 100644 index 0000000..5251bae --- /dev/null +++ b/internal/platform/pingone/resource_population_test.go @@ -0,0 +1,217 @@ +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" +) + +// ── dispatch registration ──────────────────────────────────────── + +func TestPopulationResourceRegistered(t *testing.T) { + assert.True(t, isSupported("pingone_population")) +} + +func TestPopulationResourceHandlerFunctions(t *testing.T) { + h, ok := resourceHandlers["pingone_population"] + require.True(t, ok) + assert.NotNil(t, h.list) + assert.NotNil(t, h.get) +} + +// ── test helpers ───────────────────────────────────────────────── + +// newTestManagementClient builds a management.APIClient whose requests are +// routed to the given httptest.Server, and a Client wired to use it via +// NewWithManagementClient (avoiding a real OAuth exchange). +func newTestManagementClient(t *testing.T, srv *httptest.Server, envID uuid.UUID) *Client { + t.Helper() + cfg := management.NewConfiguration() + cfg.Servers = management.ServerConfigurations{ + {URL: srv.URL}, + } + cfg.HTTPClient = srv.Client() + mgmtClient := management.NewAPIClient(cfg) + return NewWithManagementClient(nil, mgmtClient, envID) +} + +// ── listPopulations ────────────────────────────────────────────── + +func TestListPopulations_Success(t *testing.T) { + envID := uuid.New() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + 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", "name": "Population One"}, + {"id": "pop-2", "name": "Population Two"}, + }, + }, + }) + })) + defer srv.Close() + + c := newTestManagementClient(t, srv, envID) + + result, err := listPopulations(context.Background(), c, "") + require.NoError(t, err) + require.Len(t, result, 2) + + pop0, ok := result[0].(*management.Population) + require.True(t, ok) + assert.Equal(t, "pop-1", pop0.GetId()) + assert.Equal(t, "Population One", pop0.GetName()) + + pop1, ok := result[1].(*management.Population) + require.True(t, ok) + assert.Equal(t, "pop-2", pop1.GetId()) +} + +func TestListPopulations_Empty(t *testing.T) { + envID := uuid.New() + + srv := httptest.NewServer(http.HandlerFunc(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{}{}, + }, + }) + })) + defer srv.Close() + + c := newTestManagementClient(t, srv, envID) + + result, err := listPopulations(context.Background(), c, "") + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestListPopulations_NoEmbedded(t *testing.T) { + envID := uuid.New() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{}) + })) + defer srv.Close() + + c := newTestManagementClient(t, srv, envID) + + result, err := listPopulations(context.Background(), c, "") + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestListPopulations_APIError(t *testing.T) { + envID := uuid.New() + + // Use 400 (not 429/500) — the SDK retries those with backoff up to + // maxRetries times, which would make this test slow/hang-prone. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"message": "bad request"}) + })) + defer srv.Close() + + c := newTestManagementClient(t, srv, envID) + + result, err := listPopulations(context.Background(), c, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "list populations") + assert.Nil(t, result) +} + +func TestListPopulations_ManagementClientError(t *testing.T) { + // A Client with neither managementClient nor managementCfg configured + // causes c.management(ctx) to fail before any HTTP call is attempted. + c := &Client{environmentID: uuid.New()} + + result, err := listPopulations(context.Background(), c, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "management API client not configured") + assert.Nil(t, result) +} + +// ── getPopulation ──────────────────────────────────────────────── + +func TestGetPopulation_Success(t *testing.T) { + envID := uuid.New() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "pop-1", + "name": "Population One", + "description": "A test population", + }) + })) + defer srv.Close() + + c := newTestManagementClient(t, srv, envID) + + result, err := getPopulation(context.Background(), c, "", "pop-1") + require.NoError(t, err) + + pop, ok := result.(*management.Population) + require.True(t, ok) + assert.Equal(t, "pop-1", pop.GetId()) + assert.Equal(t, "Population One", pop.GetName()) + assert.Equal(t, "A test population", pop.GetDescription()) +} + +func TestGetPopulation_NotFound(t *testing.T) { + envID := uuid.New() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"message": "not found"}) + })) + defer srv.Close() + + c := newTestManagementClient(t, srv, envID) + + result, err := getPopulation(context.Background(), c, "", "missing-id") + require.Error(t, err) + assert.Contains(t, err.Error(), "get population") + assert.Nil(t, result) +} + +func TestGetPopulation_EmptyResourceID(t *testing.T) { + envID := uuid.New() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"message": "not found"}) + })) + defer srv.Close() + + c := newTestManagementClient(t, srv, envID) + + result, err := getPopulation(context.Background(), c, "", "") + require.Error(t, err) + assert.Nil(t, result) +} + +func TestGetPopulation_ManagementClientError(t *testing.T) { + c := &Client{environmentID: uuid.New()} + + result, err := getPopulation(context.Background(), c, "", "pop-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "management API client not configured") + assert.Nil(t, result) +} 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 {