From 4364675daa1d421df3b2bc2190055388dd87c937 Mon Sep 17 00:00:00 2001 From: Siddhu Warrier Date: Tue, 3 Dec 2024 17:26:17 +0000 Subject: [PATCH] feat(lh-86970): add Terraform resource to add an existing tenant to MSP portal using API token (#153) * feat(lh-86970): add Terraform resource to add an existing tenant to MSP portal using API token * fix(lh-86970): address Tal's comments and improve documentation * test(lh-86970): add more tests, make acceptance tests run temporarily on branch * revert this before merging * fix(lh-86970): add ADDED_MSP_MANAGED_TENANT_API_TOKEN to the env for acceptance tests * I hate Github * Stop running acceptance tests on branch --- .github/workflows/ci.yml | 1 + client/client.go | 4 + client/internal/url/url.go | 4 + client/msp/tenants/add.go | 23 +++++ client/msp/tenants/add_test.go | 66 +++++++++++++++ client/msp/tenants/constants_test.go | 5 ++ client/msp/tenants/create_test.go | 6 +- client/msp/tenants/models.go | 9 ++ docs/resources/msp_managed_tenant.md | 8 +- provider/.github-action.env | 3 + .../resources/msp/tenants/api_token.txt | 2 +- .../examples/resources/msp/tenants/main.tf | 4 + .../resources/msp/tenants/providers.tf | 4 +- provider/internal/acctest/environment.go | 20 +++++ provider/internal/msp/msp_tenant/models.go | 1 + provider/internal/msp/msp_tenant/resource.go | 50 ++++++++--- .../internal/msp/msp_tenant/resource_test.go | 48 +++++++++++ provider/validators/msp_tenant_name.go | 58 +++++++++++++ provider/validators/msp_tenant_name_test.go | 84 +++++++++++++++++++ 19 files changed, 376 insertions(+), 24 deletions(-) create mode 100644 client/msp/tenants/add.go create mode 100644 client/msp/tenants/add_test.go create mode 100644 client/msp/tenants/constants_test.go create mode 100644 provider/internal/msp/msp_tenant/resource_test.go create mode 100644 provider/validators/msp_tenant_name.go create mode 100644 provider/validators/msp_tenant_name_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88fe8ca..e4cb83c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,7 @@ jobs: ASA_RESOURCE_SDC_PASSWORD: ${{ secrets.ASA_RESOURCE_SDC_PASSWORD }} DUO_ADMIN_PANEL_RESOURCE_INTEGRATION_KEY: ${{ secrets.DUO_ADMIN_PANEL_RESOURCE_INTEGRATION_KEY }} DUO_ADMIN_PANEL_RESOURCE_SECRET_KEY: ${{ secrets.DUO_ADMIN_PANEL_RESOURCE_SECRET_KEY }} + ADDED_MSP_MANAGED_TENANT_API_TOKEN: ${{ secrets.ADDED_MSP_MANAGED_TENANT_API_TOKEN }} run: go test -v -cover -p 1 -run "TestAcc.*" ./... timeout-minutes: 10 tag-release-version: diff --git a/client/client.go b/client/client.go index 33530a8..223cadb 100644 --- a/client/client.go +++ b/client/client.go @@ -281,6 +281,10 @@ func (c *Client) CreateTenantUsingMspPortal(ctx context.Context, createInput ten return tenants.Create(ctx, c.client, createInput) } +func (c *Client) AddExistingTenantToMspPortalUsingApiToken(ctx context.Context, createInput tenants.MspAddExistingTenantInput) (*tenants.MspTenantOutput, *tenants.CreateError) { + return tenants.AddExistingTenantUsingApiToken(ctx, c.client, createInput) +} + func (c *Client) ReadMspManagedTenantByUid(ctx context.Context, readByUidInput tenants.ReadByUidInput) (*tenants.MspTenantOutput, error) { return tenants.ReadByUid(ctx, c.client, readByUidInput) } diff --git a/client/internal/url/url.go b/client/internal/url/url.go index da49849..e0e8633 100644 --- a/client/internal/url/url.go +++ b/client/internal/url/url.go @@ -214,6 +214,10 @@ func CreateMspManagedTenant(baseUrl string) string { return fmt.Sprintf("%s/api/rest/v1/msp/tenants/create", baseUrl) } +func AddExistingTenantToMspManagedTenant(baseUrl string) string { + return fmt.Sprintf("%s/api/rest/v1/msp/tenants", baseUrl) +} + func MspManagedTenantByUid(baseUrl string, tenantUid string) string { return fmt.Sprintf("%s/api/rest/v1/msp/tenants/%s", baseUrl, tenantUid) } diff --git a/client/msp/tenants/add.go b/client/msp/tenants/add.go new file mode 100644 index 0000000..7cdb0ef --- /dev/null +++ b/client/msp/tenants/add.go @@ -0,0 +1,23 @@ +package tenants + +import ( + "context" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/http" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/url" +) + +func AddExistingTenantUsingApiToken(ctx context.Context, client http.Client, addInp MspAddExistingTenantInput) (*MspTenantOutput, *CreateError) { + client.Logger.Println("Adding existing tenant to MSp portal...") + addUrl := url.AddExistingTenantToMspManagedTenant(client.BaseUrl()) + + req := client.NewPost(ctx, addUrl, addInp) + + var createOutp MspManagedTenantStatusInfo + if err := req.Send(&createOutp); err != nil { + return nil, &CreateError{Err: err} + } + + client.Logger.Printf("Added existing tenant %s to MSP portal using API token...", createOutp.MspManagedTenant.Name) + + return &createOutp.MspManagedTenant, nil +} diff --git a/client/msp/tenants/add_test.go b/client/msp/tenants/add_test.go new file mode 100644 index 0000000..84683e1 --- /dev/null +++ b/client/msp/tenants/add_test.go @@ -0,0 +1,66 @@ +package tenants_test + +import ( + "context" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/http" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/msp/tenants" + "github.com/google/uuid" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + netHttp "net/http" + "testing" + "time" +) + +func TestAdd(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + t.Run("successfully add tenant using API token", func(t *testing.T) { + httpmock.Reset() + apiToken := "fake-jwt-token" + addInp := tenants.MspAddExistingTenantInput{ + ApiToken: apiToken, + } + expectedResponse := tenants.MspManagedTenantStatusInfo{ + Status: "OK", + MspManagedTenant: tenants.MspTenantOutput{ + Uid: uuid.New().String(), + Name: "example-name", + DisplayName: "Human readable name", + Region: "STAGING", + }, + } + + httpmock.RegisterResponder( + netHttp.MethodPost, + "/api/rest/v1/msp/tenants", + httpmock.NewJsonResponderOrPanic(201, expectedResponse), + ) + + actual, err := tenants.AddExistingTenantUsingApiToken(context.Background(), *http.MustNewWithConfig(baseUrl, "valid_token", 0, 0, time.Minute), addInp) + + assert.NotNil(t, actual, "Response for added tenant should have not been nil") + assert.Nil(t, err, "Add tenant operation should have not been an error") + assert.Equal(t, expectedResponse.MspManagedTenant, *actual, "Add tenant operation should have returned the value of the added tenant") + }) + + t.Run("fail to add tenant using API token", func(t *testing.T) { + httpmock.Reset() + apiToken := "fake-jwt-token" + addInp := tenants.MspAddExistingTenantInput{ + ApiToken: apiToken, + } + + httpmock.RegisterResponder( + netHttp.MethodPost, + "/api/rest/v1/msp/tenants", + httpmock.NewJsonResponderOrPanic(400, nil), + ) + + actual, err := tenants.AddExistingTenantUsingApiToken(context.Background(), *http.MustNewWithConfig(baseUrl, "valid_token", 0, 0, time.Minute), addInp) + assert.Nil(t, actual, "Response for added tenant should have been nil") + assert.NotNil(t, err, "Add tenant operation should have been an error") + }) + +} diff --git a/client/msp/tenants/constants_test.go b/client/msp/tenants/constants_test.go new file mode 100644 index 0000000..a93a0a0 --- /dev/null +++ b/client/msp/tenants/constants_test.go @@ -0,0 +1,5 @@ +package tenants_test + +const ( + baseUrl = "https://unittest.cdo.cisco.com" +) diff --git a/client/msp/tenants/create_test.go b/client/msp/tenants/create_test.go index 4153841..8e9dfcc 100644 --- a/client/msp/tenants/create_test.go +++ b/client/msp/tenants/create_test.go @@ -16,10 +16,6 @@ import ( "time" ) -const ( - baseUrl = "https://unittest.cdo.cisco.com" -) - func TestCreate(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() @@ -80,7 +76,7 @@ func TestCreate(t *testing.T) { assert.NotNil(t, actual, "Created tenant should have not been nil") assert.Nil(t, err, "Created tenant operation should have not been an error") - assert.Equal(t, creationOutput, *actual, "Created tenant operation should have been the same as the created tenant") + assert.Equal(t, creationOutput, *actual, "Create tenant operation should have been the same as the created tenant") }) t.Run("tenant creation transaction fails", func(t *testing.T) { diff --git a/client/msp/tenants/models.go b/client/msp/tenants/models.go index b3a1810..17f031f 100644 --- a/client/msp/tenants/models.go +++ b/client/msp/tenants/models.go @@ -5,6 +5,15 @@ type MspCreateTenantInput struct { DisplayName string `json:"displayName"` } +type MspAddExistingTenantInput struct { + ApiToken string `json:"apiToken"` +} + +type MspManagedTenantStatusInfo struct { + Status string `json:"uid"` + MspManagedTenant MspTenantOutput `json:"mspManagedTenant"` +} + type MspTenantOutput struct { Uid string `json:"uid"` Name string `json:"name"` diff --git a/docs/resources/msp_managed_tenant.md b/docs/resources/msp_managed_tenant.md index 8723bbb..3c3f074 100644 --- a/docs/resources/msp_managed_tenant.md +++ b/docs/resources/msp_managed_tenant.md @@ -15,13 +15,11 @@ Provides an MSP managed tenant resource. This allows MSP managed tenants to be c ## Schema -### Required - -- `name` (String) Name of the tenant - ### Optional -- `display_name` (String) Display name of the tenant. If no display name is specified, the display name will be set to the tenant name. +- `api_token` (String, Sensitive) API token for an API-only user with super-admin privileges on the tenant. This should be specified only when adding an existing tenant to the MSP portal, and should not be provided if a new tenant is being created (i.e., the `name` and/or `display_name` attributes are specified). +- `display_name` (String) Display name of the tenant. If no display name is specified, the display name will be set to the tenant name. This should be specified only if a new tenant is being created, and should not be provided if an existing tenant is being added to the MSP protal (i.e., the `api_token` attribute is specified). +- `name` (String) Name of the tenant. This should be specified only if a new tenant is being created, and should not be provided if an existing tenant is being added to the MSP protal (i.e., the `api_token` attribute is specified). ### Read-Only diff --git a/provider/.github-action.env b/provider/.github-action.env index 4337730..6470898 100644 --- a/provider/.github-action.env +++ b/provider/.github-action.env @@ -66,3 +66,6 @@ MSP_TENANT_DISPLAY_NAME=terraform-provider-cdo MSP_TENANT_REGION=CI MSP_TENANT_ID=ae98d25f-1089-4286-a3c5-505dcb4431a2 TF_LOG=DEBUG +ADDED_MSP_MANAGED_TENANT_DISPLAY_NAME=Added MSP Managed Tenant +ADDED_MSP_MANAGED_TENANT_NAME=CDO_added-msp-managed-tenant__snx85b +ADDED_MSP_MANAGED_TENANT_UID=67cf9b3e-f06c-46e2-83a9-8a98747fa759 \ No newline at end of file diff --git a/provider/examples/resources/msp/tenants/api_token.txt b/provider/examples/resources/msp/tenants/api_token.txt index 6da4508..cb22900 100644 --- a/provider/examples/resources/msp/tenants/api_token.txt +++ b/provider/examples/resources/msp/tenants/api_token.txt @@ -1 +1 @@ -Paste your API token here \ No newline at end of file +add API token here \ No newline at end of file diff --git a/provider/examples/resources/msp/tenants/main.tf b/provider/examples/resources/msp/tenants/main.tf index 244db55..ebd4436 100644 --- a/provider/examples/resources/msp/tenants/main.tf +++ b/provider/examples/resources/msp/tenants/main.tf @@ -1,4 +1,8 @@ resource "cdo_msp_managed_tenant" "tenant" { name = "test-tenant-name" display_name = "Display name for tenant" +} + +resource "cdo_msp_managed_tenant" "existing_tenant" { + api_token = "existing-tenant-api-token" } \ No newline at end of file diff --git a/provider/examples/resources/msp/tenants/providers.tf b/provider/examples/resources/msp/tenants/providers.tf index 92f7cba..e13cff7 100644 --- a/provider/examples/resources/msp/tenants/providers.tf +++ b/provider/examples/resources/msp/tenants/providers.tf @@ -7,6 +7,6 @@ terraform { } provider "cdo" { - base_url = """ + base_url = "" api_token = file("${path.module}/api_token.txt") -} +} \ No newline at end of file diff --git a/provider/internal/acctest/environment.go b/provider/internal/acctest/environment.go index c14e33f..4a46b39 100644 --- a/provider/internal/acctest/environment.go +++ b/provider/internal/acctest/environment.go @@ -328,6 +328,26 @@ func (e *env) MspTenantId() string { return e.mustGetString("MSP_TENANT_ID") } +func (e *env) AddedMspManagedTenantId() string { + return e.mustGetString("ADDED_MSP_MANAGED_TENANT_UID") +} + +func (e *env) AddedMspManagedTenantName() string { + return e.mustGetString("ADDED_MSP_MANAGED_TENANT_NAME") +} + +func (e *env) AddedMspManagedTenantDisplayName() string { + return e.mustGetString("ADDED_MSP_MANAGED_TENANT_DISPLAY_NAME") +} + +func (e *env) AddedMspManagedTenantApiToken() string { + return e.mustGetString("ADDED_MSP_MANAGED_TENANT_API_TOKEN") +} + +func (e *env) MspManagedTenantRegion() string { + return e.mustGetString("ADDED_MSP_MANAGED_TENANT_REGION") +} + func (e *env) MspTenantRegion() string { return e.mustGetString("MSP_TENANT_REGION") } diff --git a/provider/internal/msp/msp_tenant/models.go b/provider/internal/msp/msp_tenant/models.go index dbbca64..876666e 100644 --- a/provider/internal/msp/msp_tenant/models.go +++ b/provider/internal/msp/msp_tenant/models.go @@ -8,6 +8,7 @@ type TenantResourceModel struct { DisplayName types.String `tfsdk:"display_name"` GeneratedName types.String `tfsdk:"generated_name"` Region types.String `tfsdk:"region"` + ApiToken types.String `tfsdk:"api_token"` } type TenantDatasourceModel struct { diff --git a/provider/internal/msp/msp_tenant/resource.go b/provider/internal/msp/msp_tenant/resource.go index 11d61b4..cb4e9a9 100644 --- a/provider/internal/msp/msp_tenant/resource.go +++ b/provider/internal/msp/msp_tenant/resource.go @@ -6,9 +6,11 @@ import ( cdoClient "github.com/CiscoDevnet/terraform-provider-cdo/go-client" "github.com/CiscoDevnet/terraform-provider-cdo/go-client/msp/tenants" "github.com/CiscoDevnet/terraform-provider-cdo/internal/util" + "github.com/CiscoDevnet/terraform-provider-cdo/validators" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" ) @@ -32,18 +34,23 @@ func (*TenantResource) Schema(ctx context.Context, request resource.SchemaReques Computed: true, }, "name": schema.StringAttribute{ - MarkdownDescription: "Name of the tenant", - Required: true, + MarkdownDescription: "Name of the tenant. This should be specified only if a new tenant is being created, and should not be provided if an existing tenant is being added to the MSP protal (i.e., the `api_token` attribute is specified).", + Optional: true, PlanModifiers: []planmodifier.String{ PreventUpdatePlanModifier{}, // Prevent updates to name }, + Validators: []validator.String{ + validators.NewMspManagedTenantNameValidator(), + }, + Computed: true, }, "display_name": schema.StringAttribute{ - MarkdownDescription: "Display name of the tenant. If no display name is specified, the display name will be set to the tenant name.", + MarkdownDescription: "Display name of the tenant. If no display name is specified, the display name will be set to the tenant name. This should be specified only if a new tenant is being created, and should not be provided if an existing tenant is being added to the MSP protal (i.e., the `api_token` attribute is specified).", Optional: true, PlanModifiers: []planmodifier.String{ PreventUpdatePlanModifier{}, // Prevent updates to name }, + Computed: true, }, "generated_name": schema.StringAttribute{ MarkdownDescription: "Actual name of the tenant returned by the API. This auto-generated name will differ from the name entered by the customer.", @@ -53,6 +60,14 @@ func (*TenantResource) Schema(ctx context.Context, request resource.SchemaReques MarkdownDescription: "CDO region in which the tenant is created. This is the same region as the region of the MSP portal.", Computed: true, }, + "api_token": schema.StringAttribute{ + MarkdownDescription: "API token for an API-only user with super-admin privileges on the tenant. This should be specified only when adding an existing tenant to the MSP portal, and should not be provided if a new tenant is being created (i.e., the `name` and/or `display_name` attributes are specified).", + Optional: true, + PlanModifiers: []planmodifier.String{ + PreventUpdatePlanModifier{}, // Prevent updates to api token + }, + Sensitive: true, + }, }, } } @@ -77,7 +92,7 @@ func (t *TenantResource) Configure(ctx context.Context, req resource.ConfigureRe } func (t *TenantResource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) { - tflog.Debug(ctx, "Creating a CDO tenant") + tflog.Debug(ctx, "Creating a CDO tenant/Adding an existing tenant using API token to the MSP portal...") // 1. Read plan data into planData var planData TenantResourceModel @@ -88,18 +103,31 @@ func (t *TenantResource) Create(ctx context.Context, request resource.CreateRequ return } - // 2. use plan data to create tenant and fill up rest of the model - createOut, err := t.client.CreateTenantUsingMspPortal(ctx, tenants.MspCreateTenantInput{ - Name: planData.Name.ValueString(), - DisplayName: planData.DisplayName.ValueString(), - }) - if err != nil { + var createOut *tenants.MspTenantOutput + var err *tenants.CreateError + if !planData.ApiToken.IsNull() { + // add tenant to MSP portal + tflog.Debug(ctx, "Adding existing tenant using API token to MSP portal") + createOut, err = t.client.AddExistingTenantToMspPortalUsingApiToken(ctx, tenants.MspAddExistingTenantInput{ApiToken: planData.ApiToken.ValueString()}) + } else { + tflog.Debug(ctx, "Creating new tenant and adding it to the MSP portal") + // 2. use plan data to create tenant and fill up rest of the model + createOut, err = t.client.CreateTenantUsingMspPortal(ctx, tenants.MspCreateTenantInput{ + Name: planData.Name.ValueString(), + DisplayName: planData.DisplayName.ValueString(), + }) + } + + if err != nil || createOut == nil { response.Diagnostics.AddError("failed to create CDO Tenant", err.Error()) return } planData.Id = types.StringValue(createOut.Uid) - planData.Name = types.StringValue(planData.Name.ValueString()) + // when a new tenant is created, the name is auto-generated, do not set it to planData.Name + if planData.Name.IsNull() || planData.Name.IsUnknown() { + planData.Name = types.StringValue(createOut.Name) + } planData.DisplayName = types.StringValue(createOut.DisplayName) planData.GeneratedName = types.StringValue(createOut.Name) planData.Region = types.StringValue(createOut.Region) diff --git a/provider/internal/msp/msp_tenant/resource_test.go b/provider/internal/msp/msp_tenant/resource_test.go new file mode 100644 index 0000000..0e9be19 --- /dev/null +++ b/provider/internal/msp/msp_tenant/resource_test.go @@ -0,0 +1,48 @@ +package msp_tenant_test + +import ( + "github.com/CiscoDevnet/terraform-provider-cdo/internal/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "strings" + "testing" +) + +var testMspTenantResource = struct { + Name string + DisplayName string + Id string + Region string + ApiToken string +}{ + ApiToken: acctest.Env.AddedMspManagedTenantApiToken(), + Name: acctest.Env.AddedMspManagedTenantName(), + DisplayName: acctest.Env.AddedMspManagedTenantDisplayName(), + Id: acctest.Env.AddedMspManagedTenantId(), + Region: strings.ToUpper(acctest.Env.MspTenantRegion()), +} + +const testMspTenantResourceTemplate = ` +resource "cdo_msp_managed_tenant" "test" { + api_token = "{{.ApiToken}}" +}` + +var testMspTenantResourceConfig = acctest.MustParseTemplate(testMspTenantResourceTemplate, testMspTenantResource) + +func TestAccMspTenantResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: acctest.PreCheckFunc(t), + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Read testing + { + Config: acctest.MspProviderConfig() + testMspTenantResourceConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("cdo_msp_managed_tenant.test", "name", testMspTenantResource.Name), + resource.TestCheckResourceAttr("cdo_msp_managed_tenant.test", "display_name", testMspTenantResource.DisplayName), + resource.TestCheckResourceAttr("cdo_msp_managed_tenant.test", "id", testMspTenantResource.Id), + resource.TestCheckResourceAttr("cdo_msp_managed_tenant.test", "region", testMspTenantResource.Region), + ), + }, + }, + }) +} diff --git a/provider/validators/msp_tenant_name.go b/provider/validators/msp_tenant_name.go new file mode 100644 index 0000000..f28f604 --- /dev/null +++ b/provider/validators/msp_tenant_name.go @@ -0,0 +1,58 @@ +package validators + +import ( + "context" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.String = mspManagedTenantNameValidator{} + +var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9-_]{1,50}$`) + +type mspManagedTenantNameValidator struct { +} + +func (v mspManagedTenantNameValidator) Description(ctx context.Context) string { + return "Ensures that if name is null and api_token is null, fail. If name is not null and api_token is not null, fail. If name is not null and does not match the regex [a-zA-Z0-9-_]{1,50}, fail." +} + +func (v mspManagedTenantNameValidator) MarkdownDescription(ctx context.Context) string { + return "Ensures that if name is null and api_token is null, fail. If name is not null and api_token is not null, fail. If name is not null and does not match the regex [a-zA-Z0-9-_]{1,50}, fail." +} + +func (v mspManagedTenantNameValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + var apiTokenAttr attr.Value + + request.Config.GetAttribute(ctx, path.Root("api_token"), &apiTokenAttr) + + if request.ConfigValue.IsNull() && apiTokenAttr.IsNull() { + response.Diagnostics.AddError( + "Invalid Configuration", + "Both name and api_token cannot be null.", + ) + return + } + + if !request.ConfigValue.IsNull() && !apiTokenAttr.IsNull() { + response.Diagnostics.AddError( + "Invalid Configuration", + "Both name and api_token cannot be specified at the same time.", + ) + return + } + + if !request.ConfigValue.IsNull() && !nameRegex.MatchString(request.ConfigValue.ValueString()) { + response.Diagnostics.AddError( + "Invalid Configuration", + "Name must match the regex `[a-zA-Z0-9-_]{1,50}`.", + ) + } +} + +func NewMspManagedTenantNameValidator() validator.String { + return mspManagedTenantNameValidator{} +} diff --git a/provider/validators/msp_tenant_name_test.go b/provider/validators/msp_tenant_name_test.go new file mode 100644 index 0000000..06c752a --- /dev/null +++ b/provider/validators/msp_tenant_name_test.go @@ -0,0 +1,84 @@ +package validators_test + +import ( + "context" + "github.com/CiscoDevnet/terraform-provider-cdo/validators" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "testing" +) + +func TestMspTenantNameValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + name types.String + apiToken attr.Value + expectError bool + } + + testCases := map[string]testCase{ + "non-null-name-and-non-null-api-token": { + name: types.StringValue("burak-crush-pineapple"), + apiToken: types.StringValue("burak-crush-api-token"), + expectError: true, + }, + "non-null-name-and-null-api-token": { + name: types.StringValue("burak-crush-pineapple"), + apiToken: nil, + expectError: false, + }, + "null-name-and-null-api-token": { + name: types.StringNull(), + apiToken: nil, + expectError: true, + }, + "null-name-and-non-null-api-token": { + name: types.StringNull(), + apiToken: types.StringValue("burak-crush-api-token"), + expectError: false, + }, + } + + for name, test := range testCases { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + req := validator.StringRequest{ // nolint + ConfigValue: test.name, + Config: tfsdk.Config{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "api_token": tftypes.String, + }, + }, map[string]tftypes.Value{ + "api_token": tftypes.NewValue(tftypes.String, test.apiToken.(types.String).ValueString()), // nolint + }), + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "api_token": schema.StringAttribute{ + MarkdownDescription: "API token for an API-only user with super-admin privileges on the tenant", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), // Prevent updates to name + }, + }, + }, + }, // Provide the actual schema if needed + }, + } + res := validator.StringResponse{} + validators.NewMspManagedTenantNameValidator().ValidateString(context.TODO(), req, &res) + if test.expectError && !res.Diagnostics.HasError() { + t.Fatalf("expected error, got none") + + } + }) + } +}