Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions internal/cmd/iam/iam.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@ func NewCommand(s *state.State) *cobra.Command {
Long: `Manage IAM resources for the Qdrant Cloud account.`,
Args: cobra.NoArgs,
}
cmd.AddCommand(
newUserCommand(s),
newInviteCommand(s),
)
return cmd
}
7 changes: 7 additions & 0 deletions internal/cmd/iam/iam_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package iam_test

// Shared test constants used across iam subcommand test files.
const (
testUserID = "7b2ea926-724b-4de2-b73a-8675c42a6ebe"
testRoleID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
)
25 changes: 25 additions & 0 deletions internal/cmd/iam/invite.go
Original file line number Diff line number Diff line change
@@ -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
}
65 changes: 65 additions & 0 deletions internal/cmd/iam/invite_delete.go
Original file line number Diff line number Diff line change
@@ -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 <invite-id>",
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)
}
39 changes: 39 additions & 0 deletions internal/cmd/iam/invite_delete_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
66 changes: 66 additions & 0 deletions internal/cmd/iam/invite_describe.go
Original file line number Diff line number Diff line change
@@ -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 <invite-id>",
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)
}
45 changes: 45 additions & 0 deletions internal/cmd/iam/invite_describe_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
64 changes: 64 additions & 0 deletions internal/cmd/iam/invite_list.go
Original file line number Diff line number Diff line change
@@ -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)
}
58 changes: 58 additions & 0 deletions internal/cmd/iam/invite_list_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading