diff --git a/internal/provider/team/team_data_source_test.go b/internal/provider/team/team_data_source_test.go new file mode 100644 index 0000000..95db8ad --- /dev/null +++ b/internal/provider/team/team_data_source_test.go @@ -0,0 +1,113 @@ +package team_test + +import ( + "fmt" + "github.com/futurice/terraform-provider-dependencytrack/internal/testutils" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "strings" + "testing" +) + +func TestAccTeamDataSource_basic(t *testing.T) { + teamName := acctest.RandomWithPrefix("test-team") + teamDataSourceName := createTeamDataSourceName("test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testutils.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: testutils.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccTeamDataSourceConfigBasic(testDependencyTrack, teamName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(teamDataSourceName, "name", teamName), + ), + }, + }, + }) +} + +func TestAccTeamDataSource_permissions(t *testing.T) { + teamName := acctest.RandomWithPrefix("test-team") + permissionNames := []string{"ACCESS_MANAGEMENT", "BOM_UPLOAD"} + teamDataSourceName := createTeamDataSourceName("test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testutils.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: testutils.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccTeamDataSourceConfigPermissions(testDependencyTrack, teamName, permissionNames), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(teamDataSourceName, "permissions.#", "2"), + resource.TestCheckTypeSetElemAttr(teamDataSourceName, "permissions.*", "ACCESS_MANAGEMENT"), + resource.TestCheckTypeSetElemAttr(teamDataSourceName, "permissions.*", "BOM_UPLOAD"), + ), + }, + }, + }) +} + +func TestAccTeamDataSource_mappedOIDCGroups(t *testing.T) { + // TODO either add the required resources or add test mappings with direct API calls to complete this test + t.Skip("Currently adding mappings is not supported") +} + +func testAccTeamDataSourceConfigBasic(testDependencyTrack *testutils.TestDependencyTrack, teamName string) string { + return testDependencyTrack.AddProviderConfiguration( + fmt.Sprintf(` +resource "dependencytrack_team" "test" { + name = %[1]q +} + +data "dependencytrack_team" "test" { + id = dependencytrack_team.test.id +} +`, + teamName, + ), + ) +} + +func testAccTeamDataSourceConfigPermissions(testDependencyTrack *testutils.TestDependencyTrack, teamName string, permissionNames []string) string { + permissionResourceNames := make([]string, len(permissionNames)) + permissionResources := make([]string, len(permissionNames)) + + for i, permissionName := range permissionNames { + permissionResourceNames[i] = fmt.Sprintf("dependencytrack_team_permission.test-%[1]s", permissionName) + + permissionResources[i] = fmt.Sprintf(` +resource "dependencytrack_team_permission" "test-%[1]s" { + team_id = dependencytrack_team.test.id + name = %[1]q +} +`, + permissionName, + ) + } + + return testDependencyTrack.AddProviderConfiguration( + testutils.ComposeConfigs( + fmt.Sprintf(` +resource "dependencytrack_team" "test" { + name = %[1]q +} +`, + teamName, + ), + testutils.ComposeConfigs(permissionResources...), + fmt.Sprintf(` +data "dependencytrack_team" "test" { + id = dependencytrack_team.test.id + depends_on = [%[1]s] +} +`, + strings.Join(permissionResourceNames, ", "), + ), + ), + ) +} + +func createTeamDataSourceName(localName string) string { + return fmt.Sprintf("data.dependencytrack_team.%s", localName) +} diff --git a/internal/provider/team/team_resource.go b/internal/provider/team/team_resource.go index 5851e2b..74bb81b 100644 --- a/internal/provider/team/team_resource.go +++ b/internal/provider/team/team_resource.go @@ -1,14 +1,11 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - package team import ( "context" "fmt" - dtrack "github.com/futurice/dependency-track-client-go" - "github.com/google/uuid" + "github.com/futurice/terraform-provider-dependencytrack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -81,40 +78,47 @@ func (r *TeamResource) Configure(ctx context.Context, req resource.ConfigureRequ } func (r *TeamResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan TeamResourceModel + var plan, state TeamResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) - if resp.Diagnostics.HasError() { return } - team := dtrack.Team{ - Name: plan.Name.ValueString(), + dtTeam, diags := TFTeamToDTTeam(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return } - respTeam, err := r.client.Team.Create(ctx, team) + respTeam, err := r.client.Team.Create(ctx, dtTeam) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create API key, got error: %s", err)) return } - plan.ID = types.StringValue(respTeam.UUID.String()) - plan.Name = types.StringValue(respTeam.Name) + state, diags = DTTeamToTFTeam(ctx, respTeam) + resp.Diagnostics.Append(diags...) - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } func (r *TeamResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state TeamResourceModel + var diags diag.Diagnostics resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + teamUUID, teamUUIDDiags := utils.ParseUUID(state.ID.ValueString()) + resp.Diagnostics.Append(teamUUIDDiags...) if resp.Diagnostics.HasError() { return } - respTeam, err := r.client.Team.Get(ctx, uuid.MustParse(state.ID.ValueString())) + respTeam, err := r.client.Team.Get(ctx, teamUUID) if err != nil { if apiErr, ok := err.(*dtrack.APIError); ok && apiErr.StatusCode == 404 { resp.State.RemoveResource(ctx) @@ -125,8 +129,8 @@ func (r *TeamResource) Read(ctx context.Context, req resource.ReadRequest, resp return } - state.ID = types.StringValue(respTeam.UUID.String()) - state.Name = types.StringValue(respTeam.Name) + state, diags = DTTeamToTFTeam(ctx, respTeam) + resp.Diagnostics.Append(diags...) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } @@ -136,24 +140,24 @@ func (r *TeamResource) Update(ctx context.Context, req resource.UpdateRequest, r resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) resp.Diagnostics.Append(req.State.Get(ctx, &state)...) - if resp.Diagnostics.HasError() { return } - team := dtrack.Team{ - Name: plan.Name.ValueString(), - UUID: uuid.MustParse(state.ID.ValueString()), + dtTeam, diags := TFTeamToDTTeam(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return } - respTeam, err := r.client.Team.Update(ctx, team) + respTeam, err := r.client.Team.Update(ctx, dtTeam) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update team, got error: %s", err)) return } - state.ID = types.StringValue(respTeam.UUID.String()) - state.Name = types.StringValue(respTeam.Name) + state, diags = DTTeamToTFTeam(ctx, respTeam) + resp.Diagnostics.Append(diags...) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } @@ -162,16 +166,17 @@ func (r *TeamResource) Delete(ctx context.Context, req resource.DeleteRequest, r var state TeamResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) - if resp.Diagnostics.HasError() { return } - team := dtrack.Team{ - UUID: uuid.MustParse(state.ID.ValueString()), + dtTeam, diags := TFTeamToDTTeam(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return } - err := r.client.Team.Delete(ctx, team) + err := r.client.Team.Delete(ctx, dtTeam) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete team, got error: %s", err)) return @@ -183,3 +188,28 @@ func (r *TeamResource) Delete(ctx context.Context, req resource.DeleteRequest, r func (r *TeamResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) } + +func DTTeamToTFTeam(ctx context.Context, dtTeam dtrack.Team) (TeamResourceModel, diag.Diagnostics) { + var diags diag.Diagnostics + team := TeamResourceModel{ + ID: types.StringValue(dtTeam.UUID.String()), + Name: types.StringValue(dtTeam.Name), + } + + return team, diags +} + +func TFTeamToDTTeam(ctx context.Context, tfTeam TeamResourceModel) (dtrack.Team, diag.Diagnostics) { + var diags diag.Diagnostics + team := dtrack.Team{ + Name: tfTeam.Name.ValueString(), + } + + if tfTeam.ID.ValueString() != "" { + teamUUID, teamUUIDDiags := utils.ParseUUID(tfTeam.ID.ValueString()) + team.UUID = teamUUID + diags.Append(teamUUIDDiags...) + } + + return team, diags +} diff --git a/internal/provider/team/team_resource_test.go b/internal/provider/team/team_resource_test.go new file mode 100644 index 0000000..4bc389e --- /dev/null +++ b/internal/provider/team/team_resource_test.go @@ -0,0 +1,148 @@ +package team_test + +import ( + "context" + "errors" + "fmt" + dtrack "github.com/futurice/dependency-track-client-go" + "github.com/futurice/terraform-provider-dependencytrack/internal/testutils" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "testing" +) + +func TestAccTeamResource_basic(t *testing.T) { + ctx := testutils.CreateTestContext(t) + + teamName := acctest.RandomWithPrefix("test-team") + otherTeamName := acctest.RandomWithPrefix("other-test-team") + teamResourceName := createTeamResourceName("test") + + testTeam := dtrack.Team{ + Name: teamName, + } + + testUpdatedTeam := testTeam + testUpdatedTeam.Name = otherTeamName + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testutils.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: testutils.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccTeamConfigBasic(testDependencyTrack, teamName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckTeamExistsAndHasExpectedData(ctx, testDependencyTrack, teamResourceName, testTeam), + resource.TestCheckResourceAttrSet(teamResourceName, "id"), + resource.TestCheckResourceAttr(teamResourceName, "name", teamName), + ), + }, + { + ResourceName: teamResourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccTeamConfigBasic(testDependencyTrack, otherTeamName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckTeamExistsAndHasExpectedData(ctx, testDependencyTrack, teamResourceName, testUpdatedTeam), + resource.TestCheckResourceAttr(teamResourceName, "name", otherTeamName), + ), + }, + }, + CheckDestroy: testAccCheckTeamDoesNotExists(ctx, testDependencyTrack, teamResourceName), + }) +} + +func testAccTeamConfigBasic(testDependencyTrack *testutils.TestDependencyTrack, teamName string) string { + return testDependencyTrack.AddProviderConfiguration( + fmt.Sprintf(` +resource "dependencytrack_team" "test" { + name = %[1]q +} +`, + teamName, + ), + ) +} + +func testAccCheckTeamExistsAndHasExpectedData(ctx context.Context, testDependencyTrack *testutils.TestDependencyTrack, resourceName string, expectedTeam dtrack.Team) resource.TestCheckFunc { + return testAccCheckTeamExistsAndHasExpectedLazyData(ctx, testDependencyTrack, resourceName, func() dtrack.Team { return expectedTeam }) +} + +func testAccCheckTeamExistsAndHasExpectedLazyData(ctx context.Context, testDependencyTrack *testutils.TestDependencyTrack, resourceName string, expectedTeamCreator func() dtrack.Team) resource.TestCheckFunc { + return func(state *terraform.State) error { + expectedTeam := expectedTeamCreator() + + team, err := findTeamByResourceName(ctx, testDependencyTrack, state, resourceName) + if err != nil { + return err + } + if team == nil { + return fmt.Errorf("team for resource %s does not exist in Dependency-Track", resourceName) + } + + diff := cmp.Diff(team, &expectedTeam, cmpopts.IgnoreFields(dtrack.Team{}, "UUID")) + if diff != "" { + return fmt.Errorf("team for resource %s is different than expected: %s", resourceName, diff) + } + + return nil + } +} + +func testAccCheckTeamDoesNotExists(ctx context.Context, testDependencyTrack *testutils.TestDependencyTrack, resourceName string) resource.TestCheckFunc { + return func(state *terraform.State) error { + team, err := findTeamByResourceName(ctx, testDependencyTrack, state, resourceName) + if err != nil { + return err + } + if team != nil { + return fmt.Errorf("team for resource %s exists in Dependency-Track, even though it shouldn't: %v", resourceName, team) + } + + return nil + } +} + +func findTeamByResourceName(ctx context.Context, testDependencyTrack *testutils.TestDependencyTrack, state *terraform.State, resourceName string) (*dtrack.Team, error) { + teamID, err := testutils.GetResourceID(state, resourceName) + if err != nil { + return nil, err + } + + team, err := findTeam(ctx, testDependencyTrack, teamID) + if err != nil { + return nil, fmt.Errorf("failed to get team for resource %s: %w", resourceName, err) + } + + return team, nil +} + +func findTeam(ctx context.Context, testDependencyTrack *testutils.TestDependencyTrack, teamID uuid.UUID) (*dtrack.Team, error) { + team, err := testDependencyTrack.Client.Team.Get(ctx, teamID) + if err != nil { + var apiErr *dtrack.APIError + ok := errors.As(err, &apiErr) + if !ok || apiErr.StatusCode != 404 { + return nil, fmt.Errorf("failed to get team from Dependency-Track: %w", err) + } + + return nil, nil + } + + // normalize the returned object not to contain an empty array reference + if len(team.Permissions) == 0 { + team.Permissions = nil + } + + return &team, nil +} + +func createTeamResourceName(localName string) string { + return fmt.Sprintf("dependencytrack_team.%s", localName) +} diff --git a/internal/provider/team/team_test.go b/internal/provider/team/team_test.go new file mode 100644 index 0000000..361cbee --- /dev/null +++ b/internal/provider/team/team_test.go @@ -0,0 +1,20 @@ +package team_test + +import ( + "github.com/futurice/terraform-provider-dependencytrack/internal/testutils" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "os" + "testing" +) + +var testDependencyTrack *testutils.TestDependencyTrack + +func TestMain(m *testing.M) { + if os.Getenv(resource.EnvTfAcc) != "" { + var cleanup func() + testDependencyTrack, cleanup = testutils.InitTestDependencyTrack() + defer cleanup() + } + + m.Run() +} diff --git a/internal/utils/terraform.go b/internal/utils/terraform.go new file mode 100644 index 0000000..dc36d9e --- /dev/null +++ b/internal/utils/terraform.go @@ -0,0 +1,49 @@ +package utils + +import ( + "context" + "fmt" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// ParseUUID parses the UUID value returning a possible error as Diagnostics instead of error. +func ParseUUID(uuidString string) (uuid.UUID, diag.Diagnostics) { + var diags diag.Diagnostics + + id, err := uuid.Parse(uuidString) + if err != nil { + diags.AddError("Incorrect UUID", fmt.Sprintf("Failed to parse string [%s] as UUID: %v", uuidString, err)) + return uuid.UUID{}, diags + } + + return id, diags +} + +// TFStringSetToStringSlice extracts strings from a string SetValue. +func TFStringSetToStringSlice(ctx context.Context, tfSet basetypes.SetValue) ([]string, diag.Diagnostics) { + var diags diag.Diagnostics + + if tfSet.IsUnknown() { + return nil, diags + } + + if tfSet.IsNull() { + return nil, diags + } + + tfStrings := make([]types.String, 0, len(tfSet.Elements())) + diags = tfSet.ElementsAs(ctx, &tfStrings, false) + if diags.HasError() { + return nil, diags + } + + strings := make([]string, len(tfStrings)) + for i, tfString := range tfStrings { + strings[i] = tfString.ValueString() + } + + return strings, diags +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go new file mode 100644 index 0000000..38c56c8 --- /dev/null +++ b/internal/utils/utils_test.go @@ -0,0 +1,97 @@ +package utils_test + +import ( + "context" + "github.com/futurice/terraform-provider-dependencytrack/internal/utils" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/types" + "reflect" + "testing" +) + +func TestParseUUID_basic(t *testing.T) { + testUUID := uuid.MustParse("8ffb30fb-77e6-4886-9f32-ff142f9bf90b") + result, diags := utils.ParseUUID(testUUID.String()) + + if result != testUUID { + t.Errorf("Parsed UUID [%s] is different than expected [%s]", result, testUUID) + } + + if diags.HasError() { + t.Errorf("Unexpected error: %v", diags) + } +} + +func TestParseUUID_invalid(t *testing.T) { + _, diags := utils.ParseUUID("not-an-UUID") + + if !diags.HasError() { + t.Errorf("Error expected, but received none") + } +} + +func TestTFStringSetToStringSlice_basic(t *testing.T) { + ctx := context.Background() + + testStrings := []string{"a", "b"} + testSet, _ := types.SetValueFrom(ctx, types.StringType, testStrings) + + result, diags := utils.TFStringSetToStringSlice(ctx, testSet) + + if !reflect.DeepEqual(result, testStrings) { + t.Errorf("Parsed strings [%v] are different than expected [%v]", result, testStrings) + } + + if diags.HasError() { + t.Errorf("Unexpected error: %v", diags) + } +} + +func TestTFStringSetToStringSlice_empty(t *testing.T) { + ctx := context.Background() + + var testStrings []string + testSet, _ := types.SetValueFrom(ctx, types.StringType, testStrings) + + result, diags := utils.TFStringSetToStringSlice(ctx, testSet) + + if !reflect.DeepEqual(result, testStrings) { + t.Errorf("Parsed strings [%v] are different than expected [%v]", result, testStrings) + } + + if diags.HasError() { + t.Errorf("Unexpected error: %v", diags) + } +} + +func TestTFStringSetToStringSlice_unknown(t *testing.T) { + ctx := context.Background() + + testSet := types.SetUnknown(types.StringType) + + result, diags := utils.TFStringSetToStringSlice(ctx, testSet) + + if result != nil { + t.Errorf("Expected nil, got strings [%v]", result) + } + + if diags.HasError() { + t.Errorf("Unexpected error: %v", diags) + } +} + +func TestTFStringSetToStringSlice_null(t *testing.T) { + ctx := context.Background() + + testSet := types.SetNull(types.StringType) + + result, diags := utils.TFStringSetToStringSlice(ctx, testSet) + + if result != nil { + t.Errorf("Expected nil, got strings [%v]", result) + } + + if diags.HasError() { + t.Errorf("Unexpected error: %v", diags) + } +}