Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 58 additions & 33 deletions cmd/harbor/root/user/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,46 +22,71 @@ import (
"github.com/spf13/cobra"
)

func UserPasswordChangeCmd() *cobra.Command {
var opts reset.PasswordChangeView
type UserPasswordChanger interface {
GetUserIDByName(username string) (int64, error)
GetUserIDFromUser() int64
FillPasswordView(resetView *reset.PasswordChangeView)
ResetPassword(userID int64, resetView reset.PasswordChangeView) error
}

type DefaultUserPasswordChanger struct{}

func (d *DefaultUserPasswordChanger) GetUserIDByName(username string) (int64, error) {
return api.GetUsersIdByName(username)
}

func (d *DefaultUserPasswordChanger) GetUserIDFromUser() int64 {
return prompt.GetUserIdFromUser()
}

func (d *DefaultUserPasswordChanger) FillPasswordView(resetView *reset.PasswordChangeView) {
reset.ChangePasswordView(resetView)
}

func (d *DefaultUserPasswordChanger) ResetPassword(userID int64, resetView reset.PasswordChangeView) error {
return api.ResetPassword(userID, resetView)
}

func ChangePassword(args []string, passwordChanger UserPasswordChanger) {
var userID int64
var err error
resetView := &reset.PasswordChangeView{}

if len(args) > 0 {
userID, err = passwordChanger.GetUserIDByName(args[0])
if err != nil {
log.Errorf("failed to get user id for '%s': %v", args[0], err)
return
}
if userID == 0 {
log.Errorf("User with name '%s' not found", args[0])
return
}
Comment on lines +55 to +64
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage gap: the production implementation api.GetUsersIdByName returns (0, nil) when the user is not found, and ChangePassword has a dedicated userID == 0 branch. The tests only cover the "not found" case via an error return, so the userID == 0 branch is currently untested.

Copilot uses AI. Check for mistakes.
} else {
userID = passwordChanger.GetUserIDFromUser()
}

passwordChanger.FillPasswordView(resetView)

err = passwordChanger.ResetPassword(userID, *resetView)
if err != nil {
if isUnauthorizedError(err) {
log.Error("Permission denied: Admin privileges are required to execute this command.")
} else {
log.Errorf("failed to reset user password: %v", err)
}
}
Comment on lines +71 to +78
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage gap: there is no test asserting the non-403 error path for ResetPassword (the failed to reset user password log branch). Adding a test case where ResetPassword returns a non-"403" error would ensure this branch remains correct.

Copilot uses AI. Check for mistakes.
}

func UserPasswordChangeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "password",
Short: "Reset user password by name or id",
Long: "Allows admin to reset the password for a specified user or select interactively if no username is provided.",
Args: cobra.MinimumNArgs(0),
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Args: cobra.MinimumNArgs(0) allows multiple positional args, but ChangePassword only uses args[0], silently ignoring the rest. Consider using cobra.MaximumNArgs(1) (or ExactArgs(1) if username is required) to prevent confusing/ignored input.

Suggested change
Args: cobra.MinimumNArgs(0),
Args: cobra.MaximumNArgs(1),

Copilot uses AI. Check for mistakes.
Run: func(cmd *cobra.Command, args []string) {
var userId int64
var err error
resetView := &reset.PasswordChangeView{
NewPassword: opts.NewPassword,
ConfirmPassword: opts.ConfirmPassword,
}

if len(args) > 0 {
userId, err = api.GetUsersIdByName(args[0])
if err != nil {
log.Errorf("failed to get user id for '%s': %v", args[0], err)
return
}
if userId == 0 {
log.Errorf("User with name '%s' not found", args[0])
return
}
} else {
userId = prompt.GetUserIdFromUser()
}

reset.ChangePasswordView(resetView)

err = api.ResetPassword(userId, opts)
if err != nil {
if isUnauthorizedError(err) {
log.Error("Permission denied: Admin privileges are required to execute this command.")
} else {
log.Errorf("failed to reset user password: %v", err)
}
}
d := &DefaultUserPasswordChanger{}
ChangePassword(args, d)
},
Comment on lines 81 to 90
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says the tests are for the "user list" command, but the changed files and new tests are for the user password command (cmd/harbor/root/user/password.go). Please update the PR description to match the actual scope to avoid confusion during review/release notes.

Copilot uses AI. Check for mistakes.
}
return cmd
Expand Down
153 changes: 153 additions & 0 deletions cmd/harbor/root/user/password_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright Project Harbor Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package user

