diff --git a/.changelog/pr-136.txt b/.changelog/pr-136.txt new file mode 100644 index 0000000..1e7c002 --- /dev/null +++ b/.changelog/pr-136.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +`resource/pingone_resource`: 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..7f8543c 100644 --- a/contributing/RESOURCE_COVERAGE.md +++ b/contributing/RESOURCE_COVERAGE.md @@ -73,7 +73,7 @@ Legend: ✅ Supported · ❌ Not yet supported | `pingone_population` | ❌ | | `pingone_population_default` | ❌ | | `pingone_population_default_identity_provider` | ❌ | -| `pingone_resource` | ❌ | +| `pingone_resource` | ✅ | | `pingone_resource_attribute` | ❌ | | `pingone_resource_scope` | ❌ | | `pingone_resource_scope_openid` | ❌ | 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/resource.yaml b/definitions/pingone/sso/resource.yaml new file mode 100644 index 0000000..9db4010 --- /dev/null +++ b/definitions/pingone/sso/resource.yaml @@ -0,0 +1,97 @@ +# PingOne Resource (OAuth 2.0 custom resource) Definition + +metadata: + platform: pingone + resource_type: pingone_resource + api_type: Resource + name: PingOne Resource + short_name: resource + version: "1.0" + +api: + sdk_package: github.com/patrickcping/pingone-go-sdk-v2/management + sdk_type: Resource + list_method: ResourcesApi.ReadAllResources + get_method: ResourcesApi.ReadOneResource + id_field: id + name_field: 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 + - 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 + + # Type - computed only (only CUSTOM can be created via Terraform) + - name: Type + terraform_name: type + type: string + source_path: Type + computed: true + + # Audience - optional string, defaults to resource name if unset + - name: Audience + terraform_name: audience + type: string + source_path: Audience + + # AccessTokenValiditySeconds - optional number, API default 3600 + - name: AccessTokenValiditySeconds + terraform_name: access_token_validity_seconds + type: number + source_path: AccessTokenValiditySeconds + + # IntrospectEndpointAuthMethod - optional string, API default CLIENT_SECRET_BASIC + - name: IntrospectEndpointAuthMethod + terraform_name: introspect_endpoint_auth_method + type: string + source_path: IntrospectEndpointAuthMethod + + # ApplicationPermissionsSettings - optional nested object + - name: ApplicationPermissionsSettings + terraform_name: application_permissions_settings + type: object + source_path: ApplicationPermissionsSettings + nested_attributes: + - name: ClaimEnabled + terraform_name: claim_enabled + type: bool + source_path: ClaimEnabled + required: true + +dependencies: + depends_on: + - resource_type: pingone_environment + field_path: Environment.Id + lookup_field: id + reference_format: "var.pingone_environment_id" + + 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..ceb87a1 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_resource", } assert.Equal(t, expected, SupportedResourceTypes()) } diff --git a/internal/platform/pingone/resource_resource.go b/internal/platform/pingone/resource_resource.go new file mode 100644 index 0000000..2b2325b --- /dev/null +++ b/internal/platform/pingone/resource_resource.go @@ -0,0 +1,75 @@ +package pingone + +import ( + "context" + "fmt" + + "github.com/patrickcping/pingone-go-sdk-v2/management" +) + +func init() { + registerResource("pingone_resource", resourceHandler{ + list: listResources, + get: getResource, + }) +} + +// listResources lists all OAuth 2.0 custom resources in the environment. +// The API also returns the two built-in resources (OPENID_CONNECT and +// PINGONE_API) that exist in every environment by default — these are not +// manageable via the pingone_resource Terraform resource (only CUSTOM +// resources can be created), so they are filtered out here, mirroring the +// precedent set by listConnectorInstances skipping the built-in User Pool +// connector. +func listResources(ctx context.Context, c *Client, _ string) ([]interface{}, error) { + mgmt, err := c.management(ctx) + if err != nil { + return nil, err + } + + var result []interface{} + iterator := mgmt.ResourcesApi.ReadAllResources(ctx, c.environmentID.String()).Execute() + for cursor, err := range iterator { + if err != nil { + return nil, fmt.Errorf("list resources: %w", err) + } + embedded, ok := cursor.EntityArray.GetEmbeddedOk() + if !ok || embedded == nil { + continue + } + resources, ok := embedded.GetResourcesOk() + if !ok { + continue + } + for i := range resources { + inner := resources[i] + res, ok := inner.GetActualInstance().(*management.Resource) + if !ok || res == nil { + // Skip the ResourceApplicationResource union variant — that + // shape represents a different embedded resource type, not + // a top-level pingone_resource. + continue + } + if resType, ok := res.GetTypeOk(); !ok || resType == nil || *resType != management.ENUMRESOURCETYPE_CUSTOM { + // Skip built-in resources (OPENID_CONNECT, PINGONE_API) — + // not manageable via the pingone_resource Terraform resource. + continue + } + result = append(result, res) + } + } + return result, nil +} + +// getResource retrieves a single OAuth 2.0 custom resource by ID. +func getResource(ctx context.Context, c *Client, _ string, resourceID string) (interface{}, error) { + mgmt, err := c.management(ctx) + if err != nil { + return nil, err + } + res, _, err := mgmt.ResourcesApi.ReadOneResource(ctx, c.environmentID.String(), resourceID).Execute() + if err != nil { + return nil, fmt.Errorf("get resource: %w", err) + } + return res, nil +} diff --git a/internal/platform/pingone/resource_resource_test.go b/internal/platform/pingone/resource_resource_test.go new file mode 100644 index 0000000..7b01101 --- /dev/null +++ b/internal/platform/pingone/resource_resource_test.go @@ -0,0 +1,201 @@ +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" +) + +// newTestManagementClient builds a management.APIClient pointed at a test +// server, bypassing the real OAuth exchange. All requests to it carry a +// fake bearer token via management.ContextAccessToken. +func newTestManagementClient(serverURL string) *management.APIClient { + cfg := management.NewConfiguration() + cfg.Servers = management.ServerConfigurations{ + {URL: serverURL}, + } + return management.NewAPIClient(cfg) +} + +func testCtx() context.Context { + return context.WithValue(context.Background(), management.ContextAccessToken, "test-token") +} + +func TestListResources(t *testing.T) { + tests := []struct { + name string + responseBody map[string]any + responseCode int + wantIDs []string + wantErr string + }{ + { + name: "only CUSTOM resources returned, built-ins filtered out", + responseBody: map[string]any{ + "_embedded": map[string]any{ + "resources": []any{ + map[string]any{"id": "res-custom-1", "name": "Custom One", "type": "CUSTOM"}, + map[string]any{"id": "res-oidc", "name": "openid", "type": "OPENID_CONNECT"}, + map[string]any{"id": "res-pingone-api", "name": "PingOne API", "type": "PINGONE_API"}, + map[string]any{"id": "res-custom-2", "name": "Custom Two", "type": "CUSTOM"}, + }, + }, + }, + responseCode: http.StatusOK, + wantIDs: []string{"res-custom-1", "res-custom-2"}, + }, + { + name: "no embedded field returns empty", + responseBody: map[string]any{ + "count": 0, + }, + responseCode: http.StatusOK, + wantIDs: nil, + }, + { + name: "empty resources list returns empty", + responseBody: map[string]any{ + "_embedded": map[string]any{ + "resources": []any{}, + }, + }, + responseCode: http.StatusOK, + wantIDs: nil, + }, + { + name: "resource missing type field is skipped", + responseBody: map[string]any{ + "_embedded": map[string]any{ + "resources": []any{ + map[string]any{"id": "res-no-type", "name": "No Type"}, + }, + }, + }, + responseCode: http.StatusOK, + wantIDs: nil, + }, + { + // 400 is used (not 500/503/429) because those status codes + // trigger the management SDK's built-in exponential-backoff + // retry loop (up to 10 attempts, tens of seconds), which would + // make this test extremely slow. + name: "400 error returns wrapped error", + responseBody: map[string]any{"message": "bad request"}, + responseCode: http.StatusBadRequest, + wantErr: "list resources", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.responseCode) + _ = json.NewEncoder(w).Encode(tt.responseBody) + })) + defer srv.Close() + + mgmt := newTestManagementClient(srv.URL) + c := NewWithManagementClient(nil, mgmt, uuid.New()) + + result, err := listResources(testCtx(), 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 { + res, ok := item.(*management.Resource) + require.True(t, ok, "expected *management.Resource, got %T", item) + gotIDs = append(gotIDs, res.GetId()) + } + assert.Equal(t, tt.wantIDs, gotIDs) + }) + } +} + +func TestListResources_ManagementClientUnavailable(t *testing.T) { + c := &Client{} + result, err := listResources(testCtx(), c, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "management API client not configured") + assert.Nil(t, result) +} + +func TestGetResource(t *testing.T) { + tests := []struct { + name string + resourceID string + responseBody map[string]any + responseCode int + wantName string + wantErr string + }{ + { + name: "valid resource returned", + resourceID: "res-custom-1", + responseBody: map[string]any{ + "id": "res-custom-1", + "name": "Custom One", + "type": "CUSTOM", + }, + responseCode: http.StatusOK, + wantName: "Custom One", + }, + { + // 404 is not in the SDK's retryable-status list, so this fails + // fast rather than triggering the backoff-retry loop. + name: "404 not found returns wrapped error", + resourceID: "missing", + responseBody: map[string]any{"message": "not found"}, + responseCode: http.StatusNotFound, + wantErr: "get resource", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.responseCode) + _ = json.NewEncoder(w).Encode(tt.responseBody) + })) + defer srv.Close() + + mgmt := newTestManagementClient(srv.URL) + c := NewWithManagementClient(nil, mgmt, uuid.New()) + + result, err := getResource(testCtx(), c, "", tt.resourceID) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + res, ok := result.(*management.Resource) + require.True(t, ok) + assert.Equal(t, tt.wantName, res.GetName()) + }) + } +} + +func TestGetResource_ManagementClientUnavailable(t *testing.T) { + c := &Client{} + result, err := getResource(testCtx(), c, "", "some-id") + 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 {