From d9e3a860f35f9cb140f3a2c201c8802afcc274dd Mon Sep 17 00:00:00 2001 From: Jillianne Ramirez Date: Tue, 2 Jun 2026 10:06:56 -0400 Subject: [PATCH] TF-33374 Add notification configurations project subscribable type --- CHANGELOG.md | 2 + helper_test.go | 47 +++ notification_configuration.go | 84 ++-- ...fication_configuration_integration_test.go | 385 ++++++++++++++++++ 4 files changed, 490 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94bee5839..6a5401217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Enhancements * Adds BETA support for listing `TFPolicyEvaluationOutcome`, which is EXPERIMENTAL, SUBJECT TO CHANGE, and may not be available to all users by @subhro-acharjee-1 [#1313](https://github.com/hashicorp/go-tfe/pull/1313) +* Adds support for `NotificationConfigurationSubscribableChoice` type `Project` to support Project Level Notifications by @jillirami [#1350](https://github.com/hashicorp/go-tfe/pull/1350) + # v1.107.0 diff --git a/helper_test.go b/helper_test.go index c5afddb51..9e9e517de 100644 --- a/helper_test.go +++ b/helper_test.go @@ -962,6 +962,53 @@ func createTeamNotificationConfiguration(t *testing.T, client *Client, team *Tea } } +func createProjectNotificationConfiguration(t *testing.T, client *Client, project *Project, options *NotificationConfigurationCreateOptions) (*NotificationConfiguration, func()) { + var pCleanup func() + + if project == nil { + project, pCleanup = createProject(t, client, nil) + } + + runTaskURL := os.Getenv("TFC_RUN_TASK_URL") + if runTaskURL == "" { + t.Error("You must set TFC_RUN_TASK_URL for run task related tests.") + } + + if options == nil { + options = &NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeGeneric), + Enabled: Bool(false), + Name: String(randomString(t)), + Token: String(randomString(t)), + URL: String(runTaskURL), + Triggers: []NotificationTriggerType{NotificationTriggerCreated}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Project: project}, + } + } + + ctx := context.Background() + nc, err := client.NotificationConfigurations.Create( + ctx, + project.ID, + *options, + ) + if err != nil { + t.Fatal(err) + } + + return nc, func() { + if err := client.NotificationConfigurations.Delete(ctx, nc.ID); err != nil { + t.Errorf("Error destroying project notification configuration! WARNING: Dangling\n"+ + "resources may exist! The full error is shown below.\n\n"+ + "NotificationConfiguration: %s\nError: %s", nc.ID, err) + } + + if pCleanup != nil { + pCleanup() + } + } +} + func createPolicySetParameter(t *testing.T, client *Client, ps *PolicySet) (*PolicySetParameter, func()) { var psCleanup func() diff --git a/notification_configuration.go b/notification_configuration.go index 550f7b70c..256ba18e7 100644 --- a/notification_configuration.go +++ b/notification_configuration.go @@ -85,6 +85,7 @@ type NotificationConfigurationList struct { // within a polymorphic relation. If a value is available, exactly one field // will be non-nil. type NotificationConfigurationSubscribableChoice struct { + Project *Project Team *Team Workspace *Workspace } @@ -165,7 +166,7 @@ type NotificationConfigurationCreateOptions struct { // Optional: The list of users belonging to the organization that will receive notification emails. EmailUsers []*User `jsonapi:"relation,users,omitempty"` - // Required: The workspace or team that the notification configuration is associated with. + // Required: The workspace, team, or project that the notification configuration is associated with. SubscribableChoice *NotificationConfigurationSubscribableChoice `jsonapi:"polyrelation,subscribable,omitempty"` } @@ -203,7 +204,6 @@ type NotificationConfigurationUpdateOptions struct { // List all the notification configurations associated with a workspace. func (s *notificationConfigurations) List(ctx context.Context, subscribableID string, options *NotificationConfigurationListOptions) (*NotificationConfigurationList, error) { - var u string if options == nil { options = &NotificationConfigurationListOptions{ SubscribableChoice: &NotificationConfigurationSubscribableChoice{ @@ -216,16 +216,9 @@ func (s *notificationConfigurations) List(ctx context.Context, subscribableID st } } - if options.SubscribableChoice.Team != nil { - if !validStringID(&subscribableID) { - return nil, ErrInvalidTeamID - } - u = fmt.Sprintf("teams/%s/notification-configurations", url.PathEscape(subscribableID)) - } else { - if !validStringID(&subscribableID) { - return nil, ErrInvalidWorkspaceID - } - u = fmt.Sprintf("workspaces/%s/notification-configurations", url.PathEscape(subscribableID)) + u, err := notificationSubscribableURL(subscribableID, options.SubscribableChoice) + if err != nil { + return nil, err } req, err := s.client.NewRequest("GET", u, options) @@ -248,26 +241,29 @@ func (s *notificationConfigurations) List(ctx context.Context, subscribableID st // Create a notification configuration with the given options. func (s *notificationConfigurations) Create(ctx context.Context, subscribableID string, options NotificationConfigurationCreateOptions) (*NotificationConfiguration, error) { - var u string - var subscribableChoice *NotificationConfigurationSubscribableChoice - if options.SubscribableChoice == nil || options.SubscribableChoice.Team == nil { - u = fmt.Sprintf("workspaces/%s/notification-configurations", url.PathEscape(subscribableID)) - options.SubscribableChoice = &NotificationConfigurationSubscribableChoice{Workspace: &Workspace{ID: subscribableID}} - } else { - u = fmt.Sprintf("teams/%s/notification-configurations", url.PathEscape(subscribableID)) + if options.SubscribableChoice != nil && options.SubscribableChoice.Team != nil { options.SubscribableChoice = &NotificationConfigurationSubscribableChoice{Team: &Team{ID: subscribableID}} + } else if options.SubscribableChoice != nil && options.SubscribableChoice.Project != nil { + options.SubscribableChoice = &NotificationConfigurationSubscribableChoice{Project: &Project{ID: subscribableID}} + } else { + options.SubscribableChoice = &NotificationConfigurationSubscribableChoice{Workspace: &Workspace{ID: subscribableID}} } if err := options.valid(); err != nil { return nil, err } + u, err := notificationSubscribableURL(subscribableID, options.SubscribableChoice) + if err != nil { + return nil, err + } + req, err := s.client.NewRequest("POST", u, &options) if err != nil { return nil, err } - nc := &NotificationConfiguration{SubscribableChoice: subscribableChoice} + nc := &NotificationConfiguration{} err = req.Do(ctx, nc) if err != nil { @@ -368,14 +364,8 @@ func (s *notificationConfigurations) Verify(ctx context.Context, notificationCon } func (o NotificationConfigurationCreateOptions) valid() error { - if o.SubscribableChoice == nil || o.SubscribableChoice.Workspace != nil { - if !validStringID(&o.SubscribableChoice.Workspace.ID) { - return ErrInvalidWorkspaceID - } - } else { - if !validStringID(&o.SubscribableChoice.Team.ID) { - return ErrInvalidTeamID - } + if err := validateSubscribableChoice(o.SubscribableChoice); err != nil { + return err } if o.DestinationType == nil { @@ -424,6 +414,44 @@ func backfillDeprecatedSubscribable(notification *NotificationConfiguration) { } } +func notificationSubscribableURL(subscribableID string, choice *NotificationConfigurationSubscribableChoice) (string, error) { + if choice != nil && choice.Team != nil { + if !validStringID(&subscribableID) { + return "", ErrInvalidTeamID + } + return fmt.Sprintf("teams/%s/notification-configurations", url.PathEscape(subscribableID)), nil + } + if choice != nil && choice.Project != nil { + if !validStringID(&subscribableID) { + return "", ErrInvalidProjectID + } + return fmt.Sprintf("projects/%s/notification-configurations", url.PathEscape(subscribableID)), nil + } + if choice == nil || !validStringID(&subscribableID) { + return "", ErrInvalidWorkspaceID + } + return fmt.Sprintf("workspaces/%s/notification-configurations", url.PathEscape(subscribableID)), nil +} + +func validateSubscribableChoice(choice *NotificationConfigurationSubscribableChoice) error { + if choice != nil && choice.Team != nil { + if !validStringID(&choice.Team.ID) { + return ErrInvalidTeamID + } + return nil + } + if choice != nil && choice.Project != nil { + if !validStringID(&choice.Project.ID) { + return ErrInvalidProjectID + } + return nil + } + if choice == nil || !validStringID(&choice.Workspace.ID) { + return ErrInvalidWorkspaceID + } + return nil +} + func validNotificationTriggerType(triggers []NotificationTriggerType) bool { for _, t := range triggers { switch t { diff --git a/notification_configuration_integration_test.go b/notification_configuration_integration_test.go index 0818521df..86def8574 100644 --- a/notification_configuration_integration_test.go +++ b/notification_configuration_integration_test.go @@ -852,3 +852,388 @@ func TestNotificationConfigurationVerify_forTeams(t *testing.T) { assert.Equal(t, err, ErrInvalidNotificationConfigID) }) } + +func TestNotificationConfigurationList_forProjects(t *testing.T) { + t.Parallel() + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + pTest, pTestCleanup := createProject(t, client, orgTest) + t.Cleanup(pTestCleanup) + + ncTest1, ncTestCleanup1 := createProjectNotificationConfiguration(t, client, pTest, nil) + t.Cleanup(ncTestCleanup1) + ncTest2, ncTestCleanup2 := createProjectNotificationConfiguration(t, client, pTest, nil) + t.Cleanup(ncTestCleanup2) + + t.Run("with a valid project", func(t *testing.T) { + ncl, err := client.NotificationConfigurations.List( + ctx, + pTest.ID, + &NotificationConfigurationListOptions{ + SubscribableChoice: &NotificationConfigurationSubscribableChoice{ + Project: pTest, + }, + }, + ) + require.NoError(t, err) + assert.Contains(t, ncl.Items, ncTest1) + assert.Contains(t, ncl.Items, ncTest2) + }) + + t.Run("without a valid project", func(t *testing.T) { + ncl, err := client.NotificationConfigurations.List( + ctx, + badIdentifier, + &NotificationConfigurationListOptions{ + SubscribableChoice: &NotificationConfigurationSubscribableChoice{ + Project: pTest, + }, + }, + ) + assert.Nil(t, ncl) + assert.EqualError(t, err, ErrInvalidProjectID.Error()) + }) +} + +func TestNotificationConfigurationCreate_forProjects(t *testing.T) { + t.Parallel() + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + pTest, pTestCleanup := createProject(t, client, orgTest) + t.Cleanup(pTestCleanup) + + // Create user to use when testing email destination type + orgMemberTest, orgMemberTestCleanup := createOrganizationMembership(t, client, orgTest) + t.Cleanup(orgMemberTestCleanup) + + t.Run("with all required values", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeGeneric), + Enabled: Bool(false), + Name: String(randomString(t)), + Token: String(randomString(t)), + URL: String("http://example.com"), + Triggers: []NotificationTriggerType{NotificationTriggerCreated}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Project: pTest}, + } + nc, err := client.NotificationConfigurations.Create(ctx, pTest.ID, options) + + require.NoError(t, err) + require.NotNil(t, nc) + }) + + t.Run("without a required value", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeGeneric), + Enabled: Bool(false), + Token: String(randomString(t)), + URL: String("http://example.com"), + Triggers: []NotificationTriggerType{NotificationTriggerCreated}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Project: pTest}, + } + nc, err := client.NotificationConfigurations.Create(ctx, pTest.ID, options) + + assert.Nil(t, nc) + assert.EqualError(t, err, ErrRequiredName.Error()) + }) + + t.Run("without a required value URL when destination type is generic", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeGeneric), + Enabled: Bool(false), + Name: String(randomString(t)), + Token: String(randomString(t)), + Triggers: []NotificationTriggerType{NotificationTriggerCreated}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Project: pTest}, + } + + nc, err := client.NotificationConfigurations.Create(ctx, pTest.ID, options) + assert.Nil(t, nc) + assert.Equal(t, err, ErrRequiredURL) + }) + + t.Run("without a required value URL when destination type is slack", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeSlack), + Enabled: Bool(false), + Name: String(randomString(t)), + Triggers: []NotificationTriggerType{NotificationTriggerCreated}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Project: pTest}, + } + + nc, err := client.NotificationConfigurations.Create(ctx, pTest.ID, options) + assert.Nil(t, nc) + assert.Equal(t, err, ErrRequiredURL) + }) + + t.Run("without a required value URL when destination type is MS Teams", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeMicrosoftTeams), + Enabled: Bool(false), + Name: String(randomString(t)), + Triggers: []NotificationTriggerType{NotificationTriggerCreated}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Project: pTest}, + } + + nc, err := client.NotificationConfigurations.Create(ctx, pTest.ID, options) + assert.Nil(t, nc) + assert.Equal(t, err, ErrRequiredURL) + }) + + t.Run("without a valid project", func(t *testing.T) { + nc, err := client.NotificationConfigurations.Create(ctx, badIdentifier, NotificationConfigurationCreateOptions{ + SubscribableChoice: &NotificationConfigurationSubscribableChoice{ + Project: pTest, + }, + }) + assert.Nil(t, nc) + assert.EqualError(t, err, ErrInvalidProjectID.Error()) + }) + + t.Run("with an invalid notification trigger", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeGeneric), + Enabled: Bool(false), + Name: String(randomString(t)), + Token: String(randomString(t)), + URL: String("http://example.com"), + Triggers: []NotificationTriggerType{"the beacons of gondor are lit"}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Project: pTest}, + } + + nc, err := client.NotificationConfigurations.Create(ctx, pTest.ID, options) + assert.Nil(t, nc) + assert.EqualError(t, err, ErrInvalidNotificationTrigger.Error()) + }) + + t.Run("with email users when destination type is email", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeEmail), + Enabled: Bool(false), + Name: String(randomString(t)), + EmailUsers: []*User{orgMemberTest.User}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Project: pTest}, + } + + _, err := client.NotificationConfigurations.Create(ctx, pTest.ID, options) + require.NoError(t, err) + }) + + t.Run("without email users when destination type is email", func(t *testing.T) { + options := NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeEmail), + Enabled: Bool(false), + Name: String(randomString(t)), + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Project: pTest}, + } + + _, err := client.NotificationConfigurations.Create(ctx, pTest.ID, options) + require.NoError(t, err) + }) +} + +func TestNotificationConfigurationRead_forProjects(t *testing.T) { + t.Parallel() + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + pTest, pTestCleanup := createProject(t, client, orgTest) + t.Cleanup(pTestCleanup) + + ncTest, ncTestCleanup := createProjectNotificationConfiguration(t, client, pTest, nil) + t.Cleanup(ncTestCleanup) + + t.Run("with a valid ID", func(t *testing.T) { + nc, err := client.NotificationConfigurations.Read(ctx, ncTest.ID) + require.NoError(t, err) + assert.Equal(t, ncTest.ID, nc.ID) + }) + + t.Run("when the notification configuration does not exist", func(t *testing.T) { + _, err := client.NotificationConfigurations.Read(ctx, "nonexisting") + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the notification configuration ID is invalid", func(t *testing.T) { + _, err := client.NotificationConfigurations.Read(ctx, badIdentifier) + assert.Equal(t, err, ErrInvalidNotificationConfigID) + }) +} + +func TestNotificationConfigurationUpdate_forProjects(t *testing.T) { + t.Parallel() + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + pTest, pTestCleanup := createProject(t, client, orgTest) + t.Cleanup(pTestCleanup) + + ncTest, ncTestCleanup := createProjectNotificationConfiguration(t, client, pTest, nil) + t.Cleanup(ncTestCleanup) + + // Create users to use when testing email destination type + orgMemberTest1, orgMemberTest1Cleanup := createOrganizationMembership(t, client, orgTest) + defer orgMemberTest1Cleanup() + orgMemberTest2, orgMemberTest2Cleanup := createOrganizationMembership(t, client, orgTest) + defer orgMemberTest2Cleanup() + + orgMemberTest1.User = &User{ID: orgMemberTest1.User.ID} + orgMemberTest2.User = &User{ID: orgMemberTest2.User.ID} + + emailOptions := &NotificationConfigurationCreateOptions{ + DestinationType: NotificationDestination(NotificationDestinationTypeEmail), + Enabled: Bool(false), + Name: String(randomString(t)), + EmailUsers: []*User{orgMemberTest1.User}, + SubscribableChoice: &NotificationConfigurationSubscribableChoice{Project: pTest}, + } + ncEmailTest, ncEmailTestCleanup := createProjectNotificationConfiguration(t, client, pTest, emailOptions) + t.Cleanup(ncEmailTestCleanup) + + t.Run("with options", func(t *testing.T) { + options := NotificationConfigurationUpdateOptions{ + Enabled: Bool(true), + Name: String("newName"), + } + + nc, err := client.NotificationConfigurations.Update(ctx, ncTest.ID, options) + require.NoError(t, err) + assert.Equal(t, nc.Enabled, true) + assert.Equal(t, nc.Name, "newName") + }) + + t.Run("with invalid notification trigger", func(t *testing.T) { + options := NotificationConfigurationUpdateOptions{ + Triggers: []NotificationTriggerType{"fly you fools!"}, + } + + nc, err := client.NotificationConfigurations.Update(ctx, ncTest.ID, options) + assert.Nil(t, nc) + assert.EqualError(t, err, ErrInvalidNotificationTrigger.Error()) + }) + + t.Run("with email users when destination type is email", func(t *testing.T) { + options := NotificationConfigurationUpdateOptions{ + Enabled: Bool(true), + Name: String("newName"), + EmailUsers: []*User{orgMemberTest1.User, orgMemberTest2.User}, + } + + nc, err := client.NotificationConfigurations.Update(ctx, ncEmailTest.ID, options) + require.NoError(t, err) + assert.Equal(t, nc.Enabled, true) + assert.Equal(t, nc.Name, "newName") + assert.Contains(t, nc.EmailUsers, orgMemberTest1.User) + assert.Contains(t, nc.EmailUsers, orgMemberTest2.User) + }) + + t.Run("without email users when destination type is email", func(t *testing.T) { + options := NotificationConfigurationUpdateOptions{ + Enabled: Bool(true), + Name: String("newName"), + } + + nc, err := client.NotificationConfigurations.Update(ctx, ncEmailTest.ID, options) + require.NoError(t, err) + assert.Equal(t, nc.Enabled, true) + assert.Equal(t, nc.Name, "newName") + assert.Empty(t, nc.EmailUsers) + }) + + t.Run("without options", func(t *testing.T) { + _, err := client.NotificationConfigurations.Update(ctx, ncTest.ID, NotificationConfigurationUpdateOptions{}) + require.NoError(t, err) + }) + + t.Run("when the notification configuration does not exist", func(t *testing.T) { + _, err := client.NotificationConfigurations.Update(ctx, "nonexisting", NotificationConfigurationUpdateOptions{}) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the notification configuration ID is invalid", func(t *testing.T) { + _, err := client.NotificationConfigurations.Update(ctx, badIdentifier, NotificationConfigurationUpdateOptions{}) + assert.Equal(t, err, ErrInvalidNotificationConfigID) + }) +} + +func TestNotificationConfigurationDelete_forProjects(t *testing.T) { + t.Parallel() + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + pTest, pTestCleanup := createProject(t, client, orgTest) + t.Cleanup(pTestCleanup) + + ncTest, _ := createProjectNotificationConfiguration(t, client, pTest, nil) + + t.Run("with a valid ID", func(t *testing.T) { + err := client.NotificationConfigurations.Delete(ctx, ncTest.ID) + require.NoError(t, err) + + _, err = client.NotificationConfigurations.Read(ctx, ncTest.ID) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the notification configuration does not exist", func(t *testing.T) { + err := client.NotificationConfigurations.Delete(ctx, "nonexisting") + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the notification configuration ID is invalid", func(t *testing.T) { + err := client.NotificationConfigurations.Delete(ctx, badIdentifier) + assert.Equal(t, err, ErrInvalidNotificationConfigID) + }) +} + +func TestNotificationConfigurationVerify_forProjects(t *testing.T) { + t.Parallel() + skipUnlessBeta(t) + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + pTest, pTestCleanup := createProject(t, client, orgTest) + t.Cleanup(pTestCleanup) + + ncTest, ncTestCleanup := createProjectNotificationConfiguration(t, client, pTest, nil) + t.Cleanup(ncTestCleanup) + + t.Run("with a valid ID", func(t *testing.T) { + _, err := client.NotificationConfigurations.Verify(ctx, ncTest.ID) + require.NoError(t, err) + }) + + t.Run("when the notification configuration does not exists", func(t *testing.T) { + _, err := client.NotificationConfigurations.Verify(ctx, "nonexisting") + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the notification configuration ID is invalid", func(t *testing.T) { + _, err := client.NotificationConfigurations.Verify(ctx, badIdentifier) + assert.Equal(t, err, ErrInvalidNotificationConfigID) + }) +}