From 9e62ca97fdc6a8984486eb143d5e4a508bc222ed Mon Sep 17 00:00:00 2001 From: Ivan Kovalkovskyi Date: Wed, 5 May 2021 18:53:49 +0300 Subject: [PATCH] SCALRCORE-17735 Add user and team. --- examples/teams/main.go | 74 +++++++++++++++ examples/users/main.go | 70 ++++++++++++++ helper_test.go | 46 +++++++++ identity_provider.go | 6 ++ scalr.go | 4 + team.go | 195 +++++++++++++++++++++++++++++++++++++++ team_test.go | 205 +++++++++++++++++++++++++++++++++++++++++ user.go | 185 ++++++++++++++++++++++++++++++++++++- user_test.go | 192 ++++++++++++++++++++++++++++++++++++++ validations.go | 8 ++ 10 files changed, 981 insertions(+), 4 deletions(-) create mode 100644 examples/teams/main.go create mode 100644 examples/users/main.go create mode 100644 identity_provider.go create mode 100644 team.go create mode 100644 team_test.go create mode 100644 user_test.go diff --git a/examples/teams/main.go b/examples/teams/main.go new file mode 100644 index 0000000..8dd0ef3 --- /dev/null +++ b/examples/teams/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "log" + + scalr "github.com/scalr/go-scalr" +) + +func main() { + config := scalr.DefaultConfig() + config.Headers.Set("Prefer", "profile=internal") + + client, err := scalr.NewClient(config) + if err != nil { + log.Fatal(err) + } + + // Create a context + ctx := context.Background() + + users := []*scalr.User{{ID: "user-suh84u6vuvidtbg"}} + + // Create a team + opts := scalr.TeamCreateOptions{ + IdentityProvider: &scalr.IdentityProvider{ID: "idp-sohkb0o1phrdmr8"}, + Account: &scalr.Account{ID: "acc-svrcncgh453bi8g"}, + Name: scalr.String("GoTeams4"), + Description: scalr.String("Team created by go-scalr"), + Users: users, // test@scalr.com + } + + team, err := client.Teams.Create(ctx, opts) + if err != nil { + log.Fatal(err) + } + + log.Printf("Team created %v", team.ID) + + // Retrieve a team + team, err = client.Teams.Read(ctx, team.ID) + if err != nil { + log.Fatal(err) + } + + log.Printf("Team %v Description: %v", team.ID, team.Description) + + // Retrieve all teams + teams, err := client.Teams.List(ctx) + if err != nil { + log.Fatal(err) + } + + log.Printf("There are %v teams on the server", len(teams.Items)) + + // Update the team + team, err = client.Teams.Update(ctx, team.ID, scalr.TeamUpdateOptions{ + Description: scalr.String("Edited"), + // Name: scalr.String("Edited"), + // Users: []*scalr.User{}, + }) + if err != nil { + log.Fatal(err) + } + log.Printf("Update Description of the team %v: %v", team.ID, team.Description) + + // Delete the team + err = client.Teams.Delete(ctx, team.ID) + if err != nil { + log.Fatal(err) + } + log.Printf("Deleted team %v", team.ID) + +} diff --git a/examples/users/main.go b/examples/users/main.go new file mode 100644 index 0000000..e4d2607 --- /dev/null +++ b/examples/users/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "log" + "os" + + scalr "github.com/scalr/go-scalr" +) + +func main() { + config := scalr.DefaultConfig() + config.Headers.Set("Prefer", "profile=internal") + + client, err := scalr.NewClient(config) + if err != nil { + log.Fatal(err) + } + + // Create a context + ctx := context.Background() + + // Create a user + opts := scalr.UserCreateOptions{ + Email: scalr.String("i.kovalkovskyi6@scalr.com"), + IdentityProvider: &scalr.IdentityProvider{ID: os.Getenv("IDP_ID")}, + Status: scalr.UserStatusActive, + } + + usr, err := client.Users.Create(ctx, opts) + if err != nil { + log.Fatal(err) + } + + log.Printf("User created %v", usr.ID) + + // Retrieve a user + usr, err = client.Users.Read(ctx, usr.ID) + if err != nil { + log.Fatal(err) + } + + log.Printf("User %v was created at: %v", usr.ID, usr.CreatedAt) + + // Retrieve all users + users, err := client.Users.List(ctx) + if err != nil { + log.Fatal(err) + } + + log.Printf("There are %v users on the server", len(users.Items)) + + // Update the user + usr, err = client.Users.Update(ctx, usr.ID, scalr.UserUpdateOptions{ + FullName: scalr.String("Ivan Kovalkovskyi"), + Status: scalr.UserStatusInactive, + }) + if err != nil { + log.Fatal(err) + } + log.Printf("Update status of the user %v: %v", usr.ID, usr.Status) + + // Delete the user + err = client.Users.Delete(ctx, usr.ID) + if err != nil { + log.Fatal(err) + } + log.Printf("Deleted user %v", usr.ID) + +} diff --git a/helper_test.go b/helper_test.go index 171509a..bab4e87 100644 --- a/helper_test.go +++ b/helper_test.go @@ -9,6 +9,8 @@ import ( ) const defaultAccountID = "acc-svrcncgh453bi8g" +const defaultIdentityProviderID = "idp-sohkb0o1phrdmr8" +const defaultUserID = "user-suh84u6vuvidtbg" const badIdentifier = "! / nope" func testClient(t *testing.T) *Client { @@ -39,6 +41,50 @@ func createEnvironment(t *testing.T, client *Client) (*Environment, func()) { } } +func createUser(t *testing.T, client *Client) (*User, func()) { + ctx := context.Background() + usr, err := client.Users.Create(ctx, UserCreateOptions{ + Email: String("tst-" + randomString(t) + "@scalr.com"), + IdentityProvider: &IdentityProvider{ID: defaultIdentityProviderID}, + Status: UserStatusActive, + }) + if err != nil { + t.Fatal(err) + } + + return usr, func() { + if err := client.Users.Delete(ctx, usr.ID); err != nil { + t.Errorf("Error destroying user! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "User: %s\nError: %s", usr.ID, err) + } + } +} + +func createTeam(t *testing.T, client *Client) (*Team, func()) { + ctx := context.Background() + users := []*User{{ID: defaultUserID}} + team, err := client.Teams.Create(ctx, TeamCreateOptions{ + Name: String("tst-" + randomString(t)), + Description: String("Team created by scalr-go tests"), + IdentityProvider: &IdentityProvider{ID: defaultIdentityProviderID}, + Account: &Account{ID: defaultAccountID}, + Users: users, + }) + + if err != nil { + t.Fatal(err) + } + + return team, func() { + if err := client.Teams.Delete(ctx, team.ID); err != nil { + t.Errorf("Error destroying team! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "Team: %s\nError: %s", team.ID, err) + } + } +} + func createWorkspace(t *testing.T, client *Client, env *Environment) (*Workspace, func()) { var envCleanup func() diff --git a/identity_provider.go b/identity_provider.go new file mode 100644 index 0000000..003a1d7 --- /dev/null +++ b/identity_provider.go @@ -0,0 +1,6 @@ +package scalr + +// IdentityProvider represents a Scalr IACP IdentityProvider. +type IdentityProvider struct { + ID string `jsonapi:"primary,identity-providers"` +} diff --git a/scalr.go b/scalr.go index e9aca6d..fd1aa8a 100644 --- a/scalr.go +++ b/scalr.go @@ -108,6 +108,8 @@ type Client struct { Environments Environments ConfigurationVersions ConfigurationVersions VcsRevisions VcsRevisions + Users Users + Teams Teams } // NewClient creates a new Scalr API client. @@ -181,6 +183,8 @@ func NewClient(cfg *Config) (*Client, error) { client.Webhooks = &webhooks{client: client} client.ConfigurationVersions = &configurationVersions{client: client} client.VcsRevisions = &vcs_revisions{client: client} + client.Users = &users{client: client} + client.Teams = &teams{client: client} return client, nil } diff --git a/team.go b/team.go new file mode 100644 index 0000000..f8a0f5d --- /dev/null +++ b/team.go @@ -0,0 +1,195 @@ +package scalr + +import ( + "context" + "errors" + "fmt" + "net/url" +) + +// Compile-time proof of interface implementation. +var _ Teams = (*teams)(nil) + +// Teams describes all the team related methods that the +// Scalr IACP API supports. +type Teams interface { + List(ctx context.Context) (*TeamList, error) + Read(ctx context.Context, teamID string) (*Team, error) + Create(ctx context.Context, options TeamCreateOptions) (*Team, error) + Update(ctx context.Context, teamID string, options TeamUpdateOptions) (*Team, error) + Delete(ctx context.Context, teamID string) error +} + +// teams implements Teams. +type teams struct { + client *Client +} + +// TeamList represents a list of teams. +type TeamList struct { + *Pagination + Items []*Team +} + +// Team represents a Scalr team. +type Team struct { + ID string `jsonapi:"primary,teams"` + Name string `jsonapi:"attr,name"` + Description string `jsonapi:"attr,description"` + + Account *Account `jsonapi:"relation,account"` + IdentityProvider *IdentityProvider `jsonapi:"relation,identity-provider"` + Users []*User `jsonapi:"relation,users"` +} + +// TeamCreateOptions represents the options for creating a new Team. +type TeamCreateOptions struct { + ID string `jsonapi:"primary,teams"` + Name *string `jsonapi:"attr,name"` + Description *string `jsonapi:"attr,description,omitempty"` + + // Relations + IdentityProvider *IdentityProvider `jsonapi:"relation,identity-provider"` + Account *Account `jsonapi:"relation,account,omitempty"` + Users []*User `jsonapi:"relation,users,omitempty"` +} + +func (o TeamCreateOptions) valid() error { + if o.IdentityProvider == nil { + return errors.New("identity provider is required") + } + if !validStringID(&o.IdentityProvider.ID) { + return errors.New("invalid value for identity provider ID") + } + if o.Account == nil { + return errors.New("account is required") + } + if o.Account != nil && !validStringID(&o.Account.ID) { + return errors.New("invalid value for account ID") + } + + if o.Name == nil { + return errors.New("name is required") + } + + for i, usr := range o.Users { + if usr != nil && !validStringID(&usr.ID) { + return errors.New(fmt.Sprintf("invalid value for user ID: %v (idx: %v)", usr.ID, i)) + } + } + return nil +} + +// Create is used to create a new Team. +func (s *teams) Create(ctx context.Context, options TeamCreateOptions) (*Team, error) { + if err := options.valid(); err != nil { + return nil, err + } + // Make sure we don't send an team provided ID. + options.ID = "" + req, err := s.client.newRequest("POST", "teams", &options) + if err != nil { + return nil, err + } + + team := &Team{} + err = s.client.do(ctx, req, team) + if err != nil { + return nil, err + } + + return team, nil +} + +// List all the teams. +func (s *teams) List(ctx context.Context) (*TeamList, error) { + + options := struct { + Include string `url:"include"` + }{ + Include: "users", + } + req, err := s.client.newRequest("GET", "teams", &options) + if err != nil { + return nil, err + } + + tl := &TeamList{} + err = s.client.do(ctx, req, tl) + if err != nil { + return nil, err + } + + return tl, nil +} + +// Read an team by its ID. +func (s *teams) Read(ctx context.Context, teamID string) (*Team, error) { + if !validStringID(&teamID) { + return nil, errors.New("invalid value for team ID") + } + options := struct { + Include string `url:"include"` + }{ + Include: "users", + } + + q := fmt.Sprintf("teams/%s", url.QueryEscape(teamID)) + req, err := s.client.newRequest("GET", q, &options) + if err != nil { + return nil, err + } + + team := &Team{} + err = s.client.do(ctx, req, team) + if err != nil { + return nil, err + } + + return team, nil +} + +// TeamUpdateOptions represents the options for updating a team. +type TeamUpdateOptions struct { + ID string `jsonapi:"primary,teams"` + Name *string `jsonapi:"attr,name,omitempty"` + Description *string `jsonapi:"attr,description,omitempty"` + + // Relations + Users []*User `jsonapi:"relation,users,omitempty"` +} + +// Update settings of an existing team. +func (s *teams) Update(ctx context.Context, teamID string, options TeamUpdateOptions) (*Team, error) { + // Make sure we don't send a team provided ID. + options.ID = "" + + u := fmt.Sprintf("teams/%s", url.QueryEscape(teamID)) + req, err := s.client.newRequest("PATCH", u, &options) + if err != nil { + return nil, err + } + + team := &Team{} + err = s.client.do(ctx, req, team) + if err != nil { + return nil, err + } + + return team, nil +} + +// Delete an team by its ID. +func (s *teams) Delete(ctx context.Context, teamID string) error { + if !validStringID(&teamID) { + return errors.New("invalid value for team ID") + } + + u := fmt.Sprintf("teams/%s", url.QueryEscape(teamID)) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} diff --git a/team_test.go b/team_test.go new file mode 100644 index 0000000..e0dd66c --- /dev/null +++ b/team_test.go @@ -0,0 +1,205 @@ +package scalr + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTeamsList(t *testing.T) { + client := testClient(t) + client.headers.Set("Prefer", "profile=internal") + ctx := context.Background() + + teaml, err := client.Teams.List(ctx) + if err != nil { + t.Fatal(err) + } + totalCount := teaml.TotalCount + teamTest1, teamTest1Cleanup := createTeam(t, client) + defer teamTest1Cleanup() + + t.Run("with no list options", func(t *testing.T) { + teaml, err := client.Teams.List(ctx) + teamlIDs := make([]string, len(teaml.Items)) + for _, team := range teaml.Items { + teamlIDs = append(teamlIDs, team.ID) + } + require.NoError(t, err) + assert.Contains(t, teamlIDs, teamTest1.ID) + + assert.Equal(t, 1, teaml.CurrentPage) + assert.Equal(t, 1+totalCount, teaml.TotalCount) + }) + +} + +func TestTeamsCreate(t *testing.T) { + client := testClient(t) + client.headers.Set("Prefer", "profile=internal") + ctx := context.Background() + t.Run("when no name is provided", func(t *testing.T) { + _, err := client.Teams.Create(ctx, TeamCreateOptions{ + IdentityProvider: &IdentityProvider{ID: defaultIdentityProviderID}, + Account: &Account{ID: defaultAccountID}, + }) + assert.EqualError(t, err, "name is required") + }) + t.Run("when no identity provider is provided", func(t *testing.T) { + _, err := client.Teams.Create(ctx, TeamCreateOptions{ + Account: &Account{ID: defaultAccountID}, + Name: String("tst-" + randomString(t)), + }) + assert.EqualError(t, err, "identity provider is required") + }) + t.Run("with invalid identity-provider id", func(t *testing.T) { + team, err := client.Teams.Create(ctx, TeamCreateOptions{ + Account: &Account{ID: defaultAccountID}, + Name: String("tst-" + randomString(t)), + IdentityProvider: &IdentityProvider{ID: badIdentifier}, + }) + assert.Nil(t, team) + assert.EqualError(t, err, "invalid value for identity provider ID") + }) + t.Run("when no account is provided", func(t *testing.T) { + _, err := client.Teams.Create(ctx, TeamCreateOptions{ + IdentityProvider: &IdentityProvider{ID: defaultIdentityProviderID}, + Name: String("tst-" + randomString(t)), + }) + assert.EqualError(t, err, "account is required") + }) + t.Run("with invalid account id", func(t *testing.T) { + team, err := client.Teams.Create(ctx, TeamCreateOptions{ + Account: &Account{ID: badIdentifier}, + Name: String("tst-" + randomString(t)), + IdentityProvider: &IdentityProvider{ID: defaultIdentityProviderID}, + }) + assert.Nil(t, team) + assert.EqualError(t, err, "invalid value for account ID") + }) + t.Run("with valid options", func(t *testing.T) { + options := TeamCreateOptions{ + Account: &Account{ID: defaultAccountID}, + Name: String("tst-" + randomString(t)), + IdentityProvider: &IdentityProvider{ID: defaultIdentityProviderID}, + Description: String("Team created by go-scalr tests."), + Users: []*User{{ID: defaultUserID}}, + } + + team, err := client.Teams.Create(ctx, options) + if err != nil { + t.Fatal(err) + } + // Get a refreshed view of the team + _, err = client.Teams.Read(ctx, team.ID) + require.NoError(t, err) + + defer client.Teams.Delete(ctx, team.ID) + + assert.Equal(t, *options.Name, team.Name) + assert.Equal(t, *options.Description, team.Description) + assert.Equal(t, options.IdentityProvider.ID, team.IdentityProvider.ID) + assert.Equal(t, options.Account.ID, team.Account.ID) + assert.Equal(t, options.Users[0].ID, team.Users[0].ID) + }) + +} + +func TestTeamsRead(t *testing.T) { + client := testClient(t) + client.headers.Set("Prefer", "profile=internal") + ctx := context.Background() + + teamTest, teamTestCleanup := createTeam(t, client) + defer teamTestCleanup() + t.Run("when the team exists", func(t *testing.T) { + _, err := client.Teams.Read(ctx, teamTest.ID) + require.NoError(t, err) + }) + + t.Run("when the team does not exist", func(t *testing.T) { + _, err := client.Teams.Read(ctx, "notexisting") + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("with invalid team ID", func(t *testing.T) { + r, err := client.Teams.Read(ctx, badIdentifier) + assert.Nil(t, r) + assert.EqualError(t, err, "invalid value for team ID") + }) +} + +func TestTeamsUpdate(t *testing.T) { + client := testClient(t) + client.headers.Set("Prefer", "profile=internal") + ctx := context.Background() + + t.Run("with valid options", func(t *testing.T) { + teamTest, teamTestCleanup := createTeam(t, client) + + options := TeamUpdateOptions{ + Name: String(fmt.Sprintf("Updated name for team %s", teamTest.ID)), + Description: String(fmt.Sprintf("Updated description for team %s", teamTest.ID)), + Users: []*User{}, + } + + team, err := client.Teams.Update(ctx, teamTest.ID, options) + if err != nil { + teamTestCleanup() + } + require.NoError(t, err) + + // Make sure we clean up the updated team. + defer client.Teams.Delete(ctx, team.ID) + + // Also get a fresh result from the API to ensure we get the + // expected values back. + refreshed, err := client.Teams.Read(ctx, team.ID) + require.NoError(t, err) + + for _, item := range []*Team{ + team, + refreshed, + } { + assert.Equal(t, *options.Name, item.Name) + assert.Equal(t, *options.Description, item.Description) + // assert.Equal(t, *options.Users[0], *item.Users[0]) + } + }) + + // this one is broken on server + // t.Run("when only updating a subset of fields", func(t *testing.T) { + // teamTest, teamTestCleanup := createTeam(t, client) + // defer teamTestCleanup() + // + // team, err := client.Teams.Update(ctx, teamTest.ID, TeamUpdateOptions{Description: String("blah")}) + // require.NoError(t, err) + // assert.Equal(t, teamTest.Name, team.Name) + // assert.Equal(t, "blah", team.Description) + // }) +} + +func TestTeamsDelete(t *testing.T) { + client := testClient(t) + client.headers.Set("Prefer", "profile=internal") + ctx := context.Background() + + t.Run("with valid options", func(t *testing.T) { + teamTest, _ := createTeam(t, client) + + err := client.Teams.Delete(ctx, teamTest.ID) + require.NoError(t, err) + + // Try fetching the team again - it should error. + _, err = client.Teams.Read(ctx, teamTest.ID) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the team does not exist", func(t *testing.T) { + err := client.Teams.Delete(ctx, randomString(t)) + assert.Equal(t, err, ErrResourceNotFound) + }) +} diff --git a/user.go b/user.go index d709558..76338cf 100644 --- a/user.go +++ b/user.go @@ -1,9 +1,186 @@ package scalr +import ( + "context" + "errors" + "fmt" + "net/url" + "time" +) + +// Compile-time proof of interface implementation. +var _ Users = (*users)(nil) + +// Users describes all the user related methods that the +// Scalr IACP API supports. +type Users interface { + List(ctx context.Context) (*UserList, error) + Read(ctx context.Context, userID string) (*User, error) + Create(ctx context.Context, options UserCreateOptions) (*User, error) + Update(ctx context.Context, userID string, options UserUpdateOptions) (*User, error) + Delete(ctx context.Context, userID string) error +} + +// users implements Users. +type users struct { + client *Client +} + +// UserStatus represents an user status. +type UserStatus = string + +// List of available user statuses. +const ( + UserStatusActive UserStatus = "Active" + UserStatusInactive UserStatus = "Inactive" + UserStatusPending UserStatus = "Pending" +) + +// UserList represents a list of users. +type UserList struct { + *Pagination + Items []*User +} + // User represents a Scalr user. type User struct { - ID string `jsonapi:"primary,users"` - Email string `jsonapi:"attr,email"` - Username string `jsonapi:"attr,username"` - FullName string `jsonapi:"attr,full-name"` + ID string `jsonapi:"primary,users"` + Email string `jsonapi:"attr,email"` + Username string `jsonapi:"attr,username"` + FullName string `jsonapi:"attr,full-name"` + Status UserStatus `jsonapi:"attr,status"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + IdentityProvider *IdentityProvider `jsonapi:"relation,identity-provider"` +} + +// UserCreateOptions represents the options for creating a new User. +type UserCreateOptions struct { + ID string `jsonapi:"primary,users"` + Username *string `jsonapi:"attr,username,omitempty"` + Email *string `jsonapi:"attr,email"` + FullName *string `jsonapi:"attr,full-name,omitempty"` + Password *string `jsonapi:"attr,password,omitempty"` + Status UserStatus `jsonapi:"attr,status"` + + // Relations + IdentityProvider *IdentityProvider `jsonapi:"relation,identity-provider"` +} + +func (o UserCreateOptions) valid() error { + if o.IdentityProvider == nil { + return errors.New("identity provider is required") + } + if !validStringID(&o.IdentityProvider.ID) { + return errors.New("invalid value for identity provider ID") + } + if o.Email == nil { + return errors.New("email is required") + } + if o.Status == "" { + return errors.New("status is required") + } + if !validEmail(o.Email) { + return errors.New("invalid value for email") + } + return nil +} + +// Create is used to create a new User. +func (s *users) Create(ctx context.Context, options UserCreateOptions) (*User, error) { + if err := options.valid(); err != nil { + return nil, err + } + // Make sure we don't send an user provided ID. + options.ID = "" + req, err := s.client.newRequest("POST", "users", &options) + if err != nil { + return nil, err + } + + user := &User{} + err = s.client.do(ctx, req, user) + if err != nil { + return nil, err + } + + return user, nil +} + +// List all the users. +func (s *users) List(ctx context.Context) (*UserList, error) { + req, err := s.client.newRequest("GET", "users", nil) + if err != nil { + return nil, err + } + + usrl := &UserList{} + err = s.client.do(ctx, req, usrl) + if err != nil { + return nil, err + } + + return usrl, nil +} + +// Read an user by its ID. +func (s *users) Read(ctx context.Context, userID string) (*User, error) { + if !validStringID(&userID) { + return nil, errors.New("invalid value for user ID") + } + + u := fmt.Sprintf("users/%s", url.QueryEscape(userID)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + usr := &User{} + err = s.client.do(ctx, req, usr) + if err != nil { + return nil, err + } + + return usr, nil +} + +// UserUpdateOptions represents the options for updating an user. +type UserUpdateOptions struct { + ID string `jsonapi:"primary,users"` + FullName *string `jsonapi:"attr,full-name,omitempty"` + Status UserStatus `jsonapi:"attr,status,omitempty"` +} + +// Update settings of an existing user. +func (s *users) Update(ctx context.Context, userID string, options UserUpdateOptions) (*User, error) { + // Make sure we don't send a user provided ID. + options.ID = "" + + u := fmt.Sprintf("users/%s", url.QueryEscape(userID)) + req, err := s.client.newRequest("PATCH", u, &options) + if err != nil { + return nil, err + } + + usr := &User{} + err = s.client.do(ctx, req, usr) + if err != nil { + return nil, err + } + + return usr, nil +} + +// Delete an user by its ID. +func (s *users) Delete(ctx context.Context, userID string) error { + if !validStringID(&userID) { + return errors.New("invalid value for user ID") + } + + u := fmt.Sprintf("users/%s", url.QueryEscape(userID)) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) } diff --git a/user_test.go b/user_test.go new file mode 100644 index 0000000..1e31d35 --- /dev/null +++ b/user_test.go @@ -0,0 +1,192 @@ +package scalr + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUsersList(t *testing.T) { + client := testClient(t) + client.headers.Set("Prefer", "profile=internal") + ctx := context.Background() + + usrl, err := client.Users.List(ctx) + if err != nil { + t.Fatal(err) + } + totalCount := usrl.TotalCount + usrTest1, usrTest1Cleanup := createUser(t, client) + defer usrTest1Cleanup() + + t.Run("with no list options", func(t *testing.T) { + usrl, err := client.Users.List(ctx) + usrlIDs := make([]string, len(usrl.Items)) + for _, usr := range usrl.Items { + usrlIDs = append(usrlIDs, usr.ID) + } + require.NoError(t, err) + assert.Contains(t, usrlIDs, usrTest1.ID) + + assert.Equal(t, 1, usrl.CurrentPage) + assert.Equal(t, 1+totalCount, usrl.TotalCount) + }) + +} + +func TestUsersCreate(t *testing.T) { + client := testClient(t) + client.headers.Set("Prefer", "profile=internal") + ctx := context.Background() + t.Run("when no email is provided", func(t *testing.T) { + _, err := client.Users.Create(ctx, UserCreateOptions{ + IdentityProvider: &IdentityProvider{ID: defaultIdentityProviderID}, + Username: String("tst-" + randomString(t)), + Status: UserStatusActive, + }) + assert.EqualError(t, err, "email is required") + }) + t.Run("when invalid email is provided", func(t *testing.T) { + _, err := client.Users.Create(ctx, UserCreateOptions{ + Email: String("go-scalr-test&scalr.com"), + IdentityProvider: &IdentityProvider{ID: defaultIdentityProviderID}, + Username: String("tst-" + randomString(t)), + Status: UserStatusActive, + }) + assert.EqualError(t, err, "invalid value for email") + }) + t.Run("when no identity provider is provided", func(t *testing.T) { + _, err := client.Users.Create(ctx, UserCreateOptions{ + Email: String("go-scalr-test@scalr.com"), + Username: String("tst-" + randomString(t)), + Status: UserStatusActive, + }) + assert.EqualError(t, err, "identity provider is required") + }) + t.Run("with invalid identity-provider id", func(t *testing.T) { + usr, err := client.Users.Create(ctx, UserCreateOptions{ + Email: String("go-scalr-test@scalr.com"), + IdentityProvider: &IdentityProvider{ID: badIdentifier}, + Username: String("tst-" + randomString(t)), + Status: UserStatusActive, + }) + assert.Nil(t, usr) + assert.EqualError(t, err, "invalid value for identity provider ID") + }) + t.Run("with valid options", func(t *testing.T) { + options := UserCreateOptions{ + IdentityProvider: &IdentityProvider{ID: defaultIdentityProviderID}, + Email: String("go-scalr-test@scalr.com"), + Status: UserStatusActive, + } + + usr, err := client.Users.Create(ctx, options) + if err != nil { + t.Fatal(err) + } + // Get a refreshed view of the user + _, err = client.Users.Read(ctx, usr.ID) + require.NoError(t, err) + + defer client.Users.Delete(ctx, usr.ID) + + assert.Equal(t, *options.Email, usr.Email) + assert.Equal(t, options.Status, usr.Status) + assert.Equal(t, (*options.IdentityProvider).ID, (*usr.IdentityProvider).ID) + }) + +} + +func TestUsersRead(t *testing.T) { + client := testClient(t) + client.headers.Set("Prefer", "profile=internal") + ctx := context.Background() + + usrTest, usrTestCleanup := createUser(t, client) + defer usrTestCleanup() + t.Run("when the user exists", func(t *testing.T) { + _, err := client.Users.Read(ctx, usrTest.ID) + require.NoError(t, err) + }) + + t.Run("when the user does not exist", func(t *testing.T) { + _, err := client.Users.Read(ctx, "notexisting") + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("with invalid usr ID", func(t *testing.T) { + r, err := client.Users.Read(ctx, badIdentifier) + assert.Nil(t, r) + assert.EqualError(t, err, "invalid value for user ID") + }) +} + +func TestUsersUpdate(t *testing.T) { + client := testClient(t) + client.headers.Set("Prefer", "profile=internal") + ctx := context.Background() + + t.Run("with valid options", func(t *testing.T) { + usrTest, usrTestCleanup := createUser(t, client) + + options := UserUpdateOptions{ + FullName: String("Leto Atreides"), + Status: UserStatusInactive, + } + + usr, err := client.Users.Update(ctx, usrTest.ID, options) + if err != nil { + usrTestCleanup() + } + require.NoError(t, err) + + // Make sure we clean up the updated usr. + defer client.Users.Delete(ctx, usr.ID) + + // Also get a fresh result from the API to ensure we get the + // expected values back. + refreshed, err := client.Users.Read(ctx, usr.ID) + require.NoError(t, err) + + for _, item := range []*User{ + usr, + refreshed, + } { + assert.Equal(t, *options.FullName, item.FullName) + assert.Equal(t, options.Status, item.Status) + } + }) + + t.Run("when only updating a subset of fields", func(t *testing.T) { + usrTest, usrTestCleanup := createUser(t, client) + defer usrTestCleanup() + + usr, err := client.Users.Update(ctx, usrTest.ID, UserUpdateOptions{}) + require.NoError(t, err) + assert.Equal(t, usrTest.FullName, usr.FullName) + }) +} + +func TestUsersDelete(t *testing.T) { + client := testClient(t) + client.headers.Set("Prefer", "profile=internal") + ctx := context.Background() + + t.Run("with valid options", func(t *testing.T) { + usrTest, _ := createUser(t, client) + + err := client.Users.Delete(ctx, usrTest.ID) + require.NoError(t, err) + + // Try fetching the usr again - it should error. + _, err = client.Users.Read(ctx, usrTest.ID) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the usr does not exist", func(t *testing.T) { + err := client.Users.Delete(ctx, randomString(t)) + assert.Equal(t, err, ErrResourceNotFound) + }) +} diff --git a/validations.go b/validations.go index 301cedb..63c4731 100644 --- a/validations.go +++ b/validations.go @@ -7,11 +7,19 @@ import ( // A regular expression used to validate common string ID patterns. var reStringID = regexp.MustCompile(`^[a-zA-Z0-9\-\._]+$`) +// A regular expression used to validate email +var reEmail = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + // validString checks if the given input is present and non-empty. func validString(v *string) bool { return v != nil && *v != "" } +// validEmail checks if the given strings mathes email regexp +func validEmail(v *string) bool { + return v != nil && reEmail.MatchString(*v) +} + // validStringID checks if the given string pointer is non-nil and // contains a typical string identifier. func validStringID(v *string) bool {