Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
98 changes: 65 additions & 33 deletions cmd/harbor/root/user/elevate.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,46 +21,78 @@ import (
"github.com/spf13/cobra"
)

type UserElevator interface {
GetUserIDByName(username string) (int64, error)
GetUserIDFromUser() int64
ConfirmElevation() (bool, error)
ElevateUser(userID int64) error
}

type DefaultUserElevator struct{}

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

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

func (d *DefaultUserElevator) ConfirmElevation() (bool, error) {
return views.ConfirmElevation()
}

func (d *DefaultUserElevator) ElevateUser(userID int64) error {
return api.ElevateUser(userID)
}

func ElevateUser(args []string, userElevator UserElevator) {
var err error
var userID int64

Comment on lines +24 to +52
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.

UserElevator, DefaultUserElevator, and ElevateUser are exported but appear to be used only within this package (and tests are in the same package). Consider making these identifiers unexported (e.g., userElevator/defaultUserElevator/elevateUser) to avoid expanding the public surface area of the cmd package unnecessarily.

Copilot uses AI. Check for mistakes.
if len(args) > 0 {
userID, err = userElevator.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
}
} else {
userID = userElevator.GetUserIDFromUser()
}

confirm, err := userElevator.ConfirmElevation()
if err != nil {
log.Errorf("failed to confirm elevation: %v", err)
return
}
if !confirm {
log.Error("User did not confirm elevation. Aborting command.")
return
}

err = userElevator.ElevateUser(userID)
if err != nil {
if isUnauthorizedError(err) {
log.Error("Permission denied: Admin privileges are required to execute this command.")
} else {
log.Errorf("failed to elevate user: %v", err)
}
}
}

func ElevateUserCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "elevate",
Short: "elevate user",
Long: "elevate user to admin role",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var err error
var userId int64
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()
}
confirm, err := views.ConfirmElevation()
if err != nil {
log.Errorf("failed to confirm elevation: %v", err)
return
}
if !confirm {
log.Error("User did not confirm elevation. Aborting command.")
return
}

err = api.ElevateUser(userId)
if err != nil {
if isUnauthorizedError(err) {
log.Error("Permission denied: Admin privileges are required to execute this command.")
} else {
log.Errorf("failed to elevate user: %v", err)
}
}
d := &DefaultUserElevator{}
ElevateUser(args, d)
},
Comment on lines 87 to 96
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.

The PR description says it adds unit tests for the "user list" command, but the actual changes are for the "user elevate" command (elevate.go / elevate_test.go). Please update the PR description/title to match the scope to avoid confusion when tracking coverage work.

Copilot uses AI. Check for mistakes.
}

Expand Down
196 changes: 196 additions & 0 deletions cmd/harbor/root/user/elevate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// 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"

log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)

type MockUserElevator struct {
id map[string]int64
admins map[int64]bool
userCnt int
expectAuthError bool
confirmElevation bool
confirmElevationErr error
}

func (m *MockUserElevator) 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 *MockUserElevator) GetUserIDFromUser() int64 {
return 999
}

func (m *MockUserElevator) ConfirmElevation() (bool, error) {
return m.confirmElevation, m.confirmElevationErr
}

func (m *MockUserElevator) ElevateUser(userID int64) error {
if m.expectAuthError {
return fmt.Errorf("403")
}
if _, ok := m.admins[userID]; !ok {
m.admins[userID] = true
return nil
}
return fmt.Errorf("user %d is already an admin", userID)
}

func initMockUserElevator(userCnt int, expectAuthError bool, confirmElevation bool, confirmElevationErr error) *MockUserElevator {
m := &MockUserElevator{
userCnt: userCnt,
expectAuthError: expectAuthError,
confirmElevation: confirmElevation,
confirmElevationErr: confirmElevationErr,
id: make(map[string]int64),
admins: make(map[int64]bool),
}
for i := 0; i < userCnt; i++ {
m.id[fmt.Sprintf("test%d", i+1)] = int64(i + 1)
}
return m
}

func TestElevateUser(t *testing.T) {
tests := []struct {
name string
setup func() *MockUserElevator
args []string
expectedAdminID []int64
expectedErr string
}{
{
name: "successfully elevate user by username",
setup: func() *MockUserElevator {
return initMockUserElevator(5, false, true, nil)
},
args: []string{"test1"},
expectedAdminID: []int64{1},
expectedErr: "",
},
{
name: "elevate user via interactive prompt",
setup: func() *MockUserElevator {
m := initMockUserElevator(5, false, true, nil)
m.id["promptuser"] = 999
return m
Comment on lines +94 to +97
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.

The "elevate user via interactive prompt" test setup writes to m.id["promptuser"], but MockUserElevator.GetUserIDFromUser() always returns 999 and never consults m.id. This line is currently redundant/misleading; either remove it or have the mock's GetUserIDFromUser return a configurable value so the test reflects the intended behavior.

Copilot uses AI. Check for mistakes.
},
args: []string{},
expectedAdminID: []int64{999},
expectedErr: "",
},
{
name: "user not found logs error",
setup: func() *MockUserElevator {
return initMockUserElevator(5, false, true, nil)
},
args: []string{"nonexistent"},
expectedAdminID: []int64{},
expectedErr: "failed to get user id",
},
{
name: "user id is zero logs not found",
setup: func() *MockUserElevator {
m := initMockUserElevator(5, false, true, nil)
m.id["ghost"] = 0
return m
},
args: []string{"ghost"},
expectedAdminID: []int64{},
expectedErr: "not found",
},
{
name: "permission denied error",
Comment on lines 103 to 124
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.

The tests cover the GetUserIDByName error path, but not the (userID==0, err==nil) "not found" branch that ElevateUser explicitly handles. Since pkg/api.GetUsersIdByName returns (0, nil) when a user is not found, consider adding a test case where GetUserIDByName returns 0 with no error and assert the "User with name ... not found" log message.

Copilot uses AI. Check for mistakes.
setup: func() *MockUserElevator {
return initMockUserElevator(5, true, true, nil)
},
args: []string{"test1"},
expectedAdminID: []int64{},
expectedErr: "Permission denied",
},
{
name: "user declines elevation confirmation",
setup: func() *MockUserElevator {
return initMockUserElevator(5, false, false, nil)
},
args: []string{"test1"},
expectedAdminID: []int64{},
expectedErr: "User did not confirm elevation",
},
{
name: "confirmation prompt returns error",
setup: func() *MockUserElevator {
return initMockUserElevator(5, false, false, fmt.Errorf("terminal error"))
},
args: []string{"test1"},
expectedAdminID: []int64{},
expectedErr: "failed to confirm elevation",
},
{
name: "elevate user that is already admin",
setup: func() *MockUserElevator {
m := initMockUserElevator(5, false, true, nil)
m.admins[1] = true
return m
},
args: []string{"test1"},
expectedAdminID: []int64{1},
expectedErr: "already an admin",
},
}

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()
ElevateUser(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)
}

for _, id := range tt.expectedAdminID {
isAdmin, exists := m.admins[id]
assert.True(t, exists && isAdmin, "User with ID %d should be an admin", id)
}
})
}
}

func TestElevateUserCmd(t *testing.T) {
cmd := ElevateUserCmd()

assert.Equal(t, "elevate", cmd.Use)
assert.Equal(t, "elevate user", cmd.Short)
assert.Equal(t, "elevate user to admin role", cmd.Long)
assert.NotNil(t, cmd.Args, "Args validator should be set")
}