diff --git a/README.md b/README.md index a520d79..46e88a0 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,30 @@ onecli user list --user-id 123 # Add a new user onecli user add "John" "Doe" "john.doe@example.com" +# Set a password for an existing user +onecli user set-password --email user@example.com --password "$PASSWORD" + +# Set a user's status (1=Active, 2=Suspended, 4=PasswordExpired, 5=AwaitingPasswordReset) +onecli user set-status --email user@example.com --status 4 + +# Send a password setup/reset invite link via email +onecli user send-invite --email user@example.com +onecli user send-invite --email user@example.com --personal-email personal@example.com + # Modify user email onecli user modify email "newemail@example.com" --email "oldemail@example.com" ``` +These single-purpose commands can be chained to create and invite a user in one go. +Setting status `4` (PasswordExpired) forces a password change on first login: + +```bash +onecli user add "John" "Doe" "john.doe@example.com" && \ +onecli user set-password --email john.doe@example.com --password "$PASSWORD" && \ +onecli user set-status --email john.doe@example.com --status 4 && \ +onecli user send-invite --email john.doe@example.com +``` + ### App Management ```bash diff --git a/cmd/user.go b/cmd/user.go index aa0395c..cf116f4 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -22,6 +22,10 @@ var ( userQueryLastname string userQueryUserID string output string + + sendInvitePersonalEmail string + setPasswordValue string + setStatusValue int32 ) // initClient initializes the OneLogin client @@ -84,24 +88,13 @@ var modifyEmailCmd = &cobra.Command{ return err } - users, err := client.GetUsers(query) + user, err := findUserByQuery(client, query) if err != nil { - return fmt.Errorf("error getting users: %v", err) - } - - if len(users) == 0 { - return fmt.Errorf("no users found matching the query") - } - - if len(users) > 1 { - return fmt.Errorf("multiple users found matching the query. Please be more specific") + return err } - - user := users[0] user.Email = newEmail - err = client.UpdateUser(int(user.ID), user) - if err != nil { + if err := client.UpdateUser(int(user.ID), user); err != nil { return fmt.Errorf("error updating user: %v", err) } @@ -111,10 +104,11 @@ var modifyEmailCmd = &cobra.Command{ } var addCmd = &cobra.Command{ - Use: "add ", - Short: "Add a new user", - Long: `Add a new user to your OneLogin organization`, - Args: cobra.ExactArgs(3), + Use: "add ", + Short: "Add a new user", + Long: `Add a new user to your OneLogin organization`, + Args: cobra.ExactArgs(3), + SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { firstName := args[0] lastName := args[1] @@ -122,7 +116,7 @@ var addCmd = &cobra.Command{ client, err := initClient() if err != nil { - return fmt.Errorf("error initializing OneLogin client: %v", err) + return err } newUser := onelogin.User{ @@ -131,8 +125,7 @@ var addCmd = &cobra.Command{ Email: email, } - err = client.CreateUser(newUser) - if err != nil { + if _, err := client.CreateUser(newUser); err != nil { return fmt.Errorf("error creating user: %v", err) } @@ -141,6 +134,96 @@ var addCmd = &cobra.Command{ }, } +var setPasswordCmd = &cobra.Command{ + Use: "set-password", + Short: "Set a password for a user", + Long: `Set a password for an existing OneLogin user`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + query := getUserQuery() + if isQueryParamsEmpty(query) { + return fmt.Errorf("at least one query parameter (email, username, firstname, lastname, or user-id) must be specified") + } + + client, err := initClient() + if err != nil { + return err + } + + user, err := findUserByQuery(client, query) + if err != nil { + return err + } + + if err := client.SetPassword(int(user.ID), setPasswordValue); err != nil { + return fmt.Errorf("error setting password: %v", err) + } + + fmt.Printf("Successfully set password for %s\n", user.Email) + return nil + }, +} + +var setStatusCmd = &cobra.Command{ + Use: "set-status", + Short: "Set the status of a user", + Long: `Set the status of an existing OneLogin user (1=Active, 2=Suspended, 4=PasswordExpired, 5=AwaitingPasswordReset)`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + query := getUserQuery() + if isQueryParamsEmpty(query) { + return fmt.Errorf("at least one query parameter (email, username, firstname, lastname, or user-id) must be specified") + } + + client, err := initClient() + if err != nil { + return err + } + + user, err := findUserByQuery(client, query) + if err != nil { + return err + } + + if err := client.UpdateUser(int(user.ID), onelogin.User{Status: setStatusValue}); err != nil { + return fmt.Errorf("error setting user status: %v", err) + } + + fmt.Printf("Successfully set status %d for %s\n", setStatusValue, user.Email) + return nil + }, +} + +var sendInviteCmd = &cobra.Command{ + Use: "send-invite", + Short: "Send a password setup/reset invite link to a user", + Long: `Send a password setup/reset invite link to a OneLogin user via email.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + query := getUserQuery() + if isQueryParamsEmpty(query) { + return fmt.Errorf("at least one query parameter (email, username, firstname, lastname, or user-id) must be specified") + } + + client, err := initClient() + if err != nil { + return err + } + + user, err := findUserByQuery(client, query) + if err != nil { + return err + } + + if err := client.SendInviteLink(user.Email, sendInvitePersonalEmail); err != nil { + return fmt.Errorf("error sending invite link: %v", err) + } + + fmt.Printf("Successfully sent invite link for %s\n", user.Email) + return nil + }, +} + func getUserQuery() onelogin.UserQuery { query := onelogin.UserQuery{} @@ -172,11 +255,30 @@ func isQueryParamsEmpty(params onelogin.UserQuery) bool { return params.Email == nil && params.Username == nil && params.Firstname == nil && params.Lastname == nil && params.UserIDs == nil } +// findUserByQuery looks up a single user matching the query and returns it. +// Errors if no user matches or if more than one matches. +func findUserByQuery(client *onelogin.Onelogin, query onelogin.UserQuery) (onelogin.User, error) { + users, err := client.GetUsers(query) + if err != nil { + return onelogin.User{}, fmt.Errorf("error getting users: %v", err) + } + if len(users) == 0 { + return onelogin.User{}, fmt.Errorf("no users found matching the query") + } + if len(users) > 1 { + return onelogin.User{}, fmt.Errorf("multiple users found matching the query. Please be more specific") + } + return users[0], nil +} + func init() { userCmd.AddCommand(listCmd) userCmd.AddCommand(modifyCmd) modifyCmd.AddCommand(modifyEmailCmd) userCmd.AddCommand(addCmd) + userCmd.AddCommand(setPasswordCmd) + userCmd.AddCommand(setStatusCmd) + userCmd.AddCommand(sendInviteCmd) listCmd.Flags().StringVarP(&output, "output", "o", "yaml", "Output format (yaml, json, csv)") listCmd.Flags().StringVar(&userQueryEmail, "email", "", "Filter users by email") @@ -190,4 +292,27 @@ func init() { modifyEmailCmd.Flags().StringVar(&userQueryFirstname, "firstname", "", "Query by first name") modifyEmailCmd.Flags().StringVar(&userQueryLastname, "lastname", "", "Query by last name") modifyEmailCmd.Flags().StringVar(&userQueryUserID, "user-id", "", "Query by user ID") + + setPasswordCmd.Flags().StringVar(&userQueryEmail, "email", "", "Query by email") + setPasswordCmd.Flags().StringVar(&userQueryUsername, "username", "", "Query by username") + setPasswordCmd.Flags().StringVar(&userQueryFirstname, "firstname", "", "Query by first name") + setPasswordCmd.Flags().StringVar(&userQueryLastname, "lastname", "", "Query by last name") + setPasswordCmd.Flags().StringVar(&userQueryUserID, "user-id", "", "Query by user ID") + setPasswordCmd.Flags().StringVar(&setPasswordValue, "password", "", "New password (required)") + _ = setPasswordCmd.MarkFlagRequired("password") + + setStatusCmd.Flags().StringVar(&userQueryEmail, "email", "", "Query by email") + setStatusCmd.Flags().StringVar(&userQueryUsername, "username", "", "Query by username") + setStatusCmd.Flags().StringVar(&userQueryFirstname, "firstname", "", "Query by first name") + setStatusCmd.Flags().StringVar(&userQueryLastname, "lastname", "", "Query by last name") + setStatusCmd.Flags().StringVar(&userQueryUserID, "user-id", "", "Query by user ID") + setStatusCmd.Flags().Int32Var(&setStatusValue, "status", 0, "Status value (1=Active, 2=Suspended, 4=PasswordExpired, 5=AwaitingPasswordReset) (required)") + _ = setStatusCmd.MarkFlagRequired("status") + + sendInviteCmd.Flags().StringVar(&userQueryEmail, "email", "", "Query by email") + sendInviteCmd.Flags().StringVar(&userQueryUsername, "username", "", "Query by username") + sendInviteCmd.Flags().StringVar(&userQueryFirstname, "firstname", "", "Query by first name") + sendInviteCmd.Flags().StringVar(&userQueryLastname, "lastname", "", "Query by last name") + sendInviteCmd.Flags().StringVar(&userQueryUserID, "user-id", "", "Query by user ID") + sendInviteCmd.Flags().StringVar(&sendInvitePersonalEmail, "personal-email", "", "Optional alternate email to send the invite link to") } diff --git a/onelogin/client.go b/onelogin/client.go index 3679e8b..4c788c2 100644 --- a/onelogin/client.go +++ b/onelogin/client.go @@ -13,6 +13,7 @@ const ( type ( User = models.User UserQuery = models.UserQuery + Invite = models.Invite ) type ( @@ -24,6 +25,8 @@ type Client interface { GetUsers(query models.Queryable) (any, error) UpdateUser(userID int, user models.User) (any, error) CreateUser(user models.User) (any, error) + UpdatePasswordInsecure(userID int, requestBody any) (any, error) + SendInviteLink(invite models.Invite) (any, error) GetApps(query models.Queryable) (any, error) GetAppUsers(appID int, query models.Queryable) (any, error) ListEvents(query models.Queryable) (any, error) diff --git a/onelogin/user.go b/onelogin/user.go index 0636ba4..20180df 100644 --- a/onelogin/user.go +++ b/onelogin/user.go @@ -3,7 +3,6 @@ package onelogin import ( "fmt" "strconv" - "time" "github.com/pepabo/onecli/utils" ) @@ -23,36 +22,42 @@ func (o *Onelogin) GetUsers(query UserQuery) ([]User, error) { // UpdateUser updates a user in Onelogin func (o *Onelogin) UpdateUser(userID int, user User) error { _, err := o.client.UpdateUser(userID, user) - if err != nil { - return err - } - return nil + return err } -// SetUserState sets the user state to active and updates the last login time -func (o *Onelogin) SetUserState(userID int) error { - user := User{ - Status: 1, - LastLogin: time.Now(), - } - err := o.UpdateUser(userID, user) +// CreateUser creates a new user in Onelogin and returns the created user's ID +func (o *Onelogin) CreateUser(user User) (int, error) { + result, err := o.client.CreateUser(user) if err != nil { - return fmt.Errorf("error setting user state: %v", err) + return 0, err } - - return nil + resultMap, ok := result.(map[string]any) + if !ok { + return 0, fmt.Errorf("unexpected response type from create user: %T", result) + } + idFloat, ok := resultMap["id"].(float64) + if !ok { + return 0, fmt.Errorf("missing or invalid id in create user response") + } + return int(idFloat), nil } -// CreateUser creates a new user in Onelogin -func (o *Onelogin) CreateUser(user User) error { - result, err := o.client.CreateUser(user) - if err != nil { - return fmt.Errorf("error creating user: %v", err) +// SetPassword sets a password for a user +func (o *Onelogin) SetPassword(userID int, password string) error { + body := map[string]string{ + "password": password, + "password_confirmation": password, } + _, err := o.client.UpdatePasswordInsecure(userID, body) + return err +} - if err := o.SetUserState(int(result.(User).ID)); err != nil { - return err +// SendInviteLink sends a password setup/reset invite link to a user +func (o *Onelogin) SendInviteLink(email, personalEmail string) error { + invite := Invite{ + Email: email, + PersonalEmail: personalEmail, } - - return nil + _, err := o.client.SendInviteLink(invite) + return err } diff --git a/onelogin/user_test.go b/onelogin/user_test.go index fbd5999..970de50 100644 --- a/onelogin/user_test.go +++ b/onelogin/user_test.go @@ -1,13 +1,11 @@ package onelogin import ( - "fmt" "testing" "github.com/onelogin/onelogin-go-sdk/v4/pkg/onelogin/models" "github.com/pepabo/onecli/utils" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" ) func TestGetUsers(t *testing.T) { @@ -136,13 +134,63 @@ func TestGetUsers(t *testing.T) { } } +func TestSendInviteLink(t *testing.T) { + tests := []struct { + name string + email string + personalEmail string + mockError error + expectedError error + }{ + { + name: "successful invite to primary email", + email: "user@example.com", + }, + { + name: "successful invite with personal email", + email: "user@example.com", + personalEmail: "user@gmail.com", + }, + { + name: "error from client", + email: "user@example.com", + mockError: assert.AnError, + expectedError: assert.AnError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(utils.MockClient) + o := &Onelogin{client: mockClient} + + expectedInvite := models.Invite{ + Email: tt.email, + PersonalEmail: tt.personalEmail, + } + mockClient.On("SendInviteLink", expectedInvite).Return(nil, tt.mockError) + + err := o.SendInviteLink(tt.email, tt.personalEmail) + + if tt.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tt.expectedError, err) + } else { + assert.NoError(t, err) + } + + mockClient.AssertExpectations(t) + }) + } +} + func TestCreateUser(t *testing.T) { tests := []struct { name string inputUser models.User mockResponse any mockError error - expectedUser models.User + expectedID int expectedError error }{ { @@ -153,20 +201,14 @@ func TestCreateUser(t *testing.T) { Firstname: "New", Lastname: "User", }, - mockResponse: models.User{ - ID: 3, - Email: "newuser@example.com", - Username: "newuser", - Firstname: "New", - Lastname: "User", - }, - expectedUser: models.User{ - ID: 3, - Email: "newuser@example.com", - Username: "newuser", - Firstname: "New", - Lastname: "User", + mockResponse: map[string]any{ + "id": float64(3), + "email": "newuser@example.com", + "username": "newuser", + "firstname": "New", + "lastname": "User", }, + expectedID: 3, }, { name: "error creating user", @@ -177,7 +219,7 @@ func TestCreateUser(t *testing.T) { Lastname: "User", }, mockError: assert.AnError, - expectedError: fmt.Errorf("error creating user: %v", assert.AnError), + expectedError: assert.AnError, }, } @@ -190,12 +232,51 @@ func TestCreateUser(t *testing.T) { mockClient.On("CreateUser", tt.inputUser).Return(tt.mockResponse, tt.mockError) - // Add expectation for UpdateUser call in SetUserState - if tt.expectedError == nil { - mockClient.On("UpdateUser", 3, mock.AnythingOfType("models.User")).Return(nil, nil) + id, err := o.CreateUser(tt.inputUser) + + if tt.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tt.expectedError, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedID, id) } - err := o.CreateUser(tt.inputUser) + mockClient.AssertExpectations(t) + }) + } +} + +func TestUpdateUser(t *testing.T) { + tests := []struct { + name string + userID int + user User + mockError error + expectedError error + }{ + { + name: "successful update", + userID: 1, + user: User{Email: "updated@example.com"}, + }, + { + name: "error from client", + userID: 1, + user: User{Email: "updated@example.com"}, + mockError: assert.AnError, + expectedError: assert.AnError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(utils.MockClient) + o := &Onelogin{client: mockClient} + + mockClient.On("UpdateUser", tt.userID, tt.user).Return(nil, tt.mockError) + + err := o.UpdateUser(tt.userID, tt.user) if tt.expectedError != nil { assert.Error(t, err) @@ -203,7 +284,52 @@ func TestCreateUser(t *testing.T) { } else { assert.NoError(t, err) } + mockClient.AssertExpectations(t) + }) + } +} +func TestSetPassword(t *testing.T) { + tests := []struct { + name string + userID int + password string + mockError error + expectedError error + }{ + { + name: "successful password set", + userID: 1, + password: "newpass123", + }, + { + name: "error from client", + userID: 1, + password: "newpass123", + mockError: assert.AnError, + expectedError: assert.AnError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(utils.MockClient) + o := &Onelogin{client: mockClient} + + expectedBody := map[string]string{ + "password": tt.password, + "password_confirmation": tt.password, + } + mockClient.On("UpdatePasswordInsecure", tt.userID, expectedBody).Return(nil, tt.mockError) + + err := o.SetPassword(tt.userID, tt.password) + + if tt.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tt.expectedError, err) + } else { + assert.NoError(t, err) + } mockClient.AssertExpectations(t) }) } diff --git a/onelogin/wrapper.go b/onelogin/wrapper.go index 4f495dd..defdacd 100644 --- a/onelogin/wrapper.go +++ b/onelogin/wrapper.go @@ -1,6 +1,7 @@ package onelogin import ( + "encoding/json" "log/slog" "net/http" "os" @@ -32,12 +33,80 @@ func (s *OneloginSDK) GetUsers(query models.Queryable) (any, error) { return s.sdk.GetUsers(query) } +// UpdateUser updates a user. It strips zero-value time.Time fields from the +// payload because models.User declares them as non-pointer time.Time, so +// json's omitempty cannot drop them and they would otherwise be sent as +// "0001-01-01T00:00:00Z" and overwrite OneLogin's server-managed timestamps. func (s *OneloginSDK) UpdateUser(userID int, user models.User) (any, error) { - return s.sdk.UpdateUser(userID, user) + body, err := userPayload(user) + if err != nil { + return nil, err + } + + p, err := utl.BuildAPIPath(o.UserPathV2, userID) + if err != nil { + return nil, err + } + + r, err := s.sdk.Client.Put(&p, body) + if err != nil { + return nil, err + } + + return utl.CheckHTTPResponse(r) } +// CreateUser creates a user. See UpdateUser for why zero-value time.Time +// fields are stripped from the payload. func (s *OneloginSDK) CreateUser(user models.User) (any, error) { - return s.sdk.CreateUser(user) + body, err := userPayload(user) + if err != nil { + return nil, err + } + + p, err := utl.BuildAPIPath(o.UserPathV2) + if err != nil { + return nil, err + } + + r, err := s.sdk.Client.Post(&p, body) + if err != nil { + return nil, err + } + + return utl.CheckHTTPResponse(r) +} + +// userPayload marshals a user and drops any field whose value is the zero +// time.Time ("0001-01-01T00:00:00Z"), which models.User emits for unset +// timestamps despite omitempty. +func userPayload(user models.User) (map[string]any, error) { + b, err := json.Marshal(user) + if err != nil { + return nil, err + } + + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + + const zeroTime = "0001-01-01T00:00:00Z" + for k, v := range m { + if s, ok := v.(string); ok && s == zeroTime { + delete(m, k) + } + } + + return m, nil +} + +func (s *OneloginSDK) UpdatePasswordInsecure(userID int, requestBody any) (any, error) { + return s.sdk.UpdatePasswordInsecure(userID, requestBody) +} + +func (s *OneloginSDK) SendInviteLink(invite models.Invite) (any, error) { + return s.sdk.SendInviteLink(invite) } func (s *OneloginSDK) GetApps(query models.Queryable) (any, error) { diff --git a/onelogin/wrapper_test.go b/onelogin/wrapper_test.go new file mode 100644 index 0000000..8767ccc --- /dev/null +++ b/onelogin/wrapper_test.go @@ -0,0 +1,52 @@ +package onelogin + +import ( + "testing" + + "github.com/onelogin/onelogin-go-sdk/v4/pkg/onelogin/models" + "github.com/stretchr/testify/assert" +) + +func TestUserPayload(t *testing.T) { + user := models.User{ + Firstname: "onecli_test", + Lastname: "inatchi", + Email: "test@inatchi.dev", + } + + payload, err := userPayload(user) + assert.NoError(t, err) + + // The fields explicitly set by the caller must be preserved. + assert.Equal(t, "onecli_test", payload["firstname"]) + assert.Equal(t, "inatchi", payload["lastname"]) + assert.Equal(t, "test@inatchi.dev", payload["email"]) + + // Zero-value time.Time fields must be stripped so OneLogin does not store + // bogus "0001-01-01T00:00:00Z" timestamps (e.g. last_login). + for _, k := range []string{ + "created_at", + "updated_at", + "activated_at", + "last_login", + "password_changed_at", + "locked_until", + "invitation_sent_at", + } { + _, ok := payload[k] + assert.Falsef(t, ok, "zero-value time field %q should be stripped from payload", k) + } +} + +func TestUserPayloadKeepsNonZeroTime(t *testing.T) { + user := models.User{ + Email: "test@inatchi.dev", + Status: 2, + } + + payload, err := userPayload(user) + assert.NoError(t, err) + + // Non-time fields with real values survive (e.g. set-status sends status only). + assert.Equal(t, float64(2), payload["status"]) +} diff --git a/utils/mock.go b/utils/mock.go index 143a1e1..24c85e5 100644 --- a/utils/mock.go +++ b/utils/mock.go @@ -28,6 +28,18 @@ func (m *MockClient) CreateUser(user models.User) (any, error) { return args.Get(0), args.Error(1) } +// UpdatePasswordInsecure mocks the UpdatePasswordInsecure method +func (m *MockClient) UpdatePasswordInsecure(userID int, requestBody any) (any, error) { + args := m.Called(userID, requestBody) + return args.Get(0), args.Error(1) +} + +// SendInviteLink mocks the SendInviteLink method +func (m *MockClient) SendInviteLink(invite models.Invite) (any, error) { + args := m.Called(invite) + return args.Get(0), args.Error(1) +} + // GetApps mocks the GetApps method func (m *MockClient) GetApps(query models.Queryable) (any, error) { args := m.Called(query)