From 911bd6773e29b6a644bf8a7fae8b819053cc5c53 Mon Sep 17 00:00:00 2001 From: dhernando Date: Thu, 2 Apr 2026 18:41:36 +0200 Subject: [PATCH 01/19] feat: add user and invite commands --- internal/cmd/iam/iam.go | 6 +- internal/cmd/iam/iam_test.go | 487 +++++++++++++++++++++++++++ internal/cmd/iam/invite.go | 25 ++ internal/cmd/iam/invite_delete.go | 65 ++++ internal/cmd/iam/invite_describe.go | 66 ++++ internal/cmd/iam/invite_list.go | 64 ++++ internal/cmd/iam/resolve.go | 78 +++++ internal/cmd/iam/user.go | 27 ++ internal/cmd/iam/user_assign_role.go | 86 +++++ internal/cmd/iam/user_describe.go | 170 ++++++++++ internal/cmd/iam/user_invite.go | 77 +++++ internal/cmd/iam/user_list.go | 62 ++++ internal/cmd/iam/user_remove_role.go | 86 +++++ internal/cmd/output/iam.go | 18 + internal/qcloudapi/client.go | 16 + internal/testutil/fake_account.go | 43 +++ internal/testutil/fake_iam.go | 49 +++ internal/testutil/server.go | 10 + 18 files changed, 1434 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/iam/iam_test.go create mode 100644 internal/cmd/iam/invite.go create mode 100644 internal/cmd/iam/invite_delete.go create mode 100644 internal/cmd/iam/invite_describe.go create mode 100644 internal/cmd/iam/invite_list.go create mode 100644 internal/cmd/iam/resolve.go create mode 100644 internal/cmd/iam/user.go create mode 100644 internal/cmd/iam/user_assign_role.go create mode 100644 internal/cmd/iam/user_describe.go create mode 100644 internal/cmd/iam/user_invite.go create mode 100644 internal/cmd/iam/user_list.go create mode 100644 internal/cmd/iam/user_remove_role.go create mode 100644 internal/cmd/output/iam.go create mode 100644 internal/testutil/fake_account.go create mode 100644 internal/testutil/fake_iam.go diff --git a/internal/cmd/iam/iam.go b/internal/cmd/iam/iam.go index 52545d5..c48b7d6 100644 --- a/internal/cmd/iam/iam.go +++ b/internal/cmd/iam/iam.go @@ -14,6 +14,10 @@ func NewCommand(s *state.State) *cobra.Command { Long: `Manage IAM resources for the Qdrant Cloud account.`, Args: cobra.NoArgs, } - cmd.AddCommand(newKeyCommand(s)) + cmd.AddCommand( + newKeyCommand(s), + newUserCommand(s), + newInviteCommand(s), + ) return cmd } diff --git a/internal/cmd/iam/iam_test.go b/internal/cmd/iam/iam_test.go new file mode 100644 index 0000000..2dcea05 --- /dev/null +++ b/internal/cmd/iam/iam_test.go @@ -0,0 +1,487 @@ +package iam_test + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +const ( + testUserID = "7b2ea926-724b-4de2-b73a-8675c42a6ebe" + testRoleID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +) + +// --- user list --- + +func TestUserList_TableOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + { + Id: "user-1", + Email: "alice@example.com", + Status: iamv1.UserStatus_USER_STATUS_ACTIVE, + CreatedAt: timestamppb.New(time.Now().Add(-48 * time.Hour)), + }, + { + Id: "user-2", + Email: "bob@example.com", + Status: iamv1.UserStatus_USER_STATUS_BLOCKED, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "list") + require.NoError(t, err) + + assert.Contains(t, stdout, "user-1") + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "ACTIVE") + assert.Contains(t, stdout, "user-2") + assert.Contains(t, stdout, "bob@example.com") + assert.Contains(t, stdout, "BLOCKED") + + req, ok := env.IAMServer.ListUsersCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) +} + +func TestUserList_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: "user-1", Email: "alice@example.com"}}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "list", "--json") + require.NoError(t, err) + + assert.Contains(t, stdout, `"id"`) + assert.Contains(t, stdout, "user-1") +} + +func TestUserList_Error(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(nil, fmt.Errorf("permission denied")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "list") + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +// --- user describe --- + +func TestUserDescribe_ByID(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + cat := "Cluster" + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + {Id: userID, Email: "alice@example.com", Status: iamv1.UserStatus_USER_STATUS_ACTIVE}, + }, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{ + { + Id: "role-id-1", + Name: "admin", + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: &cat}, + {Value: "write:clusters", Category: &cat}, + }, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID) + require.NoError(t, err) + + assert.Contains(t, stdout, userID) + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "ACTIVE") + assert.Contains(t, stdout, "role-id-1") + assert.Contains(t, stdout, "admin") + assert.Contains(t, stdout, "read:clusters") + assert.Contains(t, stdout, "write:clusters") + assert.Contains(t, stdout, "Cluster") + + req, ok := env.IAMServer.ListUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, userID, req.GetUserId()) +} + +func TestUserDescribe_PermissionsDeduplicatedWithRoles(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + cat := "Cluster" + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{ + { + Id: "role-id-1", + Name: "admin", + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: &cat}, + {Value: "write:clusters", Category: &cat}, + }, + }, + { + Id: "role-id-2", + Name: "viewer", + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: &cat}, + }, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID) + require.NoError(t, err) + + // read:clusters appears in both roles — should be listed once with both role names + assert.Contains(t, stdout, "admin, viewer") + // write:clusters only in admin + assert.Contains(t, stdout, "write:clusters") +} + +func TestUserDescribe_NoPermissions(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + {Id: userID, Email: "alice@example.com"}, + }, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{ + {Id: "role-id-1", Name: "viewer"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID) + require.NoError(t, err) + + assert.NotContains(t, stdout, "Effective Permissions") +} + +func TestUserDescribe_ByEmail(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + {Id: "user-id-abc", Email: "alice@example.com"}, + }, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", "alice@example.com") + require.NoError(t, err) + + assert.Contains(t, stdout, "alice@example.com") + req, ok := env.IAMServer.ListUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, "user-id-abc", req.GetUserId()) +} + +func TestUserDescribe_NotFound(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{Items: nil}, nil) + + _, _, err := testutil.Exec(t, env, "iam", "user", "describe", "nobody@example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +// --- user assign-role --- + +func TestUserAssignRole_ByRoleID(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + roleID := testRoleID + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{{Id: roleID, Name: "admin"}}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", + "alice@example.com", roleID) + require.NoError(t, err) + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, roleID) + assert.Contains(t, stdout, "admin") + + req, ok := env.IAMServer.AssignUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, userID, req.GetUserId()) + assert.Equal(t, []string{roleID}, req.GetRoleIdsToAdd()) +} + +func TestUserAssignRole_ByRoleName(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + roleID := testRoleID + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{{Id: roleID, Name: "admin"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{{Id: roleID, Name: "admin"}}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", + "alice@example.com", "admin") + require.NoError(t, err) + assert.Contains(t, stdout, "admin") + + req, ok := env.IAMServer.AssignUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, []string{roleID}, req.GetRoleIdsToAdd()) +} + +func TestUserAssignRole_MissingRole(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", "alice@example.com") + require.Error(t, err) +} + +// --- user remove-role --- + +func TestUserRemoveRole_ByRoleID(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + roleID := testRoleID + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", + "alice@example.com", roleID) + require.NoError(t, err) + assert.Contains(t, stdout, "alice@example.com") + + req, ok := env.IAMServer.AssignUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, userID, req.GetUserId()) + assert.Equal(t, []string{roleID}, req.GetRoleIdsToDelete()) +} + +func TestUserRemoveRole_ByRoleName(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + roleID := testRoleID + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{{Id: roleID, Name: "viewer"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) + + _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", + "alice@example.com", "viewer") + require.NoError(t, err) + + req, ok := env.IAMServer.AssignUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, []string{roleID}, req.GetRoleIdsToDelete()) +} + +func TestUserRemoveRole_MissingRole(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", "alice@example.com") + require.Error(t, err) +} + +// --- user invite --- + +func TestUserInvite(t *testing.T) { + env := testutil.NewTestEnv(t) + + inviteID := "invite-id-123" + env.AccountServer.CreateAccountInviteCalls.Returns(&accountv1.CreateAccountInviteResponse{ + AccountInvite: &accountv1.AccountInvite{Id: inviteID, UserEmail: "bob@example.com"}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "invite", + "--email", "bob@example.com") + require.NoError(t, err) + assert.Contains(t, stdout, inviteID) + assert.Contains(t, stdout, "bob@example.com") + + req, ok := env.AccountServer.CreateAccountInviteCalls.Last() + require.True(t, ok) + assert.Equal(t, "bob@example.com", req.GetAccountInvite().GetUserEmail()) + assert.Equal(t, "test-account-id", req.GetAccountInvite().GetAccountId()) +} + +func TestUserInvite_WithRole(t *testing.T) { + env := testutil.NewTestEnv(t) + + roleID := testRoleID + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{{Id: roleID, Name: "viewer"}}, + }, nil) + env.AccountServer.CreateAccountInviteCalls.Returns(&accountv1.CreateAccountInviteResponse{ + AccountInvite: &accountv1.AccountInvite{Id: "invite-id", UserEmail: "bob@example.com"}, + }, nil) + + _, _, err := testutil.Exec(t, env, "iam", "user", "invite", + "--email", "bob@example.com", "--role", "viewer") + require.NoError(t, err) + + req, ok := env.AccountServer.CreateAccountInviteCalls.Last() + require.True(t, ok) + assert.Equal(t, []string{roleID}, req.GetAccountInvite().GetUserRoleIds()) +} + +func TestUserInvite_MissingEmail(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "user", "invite") + require.Error(t, err) +} + +// --- invite list --- + +func TestInviteList_TableOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.ListAccountInvitesCalls.Returns(&accountv1.ListAccountInvitesResponse{ + Items: []*accountv1.AccountInvite{ + { + Id: "invite-1", + UserEmail: "alice@example.com", + Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_PENDING, + CreatedAt: timestamppb.New(time.Now().Add(-1 * time.Hour)), + }, + { + Id: "invite-2", + UserEmail: "bob@example.com", + Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_ACCEPTED, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "invite", "list") + require.NoError(t, err) + + assert.Contains(t, stdout, "invite-1") + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "PENDING") + assert.Contains(t, stdout, "invite-2") + assert.Contains(t, stdout, "bob@example.com") + assert.Contains(t, stdout, "ACCEPTED") + + req, ok := env.AccountServer.ListAccountInvitesCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) +} + +func TestInviteList_Error(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.ListAccountInvitesCalls.Returns(nil, fmt.Errorf("permission denied")) + + _, _, err := testutil.Exec(t, env, "iam", "invite", "list") + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +// --- invite describe --- + +func TestInviteDescribe(t *testing.T) { + env := testutil.NewTestEnv(t) + + inviteID := testUserID + env.AccountServer.GetAccountInviteCalls.Returns(&accountv1.GetAccountInviteResponse{ + AccountInvite: &accountv1.AccountInvite{ + Id: inviteID, + UserEmail: "alice@example.com", + Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_PENDING, + UserRoleIds: []string{"role-1"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "invite", "describe", inviteID) + require.NoError(t, err) + + assert.Contains(t, stdout, inviteID) + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "PENDING") + assert.Contains(t, stdout, "role-1") +} + +func TestInviteDescribe_Error(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.GetAccountInviteCalls.Returns(nil, fmt.Errorf("not found")) + + _, _, err := testutil.Exec(t, env, "iam", "invite", "describe", testUserID) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +// --- invite delete --- + +func TestInviteDelete(t *testing.T) { + env := testutil.NewTestEnv(t) + + inviteID := testUserID + env.AccountServer.DeleteAccountInviteCalls.Returns(&accountv1.DeleteAccountInviteResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "invite", "delete", inviteID, "--force") + require.NoError(t, err) + assert.Contains(t, stdout, "deleted") + + req, ok := env.AccountServer.DeleteAccountInviteCalls.Last() + require.True(t, ok) + assert.Equal(t, inviteID, req.GetInviteId()) +} + +func TestInviteDelete_Error(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.DeleteAccountInviteCalls.Returns(nil, fmt.Errorf("not found")) + + _, _, err := testutil.Exec(t, env, "iam", "invite", "delete", + testUserID, "--force") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + diff --git a/internal/cmd/iam/invite.go b/internal/cmd/iam/invite.go new file mode 100644 index 0000000..f9f086e --- /dev/null +++ b/internal/cmd/iam/invite.go @@ -0,0 +1,25 @@ +package iam + +import ( + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newInviteCommand(s *state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "invite", + Short: "Manage account invites", + Long: `Manage account invites in Qdrant Cloud. + +Provides commands to list, view, and delete account invites. +To send a new invite, use the 'iam user invite' command.`, + Args: cobra.NoArgs, + } + cmd.AddCommand( + newInviteListCommand(s), + newInviteDescribeCommand(s), + newInviteDeleteCommand(s), + ) + return cmd +} diff --git a/internal/cmd/iam/invite_delete.go b/internal/cmd/iam/invite_delete.go new file mode 100644 index 0000000..b29a782 --- /dev/null +++ b/internal/cmd/iam/invite_delete.go @@ -0,0 +1,65 @@ +package iam + +import ( + "fmt" + + "github.com/spf13/cobra" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newInviteDeleteCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an account invite", + Args: util.ExactArgs(1, "an invite ID"), + } + cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + return cmd + }, + Long: `Delete an account invite. + +Cancels a pending account invite. The invited user will no longer be able to +accept or reject the invite. Requires the delete:invites permission.`, + Example: `# Delete an invite +qcloud iam invite delete 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Delete without confirmation +qcloud iam invite delete 7b2ea926-724b-4de2-b73a-8675c42a6ebe --force`, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + force, _ := cmd.Flags().GetBool("force") + if !util.ConfirmAction(force, cmd.ErrOrStderr(), + fmt.Sprintf("Delete invite %s?", args[0])) { + fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") + return nil + } + + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return err + } + accountID, err := s.AccountID() + if err != nil { + return err + } + + _, err = client.Account().DeleteAccountInvite(ctx, &accountv1.DeleteAccountInviteRequest{ + AccountId: accountID, + InviteId: args[0], + }) + if err != nil { + return fmt.Errorf("failed to delete invite: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Invite %s deleted.\n", args[0]) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/invite_describe.go b/internal/cmd/iam/invite_describe.go new file mode 100644 index 0000000..05cd82c --- /dev/null +++ b/internal/cmd/iam/invite_describe.go @@ -0,0 +1,66 @@ +package iam + +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newInviteDescribeCommand(s *state.State) *cobra.Command { + return base.DescribeCmd[*accountv1.AccountInvite]{ + Use: "describe ", + Short: "Describe an account invite", + Long: `Describe an account invite. + +Displays the full details of a specific account invite, including the invited +email address, assigned roles, and current status. Requires the read:invites +permission.`, + Example: `# Describe an invite by ID +qcloud iam invite describe 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Output as JSON +qcloud iam invite describe 7b2ea926-724b-4de2-b73a-8675c42a6ebe --json`, + Args: util.ExactArgs(1, "an invite ID"), + Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*accountv1.AccountInvite, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + resp, err := client.Account().GetAccountInvite(ctx, &accountv1.GetAccountInviteRequest{ + AccountId: accountID, + InviteId: args[0], + }) + if err != nil { + return nil, fmt.Errorf("failed to get invite: %w", err) + } + return resp.GetAccountInvite(), nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, inv *accountv1.AccountInvite) error { + fmt.Fprintf(w, "ID: %s\n", inv.GetId()) + fmt.Fprintf(w, "Email: %s\n", inv.GetUserEmail()) + fmt.Fprintf(w, "Status: %s\n", output.AccountInviteStatus(inv.GetStatus())) + if len(inv.GetUserRoleIds()) > 0 { + fmt.Fprintf(w, "Roles: %s\n", strings.Join(inv.GetUserRoleIds(), ", ")) + } + if inv.GetCreatedAt() != nil { + t := inv.GetCreatedAt().AsTime() + fmt.Fprintf(w, "Created: %s (%s)\n", output.HumanTime(t), output.FullDateTime(t)) + } + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/invite_list.go b/internal/cmd/iam/invite_list.go new file mode 100644 index 0000000..2addc46 --- /dev/null +++ b/internal/cmd/iam/invite_list.go @@ -0,0 +1,64 @@ +package iam + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newInviteListCommand(s *state.State) *cobra.Command { + return base.ListCmd[*accountv1.ListAccountInvitesResponse]{ + Use: "list", + Short: "List account invites", + Long: `List account invites. + +Lists all invites for the current account. By default, invites of all statuses +are returned. Requires the read:invites permission.`, + Example: `# List all invites +qcloud iam invite list + +# Output as JSON +qcloud iam invite list --json`, + Fetch: func(s *state.State, cmd *cobra.Command) (*accountv1.ListAccountInvitesResponse, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + resp, err := client.Account().ListAccountInvites(ctx, &accountv1.ListAccountInvitesRequest{ + AccountId: accountID, + }) + if err != nil { + return nil, fmt.Errorf("failed to list invites: %w", err) + } + return resp, nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, resp *accountv1.ListAccountInvitesResponse) error { + t := output.NewTable[*accountv1.AccountInvite](w) + t.AddField("ID", func(v *accountv1.AccountInvite) string { return v.GetId() }) + t.AddField("EMAIL", func(v *accountv1.AccountInvite) string { return v.GetUserEmail() }) + t.AddField("STATUS", func(v *accountv1.AccountInvite) string { + return output.AccountInviteStatus(v.GetStatus()) + }) + t.AddField("CREATED", func(v *accountv1.AccountInvite) string { + if v.GetCreatedAt() != nil { + return output.HumanTime(v.GetCreatedAt().AsTime()) + } + return "" + }) + t.Write(resp.GetItems()) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/resolve.go b/internal/cmd/iam/resolve.go new file mode 100644 index 0000000..8538d91 --- /dev/null +++ b/internal/cmd/iam/resolve.go @@ -0,0 +1,78 @@ +package iam + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/qcloudapi" +) + +// resolveUser looks up a user by UUID or email from the account's user list. +func resolveUser(cmd *cobra.Command, client *qcloudapi.Client, accountID, idOrEmail string) (*iamv1.User, error) { + ctx := cmd.Context() + resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{AccountId: accountID}) + if err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + for _, u := range resp.GetItems() { + if util.IsUUID(idOrEmail) { + if u.GetId() == idOrEmail { + return u, nil + } + } else { + if u.GetEmail() == idOrEmail { + return u, nil + } + } + } + return nil, fmt.Errorf("user %s not found", idOrEmail) +} + +// resolveRoleIDs converts a slice of role names or UUIDs to UUIDs. +// Values that already look like UUIDs are passed through unchanged. +// Non-UUID values are resolved by name via ListRoles. +func resolveRoleIDs(ctx context.Context, client *qcloudapi.Client, accountID string, namesOrIDs []string) ([]string, error) { + if len(namesOrIDs) == 0 { + return nil, nil + } + + // Check whether any name resolution is needed. + var needsLookup bool + for _, v := range namesOrIDs { + if !util.IsUUID(v) { + needsLookup = true + break + } + } + + var rolesByName map[string]string + if needsLookup { + resp, err := client.IAM().ListRoles(ctx, &iamv1.ListRolesRequest{AccountId: accountID}) + if err != nil { + return nil, fmt.Errorf("failed to list roles: %w", err) + } + rolesByName = make(map[string]string, len(resp.GetItems())) + for _, r := range resp.GetItems() { + rolesByName[r.GetName()] = r.GetId() + } + } + + ids := make([]string, 0, len(namesOrIDs)) + for _, v := range namesOrIDs { + if util.IsUUID(v) { + ids = append(ids, v) + } else { + id, ok := rolesByName[v] + if !ok { + return nil, fmt.Errorf("role %q not found", v) + } + ids = append(ids, id) + } + } + return ids, nil +} diff --git a/internal/cmd/iam/user.go b/internal/cmd/iam/user.go new file mode 100644 index 0000000..570f4db --- /dev/null +++ b/internal/cmd/iam/user.go @@ -0,0 +1,27 @@ +package iam + +import ( + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newUserCommand(s *state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "user", + Short: "Manage users in Qdrant Cloud", + Long: `Manage users in the Qdrant Cloud account. + +Provides commands to list users, view user details and assigned roles, and +manage role assignments.`, + Args: cobra.NoArgs, + } + cmd.AddCommand( + newUserListCommand(s), + newUserDescribeCommand(s), + newUserAssignRoleCommand(s), + newUserRemoveRoleCommand(s), + newUserInviteCommand(s), + ) + return cmd +} diff --git a/internal/cmd/iam/user_assign_role.go b/internal/cmd/iam/user_assign_role.go new file mode 100644 index 0000000..c6f2c80 --- /dev/null +++ b/internal/cmd/iam/user_assign_role.go @@ -0,0 +1,86 @@ +package iam + +import ( + "fmt" + + "github.com/spf13/cobra" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newUserAssignRoleCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + return &cobra.Command{ + Use: "assign-role [...]", + Short: "Assign one or more roles to a user", + Args: cobra.MinimumNArgs(2), + } + }, + Long: `Assign one or more roles to a user in the account. + +Accepts either a user ID (UUID) or an email address to identify the user. +Each role argument accepts either a role UUID or a role name, which is +resolved to an ID via the IAM service. Prints the user's resulting roles +after the assignment.`, + Example: `# Assign a role by name +qcloud iam user assign-role user@example.com admin + +# Assign a role by ID +qcloud iam user assign-role user@example.com 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Assign multiple roles at once +qcloud iam user assign-role user@example.com admin viewer`, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return err + } + accountID, err := s.AccountID() + if err != nil { + return err + } + + user, err := resolveUser(cmd, client, accountID, args[0]) + if err != nil { + return err + } + + roleIDs, err := resolveRoleIDs(ctx, client, accountID, args[1:]) + if err != nil { + return err + } + + _, err = client.IAM().AssignUserRoles(ctx, &iamv1.AssignUserRolesRequest{ + AccountId: accountID, + UserId: user.GetId(), + RoleIdsToAdd: roleIDs, + }) + if err != nil { + return fmt.Errorf("failed to assign roles: %w", err) + } + + rolesResp, err := client.IAM().ListUserRoles(ctx, &iamv1.ListUserRolesRequest{ + AccountId: accountID, + UserId: user.GetId(), + }) + if err != nil { + return fmt.Errorf("failed to list user roles: %w", err) + } + + if s.Config.JSONOutput() { + return output.PrintJSON(cmd.OutOrStdout(), rolesResp) + } + + w := cmd.OutOrStdout() + fmt.Fprintf(w, "Roles for %s:\n", user.GetEmail()) + printRoles(w, rolesResp.GetRoles()) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/user_describe.go b/internal/cmd/iam/user_describe.go new file mode 100644 index 0000000..9772031 --- /dev/null +++ b/internal/cmd/iam/user_describe.go @@ -0,0 +1,170 @@ +package iam + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/spf13/cobra" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newUserDescribeCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + return &cobra.Command{ + Use: "describe ", + Short: "Describe a user and their assigned roles", + Args: util.ExactArgs(1, "a user ID or email"), + } + }, + Long: `Describe a user and their assigned roles. + +Accepts either a user ID (UUID) or an email address. Displays the user's +details and the roles currently assigned to them in the account.`, + Example: `# Describe a user by ID +qcloud iam user describe 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Describe a user by email +qcloud iam user describe user@example.com + +# Output as JSON +qcloud iam user describe user@example.com --json`, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return err + } + accountID, err := s.AccountID() + if err != nil { + return err + } + + // Resolve the user. + var user *iamv1.User + resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{AccountId: accountID}) + if err != nil { + return fmt.Errorf("failed to list users: %w", err) + } + if util.IsUUID(args[0]) { + for _, u := range resp.GetItems() { + if u.GetId() == args[0] { + user = u + break + } + } + } else { + for _, u := range resp.GetItems() { + if u.GetEmail() == args[0] { + user = u + break + } + } + } + if user == nil { + return fmt.Errorf("user %s not found", args[0]) + } + + // Fetch the user's roles. + rolesResp, err := client.IAM().ListUserRoles(ctx, &iamv1.ListUserRolesRequest{ + AccountId: accountID, + UserId: user.GetId(), + }) + if err != nil { + return fmt.Errorf("failed to list user roles: %w", err) + } + roles := rolesResp.GetRoles() + + permissions := effectivePermissions(roles) + + if s.Config.JSONOutput() { + return output.PrintJSON(cmd.OutOrStdout(), &iamv1.ListUserRolesResponse{ + Roles: append([]*iamv1.Role{}, roles...), + }) + } + + w := cmd.OutOrStdout() + return printUserWithRoles(w, user, roles, permissions) + }, + }.CobraCommand(s) +} + +func printUserWithRoles(w io.Writer, user *iamv1.User, roles []*iamv1.Role, permissions []rolePermission) error { + fmt.Fprintf(w, "ID: %s\n", user.GetId()) + fmt.Fprintf(w, "Email: %s\n", user.GetEmail()) + fmt.Fprintf(w, "Status: %s\n", output.UserStatus(user.GetStatus())) + if user.GetCreatedAt() != nil { + t := user.GetCreatedAt().AsTime() + fmt.Fprintf(w, "Created: %s (%s)\n", output.HumanTime(t), output.FullDateTime(t)) + } + if len(roles) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "Roles:") + printRoles(w, roles) + } + if len(permissions) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "Effective Permissions:") + printPermissions(w, permissions) + } + return nil +} + +func printRoles(w io.Writer, roles []*iamv1.Role) { + t := output.NewTable[*iamv1.Role](w) + t.AddField("ID", func(v *iamv1.Role) string { return v.GetId() }) + t.AddField("NAME", func(v *iamv1.Role) string { return v.GetName() }) + t.Write(roles) +} + +type rolePermission struct { + permission *iamv1.Permission + roleNames []string +} + +func printPermissions(w io.Writer, rps []rolePermission) { + t := output.NewTable[rolePermission](w) + t.AddField("PERMISSION", func(v rolePermission) string { return v.permission.GetValue() }) + t.AddField("CATEGORY", func(v rolePermission) string { return v.permission.GetCategory() }) + t.AddField("FROM ROLES", func(v rolePermission) string { return strings.Join(v.roleNames, ", ") }) + t.Write(rps) +} + +// effectivePermissions collects unique permissions across all roles, with the +// sorted list of role names that grant each permission. Results are sorted by +// permission value. +func effectivePermissions(roles []*iamv1.Role) []rolePermission { + type entry struct { + permission *iamv1.Permission + roleNames []string + } + seen := make(map[string]*entry) + order := []string{} + for _, role := range roles { + for _, p := range role.GetPermissions() { + v := p.GetValue() + if e, ok := seen[v]; ok { + e.roleNames = append(e.roleNames, role.GetName()) + } else { + seen[v] = &entry{permission: p, roleNames: []string{role.GetName()}} + order = append(order, v) + } + } + } + sort.Strings(order) + out := make([]rolePermission, 0, len(order)) + for _, v := range order { + e := seen[v] + sort.Strings(e.roleNames) + out = append(out, rolePermission{permission: e.permission, roleNames: e.roleNames}) + } + return out +} diff --git a/internal/cmd/iam/user_invite.go b/internal/cmd/iam/user_invite.go new file mode 100644 index 0000000..4883f82 --- /dev/null +++ b/internal/cmd/iam/user_invite.go @@ -0,0 +1,77 @@ +package iam + +import ( + "fmt" + + "github.com/spf13/cobra" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newUserInviteCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "invite", + Short: "Invite a user to the account", + Args: cobra.NoArgs, + } + cmd.Flags().String("email", "", "Email address of the user to invite (required)") + _ = cmd.MarkFlagRequired("email") + cmd.Flags().StringArray("role", nil, "Role ID or name to assign to the invited user (repeatable)") + return cmd + }, + Long: `Invite a user to the account. + +Sends an account invite to the specified email address. The invited user will +receive an invitation they can accept or reject. + +Use --role to pre-assign roles to the invited user upon acceptance. Each +--role flag accepts either a role UUID or a role name.`, + Example: `# Invite a user with no roles +qcloud iam user invite --email user@example.com + +# Invite a user and assign a role by name +qcloud iam user invite --email user@example.com --role admin + +# Invite a user and assign multiple roles +qcloud iam user invite --email user@example.com --role viewer --role admin`, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + email, _ := cmd.Flags().GetString("email") + roleNames, _ := cmd.Flags().GetStringArray("role") + + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return err + } + accountID, err := s.AccountID() + if err != nil { + return err + } + + roleIDs, err := resolveRoleIDs(ctx, client, accountID, roleNames) + if err != nil { + return fmt.Errorf("--role: %w", err) + } + + resp, err := client.Account().CreateAccountInvite(ctx, &accountv1.CreateAccountInviteRequest{ + AccountInvite: &accountv1.AccountInvite{ + AccountId: accountID, + UserEmail: email, + UserRoleIds: roleIDs, + }, + }) + if err != nil { + return fmt.Errorf("failed to create invite: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Invite %s sent to %s.\n", + resp.GetAccountInvite().GetId(), email) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/user_list.go b/internal/cmd/iam/user_list.go new file mode 100644 index 0000000..4f03b4b --- /dev/null +++ b/internal/cmd/iam/user_list.go @@ -0,0 +1,62 @@ +package iam + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newUserListCommand(s *state.State) *cobra.Command { + return base.ListCmd[*iamv1.ListUsersResponse]{ + Use: "list", + Short: "List users in the account", + Long: `List users in the account. + +Lists all users who are members of the current account. Requires the read:users +permission.`, + Example: `# List all users in the account +qcloud iam user list + +# Output as JSON +qcloud iam user list --json`, + Fetch: func(s *state.State, cmd *cobra.Command) (*iamv1.ListUsersResponse, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{ + AccountId: accountID, + }) + if err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + return resp, nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, resp *iamv1.ListUsersResponse) error { + t := output.NewTable[*iamv1.User](w) + t.AddField("ID", func(v *iamv1.User) string { return v.GetId() }) + t.AddField("EMAIL", func(v *iamv1.User) string { return v.GetEmail() }) + t.AddField("STATUS", func(v *iamv1.User) string { return output.UserStatus(v.GetStatus()) }) + t.AddField("CREATED", func(v *iamv1.User) string { + if v.GetCreatedAt() != nil { + return output.HumanTime(v.GetCreatedAt().AsTime()) + } + return "" + }) + t.Write(resp.GetItems()) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/user_remove_role.go b/internal/cmd/iam/user_remove_role.go new file mode 100644 index 0000000..a0e8f93 --- /dev/null +++ b/internal/cmd/iam/user_remove_role.go @@ -0,0 +1,86 @@ +package iam + +import ( + "fmt" + + "github.com/spf13/cobra" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newUserRemoveRoleCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + return &cobra.Command{ + Use: "remove-role [...]", + Short: "Remove one or more roles from a user", + Args: cobra.MinimumNArgs(2), + } + }, + Long: `Remove one or more roles from a user in the account. + +Accepts either a user ID (UUID) or an email address to identify the user. +Each role argument accepts either a role UUID or a role name, which is +resolved to an ID via the IAM service. Prints the user's resulting roles +after the removal.`, + Example: `# Remove a role by name +qcloud iam user remove-role user@example.com admin + +# Remove a role by ID +qcloud iam user remove-role user@example.com 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Remove multiple roles at once +qcloud iam user remove-role user@example.com admin viewer`, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return err + } + accountID, err := s.AccountID() + if err != nil { + return err + } + + user, err := resolveUser(cmd, client, accountID, args[0]) + if err != nil { + return err + } + + roleIDs, err := resolveRoleIDs(ctx, client, accountID, args[1:]) + if err != nil { + return err + } + + _, err = client.IAM().AssignUserRoles(ctx, &iamv1.AssignUserRolesRequest{ + AccountId: accountID, + UserId: user.GetId(), + RoleIdsToDelete: roleIDs, + }) + if err != nil { + return fmt.Errorf("failed to remove roles: %w", err) + } + + rolesResp, err := client.IAM().ListUserRoles(ctx, &iamv1.ListUserRolesRequest{ + AccountId: accountID, + UserId: user.GetId(), + }) + if err != nil { + return fmt.Errorf("failed to list user roles: %w", err) + } + + if s.Config.JSONOutput() { + return output.PrintJSON(cmd.OutOrStdout(), rolesResp) + } + + w := cmd.OutOrStdout() + fmt.Fprintf(w, "Roles for %s:\n", user.GetEmail()) + printRoles(w, rolesResp.GetRoles()) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/output/iam.go b/internal/cmd/output/iam.go new file mode 100644 index 0000000..60a42ba --- /dev/null +++ b/internal/cmd/output/iam.go @@ -0,0 +1,18 @@ +package output + +import ( + "strings" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" +) + +// UserStatus formats an iamv1.UserStatus enum for display. +func UserStatus(x iamv1.UserStatus) string { + return strings.TrimPrefix(x.String(), "USER_STATUS_") +} + +// AccountInviteStatus formats an accountv1.AccountInviteStatus enum for display. +func AccountInviteStatus(x accountv1.AccountInviteStatus) string { + return strings.TrimPrefix(x.String(), "ACCOUNT_INVITE_STATUS_") +} diff --git a/internal/qcloudapi/client.go b/internal/qcloudapi/client.go index 8b7bd4e..0a67c5f 100644 --- a/internal/qcloudapi/client.go +++ b/internal/qcloudapi/client.go @@ -7,12 +7,14 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1" bookingv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/booking/v1" clusterauthv2 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/auth/v2" backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" hybridv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/hybrid/v1" + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1" platformv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/platform/v1" ) @@ -28,6 +30,8 @@ type Client struct { hybrid hybridv1.HybridCloudServiceClient monitoring monitoringv1.MonitoringServiceClient auth authv1.AuthServiceClient + iam iamv1.IAMServiceClient + account accountv1.AccountServiceClient } // New creates a new gRPC client connected to the given endpoint with the given API key. @@ -60,6 +64,8 @@ func newFromConn(conn *grpc.ClientConn) *Client { hybrid: hybridv1.NewHybridCloudServiceClient(conn), monitoring: monitoringv1.NewMonitoringServiceClient(conn), auth: authv1.NewAuthServiceClient(conn), + iam: iamv1.NewIAMServiceClient(conn), + account: accountv1.NewAccountServiceClient(conn), } } @@ -103,6 +109,16 @@ func (c *Client) Auth() authv1.AuthServiceClient { return c.auth } +// IAM returns the IAMService gRPC client. +func (c *Client) IAM() iamv1.IAMServiceClient { + return c.iam +} + +// Account returns the AccountService gRPC client. +func (c *Client) Account() accountv1.AccountServiceClient { + return c.account +} + // Close closes the underlying gRPC connection. func (c *Client) Close() error { return c.conn.Close() diff --git a/internal/testutil/fake_account.go b/internal/testutil/fake_account.go new file mode 100644 index 0000000..76e3de0 --- /dev/null +++ b/internal/testutil/fake_account.go @@ -0,0 +1,43 @@ +package testutil + +import ( + "context" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" +) + +// FakeAccountService is a test fake that implements AccountServiceServer. +// Use the *Calls fields to configure responses and inspect captured requests. +type FakeAccountService struct { + accountv1.UnimplementedAccountServiceServer + + ListAccountInvitesCalls MethodSpy[*accountv1.ListAccountInvitesRequest, *accountv1.ListAccountInvitesResponse] + GetAccountInviteCalls MethodSpy[*accountv1.GetAccountInviteRequest, *accountv1.GetAccountInviteResponse] + CreateAccountInviteCalls MethodSpy[*accountv1.CreateAccountInviteRequest, *accountv1.CreateAccountInviteResponse] + DeleteAccountInviteCalls MethodSpy[*accountv1.DeleteAccountInviteRequest, *accountv1.DeleteAccountInviteResponse] +} + +// ListAccountInvites records the call and dispatches via ListAccountInvitesCalls. +func (f *FakeAccountService) ListAccountInvites(ctx context.Context, req *accountv1.ListAccountInvitesRequest) (*accountv1.ListAccountInvitesResponse, error) { + f.ListAccountInvitesCalls.record(req) + return f.ListAccountInvitesCalls.dispatch(ctx, req, f.UnimplementedAccountServiceServer.ListAccountInvites) +} + +// GetAccountInvite records the call and dispatches via GetAccountInviteCalls. +func (f *FakeAccountService) GetAccountInvite(ctx context.Context, req *accountv1.GetAccountInviteRequest) (*accountv1.GetAccountInviteResponse, error) { + f.GetAccountInviteCalls.record(req) + return f.GetAccountInviteCalls.dispatch(ctx, req, f.UnimplementedAccountServiceServer.GetAccountInvite) +} + +// CreateAccountInvite records the call and dispatches via CreateAccountInviteCalls. +func (f *FakeAccountService) CreateAccountInvite(ctx context.Context, req *accountv1.CreateAccountInviteRequest) (*accountv1.CreateAccountInviteResponse, error) { + f.CreateAccountInviteCalls.record(req) + return f.CreateAccountInviteCalls.dispatch(ctx, req, f.UnimplementedAccountServiceServer.CreateAccountInvite) +} + +// DeleteAccountInvite records the call and dispatches via DeleteAccountInviteCalls. +func (f *FakeAccountService) DeleteAccountInvite(ctx context.Context, req *accountv1.DeleteAccountInviteRequest) (*accountv1.DeleteAccountInviteResponse, error) { + f.DeleteAccountInviteCalls.record(req) + return f.DeleteAccountInviteCalls.dispatch(ctx, req, f.UnimplementedAccountServiceServer.DeleteAccountInvite) +} + diff --git a/internal/testutil/fake_iam.go b/internal/testutil/fake_iam.go new file mode 100644 index 0000000..f4d2e2e --- /dev/null +++ b/internal/testutil/fake_iam.go @@ -0,0 +1,49 @@ +package testutil + +import ( + "context" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" +) + +// FakeIAMService is a test fake that implements IAMServiceServer. +// Use the *Calls fields to configure responses and inspect captured requests. +type FakeIAMService struct { + iamv1.UnimplementedIAMServiceServer + + GetAuthenticatedUserCalls MethodSpy[*iamv1.GetAuthenticatedUserRequest, *iamv1.GetAuthenticatedUserResponse] + ListUsersCalls MethodSpy[*iamv1.ListUsersRequest, *iamv1.ListUsersResponse] + ListUserRolesCalls MethodSpy[*iamv1.ListUserRolesRequest, *iamv1.ListUserRolesResponse] + AssignUserRolesCalls MethodSpy[*iamv1.AssignUserRolesRequest, *iamv1.AssignUserRolesResponse] + ListRolesCalls MethodSpy[*iamv1.ListRolesRequest, *iamv1.ListRolesResponse] +} + +// GetAuthenticatedUser records the call and dispatches via GetAuthenticatedUserCalls. +func (f *FakeIAMService) GetAuthenticatedUser(ctx context.Context, req *iamv1.GetAuthenticatedUserRequest) (*iamv1.GetAuthenticatedUserResponse, error) { + f.GetAuthenticatedUserCalls.record(req) + return f.GetAuthenticatedUserCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.GetAuthenticatedUser) +} + +// ListUsers records the call and dispatches via ListUsersCalls. +func (f *FakeIAMService) ListUsers(ctx context.Context, req *iamv1.ListUsersRequest) (*iamv1.ListUsersResponse, error) { + f.ListUsersCalls.record(req) + return f.ListUsersCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.ListUsers) +} + +// ListUserRoles records the call and dispatches via ListUserRolesCalls. +func (f *FakeIAMService) ListUserRoles(ctx context.Context, req *iamv1.ListUserRolesRequest) (*iamv1.ListUserRolesResponse, error) { + f.ListUserRolesCalls.record(req) + return f.ListUserRolesCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.ListUserRoles) +} + +// AssignUserRoles records the call and dispatches via AssignUserRolesCalls. +func (f *FakeIAMService) AssignUserRoles(ctx context.Context, req *iamv1.AssignUserRolesRequest) (*iamv1.AssignUserRolesResponse, error) { + f.AssignUserRolesCalls.record(req) + return f.AssignUserRolesCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.AssignUserRoles) +} + +// ListRoles records the call and dispatches via ListRolesCalls. +func (f *FakeIAMService) ListRoles(ctx context.Context, req *iamv1.ListRolesRequest) (*iamv1.ListRolesResponse, error) { + f.ListRolesCalls.record(req) + return f.ListRolesCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.ListRoles) +} diff --git a/internal/testutil/server.go b/internal/testutil/server.go index 6c0d025..1824dc1 100644 --- a/internal/testutil/server.go +++ b/internal/testutil/server.go @@ -11,12 +11,14 @@ import ( "google.golang.org/grpc/metadata" "google.golang.org/grpc/test/bufconn" + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1" bookingv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/booking/v1" clusterauthv2 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/auth/v2" backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" hybridv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/hybrid/v1" + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1" platformv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/platform/v1" @@ -63,6 +65,8 @@ type TestEnv struct { HybridServer *FakeHybridService MonitoringServer *FakeMonitoringService AuthServer *FakeAuthService + IAMServer *FakeIAMService + AccountServer *FakeAccountService Capture *RequestCapture Cleanup func() } @@ -114,6 +118,8 @@ func newBaseTestEnv(t *testing.T, cfg *envConfig) *TestEnv { fakeHybrid := &FakeHybridService{} fakeMonitoring := &FakeMonitoringService{} fakeAuth := &FakeAuthService{} + fakeIAM := &FakeIAMService{} + fakeAccount := &FakeAccountService{} capture := &RequestCapture{} // Start gRPC server on bufconn. @@ -127,6 +133,8 @@ func newBaseTestEnv(t *testing.T, cfg *envConfig) *TestEnv { hybridv1.RegisterHybridCloudServiceServer(srv, fakeHybrid) monitoringv1.RegisterMonitoringServiceServer(srv, fakeMonitoring) authv1.RegisterAuthServiceServer(srv, fakeAuth) + iamv1.RegisterIAMServiceServer(srv, fakeIAM) + accountv1.RegisterAccountServiceServer(srv, fakeAccount) go func() { _ = srv.Serve(lis) @@ -178,6 +186,8 @@ func newBaseTestEnv(t *testing.T, cfg *envConfig) *TestEnv { HybridServer: fakeHybrid, MonitoringServer: fakeMonitoring, AuthServer: fakeAuth, + IAMServer: fakeIAM, + AccountServer: fakeAccount, Capture: capture, Cleanup: cleanup, } From ec7a3cd926c3251ea2918773b4c34eff8999edf7 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 09:28:22 +0200 Subject: [PATCH 02/19] chore: code format --- internal/cmd/iam/iam_test.go | 1 - internal/testutil/fake_account.go | 1 - 2 files changed, 2 deletions(-) diff --git a/internal/cmd/iam/iam_test.go b/internal/cmd/iam/iam_test.go index 2dcea05..13bf9c7 100644 --- a/internal/cmd/iam/iam_test.go +++ b/internal/cmd/iam/iam_test.go @@ -484,4 +484,3 @@ func TestInviteDelete_Error(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "not found") } - diff --git a/internal/testutil/fake_account.go b/internal/testutil/fake_account.go index 76e3de0..9ac7023 100644 --- a/internal/testutil/fake_account.go +++ b/internal/testutil/fake_account.go @@ -40,4 +40,3 @@ func (f *FakeAccountService) DeleteAccountInvite(ctx context.Context, req *accou f.DeleteAccountInviteCalls.record(req) return f.DeleteAccountInviteCalls.dispatch(ctx, req, f.UnimplementedAccountServiceServer.DeleteAccountInvite) } - From 15a5f3466afd7e5d02c7d4853870d379823b0cf0 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 10:21:25 +0200 Subject: [PATCH 03/19] refactor: move tests to their command files for users --- internal/cmd/iam/iam_test.go | 481 +--------------------- internal/cmd/iam/invite_delete_test.go | 39 ++ internal/cmd/iam/invite_describe_test.go | 45 ++ internal/cmd/iam/invite_list_test.go | 58 +++ internal/cmd/iam/user_assign_role_test.go | 73 ++++ internal/cmd/iam/user_describe_test.go | 139 +++++++ internal/cmd/iam/user_invite_test.go | 60 +++ internal/cmd/iam/user_list_test.go | 70 ++++ internal/cmd/iam/user_remove_role_test.go | 66 +++ 9 files changed, 551 insertions(+), 480 deletions(-) create mode 100644 internal/cmd/iam/invite_delete_test.go create mode 100644 internal/cmd/iam/invite_describe_test.go create mode 100644 internal/cmd/iam/invite_list_test.go create mode 100644 internal/cmd/iam/user_assign_role_test.go create mode 100644 internal/cmd/iam/user_describe_test.go create mode 100644 internal/cmd/iam/user_invite_test.go create mode 100644 internal/cmd/iam/user_list_test.go create mode 100644 internal/cmd/iam/user_remove_role_test.go diff --git a/internal/cmd/iam/iam_test.go b/internal/cmd/iam/iam_test.go index 13bf9c7..ba2bb90 100644 --- a/internal/cmd/iam/iam_test.go +++ b/internal/cmd/iam/iam_test.go @@ -1,486 +1,7 @@ package iam_test -import ( - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/timestamppb" - - accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" - iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" - - "github.com/qdrant/qcloud-cli/internal/testutil" -) - +// Shared test constants used across iam subcommand test files. const ( testUserID = "7b2ea926-724b-4de2-b73a-8675c42a6ebe" testRoleID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" ) - -// --- user list --- - -func TestUserList_TableOutput(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ - Items: []*iamv1.User{ - { - Id: "user-1", - Email: "alice@example.com", - Status: iamv1.UserStatus_USER_STATUS_ACTIVE, - CreatedAt: timestamppb.New(time.Now().Add(-48 * time.Hour)), - }, - { - Id: "user-2", - Email: "bob@example.com", - Status: iamv1.UserStatus_USER_STATUS_BLOCKED, - }, - }, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "user", "list") - require.NoError(t, err) - - assert.Contains(t, stdout, "user-1") - assert.Contains(t, stdout, "alice@example.com") - assert.Contains(t, stdout, "ACTIVE") - assert.Contains(t, stdout, "user-2") - assert.Contains(t, stdout, "bob@example.com") - assert.Contains(t, stdout, "BLOCKED") - - req, ok := env.IAMServer.ListUsersCalls.Last() - require.True(t, ok) - assert.Equal(t, "test-account-id", req.GetAccountId()) -} - -func TestUserList_JSONOutput(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ - Items: []*iamv1.User{{Id: "user-1", Email: "alice@example.com"}}, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "user", "list", "--json") - require.NoError(t, err) - - assert.Contains(t, stdout, `"id"`) - assert.Contains(t, stdout, "user-1") -} - -func TestUserList_Error(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.IAMServer.ListUsersCalls.Returns(nil, fmt.Errorf("permission denied")) - - _, _, err := testutil.Exec(t, env, "iam", "user", "list") - require.Error(t, err) - assert.Contains(t, err.Error(), "permission denied") -} - -// --- user describe --- - -func TestUserDescribe_ByID(t *testing.T) { - env := testutil.NewTestEnv(t) - - userID := testUserID - cat := "Cluster" - env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ - Items: []*iamv1.User{ - {Id: userID, Email: "alice@example.com", Status: iamv1.UserStatus_USER_STATUS_ACTIVE}, - }, - }, nil) - env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ - Roles: []*iamv1.Role{ - { - Id: "role-id-1", - Name: "admin", - Permissions: []*iamv1.Permission{ - {Value: "read:clusters", Category: &cat}, - {Value: "write:clusters", Category: &cat}, - }, - }, - }, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID) - require.NoError(t, err) - - assert.Contains(t, stdout, userID) - assert.Contains(t, stdout, "alice@example.com") - assert.Contains(t, stdout, "ACTIVE") - assert.Contains(t, stdout, "role-id-1") - assert.Contains(t, stdout, "admin") - assert.Contains(t, stdout, "read:clusters") - assert.Contains(t, stdout, "write:clusters") - assert.Contains(t, stdout, "Cluster") - - req, ok := env.IAMServer.ListUserRolesCalls.Last() - require.True(t, ok) - assert.Equal(t, userID, req.GetUserId()) -} - -func TestUserDescribe_PermissionsDeduplicatedWithRoles(t *testing.T) { - env := testutil.NewTestEnv(t) - - userID := testUserID - cat := "Cluster" - env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ - Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, - }, nil) - env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ - Roles: []*iamv1.Role{ - { - Id: "role-id-1", - Name: "admin", - Permissions: []*iamv1.Permission{ - {Value: "read:clusters", Category: &cat}, - {Value: "write:clusters", Category: &cat}, - }, - }, - { - Id: "role-id-2", - Name: "viewer", - Permissions: []*iamv1.Permission{ - {Value: "read:clusters", Category: &cat}, - }, - }, - }, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID) - require.NoError(t, err) - - // read:clusters appears in both roles — should be listed once with both role names - assert.Contains(t, stdout, "admin, viewer") - // write:clusters only in admin - assert.Contains(t, stdout, "write:clusters") -} - -func TestUserDescribe_NoPermissions(t *testing.T) { - env := testutil.NewTestEnv(t) - - userID := testUserID - env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ - Items: []*iamv1.User{ - {Id: userID, Email: "alice@example.com"}, - }, - }, nil) - env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ - Roles: []*iamv1.Role{ - {Id: "role-id-1", Name: "viewer"}, - }, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID) - require.NoError(t, err) - - assert.NotContains(t, stdout, "Effective Permissions") -} - -func TestUserDescribe_ByEmail(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ - Items: []*iamv1.User{ - {Id: "user-id-abc", Email: "alice@example.com"}, - }, - }, nil) - env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", "alice@example.com") - require.NoError(t, err) - - assert.Contains(t, stdout, "alice@example.com") - req, ok := env.IAMServer.ListUserRolesCalls.Last() - require.True(t, ok) - assert.Equal(t, "user-id-abc", req.GetUserId()) -} - -func TestUserDescribe_NotFound(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{Items: nil}, nil) - - _, _, err := testutil.Exec(t, env, "iam", "user", "describe", "nobody@example.com") - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} - -// --- user assign-role --- - -func TestUserAssignRole_ByRoleID(t *testing.T) { - env := testutil.NewTestEnv(t) - - userID := testUserID - roleID := testRoleID - - env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ - Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, - }, nil) - env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) - env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ - Roles: []*iamv1.Role{{Id: roleID, Name: "admin"}}, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", - "alice@example.com", roleID) - require.NoError(t, err) - assert.Contains(t, stdout, "alice@example.com") - assert.Contains(t, stdout, roleID) - assert.Contains(t, stdout, "admin") - - req, ok := env.IAMServer.AssignUserRolesCalls.Last() - require.True(t, ok) - assert.Equal(t, userID, req.GetUserId()) - assert.Equal(t, []string{roleID}, req.GetRoleIdsToAdd()) -} - -func TestUserAssignRole_ByRoleName(t *testing.T) { - env := testutil.NewTestEnv(t) - - userID := testUserID - roleID := testRoleID - - env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ - Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, - }, nil) - env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ - Items: []*iamv1.Role{{Id: roleID, Name: "admin"}}, - }, nil) - env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) - env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ - Roles: []*iamv1.Role{{Id: roleID, Name: "admin"}}, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", - "alice@example.com", "admin") - require.NoError(t, err) - assert.Contains(t, stdout, "admin") - - req, ok := env.IAMServer.AssignUserRolesCalls.Last() - require.True(t, ok) - assert.Equal(t, []string{roleID}, req.GetRoleIdsToAdd()) -} - -func TestUserAssignRole_MissingRole(t *testing.T) { - env := testutil.NewTestEnv(t) - - _, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", "alice@example.com") - require.Error(t, err) -} - -// --- user remove-role --- - -func TestUserRemoveRole_ByRoleID(t *testing.T) { - env := testutil.NewTestEnv(t) - - userID := testUserID - roleID := testRoleID - - env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ - Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, - }, nil) - env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) - env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", - "alice@example.com", roleID) - require.NoError(t, err) - assert.Contains(t, stdout, "alice@example.com") - - req, ok := env.IAMServer.AssignUserRolesCalls.Last() - require.True(t, ok) - assert.Equal(t, userID, req.GetUserId()) - assert.Equal(t, []string{roleID}, req.GetRoleIdsToDelete()) -} - -func TestUserRemoveRole_ByRoleName(t *testing.T) { - env := testutil.NewTestEnv(t) - - userID := testUserID - roleID := testRoleID - - env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ - Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, - }, nil) - env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ - Items: []*iamv1.Role{{Id: roleID, Name: "viewer"}}, - }, nil) - env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) - env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) - - _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", - "alice@example.com", "viewer") - require.NoError(t, err) - - req, ok := env.IAMServer.AssignUserRolesCalls.Last() - require.True(t, ok) - assert.Equal(t, []string{roleID}, req.GetRoleIdsToDelete()) -} - -func TestUserRemoveRole_MissingRole(t *testing.T) { - env := testutil.NewTestEnv(t) - - _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", "alice@example.com") - require.Error(t, err) -} - -// --- user invite --- - -func TestUserInvite(t *testing.T) { - env := testutil.NewTestEnv(t) - - inviteID := "invite-id-123" - env.AccountServer.CreateAccountInviteCalls.Returns(&accountv1.CreateAccountInviteResponse{ - AccountInvite: &accountv1.AccountInvite{Id: inviteID, UserEmail: "bob@example.com"}, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "user", "invite", - "--email", "bob@example.com") - require.NoError(t, err) - assert.Contains(t, stdout, inviteID) - assert.Contains(t, stdout, "bob@example.com") - - req, ok := env.AccountServer.CreateAccountInviteCalls.Last() - require.True(t, ok) - assert.Equal(t, "bob@example.com", req.GetAccountInvite().GetUserEmail()) - assert.Equal(t, "test-account-id", req.GetAccountInvite().GetAccountId()) -} - -func TestUserInvite_WithRole(t *testing.T) { - env := testutil.NewTestEnv(t) - - roleID := testRoleID - env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ - Items: []*iamv1.Role{{Id: roleID, Name: "viewer"}}, - }, nil) - env.AccountServer.CreateAccountInviteCalls.Returns(&accountv1.CreateAccountInviteResponse{ - AccountInvite: &accountv1.AccountInvite{Id: "invite-id", UserEmail: "bob@example.com"}, - }, nil) - - _, _, err := testutil.Exec(t, env, "iam", "user", "invite", - "--email", "bob@example.com", "--role", "viewer") - require.NoError(t, err) - - req, ok := env.AccountServer.CreateAccountInviteCalls.Last() - require.True(t, ok) - assert.Equal(t, []string{roleID}, req.GetAccountInvite().GetUserRoleIds()) -} - -func TestUserInvite_MissingEmail(t *testing.T) { - env := testutil.NewTestEnv(t) - - _, _, err := testutil.Exec(t, env, "iam", "user", "invite") - require.Error(t, err) -} - -// --- invite list --- - -func TestInviteList_TableOutput(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.AccountServer.ListAccountInvitesCalls.Returns(&accountv1.ListAccountInvitesResponse{ - Items: []*accountv1.AccountInvite{ - { - Id: "invite-1", - UserEmail: "alice@example.com", - Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_PENDING, - CreatedAt: timestamppb.New(time.Now().Add(-1 * time.Hour)), - }, - { - Id: "invite-2", - UserEmail: "bob@example.com", - Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_ACCEPTED, - }, - }, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "invite", "list") - require.NoError(t, err) - - assert.Contains(t, stdout, "invite-1") - assert.Contains(t, stdout, "alice@example.com") - assert.Contains(t, stdout, "PENDING") - assert.Contains(t, stdout, "invite-2") - assert.Contains(t, stdout, "bob@example.com") - assert.Contains(t, stdout, "ACCEPTED") - - req, ok := env.AccountServer.ListAccountInvitesCalls.Last() - require.True(t, ok) - assert.Equal(t, "test-account-id", req.GetAccountId()) -} - -func TestInviteList_Error(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.AccountServer.ListAccountInvitesCalls.Returns(nil, fmt.Errorf("permission denied")) - - _, _, err := testutil.Exec(t, env, "iam", "invite", "list") - require.Error(t, err) - assert.Contains(t, err.Error(), "permission denied") -} - -// --- invite describe --- - -func TestInviteDescribe(t *testing.T) { - env := testutil.NewTestEnv(t) - - inviteID := testUserID - env.AccountServer.GetAccountInviteCalls.Returns(&accountv1.GetAccountInviteResponse{ - AccountInvite: &accountv1.AccountInvite{ - Id: inviteID, - UserEmail: "alice@example.com", - Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_PENDING, - UserRoleIds: []string{"role-1"}, - }, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "invite", "describe", inviteID) - require.NoError(t, err) - - assert.Contains(t, stdout, inviteID) - assert.Contains(t, stdout, "alice@example.com") - assert.Contains(t, stdout, "PENDING") - assert.Contains(t, stdout, "role-1") -} - -func TestInviteDescribe_Error(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.AccountServer.GetAccountInviteCalls.Returns(nil, fmt.Errorf("not found")) - - _, _, err := testutil.Exec(t, env, "iam", "invite", "describe", testUserID) - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} - -// --- invite delete --- - -func TestInviteDelete(t *testing.T) { - env := testutil.NewTestEnv(t) - - inviteID := testUserID - env.AccountServer.DeleteAccountInviteCalls.Returns(&accountv1.DeleteAccountInviteResponse{}, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "invite", "delete", inviteID, "--force") - require.NoError(t, err) - assert.Contains(t, stdout, "deleted") - - req, ok := env.AccountServer.DeleteAccountInviteCalls.Last() - require.True(t, ok) - assert.Equal(t, inviteID, req.GetInviteId()) -} - -func TestInviteDelete_Error(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.AccountServer.DeleteAccountInviteCalls.Returns(nil, fmt.Errorf("not found")) - - _, _, err := testutil.Exec(t, env, "iam", "invite", "delete", - testUserID, "--force") - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} diff --git a/internal/cmd/iam/invite_delete_test.go b/internal/cmd/iam/invite_delete_test.go new file mode 100644 index 0000000..6b9d09b --- /dev/null +++ b/internal/cmd/iam/invite_delete_test.go @@ -0,0 +1,39 @@ +package iam_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestInviteDelete(t *testing.T) { + env := testutil.NewTestEnv(t) + + inviteID := testUserID + env.AccountServer.DeleteAccountInviteCalls.Returns(&accountv1.DeleteAccountInviteResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "invite", "delete", inviteID, "--force") + require.NoError(t, err) + assert.Contains(t, stdout, "deleted") + + req, ok := env.AccountServer.DeleteAccountInviteCalls.Last() + require.True(t, ok) + assert.Equal(t, inviteID, req.GetInviteId()) +} + +func TestInviteDelete_Error(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.DeleteAccountInviteCalls.Returns(nil, fmt.Errorf("not found")) + + _, _, err := testutil.Exec(t, env, "iam", "invite", "delete", + testUserID, "--force") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} diff --git a/internal/cmd/iam/invite_describe_test.go b/internal/cmd/iam/invite_describe_test.go new file mode 100644 index 0000000..ece86e1 --- /dev/null +++ b/internal/cmd/iam/invite_describe_test.go @@ -0,0 +1,45 @@ +package iam_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestInviteDescribe(t *testing.T) { + env := testutil.NewTestEnv(t) + + inviteID := testUserID + env.AccountServer.GetAccountInviteCalls.Returns(&accountv1.GetAccountInviteResponse{ + AccountInvite: &accountv1.AccountInvite{ + Id: inviteID, + UserEmail: "alice@example.com", + Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_PENDING, + UserRoleIds: []string{"role-1"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "invite", "describe", inviteID) + require.NoError(t, err) + + assert.Contains(t, stdout, inviteID) + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "PENDING") + assert.Contains(t, stdout, "role-1") +} + +func TestInviteDescribe_Error(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.GetAccountInviteCalls.Returns(nil, fmt.Errorf("not found")) + + _, _, err := testutil.Exec(t, env, "iam", "invite", "describe", testUserID) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} diff --git a/internal/cmd/iam/invite_list_test.go b/internal/cmd/iam/invite_list_test.go new file mode 100644 index 0000000..9d6bf42 --- /dev/null +++ b/internal/cmd/iam/invite_list_test.go @@ -0,0 +1,58 @@ +package iam_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestInviteList_TableOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.ListAccountInvitesCalls.Returns(&accountv1.ListAccountInvitesResponse{ + Items: []*accountv1.AccountInvite{ + { + Id: "invite-1", + UserEmail: "alice@example.com", + Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_PENDING, + CreatedAt: timestamppb.Now(), + }, + { + Id: "invite-2", + UserEmail: "bob@example.com", + Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_ACCEPTED, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "invite", "list") + require.NoError(t, err) + + assert.Contains(t, stdout, "invite-1") + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "PENDING") + assert.Contains(t, stdout, "invite-2") + assert.Contains(t, stdout, "bob@example.com") + assert.Contains(t, stdout, "ACCEPTED") + + req, ok := env.AccountServer.ListAccountInvitesCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) +} + +func TestInviteList_Error(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.ListAccountInvitesCalls.Returns(nil, fmt.Errorf("permission denied")) + + _, _, err := testutil.Exec(t, env, "iam", "invite", "list") + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} diff --git a/internal/cmd/iam/user_assign_role_test.go b/internal/cmd/iam/user_assign_role_test.go new file mode 100644 index 0000000..6ec3695 --- /dev/null +++ b/internal/cmd/iam/user_assign_role_test.go @@ -0,0 +1,73 @@ +package iam_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestUserAssignRole_ByRoleID(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + roleID := testRoleID + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{{Id: roleID, Name: "admin"}}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", + "alice@example.com", roleID) + require.NoError(t, err) + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, roleID) + assert.Contains(t, stdout, "admin") + + req, ok := env.IAMServer.AssignUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, userID, req.GetUserId()) + assert.Equal(t, []string{roleID}, req.GetRoleIdsToAdd()) +} + +func TestUserAssignRole_ByRoleName(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + roleID := testRoleID + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{{Id: roleID, Name: "admin"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{{Id: roleID, Name: "admin"}}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", + "alice@example.com", "admin") + require.NoError(t, err) + assert.Contains(t, stdout, "admin") + + req, ok := env.IAMServer.AssignUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, []string{roleID}, req.GetRoleIdsToAdd()) +} + +func TestUserAssignRole_MissingRole(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", "alice@example.com") + require.Error(t, err) +} diff --git a/internal/cmd/iam/user_describe_test.go b/internal/cmd/iam/user_describe_test.go new file mode 100644 index 0000000..27126b1 --- /dev/null +++ b/internal/cmd/iam/user_describe_test.go @@ -0,0 +1,139 @@ +package iam_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestUserDescribe_ByID(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + cat := "Cluster" + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + {Id: userID, Email: "alice@example.com", Status: iamv1.UserStatus_USER_STATUS_ACTIVE}, + }, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{ + { + Id: "role-id-1", + Name: "admin", + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: &cat}, + {Value: "write:clusters", Category: &cat}, + }, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID) + require.NoError(t, err) + + assert.Contains(t, stdout, userID) + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "ACTIVE") + assert.Contains(t, stdout, "role-id-1") + assert.Contains(t, stdout, "admin") + assert.Contains(t, stdout, "read:clusters") + assert.Contains(t, stdout, "write:clusters") + assert.Contains(t, stdout, "Cluster") + + req, ok := env.IAMServer.ListUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, userID, req.GetUserId()) +} + +func TestUserDescribe_PermissionsDeduplicatedWithRoles(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + cat := "Cluster" + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{ + { + Id: "role-id-1", + Name: "admin", + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: &cat}, + {Value: "write:clusters", Category: &cat}, + }, + }, + { + Id: "role-id-2", + Name: "viewer", + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: &cat}, + }, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID) + require.NoError(t, err) + + // read:clusters appears in both roles — should be listed once with both role names + assert.Contains(t, stdout, "admin, viewer") + // write:clusters only in admin + assert.Contains(t, stdout, "write:clusters") +} + +func TestUserDescribe_NoPermissions(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + {Id: userID, Email: "alice@example.com"}, + }, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{ + {Id: "role-id-1", Name: "viewer"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID) + require.NoError(t, err) + + assert.NotContains(t, stdout, "Effective Permissions") +} + +func TestUserDescribe_ByEmail(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + {Id: "user-id-abc", Email: "alice@example.com"}, + }, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", "alice@example.com") + require.NoError(t, err) + + assert.Contains(t, stdout, "alice@example.com") + req, ok := env.IAMServer.ListUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, "user-id-abc", req.GetUserId()) +} + +func TestUserDescribe_NotFound(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{Items: nil}, nil) + + _, _, err := testutil.Exec(t, env, "iam", "user", "describe", "nobody@example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} diff --git a/internal/cmd/iam/user_invite_test.go b/internal/cmd/iam/user_invite_test.go new file mode 100644 index 0000000..9428a78 --- /dev/null +++ b/internal/cmd/iam/user_invite_test.go @@ -0,0 +1,60 @@ +package iam_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestUserInvite(t *testing.T) { + env := testutil.NewTestEnv(t) + + inviteID := "invite-id-123" + env.AccountServer.CreateAccountInviteCalls.Returns(&accountv1.CreateAccountInviteResponse{ + AccountInvite: &accountv1.AccountInvite{Id: inviteID, UserEmail: "bob@example.com"}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "invite", + "--email", "bob@example.com") + require.NoError(t, err) + assert.Contains(t, stdout, inviteID) + assert.Contains(t, stdout, "bob@example.com") + + req, ok := env.AccountServer.CreateAccountInviteCalls.Last() + require.True(t, ok) + assert.Equal(t, "bob@example.com", req.GetAccountInvite().GetUserEmail()) + assert.Equal(t, "test-account-id", req.GetAccountInvite().GetAccountId()) +} + +func TestUserInvite_WithRole(t *testing.T) { + env := testutil.NewTestEnv(t) + + roleID := testRoleID + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{{Id: roleID, Name: "viewer"}}, + }, nil) + env.AccountServer.CreateAccountInviteCalls.Returns(&accountv1.CreateAccountInviteResponse{ + AccountInvite: &accountv1.AccountInvite{Id: "invite-id", UserEmail: "bob@example.com"}, + }, nil) + + _, _, err := testutil.Exec(t, env, "iam", "user", "invite", + "--email", "bob@example.com", "--role", "viewer") + require.NoError(t, err) + + req, ok := env.AccountServer.CreateAccountInviteCalls.Last() + require.True(t, ok) + assert.Equal(t, []string{roleID}, req.GetAccountInvite().GetUserRoleIds()) +} + +func TestUserInvite_MissingEmail(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "user", "invite") + require.Error(t, err) +} diff --git a/internal/cmd/iam/user_list_test.go b/internal/cmd/iam/user_list_test.go new file mode 100644 index 0000000..3042f6e --- /dev/null +++ b/internal/cmd/iam/user_list_test.go @@ -0,0 +1,70 @@ +package iam_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestUserList_TableOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + { + Id: "user-1", + Email: "alice@example.com", + Status: iamv1.UserStatus_USER_STATUS_ACTIVE, + }, + { + Id: "user-2", + Email: "bob@example.com", + Status: iamv1.UserStatus_USER_STATUS_BLOCKED, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "list") + require.NoError(t, err) + + assert.Contains(t, stdout, "user-1") + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "ACTIVE") + assert.Contains(t, stdout, "user-2") + assert.Contains(t, stdout, "bob@example.com") + assert.Contains(t, stdout, "BLOCKED") + + req, ok := env.IAMServer.ListUsersCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) +} + +func TestUserList_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: "user-1", Email: "alice@example.com"}}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "list", "--json") + require.NoError(t, err) + + assert.Contains(t, stdout, `"id"`) + assert.Contains(t, stdout, "user-1") +} + +func TestUserList_Error(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(nil, fmt.Errorf("permission denied")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "list") + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} diff --git a/internal/cmd/iam/user_remove_role_test.go b/internal/cmd/iam/user_remove_role_test.go new file mode 100644 index 0000000..4c50b0e --- /dev/null +++ b/internal/cmd/iam/user_remove_role_test.go @@ -0,0 +1,66 @@ +package iam_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestUserRemoveRole_ByRoleID(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + roleID := testRoleID + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", + "alice@example.com", roleID) + require.NoError(t, err) + assert.Contains(t, stdout, "alice@example.com") + + req, ok := env.IAMServer.AssignUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, userID, req.GetUserId()) + assert.Equal(t, []string{roleID}, req.GetRoleIdsToDelete()) +} + +func TestUserRemoveRole_ByRoleName(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + roleID := testRoleID + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{{Id: roleID, Name: "viewer"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) + + _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", + "alice@example.com", "viewer") + require.NoError(t, err) + + req, ok := env.IAMServer.AssignUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, []string{roleID}, req.GetRoleIdsToDelete()) +} + +func TestUserRemoveRole_MissingRole(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", "alice@example.com") + require.Error(t, err) +} From 5aa44f40718d7cd1d61a5ccc088bfa048a76f0b1 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 10:54:35 +0200 Subject: [PATCH 04/19] refactor: extract duplicated code into functions and reuse resolveUser in the user describe command. --- internal/cmd/iam/resolve.go | 36 ++++++++++++++++++++++++++++ internal/cmd/iam/user_assign_role.go | 34 +------------------------- internal/cmd/iam/user_describe.go | 24 ++----------------- internal/cmd/iam/user_remove_role.go | 34 +------------------------- 4 files changed, 40 insertions(+), 88 deletions(-) diff --git a/internal/cmd/iam/resolve.go b/internal/cmd/iam/resolve.go index 8538d91..e095d32 100644 --- a/internal/cmd/iam/resolve.go +++ b/internal/cmd/iam/resolve.go @@ -8,8 +8,10 @@ import ( iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + "github.com/qdrant/qcloud-cli/internal/cmd/output" "github.com/qdrant/qcloud-cli/internal/cmd/util" "github.com/qdrant/qcloud-cli/internal/qcloudapi" + "github.com/qdrant/qcloud-cli/internal/state" ) // resolveUser looks up a user by UUID or email from the account's user list. @@ -76,3 +78,37 @@ func resolveRoleIDs(ctx context.Context, client *qcloudapi.Client, accountID str } return ids, nil } + +// modifyUserRoles calls AssignUserRoles with the given add/delete IDs, then +// fetches and prints the resulting role list. errVerb is used in the error +// message ("failed to roles"). +func modifyUserRoles(s *state.State, cmd *cobra.Command, client *qcloudapi.Client, accountID string, user *iamv1.User, addIDs, removeIDs []string, errVerb string) error { + ctx := cmd.Context() + + _, err := client.IAM().AssignUserRoles(ctx, &iamv1.AssignUserRolesRequest{ + AccountId: accountID, + UserId: user.GetId(), + RoleIdsToAdd: addIDs, + RoleIdsToDelete: removeIDs, + }) + if err != nil { + return fmt.Errorf("failed to %s roles: %w", errVerb, err) + } + + rolesResp, err := client.IAM().ListUserRoles(ctx, &iamv1.ListUserRolesRequest{ + AccountId: accountID, + UserId: user.GetId(), + }) + if err != nil { + return fmt.Errorf("failed to list user roles: %w", err) + } + + if s.Config.JSONOutput() { + return output.PrintJSON(cmd.OutOrStdout(), rolesResp) + } + + w := cmd.OutOrStdout() + fmt.Fprintf(w, "Roles for %s:\n", user.GetEmail()) + printRoles(w, rolesResp.GetRoles()) + return nil +} diff --git a/internal/cmd/iam/user_assign_role.go b/internal/cmd/iam/user_assign_role.go index c6f2c80..e1df8dc 100644 --- a/internal/cmd/iam/user_assign_role.go +++ b/internal/cmd/iam/user_assign_role.go @@ -1,14 +1,9 @@ package iam import ( - "fmt" - "github.com/spf13/cobra" - iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" - "github.com/qdrant/qcloud-cli/internal/cmd/base" - "github.com/qdrant/qcloud-cli/internal/cmd/output" "github.com/qdrant/qcloud-cli/internal/state" ) @@ -45,42 +40,15 @@ qcloud iam user assign-role user@example.com admin viewer`, if err != nil { return err } - user, err := resolveUser(cmd, client, accountID, args[0]) if err != nil { return err } - roleIDs, err := resolveRoleIDs(ctx, client, accountID, args[1:]) if err != nil { return err } - - _, err = client.IAM().AssignUserRoles(ctx, &iamv1.AssignUserRolesRequest{ - AccountId: accountID, - UserId: user.GetId(), - RoleIdsToAdd: roleIDs, - }) - if err != nil { - return fmt.Errorf("failed to assign roles: %w", err) - } - - rolesResp, err := client.IAM().ListUserRoles(ctx, &iamv1.ListUserRolesRequest{ - AccountId: accountID, - UserId: user.GetId(), - }) - if err != nil { - return fmt.Errorf("failed to list user roles: %w", err) - } - - if s.Config.JSONOutput() { - return output.PrintJSON(cmd.OutOrStdout(), rolesResp) - } - - w := cmd.OutOrStdout() - fmt.Fprintf(w, "Roles for %s:\n", user.GetEmail()) - printRoles(w, rolesResp.GetRoles()) - return nil + return modifyUserRoles(s, cmd, client, accountID, user, roleIDs, nil, "assign") }, }.CobraCommand(s) } diff --git a/internal/cmd/iam/user_describe.go b/internal/cmd/iam/user_describe.go index 9772031..d2fe643 100644 --- a/internal/cmd/iam/user_describe.go +++ b/internal/cmd/iam/user_describe.go @@ -48,29 +48,9 @@ qcloud iam user describe user@example.com --json`, return err } - // Resolve the user. - var user *iamv1.User - resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{AccountId: accountID}) + user, err := resolveUser(cmd, client, accountID, args[0]) if err != nil { - return fmt.Errorf("failed to list users: %w", err) - } - if util.IsUUID(args[0]) { - for _, u := range resp.GetItems() { - if u.GetId() == args[0] { - user = u - break - } - } - } else { - for _, u := range resp.GetItems() { - if u.GetEmail() == args[0] { - user = u - break - } - } - } - if user == nil { - return fmt.Errorf("user %s not found", args[0]) + return err } // Fetch the user's roles. diff --git a/internal/cmd/iam/user_remove_role.go b/internal/cmd/iam/user_remove_role.go index a0e8f93..a16bb9b 100644 --- a/internal/cmd/iam/user_remove_role.go +++ b/internal/cmd/iam/user_remove_role.go @@ -1,14 +1,9 @@ package iam import ( - "fmt" - "github.com/spf13/cobra" - iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" - "github.com/qdrant/qcloud-cli/internal/cmd/base" - "github.com/qdrant/qcloud-cli/internal/cmd/output" "github.com/qdrant/qcloud-cli/internal/state" ) @@ -45,42 +40,15 @@ qcloud iam user remove-role user@example.com admin viewer`, if err != nil { return err } - user, err := resolveUser(cmd, client, accountID, args[0]) if err != nil { return err } - roleIDs, err := resolveRoleIDs(ctx, client, accountID, args[1:]) if err != nil { return err } - - _, err = client.IAM().AssignUserRoles(ctx, &iamv1.AssignUserRolesRequest{ - AccountId: accountID, - UserId: user.GetId(), - RoleIdsToDelete: roleIDs, - }) - if err != nil { - return fmt.Errorf("failed to remove roles: %w", err) - } - - rolesResp, err := client.IAM().ListUserRoles(ctx, &iamv1.ListUserRolesRequest{ - AccountId: accountID, - UserId: user.GetId(), - }) - if err != nil { - return fmt.Errorf("failed to list user roles: %w", err) - } - - if s.Config.JSONOutput() { - return output.PrintJSON(cmd.OutOrStdout(), rolesResp) - } - - w := cmd.OutOrStdout() - fmt.Fprintf(w, "Roles for %s:\n", user.GetEmail()) - printRoles(w, rolesResp.GetRoles()) - return nil + return modifyUserRoles(s, cmd, client, accountID, user, nil, roleIDs, "remove") }, }.CobraCommand(s) } From a272ccb4c385434390cd2a58b9b8562c5db1c505 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 10:57:21 +0200 Subject: [PATCH 05/19] fix: included the user object in the user describe command --- internal/cmd/iam/user_describe.go | 7 +++-- internal/cmd/iam/user_describe_test.go | 40 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/internal/cmd/iam/user_describe.go b/internal/cmd/iam/user_describe.go index d2fe643..dff713b 100644 --- a/internal/cmd/iam/user_describe.go +++ b/internal/cmd/iam/user_describe.go @@ -66,9 +66,10 @@ qcloud iam user describe user@example.com --json`, permissions := effectivePermissions(roles) if s.Config.JSONOutput() { - return output.PrintJSON(cmd.OutOrStdout(), &iamv1.ListUserRolesResponse{ - Roles: append([]*iamv1.Role{}, roles...), - }) + return output.PrintJSON(cmd.OutOrStdout(), struct { + User *iamv1.User `json:"user"` + Roles []*iamv1.Role `json:"roles"` + }{User: user, Roles: roles}) } w := cmd.OutOrStdout() diff --git a/internal/cmd/iam/user_describe_test.go b/internal/cmd/iam/user_describe_test.go index 27126b1..0f0532b 100644 --- a/internal/cmd/iam/user_describe_test.go +++ b/internal/cmd/iam/user_describe_test.go @@ -1,6 +1,7 @@ package iam_test import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -128,6 +129,45 @@ func TestUserDescribe_ByEmail(t *testing.T) { assert.Equal(t, "user-id-abc", req.GetUserId()) } +func TestUserDescribe_JSON(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + cat := "Cluster" + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + {Id: userID, Email: "alice@example.com", Status: iamv1.UserStatus_USER_STATUS_ACTIVE}, + }, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{ + {Id: "role-id-1", Name: "admin", Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: &cat}, + }}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID, "--json") + require.NoError(t, err) + + var got struct { + User struct { + Id string `json:"id"` + Email string `json:"email"` + } `json:"user"` + Roles []struct { + Id string `json:"id"` + Name string `json:"name"` + } `json:"roles"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &got)) + assert.Equal(t, userID, got.User.Id) + assert.Equal(t, "alice@example.com", got.User.Email) + require.Len(t, got.Roles, 1) + assert.Equal(t, "role-id-1", got.Roles[0].Id) + assert.Equal(t, "admin", got.Roles[0].Name) +} + func TestUserDescribe_NotFound(t *testing.T) { env := testutil.NewTestEnv(t) From 863401316a10ab751ff9ee18cc5f533698b6a623 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 11:01:01 +0200 Subject: [PATCH 06/19] refactor: add util.MinimumNArgs function to return a descriptive error if the arguments were not provided properly --- internal/cmd/iam/user_assign_role.go | 3 ++- internal/cmd/iam/user_remove_role.go | 3 ++- internal/cmd/util/util.go | 10 ++++++++++ internal/cmd/util/util_test.go | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/internal/cmd/iam/user_assign_role.go b/internal/cmd/iam/user_assign_role.go index e1df8dc..bb90320 100644 --- a/internal/cmd/iam/user_assign_role.go +++ b/internal/cmd/iam/user_assign_role.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/util" "github.com/qdrant/qcloud-cli/internal/state" ) @@ -13,7 +14,7 @@ func newUserAssignRoleCommand(s *state.State) *cobra.Command { return &cobra.Command{ Use: "assign-role [...]", Short: "Assign one or more roles to a user", - Args: cobra.MinimumNArgs(2), + Args: util.MinimumNArgs(2, "a user ID or email and at least one role"), } }, Long: `Assign one or more roles to a user in the account. diff --git a/internal/cmd/iam/user_remove_role.go b/internal/cmd/iam/user_remove_role.go index a16bb9b..93dfbe7 100644 --- a/internal/cmd/iam/user_remove_role.go +++ b/internal/cmd/iam/user_remove_role.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/util" "github.com/qdrant/qcloud-cli/internal/state" ) @@ -13,7 +14,7 @@ func newUserRemoveRoleCommand(s *state.State) *cobra.Command { return &cobra.Command{ Use: "remove-role [...]", Short: "Remove one or more roles from a user", - Args: cobra.MinimumNArgs(2), + Args: util.MinimumNArgs(2, "a user ID or email and at least one role"), } }, Long: `Remove one or more roles from a user in the account. diff --git a/internal/cmd/util/util.go b/internal/cmd/util/util.go index f96c6ba..e8631e3 100644 --- a/internal/cmd/util/util.go +++ b/internal/cmd/util/util.go @@ -48,3 +48,13 @@ func ExactArgs(n int, usage string) cobra.PositionalArgs { return nil } } + +// MinimumNArgs returns a PositionalArgs that requires at least n args with a descriptive error. +func MinimumNArgs(n int, usage string) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if len(args) < n { + return fmt.Errorf("requires %s\n\nUsage: %s", usage, cmd.UseLine()) + } + return nil + } +} diff --git a/internal/cmd/util/util_test.go b/internal/cmd/util/util_test.go index 563bcd5..eecdea2 100644 --- a/internal/cmd/util/util_test.go +++ b/internal/cmd/util/util_test.go @@ -28,6 +28,24 @@ func TestIsUUID(t *testing.T) { } } +func TestMinimumNArgs(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + fn := util.MinimumNArgs(2, "a user and at least one role") + + t.Run("errors when too few args", func(t *testing.T) { + err := fn(cmd, []string{"only-one"}) + assert.ErrorContains(t, err, "a user and at least one role") + }) + + t.Run("passes with exact minimum", func(t *testing.T) { + assert.NoError(t, fn(cmd, []string{"a", "b"})) + }) + + t.Run("passes with more than minimum", func(t *testing.T) { + assert.NoError(t, fn(cmd, []string{"a", "b", "c"})) + }) +} + func TestAnyFlagChanged(t *testing.T) { t.Run("returns true when one flag is changed", func(t *testing.T) { cmd := &cobra.Command{Use: "test"} From 40940847154890f5250d969087d245f5a5affa83 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 14:29:19 +0200 Subject: [PATCH 07/19] chore: code format --- internal/cmd/iam/user_describe_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/cmd/iam/user_describe_test.go b/internal/cmd/iam/user_describe_test.go index 0f0532b..72cc861 100644 --- a/internal/cmd/iam/user_describe_test.go +++ b/internal/cmd/iam/user_describe_test.go @@ -12,11 +12,13 @@ import ( "github.com/qdrant/qcloud-cli/internal/testutil" ) +const testRoleCategory = "Cluster" + func TestUserDescribe_ByID(t *testing.T) { env := testutil.NewTestEnv(t) userID := testUserID - cat := "Cluster" + cat := testRoleCategory env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ Items: []*iamv1.User{ {Id: userID, Email: "alice@example.com", Status: iamv1.UserStatus_USER_STATUS_ACTIVE}, @@ -56,7 +58,7 @@ func TestUserDescribe_PermissionsDeduplicatedWithRoles(t *testing.T) { env := testutil.NewTestEnv(t) userID := testUserID - cat := "Cluster" + cat := testRoleCategory env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, }, nil) @@ -133,7 +135,7 @@ func TestUserDescribe_JSON(t *testing.T) { env := testutil.NewTestEnv(t) userID := testUserID - cat := "Cluster" + cat := testRoleCategory env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ Items: []*iamv1.User{ {Id: userID, Email: "alice@example.com", Status: iamv1.UserStatus_USER_STATUS_ACTIVE}, From cde16b76bdac050c6bd98a36dfdece2bb0cfe3c6 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 14:52:11 +0200 Subject: [PATCH 08/19] fix: bring back the use of DescribeCmd for user describe --- internal/cmd/iam/user_describe.go | 45 ++++++++++---------------- internal/cmd/iam/user_describe_test.go | 26 +++------------ 2 files changed, 22 insertions(+), 49 deletions(-) diff --git a/internal/cmd/iam/user_describe.go b/internal/cmd/iam/user_describe.go index dff713b..2796476 100644 --- a/internal/cmd/iam/user_describe.go +++ b/internal/cmd/iam/user_describe.go @@ -17,14 +17,10 @@ import ( ) func newUserDescribeCommand(s *state.State) *cobra.Command { - return base.Cmd{ - BaseCobraCommand: func() *cobra.Command { - return &cobra.Command{ - Use: "describe ", - Short: "Describe a user and their assigned roles", - Args: util.ExactArgs(1, "a user ID or email"), - } - }, + return base.DescribeCmd[*iamv1.User]{ + Use: "describe ", + Short: "Describe a user and their assigned roles", + Args: util.ExactArgs(1, "a user ID or email"), Long: `Describe a user and their assigned roles. Accepts either a user ID (UUID) or an email address. Displays the user's @@ -37,23 +33,27 @@ qcloud iam user describe user@example.com # Output as JSON qcloud iam user describe user@example.com --json`, - Run: func(s *state.State, cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := s.Client(ctx) + Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*iamv1.User, error) { + client, err := s.Client(cmd.Context()) if err != nil { - return err + return nil, err } accountID, err := s.AccountID() + if err != nil { + return nil, err + } + return resolveUser(cmd, client, accountID, args[0]) + }, + PrintText: func(cmd *cobra.Command, w io.Writer, user *iamv1.User) error { + ctx := cmd.Context() + client, err := s.Client(ctx) if err != nil { return err } - - user, err := resolveUser(cmd, client, accountID, args[0]) + accountID, err := s.AccountID() if err != nil { return err } - - // Fetch the user's roles. rolesResp, err := client.IAM().ListUserRoles(ctx, &iamv1.ListUserRolesRequest{ AccountId: accountID, UserId: user.GetId(), @@ -62,18 +62,7 @@ qcloud iam user describe user@example.com --json`, return fmt.Errorf("failed to list user roles: %w", err) } roles := rolesResp.GetRoles() - - permissions := effectivePermissions(roles) - - if s.Config.JSONOutput() { - return output.PrintJSON(cmd.OutOrStdout(), struct { - User *iamv1.User `json:"user"` - Roles []*iamv1.Role `json:"roles"` - }{User: user, Roles: roles}) - } - - w := cmd.OutOrStdout() - return printUserWithRoles(w, user, roles, permissions) + return printUserWithRoles(w, user, roles, effectivePermissions(roles)) }, }.CobraCommand(s) } diff --git a/internal/cmd/iam/user_describe_test.go b/internal/cmd/iam/user_describe_test.go index 72cc861..0e6b0ad 100644 --- a/internal/cmd/iam/user_describe_test.go +++ b/internal/cmd/iam/user_describe_test.go @@ -135,39 +135,23 @@ func TestUserDescribe_JSON(t *testing.T) { env := testutil.NewTestEnv(t) userID := testUserID - cat := testRoleCategory env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ Items: []*iamv1.User{ {Id: userID, Email: "alice@example.com", Status: iamv1.UserStatus_USER_STATUS_ACTIVE}, }, }, nil) - env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ - Roles: []*iamv1.Role{ - {Id: "role-id-1", Name: "admin", Permissions: []*iamv1.Permission{ - {Value: "read:clusters", Category: &cat}, - }}, - }, - }, nil) + // JSON output does not fetch roles; no ListUserRoles call expected. stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID, "--json") require.NoError(t, err) var got struct { - User struct { - Id string `json:"id"` - Email string `json:"email"` - } `json:"user"` - Roles []struct { - Id string `json:"id"` - Name string `json:"name"` - } `json:"roles"` + Id string `json:"id"` + Email string `json:"email"` } require.NoError(t, json.Unmarshal([]byte(stdout), &got)) - assert.Equal(t, userID, got.User.Id) - assert.Equal(t, "alice@example.com", got.User.Email) - require.Len(t, got.Roles, 1) - assert.Equal(t, "role-id-1", got.Roles[0].Id) - assert.Equal(t, "admin", got.Roles[0].Name) + assert.Equal(t, userID, got.Id) + assert.Equal(t, "alice@example.com", got.Email) } func TestUserDescribe_NotFound(t *testing.T) { From 7cd0e8e126e2c07799725a2e0d9abccb23f9cea4 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 15:10:14 +0200 Subject: [PATCH 09/19] feat: add completion for iam user/invite commands --- internal/cmd/completion/iam.go | 37 +++++++++ internal/cmd/iam/completion.go | 91 +++++++++++++++++++++ internal/cmd/iam/completion_test.go | 116 +++++++++++++++++++++++++++ internal/cmd/iam/invite_delete.go | 1 + internal/cmd/iam/invite_describe.go | 5 +- internal/cmd/iam/user_assign_role.go | 1 + internal/cmd/iam/user_describe.go | 7 +- internal/cmd/iam/user_invite.go | 6 +- internal/cmd/iam/user_remove_role.go | 1 + 9 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 internal/cmd/completion/iam.go create mode 100644 internal/cmd/iam/completion.go create mode 100644 internal/cmd/iam/completion_test.go diff --git a/internal/cmd/completion/iam.go b/internal/cmd/completion/iam.go new file mode 100644 index 0000000..cd11e47 --- /dev/null +++ b/internal/cmd/completion/iam.go @@ -0,0 +1,37 @@ +package completion + +import ( + "github.com/spf13/cobra" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +// RoleCompletion returns a completion function that completes IAM role names +// with their ID as description. +func RoleCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + accountID, err := s.AccountID() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + resp, err := client.IAM().ListRoles(ctx, &iamv1.ListRolesRequest{AccountId: accountID}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + completions := make([]string, 0, len(resp.GetItems())) + for _, r := range resp.GetItems() { + completions = append(completions, r.GetName()+"\t"+r.GetId()) + } + return completions, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/internal/cmd/iam/completion.go b/internal/cmd/iam/completion.go new file mode 100644 index 0000000..0cc60d9 --- /dev/null +++ b/internal/cmd/iam/completion.go @@ -0,0 +1,91 @@ +package iam + +import ( + "github.com/spf13/cobra" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/completion" + "github.com/qdrant/qcloud-cli/internal/state" +) + +// userCompletion returns a ValidArgsFunction that completes user IDs with +// their email as description. It only completes the first positional argument. +func userCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return listUserCompletions(s, cmd) + } +} + +// userThenRoleCompletion returns a ValidArgsFunction that completes user +// IDs/emails for the first positional argument, and role names/IDs for all +// subsequent arguments. +func userThenRoleCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return listUserCompletions(s, cmd) + } + return completion.RoleCompletion(s)(cmd, args, "") + } +} + +// inviteCompletion returns a ValidArgsFunction that completes invite IDs with +// the invited email as description. It only completes the first positional +// argument. +func inviteCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + accountID, err := s.AccountID() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + resp, err := client.Account().ListAccountInvites(ctx, &accountv1.ListAccountInvitesRequest{ + AccountId: accountID, + }) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + completions := make([]string, 0, len(resp.GetItems())) + for _, inv := range resp.GetItems() { + completions = append(completions, inv.GetId()+"\t"+inv.GetUserEmail()) + } + return completions, cobra.ShellCompDirectiveNoFileComp + } +} + +func listUserCompletions(s *state.State, cmd *cobra.Command) ([]string, cobra.ShellCompDirective) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + accountID, err := s.AccountID() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{AccountId: accountID}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + completions := make([]string, 0, len(resp.GetItems())) + for _, u := range resp.GetItems() { + completions = append(completions, u.GetId()+"\t"+u.GetEmail()) + } + return completions, cobra.ShellCompDirectiveNoFileComp +} diff --git a/internal/cmd/iam/completion_test.go b/internal/cmd/iam/completion_test.go new file mode 100644 index 0000000..0883412 --- /dev/null +++ b/internal/cmd/iam/completion_test.go @@ -0,0 +1,116 @@ +package iam_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestUserCompletion(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + {Id: "user-uuid-1", Email: "alice@example.com"}, + {Id: "user-uuid-2", Email: "bob@example.com"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "describe", "") + require.NoError(t, err) + assert.Contains(t, stdout, "user-uuid-1") + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "user-uuid-2") + assert.Contains(t, stdout, "bob@example.com") +} + +func TestUserCompletion_StopsAfterFirstArg(t *testing.T) { + env := testutil.NewTestEnv(t) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "describe", "user-uuid-1", "") + require.NoError(t, err) + assert.NotContains(t, stdout, "user-uuid") +} + +func TestUserThenRoleCompletion_FirstArg(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + {Id: "user-uuid-1", Email: "alice@example.com"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "assign-role", "") + require.NoError(t, err) + assert.Contains(t, stdout, "user-uuid-1") + assert.Contains(t, stdout, "alice@example.com") +} + +func TestUserThenRoleCompletion_RoleArg(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{ + {Id: "role-uuid-1", Name: "admin"}, + {Id: "role-uuid-2", Name: "viewer"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "assign-role", "alice@example.com", "") + require.NoError(t, err) + assert.Contains(t, stdout, "admin") + assert.Contains(t, stdout, "role-uuid-1") + assert.Contains(t, stdout, "viewer") + assert.Contains(t, stdout, "role-uuid-2") +} + +func TestRoleFlagCompletion(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{ + {Id: "role-uuid-1", Name: "admin"}, + {Id: "role-uuid-2", Name: "viewer"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "invite", "--email", "new@example.com", "--role", "") + require.NoError(t, err) + assert.Contains(t, stdout, "admin") + assert.Contains(t, stdout, "role-uuid-1") + assert.Contains(t, stdout, "viewer") + assert.Contains(t, stdout, "role-uuid-2") +} + +func TestInviteCompletion(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.ListAccountInvitesCalls.Returns(&accountv1.ListAccountInvitesResponse{ + Items: []*accountv1.AccountInvite{ + {Id: "invite-uuid-1", UserEmail: "alice@example.com"}, + {Id: "invite-uuid-2", UserEmail: "bob@example.com"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "invite", "describe", "") + require.NoError(t, err) + assert.Contains(t, stdout, "invite-uuid-1") + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "invite-uuid-2") + assert.Contains(t, stdout, "bob@example.com") +} + +func TestInviteCompletion_StopsAfterFirstArg(t *testing.T) { + env := testutil.NewTestEnv(t) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "invite", "describe", "invite-uuid-1", "") + require.NoError(t, err) + assert.NotContains(t, stdout, "invite-uuid") +} diff --git a/internal/cmd/iam/invite_delete.go b/internal/cmd/iam/invite_delete.go index b29a782..2f40b91 100644 --- a/internal/cmd/iam/invite_delete.go +++ b/internal/cmd/iam/invite_delete.go @@ -23,6 +23,7 @@ func newInviteDeleteCommand(s *state.State) *cobra.Command { cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") return cmd }, + ValidArgsFunction: inviteCompletion(s), Long: `Delete an account invite. Cancels a pending account invite. The invited user will no longer be able to diff --git a/internal/cmd/iam/invite_describe.go b/internal/cmd/iam/invite_describe.go index 05cd82c..6fdae5d 100644 --- a/internal/cmd/iam/invite_describe.go +++ b/internal/cmd/iam/invite_describe.go @@ -17,8 +17,9 @@ import ( func newInviteDescribeCommand(s *state.State) *cobra.Command { return base.DescribeCmd[*accountv1.AccountInvite]{ - Use: "describe ", - Short: "Describe an account invite", + Use: "describe ", + Short: "Describe an account invite", + ValidArgsFunction: inviteCompletion(s), Long: `Describe an account invite. Displays the full details of a specific account invite, including the invited diff --git a/internal/cmd/iam/user_assign_role.go b/internal/cmd/iam/user_assign_role.go index bb90320..f9b5ef7 100644 --- a/internal/cmd/iam/user_assign_role.go +++ b/internal/cmd/iam/user_assign_role.go @@ -17,6 +17,7 @@ func newUserAssignRoleCommand(s *state.State) *cobra.Command { Args: util.MinimumNArgs(2, "a user ID or email and at least one role"), } }, + ValidArgsFunction: userThenRoleCompletion(s), Long: `Assign one or more roles to a user in the account. Accepts either a user ID (UUID) or an email address to identify the user. diff --git a/internal/cmd/iam/user_describe.go b/internal/cmd/iam/user_describe.go index 2796476..3b0d9f2 100644 --- a/internal/cmd/iam/user_describe.go +++ b/internal/cmd/iam/user_describe.go @@ -18,9 +18,10 @@ import ( func newUserDescribeCommand(s *state.State) *cobra.Command { return base.DescribeCmd[*iamv1.User]{ - Use: "describe ", - Short: "Describe a user and their assigned roles", - Args: util.ExactArgs(1, "a user ID or email"), + Use: "describe ", + Short: "Describe a user and their assigned roles", + Args: util.ExactArgs(1, "a user ID or email"), + ValidArgsFunction: userCompletion(s), Long: `Describe a user and their assigned roles. Accepts either a user ID (UUID) or an email address. Displays the user's diff --git a/internal/cmd/iam/user_invite.go b/internal/cmd/iam/user_invite.go index 4883f82..1c2fcca 100644 --- a/internal/cmd/iam/user_invite.go +++ b/internal/cmd/iam/user_invite.go @@ -8,11 +8,12 @@ import ( accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/completion" "github.com/qdrant/qcloud-cli/internal/state" ) func newUserInviteCommand(s *state.State) *cobra.Command { - return base.Cmd{ + cmd := base.Cmd{ BaseCobraCommand: func() *cobra.Command { cmd := &cobra.Command{ Use: "invite", @@ -74,4 +75,7 @@ qcloud iam user invite --email user@example.com --role viewer --role admin`, return nil }, }.CobraCommand(s) + + _ = cmd.RegisterFlagCompletionFunc("role", completion.RoleCompletion(s)) + return cmd } diff --git a/internal/cmd/iam/user_remove_role.go b/internal/cmd/iam/user_remove_role.go index 93dfbe7..073c834 100644 --- a/internal/cmd/iam/user_remove_role.go +++ b/internal/cmd/iam/user_remove_role.go @@ -17,6 +17,7 @@ func newUserRemoveRoleCommand(s *state.State) *cobra.Command { Args: util.MinimumNArgs(2, "a user ID or email and at least one role"), } }, + ValidArgsFunction: userThenRoleCompletion(s), Long: `Remove one or more roles from a user in the account. Accepts either a user ID (UUID) or an email address to identify the user. From 5387098148f7e307830e13c268c70275d1c32442 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 16:58:06 +0200 Subject: [PATCH 10/19] fix: remove user invite command because the api call can only be issued from a cookie authenticated user --- internal/cmd/iam/user.go | 1 - internal/cmd/iam/user_invite.go | 81 ---------------------------- internal/cmd/iam/user_invite_test.go | 60 --------------------- 3 files changed, 142 deletions(-) delete mode 100644 internal/cmd/iam/user_invite.go delete mode 100644 internal/cmd/iam/user_invite_test.go diff --git a/internal/cmd/iam/user.go b/internal/cmd/iam/user.go index 570f4db..fa8d4d8 100644 --- a/internal/cmd/iam/user.go +++ b/internal/cmd/iam/user.go @@ -21,7 +21,6 @@ manage role assignments.`, newUserDescribeCommand(s), newUserAssignRoleCommand(s), newUserRemoveRoleCommand(s), - newUserInviteCommand(s), ) return cmd } diff --git a/internal/cmd/iam/user_invite.go b/internal/cmd/iam/user_invite.go deleted file mode 100644 index 1c2fcca..0000000 --- a/internal/cmd/iam/user_invite.go +++ /dev/null @@ -1,81 +0,0 @@ -package iam - -import ( - "fmt" - - "github.com/spf13/cobra" - - accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" - - "github.com/qdrant/qcloud-cli/internal/cmd/base" - "github.com/qdrant/qcloud-cli/internal/cmd/completion" - "github.com/qdrant/qcloud-cli/internal/state" -) - -func newUserInviteCommand(s *state.State) *cobra.Command { - cmd := base.Cmd{ - BaseCobraCommand: func() *cobra.Command { - cmd := &cobra.Command{ - Use: "invite", - Short: "Invite a user to the account", - Args: cobra.NoArgs, - } - cmd.Flags().String("email", "", "Email address of the user to invite (required)") - _ = cmd.MarkFlagRequired("email") - cmd.Flags().StringArray("role", nil, "Role ID or name to assign to the invited user (repeatable)") - return cmd - }, - Long: `Invite a user to the account. - -Sends an account invite to the specified email address. The invited user will -receive an invitation they can accept or reject. - -Use --role to pre-assign roles to the invited user upon acceptance. Each ---role flag accepts either a role UUID or a role name.`, - Example: `# Invite a user with no roles -qcloud iam user invite --email user@example.com - -# Invite a user and assign a role by name -qcloud iam user invite --email user@example.com --role admin - -# Invite a user and assign multiple roles -qcloud iam user invite --email user@example.com --role viewer --role admin`, - Run: func(s *state.State, cmd *cobra.Command, args []string) error { - email, _ := cmd.Flags().GetString("email") - roleNames, _ := cmd.Flags().GetStringArray("role") - - ctx := cmd.Context() - client, err := s.Client(ctx) - if err != nil { - return err - } - accountID, err := s.AccountID() - if err != nil { - return err - } - - roleIDs, err := resolveRoleIDs(ctx, client, accountID, roleNames) - if err != nil { - return fmt.Errorf("--role: %w", err) - } - - resp, err := client.Account().CreateAccountInvite(ctx, &accountv1.CreateAccountInviteRequest{ - AccountInvite: &accountv1.AccountInvite{ - AccountId: accountID, - UserEmail: email, - UserRoleIds: roleIDs, - }, - }) - if err != nil { - return fmt.Errorf("failed to create invite: %w", err) - } - - fmt.Fprintf(cmd.OutOrStdout(), "Invite %s sent to %s.\n", - resp.GetAccountInvite().GetId(), email) - return nil - }, - }.CobraCommand(s) - - _ = cmd.RegisterFlagCompletionFunc("role", completion.RoleCompletion(s)) - return cmd -} diff --git a/internal/cmd/iam/user_invite_test.go b/internal/cmd/iam/user_invite_test.go deleted file mode 100644 index 9428a78..0000000 --- a/internal/cmd/iam/user_invite_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package iam_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" - iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" - - "github.com/qdrant/qcloud-cli/internal/testutil" -) - -func TestUserInvite(t *testing.T) { - env := testutil.NewTestEnv(t) - - inviteID := "invite-id-123" - env.AccountServer.CreateAccountInviteCalls.Returns(&accountv1.CreateAccountInviteResponse{ - AccountInvite: &accountv1.AccountInvite{Id: inviteID, UserEmail: "bob@example.com"}, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "user", "invite", - "--email", "bob@example.com") - require.NoError(t, err) - assert.Contains(t, stdout, inviteID) - assert.Contains(t, stdout, "bob@example.com") - - req, ok := env.AccountServer.CreateAccountInviteCalls.Last() - require.True(t, ok) - assert.Equal(t, "bob@example.com", req.GetAccountInvite().GetUserEmail()) - assert.Equal(t, "test-account-id", req.GetAccountInvite().GetAccountId()) -} - -func TestUserInvite_WithRole(t *testing.T) { - env := testutil.NewTestEnv(t) - - roleID := testRoleID - env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ - Items: []*iamv1.Role{{Id: roleID, Name: "viewer"}}, - }, nil) - env.AccountServer.CreateAccountInviteCalls.Returns(&accountv1.CreateAccountInviteResponse{ - AccountInvite: &accountv1.AccountInvite{Id: "invite-id", UserEmail: "bob@example.com"}, - }, nil) - - _, _, err := testutil.Exec(t, env, "iam", "user", "invite", - "--email", "bob@example.com", "--role", "viewer") - require.NoError(t, err) - - req, ok := env.AccountServer.CreateAccountInviteCalls.Last() - require.True(t, ok) - assert.Equal(t, []string{roleID}, req.GetAccountInvite().GetUserRoleIds()) -} - -func TestUserInvite_MissingEmail(t *testing.T) { - env := testutil.NewTestEnv(t) - - _, _, err := testutil.Exec(t, env, "iam", "user", "invite") - require.Error(t, err) -} From 675c973e839aa29f1e1ed318d17e0a906128b448 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 16:59:49 +0200 Subject: [PATCH 11/19] fix: remove invite management commands They are not useful while use a management api key, since invites can't be accepted/rejected while using one. --- internal/cmd/iam/iam.go | 1 - internal/cmd/iam/invite.go | 25 --------- internal/cmd/iam/invite_delete.go | 66 ----------------------- internal/cmd/iam/invite_delete_test.go | 39 -------------- internal/cmd/iam/invite_describe.go | 67 ------------------------ internal/cmd/iam/invite_describe_test.go | 45 ---------------- internal/cmd/iam/invite_list.go | 64 ---------------------- internal/cmd/iam/invite_list_test.go | 58 -------------------- 8 files changed, 365 deletions(-) delete mode 100644 internal/cmd/iam/invite.go delete mode 100644 internal/cmd/iam/invite_delete.go delete mode 100644 internal/cmd/iam/invite_delete_test.go delete mode 100644 internal/cmd/iam/invite_describe.go delete mode 100644 internal/cmd/iam/invite_describe_test.go delete mode 100644 internal/cmd/iam/invite_list.go delete mode 100644 internal/cmd/iam/invite_list_test.go diff --git a/internal/cmd/iam/iam.go b/internal/cmd/iam/iam.go index c48b7d6..f86ba44 100644 --- a/internal/cmd/iam/iam.go +++ b/internal/cmd/iam/iam.go @@ -17,7 +17,6 @@ func NewCommand(s *state.State) *cobra.Command { cmd.AddCommand( newKeyCommand(s), newUserCommand(s), - newInviteCommand(s), ) return cmd } diff --git a/internal/cmd/iam/invite.go b/internal/cmd/iam/invite.go deleted file mode 100644 index f9f086e..0000000 --- a/internal/cmd/iam/invite.go +++ /dev/null @@ -1,25 +0,0 @@ -package iam - -import ( - "github.com/spf13/cobra" - - "github.com/qdrant/qcloud-cli/internal/state" -) - -func newInviteCommand(s *state.State) *cobra.Command { - cmd := &cobra.Command{ - Use: "invite", - Short: "Manage account invites", - Long: `Manage account invites in Qdrant Cloud. - -Provides commands to list, view, and delete account invites. -To send a new invite, use the 'iam user invite' command.`, - Args: cobra.NoArgs, - } - cmd.AddCommand( - newInviteListCommand(s), - newInviteDescribeCommand(s), - newInviteDeleteCommand(s), - ) - return cmd -} diff --git a/internal/cmd/iam/invite_delete.go b/internal/cmd/iam/invite_delete.go deleted file mode 100644 index 2f40b91..0000000 --- a/internal/cmd/iam/invite_delete.go +++ /dev/null @@ -1,66 +0,0 @@ -package iam - -import ( - "fmt" - - "github.com/spf13/cobra" - - accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" - - "github.com/qdrant/qcloud-cli/internal/cmd/base" - "github.com/qdrant/qcloud-cli/internal/cmd/util" - "github.com/qdrant/qcloud-cli/internal/state" -) - -func newInviteDeleteCommand(s *state.State) *cobra.Command { - return base.Cmd{ - BaseCobraCommand: func() *cobra.Command { - cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete an account invite", - Args: util.ExactArgs(1, "an invite ID"), - } - cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") - return cmd - }, - ValidArgsFunction: inviteCompletion(s), - Long: `Delete an account invite. - -Cancels a pending account invite. The invited user will no longer be able to -accept or reject the invite. Requires the delete:invites permission.`, - Example: `# Delete an invite -qcloud iam invite delete 7b2ea926-724b-4de2-b73a-8675c42a6ebe - -# Delete without confirmation -qcloud iam invite delete 7b2ea926-724b-4de2-b73a-8675c42a6ebe --force`, - Run: func(s *state.State, cmd *cobra.Command, args []string) error { - force, _ := cmd.Flags().GetBool("force") - if !util.ConfirmAction(force, cmd.ErrOrStderr(), - fmt.Sprintf("Delete invite %s?", args[0])) { - fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") - return nil - } - - ctx := cmd.Context() - client, err := s.Client(ctx) - if err != nil { - return err - } - accountID, err := s.AccountID() - if err != nil { - return err - } - - _, err = client.Account().DeleteAccountInvite(ctx, &accountv1.DeleteAccountInviteRequest{ - AccountId: accountID, - InviteId: args[0], - }) - if err != nil { - return fmt.Errorf("failed to delete invite: %w", err) - } - - fmt.Fprintf(cmd.OutOrStdout(), "Invite %s deleted.\n", args[0]) - return nil - }, - }.CobraCommand(s) -} diff --git a/internal/cmd/iam/invite_delete_test.go b/internal/cmd/iam/invite_delete_test.go deleted file mode 100644 index 6b9d09b..0000000 --- a/internal/cmd/iam/invite_delete_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package iam_test - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" - - "github.com/qdrant/qcloud-cli/internal/testutil" -) - -func TestInviteDelete(t *testing.T) { - env := testutil.NewTestEnv(t) - - inviteID := testUserID - env.AccountServer.DeleteAccountInviteCalls.Returns(&accountv1.DeleteAccountInviteResponse{}, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "invite", "delete", inviteID, "--force") - require.NoError(t, err) - assert.Contains(t, stdout, "deleted") - - req, ok := env.AccountServer.DeleteAccountInviteCalls.Last() - require.True(t, ok) - assert.Equal(t, inviteID, req.GetInviteId()) -} - -func TestInviteDelete_Error(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.AccountServer.DeleteAccountInviteCalls.Returns(nil, fmt.Errorf("not found")) - - _, _, err := testutil.Exec(t, env, "iam", "invite", "delete", - testUserID, "--force") - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} diff --git a/internal/cmd/iam/invite_describe.go b/internal/cmd/iam/invite_describe.go deleted file mode 100644 index 6fdae5d..0000000 --- a/internal/cmd/iam/invite_describe.go +++ /dev/null @@ -1,67 +0,0 @@ -package iam - -import ( - "fmt" - "io" - "strings" - - "github.com/spf13/cobra" - - accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" - - "github.com/qdrant/qcloud-cli/internal/cmd/base" - "github.com/qdrant/qcloud-cli/internal/cmd/output" - "github.com/qdrant/qcloud-cli/internal/cmd/util" - "github.com/qdrant/qcloud-cli/internal/state" -) - -func newInviteDescribeCommand(s *state.State) *cobra.Command { - return base.DescribeCmd[*accountv1.AccountInvite]{ - Use: "describe ", - Short: "Describe an account invite", - ValidArgsFunction: inviteCompletion(s), - Long: `Describe an account invite. - -Displays the full details of a specific account invite, including the invited -email address, assigned roles, and current status. Requires the read:invites -permission.`, - Example: `# Describe an invite by ID -qcloud iam invite describe 7b2ea926-724b-4de2-b73a-8675c42a6ebe - -# Output as JSON -qcloud iam invite describe 7b2ea926-724b-4de2-b73a-8675c42a6ebe --json`, - Args: util.ExactArgs(1, "an invite ID"), - Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*accountv1.AccountInvite, error) { - ctx := cmd.Context() - client, err := s.Client(ctx) - if err != nil { - return nil, err - } - accountID, err := s.AccountID() - if err != nil { - return nil, err - } - resp, err := client.Account().GetAccountInvite(ctx, &accountv1.GetAccountInviteRequest{ - AccountId: accountID, - InviteId: args[0], - }) - if err != nil { - return nil, fmt.Errorf("failed to get invite: %w", err) - } - return resp.GetAccountInvite(), nil - }, - PrintText: func(_ *cobra.Command, w io.Writer, inv *accountv1.AccountInvite) error { - fmt.Fprintf(w, "ID: %s\n", inv.GetId()) - fmt.Fprintf(w, "Email: %s\n", inv.GetUserEmail()) - fmt.Fprintf(w, "Status: %s\n", output.AccountInviteStatus(inv.GetStatus())) - if len(inv.GetUserRoleIds()) > 0 { - fmt.Fprintf(w, "Roles: %s\n", strings.Join(inv.GetUserRoleIds(), ", ")) - } - if inv.GetCreatedAt() != nil { - t := inv.GetCreatedAt().AsTime() - fmt.Fprintf(w, "Created: %s (%s)\n", output.HumanTime(t), output.FullDateTime(t)) - } - return nil - }, - }.CobraCommand(s) -} diff --git a/internal/cmd/iam/invite_describe_test.go b/internal/cmd/iam/invite_describe_test.go deleted file mode 100644 index ece86e1..0000000 --- a/internal/cmd/iam/invite_describe_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package iam_test - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" - - "github.com/qdrant/qcloud-cli/internal/testutil" -) - -func TestInviteDescribe(t *testing.T) { - env := testutil.NewTestEnv(t) - - inviteID := testUserID - env.AccountServer.GetAccountInviteCalls.Returns(&accountv1.GetAccountInviteResponse{ - AccountInvite: &accountv1.AccountInvite{ - Id: inviteID, - UserEmail: "alice@example.com", - Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_PENDING, - UserRoleIds: []string{"role-1"}, - }, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "invite", "describe", inviteID) - require.NoError(t, err) - - assert.Contains(t, stdout, inviteID) - assert.Contains(t, stdout, "alice@example.com") - assert.Contains(t, stdout, "PENDING") - assert.Contains(t, stdout, "role-1") -} - -func TestInviteDescribe_Error(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.AccountServer.GetAccountInviteCalls.Returns(nil, fmt.Errorf("not found")) - - _, _, err := testutil.Exec(t, env, "iam", "invite", "describe", testUserID) - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} diff --git a/internal/cmd/iam/invite_list.go b/internal/cmd/iam/invite_list.go deleted file mode 100644 index 2addc46..0000000 --- a/internal/cmd/iam/invite_list.go +++ /dev/null @@ -1,64 +0,0 @@ -package iam - -import ( - "fmt" - "io" - - "github.com/spf13/cobra" - - accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" - - "github.com/qdrant/qcloud-cli/internal/cmd/base" - "github.com/qdrant/qcloud-cli/internal/cmd/output" - "github.com/qdrant/qcloud-cli/internal/state" -) - -func newInviteListCommand(s *state.State) *cobra.Command { - return base.ListCmd[*accountv1.ListAccountInvitesResponse]{ - Use: "list", - Short: "List account invites", - Long: `List account invites. - -Lists all invites for the current account. By default, invites of all statuses -are returned. Requires the read:invites permission.`, - Example: `# List all invites -qcloud iam invite list - -# Output as JSON -qcloud iam invite list --json`, - Fetch: func(s *state.State, cmd *cobra.Command) (*accountv1.ListAccountInvitesResponse, error) { - ctx := cmd.Context() - client, err := s.Client(ctx) - if err != nil { - return nil, err - } - accountID, err := s.AccountID() - if err != nil { - return nil, err - } - resp, err := client.Account().ListAccountInvites(ctx, &accountv1.ListAccountInvitesRequest{ - AccountId: accountID, - }) - if err != nil { - return nil, fmt.Errorf("failed to list invites: %w", err) - } - return resp, nil - }, - PrintText: func(_ *cobra.Command, w io.Writer, resp *accountv1.ListAccountInvitesResponse) error { - t := output.NewTable[*accountv1.AccountInvite](w) - t.AddField("ID", func(v *accountv1.AccountInvite) string { return v.GetId() }) - t.AddField("EMAIL", func(v *accountv1.AccountInvite) string { return v.GetUserEmail() }) - t.AddField("STATUS", func(v *accountv1.AccountInvite) string { - return output.AccountInviteStatus(v.GetStatus()) - }) - t.AddField("CREATED", func(v *accountv1.AccountInvite) string { - if v.GetCreatedAt() != nil { - return output.HumanTime(v.GetCreatedAt().AsTime()) - } - return "" - }) - t.Write(resp.GetItems()) - return nil - }, - }.CobraCommand(s) -} diff --git a/internal/cmd/iam/invite_list_test.go b/internal/cmd/iam/invite_list_test.go deleted file mode 100644 index 9d6bf42..0000000 --- a/internal/cmd/iam/invite_list_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package iam_test - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/timestamppb" - - accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" - - "github.com/qdrant/qcloud-cli/internal/testutil" -) - -func TestInviteList_TableOutput(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.AccountServer.ListAccountInvitesCalls.Returns(&accountv1.ListAccountInvitesResponse{ - Items: []*accountv1.AccountInvite{ - { - Id: "invite-1", - UserEmail: "alice@example.com", - Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_PENDING, - CreatedAt: timestamppb.Now(), - }, - { - Id: "invite-2", - UserEmail: "bob@example.com", - Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_ACCEPTED, - }, - }, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "iam", "invite", "list") - require.NoError(t, err) - - assert.Contains(t, stdout, "invite-1") - assert.Contains(t, stdout, "alice@example.com") - assert.Contains(t, stdout, "PENDING") - assert.Contains(t, stdout, "invite-2") - assert.Contains(t, stdout, "bob@example.com") - assert.Contains(t, stdout, "ACCEPTED") - - req, ok := env.AccountServer.ListAccountInvitesCalls.Last() - require.True(t, ok) - assert.Equal(t, "test-account-id", req.GetAccountId()) -} - -func TestInviteList_Error(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.AccountServer.ListAccountInvitesCalls.Returns(nil, fmt.Errorf("permission denied")) - - _, _, err := testutil.Exec(t, env, "iam", "invite", "list") - require.Error(t, err) - assert.Contains(t, err.Error(), "permission denied") -} From db34490c077a3f044945905d13f8a6d49afec728 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 17:02:03 +0200 Subject: [PATCH 12/19] chore: code format --- internal/cmd/iam/completion.go | 35 ---------------------- internal/cmd/iam/completion_test.go | 45 ----------------------------- 2 files changed, 80 deletions(-) diff --git a/internal/cmd/iam/completion.go b/internal/cmd/iam/completion.go index 0cc60d9..0fa8aab 100644 --- a/internal/cmd/iam/completion.go +++ b/internal/cmd/iam/completion.go @@ -3,7 +3,6 @@ package iam import ( "github.com/spf13/cobra" - accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" "github.com/qdrant/qcloud-cli/internal/cmd/completion" @@ -33,40 +32,6 @@ func userThenRoleCompletion(s *state.State) func(*cobra.Command, []string, strin } } -// inviteCompletion returns a ValidArgsFunction that completes invite IDs with -// the invited email as description. It only completes the first positional -// argument. -func inviteCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { - return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { - if len(args) > 0 { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - ctx := cmd.Context() - client, err := s.Client(ctx) - if err != nil { - return nil, cobra.ShellCompDirectiveError - } - accountID, err := s.AccountID() - if err != nil { - return nil, cobra.ShellCompDirectiveError - } - - resp, err := client.Account().ListAccountInvites(ctx, &accountv1.ListAccountInvitesRequest{ - AccountId: accountID, - }) - if err != nil { - return nil, cobra.ShellCompDirectiveError - } - - completions := make([]string, 0, len(resp.GetItems())) - for _, inv := range resp.GetItems() { - completions = append(completions, inv.GetId()+"\t"+inv.GetUserEmail()) - } - return completions, cobra.ShellCompDirectiveNoFileComp - } -} - func listUserCompletions(s *state.State, cmd *cobra.Command) ([]string, cobra.ShellCompDirective) { ctx := cmd.Context() client, err := s.Client(ctx) diff --git a/internal/cmd/iam/completion_test.go b/internal/cmd/iam/completion_test.go index 0883412..5c583ec 100644 --- a/internal/cmd/iam/completion_test.go +++ b/internal/cmd/iam/completion_test.go @@ -6,7 +6,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" "github.com/qdrant/qcloud-cli/internal/testutil" @@ -70,47 +69,3 @@ func TestUserThenRoleCompletion_RoleArg(t *testing.T) { assert.Contains(t, stdout, "viewer") assert.Contains(t, stdout, "role-uuid-2") } - -func TestRoleFlagCompletion(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ - Items: []*iamv1.Role{ - {Id: "role-uuid-1", Name: "admin"}, - {Id: "role-uuid-2", Name: "viewer"}, - }, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "invite", "--email", "new@example.com", "--role", "") - require.NoError(t, err) - assert.Contains(t, stdout, "admin") - assert.Contains(t, stdout, "role-uuid-1") - assert.Contains(t, stdout, "viewer") - assert.Contains(t, stdout, "role-uuid-2") -} - -func TestInviteCompletion(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.AccountServer.ListAccountInvitesCalls.Returns(&accountv1.ListAccountInvitesResponse{ - Items: []*accountv1.AccountInvite{ - {Id: "invite-uuid-1", UserEmail: "alice@example.com"}, - {Id: "invite-uuid-2", UserEmail: "bob@example.com"}, - }, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "invite", "describe", "") - require.NoError(t, err) - assert.Contains(t, stdout, "invite-uuid-1") - assert.Contains(t, stdout, "alice@example.com") - assert.Contains(t, stdout, "invite-uuid-2") - assert.Contains(t, stdout, "bob@example.com") -} - -func TestInviteCompletion_StopsAfterFirstArg(t *testing.T) { - env := testutil.NewTestEnv(t) - - stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "invite", "describe", "invite-uuid-1", "") - require.NoError(t, err) - assert.NotContains(t, stdout, "invite-uuid") -} From cc0710325cc9ffbafa593bccda79d72993c38287 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 17:28:47 +0200 Subject: [PATCH 13/19] refactor: use --role flags instead of arguments for listing roles on assign/remove role commands to keep consistency with permission management --- internal/cmd/iam/completion_test.go | 17 ------------- internal/cmd/iam/user_assign_role.go | 29 ++++++++++++++++------- internal/cmd/iam/user_assign_role_test.go | 4 ++-- internal/cmd/iam/user_remove_role.go | 24 ++++++++++++------- internal/cmd/iam/user_remove_role_test.go | 4 ++-- internal/cmd/util/util.go | 9 ------- internal/cmd/util/util_test.go | 18 -------------- 7 files changed, 40 insertions(+), 65 deletions(-) diff --git a/internal/cmd/iam/completion_test.go b/internal/cmd/iam/completion_test.go index 5c583ec..1cd071b 100644 --- a/internal/cmd/iam/completion_test.go +++ b/internal/cmd/iam/completion_test.go @@ -52,20 +52,3 @@ func TestUserThenRoleCompletion_FirstArg(t *testing.T) { assert.Contains(t, stdout, "alice@example.com") } -func TestUserThenRoleCompletion_RoleArg(t *testing.T) { - env := testutil.NewTestEnv(t) - - env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ - Items: []*iamv1.Role{ - {Id: "role-uuid-1", Name: "admin"}, - {Id: "role-uuid-2", Name: "viewer"}, - }, - }, nil) - - stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "assign-role", "alice@example.com", "") - require.NoError(t, err) - assert.Contains(t, stdout, "admin") - assert.Contains(t, stdout, "role-uuid-1") - assert.Contains(t, stdout, "viewer") - assert.Contains(t, stdout, "role-uuid-2") -} diff --git a/internal/cmd/iam/user_assign_role.go b/internal/cmd/iam/user_assign_role.go index f9b5ef7..2b385fc 100644 --- a/internal/cmd/iam/user_assign_role.go +++ b/internal/cmd/iam/user_assign_role.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/completion" "github.com/qdrant/qcloud-cli/internal/cmd/util" "github.com/qdrant/qcloud-cli/internal/state" ) @@ -11,27 +12,34 @@ import ( func newUserAssignRoleCommand(s *state.State) *cobra.Command { return base.Cmd{ BaseCobraCommand: func() *cobra.Command { - return &cobra.Command{ - Use: "assign-role [...]", + cmd := &cobra.Command{ + Use: "assign-role ", Short: "Assign one or more roles to a user", - Args: util.MinimumNArgs(2, "a user ID or email and at least one role"), + Args: util.ExactArgs(1, "a user ID or email"), } + + cmd.Flags().StringSliceP("role", "r", nil, "A role ID or name") + cmd.RegisterFlagCompletionFunc("role", completion.RoleCompletion(s)) + return cmd }, - ValidArgsFunction: userThenRoleCompletion(s), + ValidArgsFunction: userCompletion(s), Long: `Assign one or more roles to a user in the account. Accepts either a user ID (UUID) or an email address to identify the user. -Each role argument accepts either a role UUID or a role name, which is +Each role accepts either a role UUID or a role name, which is resolved to an ID via the IAM service. Prints the user's resulting roles after the assignment.`, Example: `# Assign a role by name -qcloud iam user assign-role user@example.com admin +qcloud iam user assign-role user@example.com --role admin # Assign a role by ID -qcloud iam user assign-role user@example.com 7b2ea926-724b-4de2-b73a-8675c42a6ebe +qcloud iam user assign-role user@example.com --role 7b2ea926-724b-4de2-b73a-8675c42a6ebe # Assign multiple roles at once -qcloud iam user assign-role user@example.com admin viewer`, +qcloud iam user assign-role user@example.com --role admin --role viewer + +# Assign multiple roles at once using comma separated values +qcloud iam user assign-role user@example.com --role admin,viewer`, Run: func(s *state.State, cmd *cobra.Command, args []string) error { ctx := cmd.Context() client, err := s.Client(ctx) @@ -46,10 +54,13 @@ qcloud iam user assign-role user@example.com admin viewer`, if err != nil { return err } - roleIDs, err := resolveRoleIDs(ctx, client, accountID, args[1:]) + + roles, _ := cmd.Flags().GetStringSlice("role") + roleIDs, err := resolveRoleIDs(ctx, client, accountID, roles) if err != nil { return err } + return modifyUserRoles(s, cmd, client, accountID, user, roleIDs, nil, "assign") }, }.CobraCommand(s) diff --git a/internal/cmd/iam/user_assign_role_test.go b/internal/cmd/iam/user_assign_role_test.go index 6ec3695..7c04c35 100644 --- a/internal/cmd/iam/user_assign_role_test.go +++ b/internal/cmd/iam/user_assign_role_test.go @@ -26,7 +26,7 @@ func TestUserAssignRole_ByRoleID(t *testing.T) { }, nil) stdout, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", - "alice@example.com", roleID) + "alice@example.com", "--role", roleID) require.NoError(t, err) assert.Contains(t, stdout, "alice@example.com") assert.Contains(t, stdout, roleID) @@ -56,7 +56,7 @@ func TestUserAssignRole_ByRoleName(t *testing.T) { }, nil) stdout, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", - "alice@example.com", "admin") + "alice@example.com", "--role", "admin") require.NoError(t, err) assert.Contains(t, stdout, "admin") diff --git a/internal/cmd/iam/user_remove_role.go b/internal/cmd/iam/user_remove_role.go index 073c834..71b7f5a 100644 --- a/internal/cmd/iam/user_remove_role.go +++ b/internal/cmd/iam/user_remove_role.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/completion" "github.com/qdrant/qcloud-cli/internal/cmd/util" "github.com/qdrant/qcloud-cli/internal/state" ) @@ -11,13 +12,17 @@ import ( func newUserRemoveRoleCommand(s *state.State) *cobra.Command { return base.Cmd{ BaseCobraCommand: func() *cobra.Command { - return &cobra.Command{ - Use: "remove-role [...]", + cmd := &cobra.Command{ + Use: "remove-role ", Short: "Remove one or more roles from a user", - Args: util.MinimumNArgs(2, "a user ID or email and at least one role"), + Args: util.ExactArgs(1, "a user ID or email"), } + + cmd.Flags().StringSliceP("role", "r", nil, "A role ID or name") + cmd.RegisterFlagCompletionFunc("role", completion.RoleCompletion(s)) + return cmd }, - ValidArgsFunction: userThenRoleCompletion(s), + ValidArgsFunction: userCompletion(s), Long: `Remove one or more roles from a user in the account. Accepts either a user ID (UUID) or an email address to identify the user. @@ -25,13 +30,13 @@ Each role argument accepts either a role UUID or a role name, which is resolved to an ID via the IAM service. Prints the user's resulting roles after the removal.`, Example: `# Remove a role by name -qcloud iam user remove-role user@example.com admin +qcloud iam user remove-role user@example.com --role admin # Remove a role by ID -qcloud iam user remove-role user@example.com 7b2ea926-724b-4de2-b73a-8675c42a6ebe +qcloud iam user remove-role user@example.com --role 7b2ea926-724b-4de2-b73a-8675c42a6ebe # Remove multiple roles at once -qcloud iam user remove-role user@example.com admin viewer`, +qcloud iam user remove-role user@example.com --role admin --role viewer`, Run: func(s *state.State, cmd *cobra.Command, args []string) error { ctx := cmd.Context() client, err := s.Client(ctx) @@ -46,10 +51,13 @@ qcloud iam user remove-role user@example.com admin viewer`, if err != nil { return err } - roleIDs, err := resolveRoleIDs(ctx, client, accountID, args[1:]) + + roles, _ := cmd.Flags().GetStringSlice("role") + roleIDs, err := resolveRoleIDs(ctx, client, accountID, roles) if err != nil { return err } + return modifyUserRoles(s, cmd, client, accountID, user, nil, roleIDs, "remove") }, }.CobraCommand(s) diff --git a/internal/cmd/iam/user_remove_role_test.go b/internal/cmd/iam/user_remove_role_test.go index 4c50b0e..6fcfa0e 100644 --- a/internal/cmd/iam/user_remove_role_test.go +++ b/internal/cmd/iam/user_remove_role_test.go @@ -24,7 +24,7 @@ func TestUserRemoveRole_ByRoleID(t *testing.T) { env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) stdout, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", - "alice@example.com", roleID) + "alice@example.com", "--role", roleID) require.NoError(t, err) assert.Contains(t, stdout, "alice@example.com") @@ -50,7 +50,7 @@ func TestUserRemoveRole_ByRoleName(t *testing.T) { env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", - "alice@example.com", "viewer") + "alice@example.com", "--role", "viewer") require.NoError(t, err) req, ok := env.IAMServer.AssignUserRolesCalls.Last() diff --git a/internal/cmd/util/util.go b/internal/cmd/util/util.go index e8631e3..78c0e2c 100644 --- a/internal/cmd/util/util.go +++ b/internal/cmd/util/util.go @@ -49,12 +49,3 @@ func ExactArgs(n int, usage string) cobra.PositionalArgs { } } -// MinimumNArgs returns a PositionalArgs that requires at least n args with a descriptive error. -func MinimumNArgs(n int, usage string) cobra.PositionalArgs { - return func(cmd *cobra.Command, args []string) error { - if len(args) < n { - return fmt.Errorf("requires %s\n\nUsage: %s", usage, cmd.UseLine()) - } - return nil - } -} diff --git a/internal/cmd/util/util_test.go b/internal/cmd/util/util_test.go index eecdea2..563bcd5 100644 --- a/internal/cmd/util/util_test.go +++ b/internal/cmd/util/util_test.go @@ -28,24 +28,6 @@ func TestIsUUID(t *testing.T) { } } -func TestMinimumNArgs(t *testing.T) { - cmd := &cobra.Command{Use: "test"} - fn := util.MinimumNArgs(2, "a user and at least one role") - - t.Run("errors when too few args", func(t *testing.T) { - err := fn(cmd, []string{"only-one"}) - assert.ErrorContains(t, err, "a user and at least one role") - }) - - t.Run("passes with exact minimum", func(t *testing.T) { - assert.NoError(t, fn(cmd, []string{"a", "b"})) - }) - - t.Run("passes with more than minimum", func(t *testing.T) { - assert.NoError(t, fn(cmd, []string{"a", "b", "c"})) - }) -} - func TestAnyFlagChanged(t *testing.T) { t.Run("returns true when one flag is changed", func(t *testing.T) { cmd := &cobra.Command{Use: "test"} From 1715f4752b2b16e794a9c0f7dfe3a6357fc6922d Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 17:44:57 +0200 Subject: [PATCH 14/19] fix: add api error test cases for user management commands --- internal/cmd/iam/completion.go | 13 ----- internal/cmd/iam/completion_test.go | 1 - internal/cmd/iam/user_assign_role.go | 6 +- internal/cmd/iam/user_assign_role_test.go | 71 +++++++++++++++++++++++ internal/cmd/iam/user_describe_test.go | 24 ++++++++ internal/cmd/iam/user_remove_role.go | 8 +-- internal/cmd/iam/user_remove_role_test.go | 71 +++++++++++++++++++++++ internal/cmd/util/util.go | 1 - 8 files changed, 173 insertions(+), 22 deletions(-) diff --git a/internal/cmd/iam/completion.go b/internal/cmd/iam/completion.go index 0fa8aab..efb6952 100644 --- a/internal/cmd/iam/completion.go +++ b/internal/cmd/iam/completion.go @@ -5,7 +5,6 @@ import ( iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" - "github.com/qdrant/qcloud-cli/internal/cmd/completion" "github.com/qdrant/qcloud-cli/internal/state" ) @@ -20,18 +19,6 @@ func userCompletion(s *state.State) func(*cobra.Command, []string, string) ([]st } } -// userThenRoleCompletion returns a ValidArgsFunction that completes user -// IDs/emails for the first positional argument, and role names/IDs for all -// subsequent arguments. -func userThenRoleCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { - return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { - if len(args) == 0 { - return listUserCompletions(s, cmd) - } - return completion.RoleCompletion(s)(cmd, args, "") - } -} - func listUserCompletions(s *state.State, cmd *cobra.Command) ([]string, cobra.ShellCompDirective) { ctx := cmd.Context() client, err := s.Client(ctx) diff --git a/internal/cmd/iam/completion_test.go b/internal/cmd/iam/completion_test.go index 1cd071b..80fbce7 100644 --- a/internal/cmd/iam/completion_test.go +++ b/internal/cmd/iam/completion_test.go @@ -51,4 +51,3 @@ func TestUserThenRoleCompletion_FirstArg(t *testing.T) { assert.Contains(t, stdout, "user-uuid-1") assert.Contains(t, stdout, "alice@example.com") } - diff --git a/internal/cmd/iam/user_assign_role.go b/internal/cmd/iam/user_assign_role.go index 2b385fc..b0ccc15 100644 --- a/internal/cmd/iam/user_assign_role.go +++ b/internal/cmd/iam/user_assign_role.go @@ -18,8 +18,8 @@ func newUserAssignRoleCommand(s *state.State) *cobra.Command { Args: util.ExactArgs(1, "a user ID or email"), } - cmd.Flags().StringSliceP("role", "r", nil, "A role ID or name") - cmd.RegisterFlagCompletionFunc("role", completion.RoleCompletion(s)) + _ = cmd.Flags().StringSliceP("role", "r", nil, "A role ID or name") + _ = cmd.RegisterFlagCompletionFunc("role", completion.RoleCompletion(s)) return cmd }, ValidArgsFunction: userCompletion(s), @@ -55,7 +55,7 @@ qcloud iam user assign-role user@example.com --role admin,viewer`, return err } - roles, _ := cmd.Flags().GetStringSlice("role") + roles, _ := cmd.Flags().GetStringSlice("role") roleIDs, err := resolveRoleIDs(ctx, client, accountID, roles) if err != nil { return err diff --git a/internal/cmd/iam/user_assign_role_test.go b/internal/cmd/iam/user_assign_role_test.go index 7c04c35..0fd1629 100644 --- a/internal/cmd/iam/user_assign_role_test.go +++ b/internal/cmd/iam/user_assign_role_test.go @@ -1,6 +1,7 @@ package iam_test import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -71,3 +72,73 @@ func TestUserAssignRole_MissingRole(t *testing.T) { _, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", "alice@example.com") require.Error(t, err) } + +func TestUserAssignRole_ResolveUserError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(nil, fmt.Errorf("connection refused")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", + "alice@example.com", "--role", testRoleID) + require.Error(t, err) + assert.Contains(t, err.Error(), "connection refused") +} + +func TestUserAssignRole_ResolveRoleError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: testUserID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListRolesCalls.Returns(nil, fmt.Errorf("service unavailable")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", + "alice@example.com", "--role", "admin") + require.Error(t, err) + assert.Contains(t, err.Error(), "service unavailable") +} + +func TestUserAssignRole_RoleNameNotFound(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: testUserID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{{Id: testRoleID, Name: "viewer"}}, + }, nil) + + _, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", + "alice@example.com", "--role", "nonexistent") + require.Error(t, err) + assert.Contains(t, err.Error(), `role "nonexistent" not found`) +} + +func TestUserAssignRole_AssignError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: testUserID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(nil, fmt.Errorf("forbidden")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", + "alice@example.com", "--role", testRoleID) + require.Error(t, err) + assert.Contains(t, err.Error(), "forbidden") +} + +func TestUserAssignRole_ListUserRolesError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: testUserID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(nil, fmt.Errorf("timeout")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", + "alice@example.com", "--role", testRoleID) + require.Error(t, err) + assert.Contains(t, err.Error(), "timeout") +} diff --git a/internal/cmd/iam/user_describe_test.go b/internal/cmd/iam/user_describe_test.go index 0e6b0ad..6354a52 100644 --- a/internal/cmd/iam/user_describe_test.go +++ b/internal/cmd/iam/user_describe_test.go @@ -2,6 +2,7 @@ package iam_test import ( "encoding/json" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -163,3 +164,26 @@ func TestUserDescribe_NotFound(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "not found") } + +func TestUserDescribe_ListUsersError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(nil, fmt.Errorf("permission denied")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "describe", testUserID) + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestUserDescribe_ListUserRolesError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: testUserID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(nil, fmt.Errorf("internal server error")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "describe", testUserID) + require.Error(t, err) + assert.Contains(t, err.Error(), "internal server error") +} diff --git a/internal/cmd/iam/user_remove_role.go b/internal/cmd/iam/user_remove_role.go index 71b7f5a..5e1fb4d 100644 --- a/internal/cmd/iam/user_remove_role.go +++ b/internal/cmd/iam/user_remove_role.go @@ -18,15 +18,15 @@ func newUserRemoveRoleCommand(s *state.State) *cobra.Command { Args: util.ExactArgs(1, "a user ID or email"), } - cmd.Flags().StringSliceP("role", "r", nil, "A role ID or name") - cmd.RegisterFlagCompletionFunc("role", completion.RoleCompletion(s)) + _ = cmd.Flags().StringSliceP("role", "r", nil, "A role ID or name") + _ = cmd.RegisterFlagCompletionFunc("role", completion.RoleCompletion(s)) return cmd }, ValidArgsFunction: userCompletion(s), Long: `Remove one or more roles from a user in the account. Accepts either a user ID (UUID) or an email address to identify the user. -Each role argument accepts either a role UUID or a role name, which is +Each role accepts either a role UUID or a role name, which is resolved to an ID via the IAM service. Prints the user's resulting roles after the removal.`, Example: `# Remove a role by name @@ -52,7 +52,7 @@ qcloud iam user remove-role user@example.com --role admin --role viewer`, return err } - roles, _ := cmd.Flags().GetStringSlice("role") + roles, _ := cmd.Flags().GetStringSlice("role") roleIDs, err := resolveRoleIDs(ctx, client, accountID, roles) if err != nil { return err diff --git a/internal/cmd/iam/user_remove_role_test.go b/internal/cmd/iam/user_remove_role_test.go index 6fcfa0e..e8f5a71 100644 --- a/internal/cmd/iam/user_remove_role_test.go +++ b/internal/cmd/iam/user_remove_role_test.go @@ -1,6 +1,7 @@ package iam_test import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -64,3 +65,73 @@ func TestUserRemoveRole_MissingRole(t *testing.T) { _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", "alice@example.com") require.Error(t, err) } + +func TestUserRemoveRole_ResolveUserError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(nil, fmt.Errorf("connection refused")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", + "alice@example.com", "--role", testRoleID) + require.Error(t, err) + assert.Contains(t, err.Error(), "connection refused") +} + +func TestUserRemoveRole_ResolveRoleError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: testUserID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListRolesCalls.Returns(nil, fmt.Errorf("service unavailable")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", + "alice@example.com", "--role", "admin") + require.Error(t, err) + assert.Contains(t, err.Error(), "service unavailable") +} + +func TestUserRemoveRole_RoleNameNotFound(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: testUserID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{{Id: testRoleID, Name: "viewer"}}, + }, nil) + + _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", + "alice@example.com", "--role", "nonexistent") + require.Error(t, err) + assert.Contains(t, err.Error(), `role "nonexistent" not found`) +} + +func TestUserRemoveRole_RemoveError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: testUserID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(nil, fmt.Errorf("forbidden")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", + "alice@example.com", "--role", testRoleID) + require.Error(t, err) + assert.Contains(t, err.Error(), "forbidden") +} + +func TestUserRemoveRole_ListUserRolesError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: testUserID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(nil, fmt.Errorf("timeout")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", + "alice@example.com", "--role", testRoleID) + require.Error(t, err) + assert.Contains(t, err.Error(), "timeout") +} diff --git a/internal/cmd/util/util.go b/internal/cmd/util/util.go index 78c0e2c..f96c6ba 100644 --- a/internal/cmd/util/util.go +++ b/internal/cmd/util/util.go @@ -48,4 +48,3 @@ func ExactArgs(n int, usage string) cobra.PositionalArgs { return nil } } - From 2514c0c337bd31d113bf91d4ba2a9871763215d2 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 18:26:17 +0200 Subject: [PATCH 15/19] chore: remove unused lsitUserCompletion function that was only used in one place --- internal/cmd/iam/completion.go | 40 +++++++++++++++------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/internal/cmd/iam/completion.go b/internal/cmd/iam/completion.go index efb6952..7b28594 100644 --- a/internal/cmd/iam/completion.go +++ b/internal/cmd/iam/completion.go @@ -15,29 +15,25 @@ func userCompletion(s *state.State) func(*cobra.Command, []string, string) ([]st if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return listUserCompletions(s, cmd) - } -} - -func listUserCompletions(s *state.State, cmd *cobra.Command) ([]string, cobra.ShellCompDirective) { - ctx := cmd.Context() - client, err := s.Client(ctx) - if err != nil { - return nil, cobra.ShellCompDirectiveError - } - accountID, err := s.AccountID() - if err != nil { - return nil, cobra.ShellCompDirectiveError - } + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + accountID, err := s.AccountID() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } - resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{AccountId: accountID}) - if err != nil { - return nil, cobra.ShellCompDirectiveError - } + resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{AccountId: accountID}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } - completions := make([]string, 0, len(resp.GetItems())) - for _, u := range resp.GetItems() { - completions = append(completions, u.GetId()+"\t"+u.GetEmail()) + completions := make([]string, 0, len(resp.GetItems())) + for _, u := range resp.GetItems() { + completions = append(completions, u.GetId()+"\t"+u.GetEmail()) + } + return completions, cobra.ShellCompDirectiveNoFileComp } - return completions, cobra.ShellCompDirectiveNoFileComp } From 45204cac01f5c28c69b898f76eb15ba50e1b1336 Mon Sep 17 00:00:00 2001 From: dhernando Date: Tue, 7 Apr 2026 18:31:05 +0200 Subject: [PATCH 16/19] chore: remove unused AccountInviteStatus function --- internal/cmd/output/iam.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/cmd/output/iam.go b/internal/cmd/output/iam.go index 60a42ba..b81b543 100644 --- a/internal/cmd/output/iam.go +++ b/internal/cmd/output/iam.go @@ -3,7 +3,6 @@ package output import ( "strings" - accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" ) @@ -11,8 +10,3 @@ import ( func UserStatus(x iamv1.UserStatus) string { return strings.TrimPrefix(x.String(), "USER_STATUS_") } - -// AccountInviteStatus formats an accountv1.AccountInviteStatus enum for display. -func AccountInviteStatus(x accountv1.AccountInviteStatus) string { - return strings.TrimPrefix(x.String(), "ACCOUNT_INVITE_STATUS_") -} From 6d45e91d0551293619844865207b90f82d6b0147 Mon Sep 17 00:00:00 2001 From: dhernando Date: Fri, 10 Apr 2026 11:28:52 +0200 Subject: [PATCH 17/19] chore: code format --- internal/testutil/fake_account.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/testutil/fake_account.go b/internal/testutil/fake_account.go index d92ae9d..58b6f1d 100644 --- a/internal/testutil/fake_account.go +++ b/internal/testutil/fake_account.go @@ -15,14 +15,13 @@ type FakeAccountService struct { GetAccountInviteCalls MethodSpy[*accountv1.GetAccountInviteRequest, *accountv1.GetAccountInviteResponse] CreateAccountInviteCalls MethodSpy[*accountv1.CreateAccountInviteRequest, *accountv1.CreateAccountInviteResponse] DeleteAccountInviteCalls MethodSpy[*accountv1.DeleteAccountInviteRequest, *accountv1.DeleteAccountInviteResponse] - ListAccountsCalls MethodSpy[*accountv1.ListAccountsRequest, *accountv1.ListAccountsResponse] - GetAccountCalls MethodSpy[*accountv1.GetAccountRequest, *accountv1.GetAccountResponse] - UpdateAccountCalls MethodSpy[*accountv1.UpdateAccountRequest, *accountv1.UpdateAccountResponse] - ListAccountMembersCalls MethodSpy[*accountv1.ListAccountMembersRequest, *accountv1.ListAccountMembersResponse] - GetAccountMemberCalls MethodSpy[*accountv1.GetAccountMemberRequest, *accountv1.GetAccountMemberResponse] + ListAccountsCalls MethodSpy[*accountv1.ListAccountsRequest, *accountv1.ListAccountsResponse] + GetAccountCalls MethodSpy[*accountv1.GetAccountRequest, *accountv1.GetAccountResponse] + UpdateAccountCalls MethodSpy[*accountv1.UpdateAccountRequest, *accountv1.UpdateAccountResponse] + ListAccountMembersCalls MethodSpy[*accountv1.ListAccountMembersRequest, *accountv1.ListAccountMembersResponse] + GetAccountMemberCalls MethodSpy[*accountv1.GetAccountMemberRequest, *accountv1.GetAccountMemberResponse] } - // ListAccountInvites records the call and dispatches via ListAccountInvitesCalls. func (f *FakeAccountService) ListAccountInvites(ctx context.Context, req *accountv1.ListAccountInvitesRequest) (*accountv1.ListAccountInvitesResponse, error) { f.ListAccountInvitesCalls.record(req) From c2ed03f4848197d5ff0c6e53e326a5c860912c32 Mon Sep 17 00:00:00 2001 From: dhernando Date: Fri, 10 Apr 2026 12:51:15 +0200 Subject: [PATCH 18/19] refactor: removed errVerb argument from the modifyUserRoles function, it's too much and a generic error can be returned --- internal/cmd/iam/resolve.go | 4 ++-- internal/cmd/iam/user_assign_role.go | 2 +- internal/cmd/iam/user_remove_role.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/iam/resolve.go b/internal/cmd/iam/resolve.go index e095d32..8c8ad38 100644 --- a/internal/cmd/iam/resolve.go +++ b/internal/cmd/iam/resolve.go @@ -82,7 +82,7 @@ func resolveRoleIDs(ctx context.Context, client *qcloudapi.Client, accountID str // modifyUserRoles calls AssignUserRoles with the given add/delete IDs, then // fetches and prints the resulting role list. errVerb is used in the error // message ("failed to roles"). -func modifyUserRoles(s *state.State, cmd *cobra.Command, client *qcloudapi.Client, accountID string, user *iamv1.User, addIDs, removeIDs []string, errVerb string) error { +func modifyUserRoles(s *state.State, cmd *cobra.Command, client *qcloudapi.Client, accountID string, user *iamv1.User, addIDs, removeIDs []string) error { ctx := cmd.Context() _, err := client.IAM().AssignUserRoles(ctx, &iamv1.AssignUserRolesRequest{ @@ -92,7 +92,7 @@ func modifyUserRoles(s *state.State, cmd *cobra.Command, client *qcloudapi.Clien RoleIdsToDelete: removeIDs, }) if err != nil { - return fmt.Errorf("failed to %s roles: %w", errVerb, err) + return fmt.Errorf("failed to modify roles: %w", err) } rolesResp, err := client.IAM().ListUserRoles(ctx, &iamv1.ListUserRolesRequest{ diff --git a/internal/cmd/iam/user_assign_role.go b/internal/cmd/iam/user_assign_role.go index b0ccc15..35b51df 100644 --- a/internal/cmd/iam/user_assign_role.go +++ b/internal/cmd/iam/user_assign_role.go @@ -61,7 +61,7 @@ qcloud iam user assign-role user@example.com --role admin,viewer`, return err } - return modifyUserRoles(s, cmd, client, accountID, user, roleIDs, nil, "assign") + return modifyUserRoles(s, cmd, client, accountID, user, roleIDs, nil) }, }.CobraCommand(s) } diff --git a/internal/cmd/iam/user_remove_role.go b/internal/cmd/iam/user_remove_role.go index 5e1fb4d..484c715 100644 --- a/internal/cmd/iam/user_remove_role.go +++ b/internal/cmd/iam/user_remove_role.go @@ -58,7 +58,7 @@ qcloud iam user remove-role user@example.com --role admin --role viewer`, return err } - return modifyUserRoles(s, cmd, client, accountID, user, nil, roleIDs, "remove") + return modifyUserRoles(s, cmd, client, accountID, user, nil, roleIDs) }, }.CobraCommand(s) } From 1e83157f19dc56b892a23b632b89e0296e070280 Mon Sep 17 00:00:00 2001 From: dhernando Date: Fri, 10 Apr 2026 12:51:59 +0200 Subject: [PATCH 19/19] chore: remove comment regarding errVerb --- internal/cmd/iam/resolve.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/cmd/iam/resolve.go b/internal/cmd/iam/resolve.go index 8c8ad38..0c07837 100644 --- a/internal/cmd/iam/resolve.go +++ b/internal/cmd/iam/resolve.go @@ -80,8 +80,7 @@ func resolveRoleIDs(ctx context.Context, client *qcloudapi.Client, accountID str } // modifyUserRoles calls AssignUserRoles with the given add/delete IDs, then -// fetches and prints the resulting role list. errVerb is used in the error -// message ("failed to roles"). +// fetches and prints the resulting role list. func modifyUserRoles(s *state.State, cmd *cobra.Command, client *qcloudapi.Client, accountID string, user *iamv1.User, addIDs, removeIDs []string) error { ctx := cmd.Context()