import (
"bytes"
"fmt"
"testing"

"github.com/goharbor/harbor-cli/pkg/views/password/reset"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)

type MockUserPasswordChanger struct {
id map[string]int64
passwords map[int64]string
userCnt int
expectAuthError bool
}
Comment on lines +26 to +31
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MockUserPasswordChanger.userCnt is never read in the test helper; keeping unused fields makes the mock harder to understand/maintain. Consider removing it, or using it (e.g., to drive GetUserIDFromUser behavior) so it provides value.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is used when populating the users in the init function


func (m *MockUserPasswordChanger) GetUserIDByName(username string) (int64, error) {
if v, ok := m.id[username]; ok {
return v, nil
}
return 0, fmt.Errorf("username %s not found", username)
}

func (m *MockUserPasswordChanger) GetUserIDFromUser() int64 {
return 999
}

func (m *MockUserPasswordChanger) FillPasswordView(resetView *reset.PasswordChangeView) {
resetView.NewPassword = "NewPass456"
resetView.ConfirmPassword = "NewPass456"
}

func (m *MockUserPasswordChanger) ResetPassword(userID int64, resetView reset.PasswordChangeView) error {
if m.expectAuthError {
return fmt.Errorf("403")
}
if _, ok := m.passwords[userID]; !ok {
return fmt.Errorf("user %d not found", userID)
}
m.passwords[userID] = resetView.NewPassword
return nil
}

func initMockUserPasswordChanger(userCnt int, expectAuthError bool) *MockUserPasswordChanger {
m := &MockUserPasswordChanger{
userCnt: userCnt,
expectAuthError: expectAuthError,
id: make(map[string]int64),
passwords: make(map[int64]string),
}
for i := 0; i < userCnt; i++ {
m.id[fmt.Sprintf("test%d", i+1)] = int64(i + 1)
m.passwords[int64(i+1)] = "InitialPass123"
}
return m
}

func TestChangePassword(t *testing.T) {
tests := []struct {
name string
setup func() *MockUserPasswordChanger
args []string
expectedPasswordID int64
expectedNewPassword string
expectedErr string
}{
{
name: "successfully change password by username",
setup: func() *MockUserPasswordChanger {
return initMockUserPasswordChanger(5, false)
},
args: []string{"test1"},
expectedPasswordID: 1,
expectedNewPassword: "NewPass456",
expectedErr: "",
},
{
name: "change password via interactive prompt",
setup: func() *MockUserPasswordChanger {
m := initMockUserPasswordChanger(5, false)
m.id["promptuser"] = 999
m.passwords[999] = "InitialPass123"
return m
Comment on lines +95 to +99
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the interactive prompt test setup, m.id["promptuser"] = 999 is unused because GetUserIDFromUser() always returns 999 and never consults m.id. Consider either removing these unused setup lines or updating GetUserIDFromUser() to derive the returned ID from the mock state to better reflect the intended scenario.

Copilot uses AI. Check for mistakes.
},
args: []string{},
expectedPasswordID: 999,
expectedNewPassword: "NewPass456",
expectedErr: "",
},
{
name: "user not found logs error",
setup: func() *MockUserPasswordChanger {
return initMockUserPasswordChanger(5, false)
},
args: []string{"nonexistent"},
expectedPasswordID: 0,
expectedNewPassword: "",
expectedErr: "failed to get user id",
},
{
name: "permission denied error",
setup: func() *MockUserPasswordChanger {
return initMockUserPasswordChanger(5, true)
},
args: []string{"test1"},
expectedPasswordID: 0,
expectedNewPassword: "",
expectedErr: "Permission denied",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
originalLogOutput := log.StandardLogger().Out
log.SetOutput(&buf)
defer log.SetOutput(originalLogOutput)

m := tt.setup()
ChangePassword(tt.args, m)

logs := buf.String()

if tt.expectedErr != "" {
assert.Contains(t, logs, tt.expectedErr, "Expected error logs to contain %s but got %s", tt.expectedErr, logs)
} else {
assert.Empty(t, logs, "Expected no error logs but got: %s", logs)
}

if tt.expectedPasswordID != 0 {
password, exists := m.passwords[tt.expectedPasswordID]
assert.True(t, exists, "User with ID %d should exist", tt.expectedPasswordID)
assert.Equal(t, tt.expectedNewPassword, password, "Password for user %d should be changed to %s", tt.expectedPasswordID, tt.expectedNewPassword)
}
})
}
}
Loading