diff --git a/.github/workflows/pr-cost.yml b/.github/workflows/pr-cost.yml index e0eff3c..d713c90 100644 --- a/.github/workflows/pr-cost.yml +++ b/.github/workflows/pr-cost.yml @@ -16,7 +16,7 @@ jobs: cost: runs-on: ubuntu-latest permissions: - pull-requests: read + pull-requests: write issues: write steps: - uses: actions/github-script@v7 diff --git a/cmd/groups.go b/cmd/groups.go new file mode 100644 index 0000000..82c3d39 --- /dev/null +++ b/cmd/groups.go @@ -0,0 +1,415 @@ +package cmd + +import ( + "cmp" + "fmt" + "strconv" + + "github.com/requestyai/cli/internal/client" + "github.com/requestyai/cli/internal/util" + "github.com/spf13/cobra" +) + +const ( + groupNameFlag = "name" + groupMonthlyLimitFlag = "monthly-limit" + groupRoleFlag = "role" + groupYesFlag = "yes" +) + +func newGroupsCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "groups", + Aliases: []string{"group"}, + Short: "Manage the groups in your organization", + Long: "Manage the groups in your organization.\n\n" + + "A group gathers users under a shared budget. Members are existing users of\n" + + "your organization, referred to by their user id.", + } + + cmd.PersistentFlags().Bool(jsonFlag, false, "print JSON instead of a table") + cmd.AddCommand( + newGroupsListCommand(env), + newGroupsShowCommand(env), + newGroupsCreateCommand(env), + newGroupsDeleteCommand(env), + newGroupsMembersCommand(env), + ) + + return cmd +} + +func newGroupsListCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List the groups in your organization", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + groups, err := env.apiv2Client.Groups(cmd.Context()) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, groups) + } + if len(groups) == 0 { + _, err := fmt.Fprintln(out, "No groups yet.") + return err + } + + rows := make([][]string, 0, len(groups)) + for _, group := range groups { + rows = append(rows, []string{ + group.ID, + group.Name, + strconv.Itoa(group.MembersCount), + formatMoney(group.MonthlySpend), + // The budget mode decides which field carries the cap. + formatOptionalLimit(cmp.Or(group.MonthlyLimit, group.MonthlyBudget)), + formatDate(group.CreatedAt), + }) + } + + return writeTable(out, []string{"ID", "NAME", "MEMBERS", "SPEND", "LIMIT", "CREATED"}, rows) + }, + } +} + +func newGroupsShowCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show one group and its members", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := util.ParseGroupID(args[0]) + if err != nil { + return err + } + + group, err := env.apiv2Client.Group(cmd.Context(), id) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, group) + } + + fields := [][2]string{ + {"ID", group.ID}, + {"Name", group.Name}, + {"Organization", group.OrganizationID}, + } + fields = append(fields, groupBudgetFields(group)...) + fields = append(fields, + [2]string{"Created by", group.CreatedBy}, + [2]string{"Created at", formatTime(group.CreatedAt)}, + [2]string{"Members", strconv.Itoa(len(group.Members))}, + ) + + if err := writeFields(out, fields); err != nil { + return err + } + + if len(group.Members) == 0 { + return nil + } + + if _, err := fmt.Fprintln(out); err != nil { + return err + } + + rows := make([][]string, 0, len(group.Members)) + for _, member := range group.Members { + rows = append(rows, []string{ + member.ID, + member.Email, + string(member.Role), + formatMoney(member.MonthlySpend), + formatOptionalLimit(member.MonthlyLimit), + formatRateLimit(member.RateLimit), + strconv.FormatBool(member.Active), + }) + } + + return writeTable(out, []string{"MEMBER", "EMAIL", "ROLE", "SPEND", "LIMIT", "RATE LIMIT", "ACTIVE"}, rows) + }, + } +} + +// groupBudgetFields lists whichever budget the API reported. Global mode fills +// in a monthly limit while Group Budget mode fills in a budget for the group +// and one for each member, so report only what came back. +func groupBudgetFields(group client.GroupDetails) [][2]string { + fields := make([][2]string, 0, 3) + + if group.MonthlyBudget != nil { + fields = append(fields, [2]string{"Monthly budget", formatOptionalLimit(group.MonthlyBudget)}) + } + if group.MonthlyBudgetPerUser != nil { + fields = append(fields, [2]string{"Monthly budget per user", formatOptionalLimit(group.MonthlyBudgetPerUser)}) + } + if group.MonthlyLimit != nil || len(fields) == 0 { + fields = append(fields, [2]string{"Monthly limit", formatOptionalLimit(group.MonthlyLimit)}) + } + + return fields +} +func newGroupsCreateCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a group", + Long: "Create a group.\n\n" + + "How the monthly limit is applied depends on your organization's budget mode:\n" + + "it caps the group as a whole in Group Budget mode, and each member of the\n" + + "group separately in Global mode.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + flags := cmd.Flags() + + name, err := flags.GetString(groupNameFlag) + if err != nil { + return err + } + + input := client.CreateGroupInput{Name: name} + + if flags.Changed(groupMonthlyLimitFlag) { + raw, err := flags.GetString(groupMonthlyLimitFlag) + if err != nil { + return err + } + limit, err := util.ParseMoney("--"+groupMonthlyLimitFlag, raw) + if err != nil { + return err + } + input.MonthlyLimit = &limit + } + + created, err := env.apiv2Client.CreateGroup(cmd.Context(), input) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, created) + } + + _, err = fmt.Fprintf(out, "Created group %s with id %s.\n", name, created.ID) + return err + }, + } + + flags := cmd.Flags() + flags.String(groupNameFlag, "", "name for the new group") + flags.String(groupMonthlyLimitFlag, "", "monthly spending cap in dollars, for example 1000 (default: the organization setting)") + if err := cmd.MarkFlagRequired(groupNameFlag); err != nil { + panic(err) + } + + return cmd +} + +func newGroupsDeleteCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a group", + Long: "Delete a group.\n\n" + + "Deletion is permanent. The members keep their accounts, but lose the budget\n" + + "and the access the group gave them.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := util.ParseGroupID(args[0]) + if err != nil { + return err + } + + skipPrompt, err := cmd.Flags().GetBool(groupYesFlag) + if err != nil { + return err + } + + if !skipPrompt { + confirmed, err := util.Confirm( + cmd.InOrStdin(), + cmd.OutOrStdout(), + fmt.Sprintf("Delete group %s? This cannot be undone [y/N]: ", id), + ) + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "Left it alone.") + return err + } + } + + if err := env.apiv2Client.DeleteGroup(cmd.Context(), id); err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, map[string]any{"id": id, "deleted": true}) + } + + _, err = fmt.Fprintf(out, "Deleted group %s.\n", id) + return err + }, + } + + cmd.Flags().BoolP(groupYesFlag, "y", false, "delete without asking first") + + return cmd +} + +func newGroupsMembersCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "members", + Aliases: []string{"member"}, + Short: "Manage the members of a group", + Long: "Manage the members of a group.\n\n" + + "Members are existing users of your organization. Run groups show to see who\n" + + "is in a group and what their user ids are.", + } + cmd.AddCommand( + newGroupsMembersAddCommand(env), + newGroupsMembersUpdateCommand(env), + newGroupsMembersRemoveCommand(env), + ) + + return cmd +} + +func newGroupsMembersAddCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a member to a group", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + groupID, userID, err := parseGroupMemberIDs(args) + if err != nil { + return err + } + + raw, err := cmd.Flags().GetString(groupRoleFlag) + if err != nil { + return err + } + role, err := util.ParseRole(raw) + if err != nil { + return err + } + + if err := env.apiv2Client.AddGroupMember(cmd.Context(), groupID, userID, role); err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, map[string]any{ + "group_id": groupID, + "user_id": userID, + "role": role, + "added": true, + }) + } + + _, err = fmt.Fprintf(out, "Added user %s to group %s as %s.\n", userID, groupID, role) + return err + }, + } + + cmd.Flags().String(groupRoleFlag, string(client.GroupRoleMember), "role for the new member: admin or member") + + return cmd +} + +func newGroupsMembersUpdateCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "update --role ", + Short: "Update the role of a group member", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + groupID, userID, err := parseGroupMemberIDs(args) + if err != nil { + return err + } + + raw, err := cmd.Flags().GetString(groupRoleFlag) + if err != nil { + return err + } + role, err := util.ParseRole(raw) + if err != nil { + return err + } + + if err := env.apiv2Client.UpdateGroupMemberRole(cmd.Context(), groupID, userID, role); err != nil { + return err + } + + return reportUpdate(cmd, userID, "role", + fmt.Sprintf("User %s is now %s in group %s.", userID, role, groupID)) + }, + } + + cmd.Flags().String(groupRoleFlag, "", "new role for the member: admin or member") + if err := cmd.MarkFlagRequired(groupRoleFlag); err != nil { + panic(err) + } + + return cmd +} + +func newGroupsMembersRemoveCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Aliases: []string{"rm"}, + Short: "Remove a member from a group", + Long: "Remove a member from a group.\n\n" + + "The user keeps their account and can be added back at any time.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + groupID, userID, err := parseGroupMemberIDs(args) + if err != nil { + return err + } + + if err := env.apiv2Client.RemoveGroupMember(cmd.Context(), groupID, userID); err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, map[string]any{ + "group_id": groupID, + "user_id": userID, + "removed": true, + }) + } + + _, err = fmt.Fprintf(out, "Removed user %s from group %s.\n", userID, groupID) + return err + }, + } +} + +// parseGroupMemberIDs reads the group and user a member command addresses. +func parseGroupMemberIDs(args []string) (string, string, error) { + groupID, err := util.ParseGroupID(args[0]) + if err != nil { + return "", "", err + } + + userID, err := util.ParseUserID(args[1]) + if err != nil { + return "", "", err + } + + return groupID, userID, nil +} diff --git a/cmd/output.go b/cmd/output.go index 4b30657..1638024 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -5,8 +5,10 @@ import ( "fmt" "io" "sort" + "strconv" "strings" "text/tabwriter" + "time" "github.com/requestyai/cli/internal/client" "github.com/shopspring/decimal" @@ -72,6 +74,43 @@ func formatLimit(limit decimal.Decimal) string { return formatMoney(limit) } +// formatOptionalLimit renders a spending cap the API may leave out, where both +// an absent value and zero mean there is none. +func formatOptionalLimit(limit *decimal.Decimal) string { + if limit == nil { + return "unlimited" + } + + return formatLimit(*limit) +} + +// formatRateLimit renders a member's rate limit, which may not be set. +func formatRateLimit(rateLimit *int64) string { + if rateLimit == nil { + return "-" + } + + return strconv.FormatInt(*rateLimit, 10) +} + +// formatTime renders a timestamp the way the CLI accepts one back. +func formatTime(moment time.Time) string { + if moment.IsZero() { + return "-" + } + + return moment.Format(time.RFC3339) +} + +// formatDate renders a timestamp as a calendar date, to keep table rows narrow. +func formatDate(moment time.Time) string { + if moment.IsZero() { + return "-" + } + + return moment.Format(time.DateOnly) +} + // formatLabels renders labels as sorted key=value pairs. func formatLabels(labels map[string]string) string { if len(labels) == 0 { diff --git a/cmd/requesty.go b/cmd/requesty.go index 9da434b..d078473 100644 --- a/cmd/requesty.go +++ b/cmd/requesty.go @@ -58,7 +58,10 @@ func newRootCommand(env environment) *cobra.Command { }, } - root.AddCommand(newAPIKeysCommand(env)) + root.AddCommand( + newAPIKeysCommand(env), + newGroupsCommand(env), + ) return root } diff --git a/internal/client/groups.go b/internal/client/groups.go new file mode 100644 index 0000000..8584225 --- /dev/null +++ b/internal/client/groups.go @@ -0,0 +1,184 @@ +package client + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/shopspring/decimal" +) + +// GroupRole is the standing a member has within a group. +type GroupRole string + +const ( + GroupRoleAdmin GroupRole = "admin" + GroupRoleMember GroupRole = "member" +) + +// Group is a group as it appears in the organization listing. +type Group struct { + ID string `json:"id"` + OrganizationID string `json:"organization_id"` + Name string `json:"name"` + MembersCount int `json:"members_count"` + MonthlySpend decimal.Decimal `json:"monthly_spend"` + MonthlyLimit *decimal.Decimal `json:"monthly_limit,omitempty"` + MonthlyBudget *decimal.Decimal `json:"monthly_budget,omitempty"` + MonthlyBudgetPerUser *decimal.Decimal `json:"monthly_budget_per_user,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` +} + +// GroupDetails is a single group. The listing does not report members, so this +// is a wider record than Group rather than the same one. +type GroupDetails struct { + ID string `json:"id"` + OrganizationID string `json:"organization_id"` + Name string `json:"name"` + MonthlyLimit *decimal.Decimal `json:"monthly_limit,omitempty"` + MonthlyBudget *decimal.Decimal `json:"monthly_budget,omitempty"` + MonthlyBudgetPerUser *decimal.Decimal `json:"monthly_budget_per_user,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + Members []GroupMember `json:"members"` +} + +// GroupMember is one person in a group. A nil MonthlyLimit or RateLimit means +// the member is not capped beyond whatever the group and organization impose. +type GroupMember struct { + ID string `json:"id"` + Email string `json:"email"` + Role GroupRole `json:"role"` + MonthlyLimit *decimal.Decimal `json:"monthly_limit,omitempty"` + MonthlySpend decimal.Decimal `json:"monthly_spend"` + RateLimit *int64 `json:"rate_limit,omitempty"` + Active bool `json:"active"` +} + +// CreateGroupInput describes a group to create. A nil MonthlyLimit leaves the +// organization default in place. +type CreateGroupInput struct { + Name string + MonthlyLimit *decimal.Decimal +} + +// CreatedGroup identifies a group that was just created. +type CreatedGroup struct { + ID string `json:"group_id"` +} + +// Groups lists the organization's groups. +func (c *Client) Groups(ctx context.Context) ([]Group, error) { + endpoint, err := c.manageURL("group") + if err != nil { + return nil, err + } + + var response struct { + Groups []Group `json:"groups"` + } + if err := c.do(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return nil, err + } + + return response.Groups, nil +} + +// Group returns one group along with its members. +func (c *Client) Group(ctx context.Context, id string) (GroupDetails, error) { + endpoint, err := c.manageURL("group", id) + if err != nil { + return GroupDetails{}, err + } + + var response struct { + Group GroupDetails `json:"group"` + } + if err := c.do(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return GroupDetails{}, err + } + + return response.Group, nil +} + +// CreateGroup adds a group to the organization. In Group Budget mode the limit +// caps the group as a whole; in Global mode it caps each member separately. +func (c *Client) CreateGroup(ctx context.Context, input CreateGroupInput) (CreatedGroup, error) { + endpoint, err := c.manageURL("group") + if err != nil { + return CreatedGroup{}, err + } + + // The group endpoints take the limit as a JSON number, where the API key + // endpoints take a string. + body := struct { + Name string `json:"name"` + MonthlyLimit *json.Number `json:"monthly_limit,omitempty"` + }{Name: input.Name} + if input.MonthlyLimit != nil { + limit := json.Number(input.MonthlyLimit.String()) + body.MonthlyLimit = &limit + } + + var created CreatedGroup + if err := c.do(ctx, http.MethodPost, endpoint, body, &created); err != nil { + return CreatedGroup{}, err + } + + return created, nil +} + +// DeleteGroup removes a group for good. Its members keep their accounts. +func (c *Client) DeleteGroup(ctx context.Context, id string) error { + endpoint, err := c.manageURL("group", id) + if err != nil { + return err + } + + return c.do(ctx, http.MethodDelete, endpoint, nil, nil) +} + +// AddGroupMember puts an existing organization user into a group. +func (c *Client) AddGroupMember(ctx context.Context, groupID, userID string, role GroupRole) error { + endpoint, err := c.manageURL("group", groupID, "member") + if err != nil { + return err + } + + body := struct { + UserID string `json:"user_id"` + Role GroupRole `json:"role"` + }{UserID: userID, Role: role} + + return c.do(ctx, http.MethodPost, endpoint, body, nil) +} + +// UpdateGroupMemberRole changes what a member may do within the group. +func (c *Client) UpdateGroupMemberRole(ctx context.Context, groupID, userID string, role GroupRole) error { + endpoint, err := c.manageURL("group", groupID, "member", userID, "role") + if err != nil { + return err + } + + body := struct { + Role GroupRole `json:"role"` + }{Role: role} + + return c.do(ctx, http.MethodPut, endpoint, body, nil) +} + +// RemoveGroupMember takes a member out of a group, leaving the user in place. +func (c *Client) RemoveGroupMember(ctx context.Context, groupID, userID string) error { + endpoint, err := c.manageURL("group", groupID, "member") + if err != nil { + return err + } + + body := struct { + UserID string `json:"user_id"` + }{UserID: userID} + + return c.do(ctx, http.MethodDelete, endpoint, body, nil) +} diff --git a/internal/client/groups_test.go b/internal/client/groups_test.go new file mode 100644 index 0000000..2f72038 --- /dev/null +++ b/internal/client/groups_test.go @@ -0,0 +1,189 @@ +package client + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ptr returns the address of value, for the fields the API may leave out. +func ptr[T any](value T) *T { + return &value +} + +func TestClientGroups(t *testing.T) { + // The group endpoints report money as JSON numbers rather than strings, and + // which budget field is filled in depends on the organization's budget mode. + reply := `{"groups":[` + + `{"id":"group-1","organization_id":"org-123","name":"Engineering Team","members_count":5,` + + `"monthly_spend":450.25,"monthly_limit":1000,"created_by":"user-123",` + + `"created_at":"2026-01-15T10:30:00Z"},` + + `{"id":"group-2","organization_id":"org-123","name":"Research","members_count":0,` + + `"monthly_spend":0,"monthly_limit":null,"monthly_budget":2000,"monthly_budget_per_user":250,` + + `"created_by":"user-123","created_at":"2026-02-01T00:00:00Z"}]}` + client, seen := newTestClient(t, http.StatusOK, reply) + + groups, err := client.Groups(context.Background()) + + require.NoError(t, err) + assert.Equal(t, http.MethodGet, seen.method) + assert.Equal(t, "/v1/manage/group", seen.path) + assert.Equal(t, "Bearer test-key", seen.auth) + assert.Equal(t, []Group{ + { + ID: "group-1", + OrganizationID: "org-123", + Name: "Engineering Team", + MembersCount: 5, + MonthlySpend: decimal.RequireFromString("450.25"), + MonthlyLimit: ptr(decimal.RequireFromString("1000")), + CreatedBy: "user-123", + CreatedAt: time.Date(2026, time.January, 15, 10, 30, 0, 0, time.UTC), + }, + { + ID: "group-2", + OrganizationID: "org-123", + Name: "Research", + MonthlySpend: decimal.RequireFromString("0"), + MonthlyBudget: ptr(decimal.RequireFromString("2000")), + MonthlyBudgetPerUser: ptr(decimal.RequireFromString("250")), + CreatedBy: "user-123", + CreatedAt: time.Date(2026, time.February, 1, 0, 0, 0, 0, time.UTC), + }, + }, groups) +} + +func TestClientGroup(t *testing.T) { + reply := `{"group":{"id":"group-1","organization_id":"org-123","name":"Engineering Team",` + + `"monthly_limit":1000,"created_by":"user-123","created_at":"2026-01-15T10:30:00Z","members":[` + + `{"id":"user-1","email":"lead@example.com","role":"admin","monthly_limit":500,` + + `"monthly_spend":250.5,"rate_limit":100,"active":true},` + + `{"id":"user-2","email":"dev@example.com","role":"member","monthly_limit":null,` + + `"monthly_spend":0,"rate_limit":null,"active":false}]}}` + client, seen := newTestClient(t, http.StatusOK, reply) + + group, err := client.Group(context.Background(), "group-1") + + require.NoError(t, err) + assert.Equal(t, http.MethodGet, seen.method) + assert.Equal(t, "/v1/manage/group/group-1", seen.path) + assert.Equal(t, GroupDetails{ + ID: "group-1", + OrganizationID: "org-123", + Name: "Engineering Team", + MonthlyLimit: ptr(decimal.RequireFromString("1000")), + CreatedBy: "user-123", + CreatedAt: time.Date(2026, time.January, 15, 10, 30, 0, 0, time.UTC), + Members: []GroupMember{ + { + ID: "user-1", + Email: "lead@example.com", + Role: GroupRoleAdmin, + MonthlyLimit: ptr(decimal.RequireFromString("500")), + MonthlySpend: decimal.RequireFromString("250.5"), + RateLimit: ptr(int64(100)), + Active: true, + }, + { + ID: "user-2", + Email: "dev@example.com", + Role: GroupRoleMember, + MonthlySpend: decimal.RequireFromString("0"), + }, + }, + }, group) +} + +func TestClientGroupEscapesID(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, `{"group":{"id":"group-1"}}`) + + _, err := client.Group(context.Background(), "../apikey") + + require.NoError(t, err) + assert.Equal(t, "/v1/manage/group/..%2Fapikey", seen.path) +} + +func TestClientCreateGroup(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, `{"group_id":"group-1"}`) + + limit := decimal.RequireFromString("1000") + created, err := client.CreateGroup(context.Background(), CreateGroupInput{ + Name: "Engineering Team", + MonthlyLimit: &limit, + }) + + require.NoError(t, err) + assert.Equal(t, http.MethodPost, seen.method) + assert.Equal(t, "/v1/manage/group", seen.path) + // The limit goes out as a number, which is what this endpoint accepts. + assert.JSONEq(t, `{"name":"Engineering Team","monthly_limit":1000}`, seen.body) + assert.Equal(t, CreatedGroup{ID: "group-1"}, created) +} + +func TestClientCreateGroupOmitsUnsetLimit(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, `{"group_id":"group-1"}`) + + _, err := client.CreateGroup(context.Background(), CreateGroupInput{Name: "Engineering Team"}) + + require.NoError(t, err) + assert.JSONEq(t, `{"name":"Engineering Team"}`, seen.body) +} + +func TestClientDeleteGroup(t *testing.T) { + client, seen := newTestClient(t, http.StatusNoContent, "") + + err := client.DeleteGroup(context.Background(), "group-1") + + require.NoError(t, err) + assert.Equal(t, http.MethodDelete, seen.method) + assert.Equal(t, "/v1/manage/group/group-1", seen.path) + assert.Empty(t, seen.body) +} + +func TestClientAddGroupMember(t *testing.T) { + client, seen := newTestClient(t, http.StatusNoContent, "") + + err := client.AddGroupMember(context.Background(), "group-1", "user-1", GroupRoleMember) + + require.NoError(t, err) + assert.Equal(t, http.MethodPost, seen.method) + assert.Equal(t, "/v1/manage/group/group-1/member", seen.path) + assert.JSONEq(t, `{"user_id":"user-1","role":"member"}`, seen.body) +} + +func TestClientUpdateGroupMemberRole(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, "") + + err := client.UpdateGroupMemberRole(context.Background(), "group-1", "user-1", GroupRoleAdmin) + + require.NoError(t, err) + assert.Equal(t, http.MethodPut, seen.method) + assert.Equal(t, "/v1/manage/group/group-1/member/user-1/role", seen.path) + assert.JSONEq(t, `{"role":"admin"}`, seen.body) +} + +func TestClientRemoveGroupMember(t *testing.T) { + client, seen := newTestClient(t, http.StatusNoContent, "") + + err := client.RemoveGroupMember(context.Background(), "group-1", "user-1") + + require.NoError(t, err) + assert.Equal(t, http.MethodDelete, seen.method) + assert.Equal(t, "/v1/manage/group/group-1/member", seen.path) + assert.JSONEq(t, `{"user_id":"user-1"}`, seen.body) +} + +func TestClientGroupReportsAPIError(t *testing.T) { + client, _ := newTestClient(t, http.StatusNotFound, `{"error":{"origin":"router","message":"group not found"}}`) + + _, err := client.Group(context.Background(), "group-1") + + require.Error(t, err) + assert.Contains(t, err.Error(), "group not found") + assert.Contains(t, err.Error(), "404") +} diff --git a/internal/util/parse.go b/internal/util/parse.go index 659504f..ede98a0 100644 --- a/internal/util/parse.go +++ b/internal/util/parse.go @@ -1,7 +1,6 @@ package util import ( - "errors" "fmt" "strings" "time" @@ -13,6 +12,7 @@ import ( const ( managePermissionFlag = "manage-permission" completionsPermissionFlag = "completions-permission" + roleFlag = "role" // NeverExpires is the expiry argument for a key that should keep working. NeverExpires = "never" @@ -20,14 +20,39 @@ const ( // ParseID trims an API key ID and rejects an empty value. func ParseID(value string) (string, error) { + return parseID("api key", value) +} + +// ParseGroupID trims a group ID and rejects an empty value. +func ParseGroupID(value string) (string, error) { + return parseID("group", value) +} + +// ParseUserID trims a user ID and rejects an empty value. +func ParseUserID(value string) (string, error) { + return parseID("user", value) +} + +// parseID trims an identifier and names what was missing when it is empty. +func parseID(subject, value string) (string, error) { id := strings.TrimSpace(value) if id == "" { - return "", errors.New("missing api key id") + return "", fmt.Errorf("missing %s id", subject) } return id, nil } +// ParseRole validates a group member role. +func ParseRole(value string) (client.GroupRole, error) { + switch role := client.GroupRole(strings.TrimSpace(value)); role { + case client.GroupRoleAdmin, client.GroupRoleMember: + return role, nil + default: + return "", fmt.Errorf("invalid --%s %q: want admin or member", roleFlag, value) + } +} + // ParsePermissions validates and combines the management and completions // permissions. Both values must be provided together, or both left empty. func ParsePermissions(manage, completions string) (*client.APIKeyPermissions, error) { diff --git a/internal/util/parse_test.go b/internal/util/parse_test.go index 58fe78f..ecab681 100644 --- a/internal/util/parse_test.go +++ b/internal/util/parse_test.go @@ -19,6 +19,37 @@ func TestParseID(t *testing.T) { assert.EqualError(t, err, "missing api key id") } +func TestParseGroupID(t *testing.T) { + id, err := ParseGroupID(" group-123 ") + require.NoError(t, err) + assert.Equal(t, "group-123", id) + + _, err = ParseGroupID(" \t ") + assert.EqualError(t, err, "missing group id") +} + +func TestParseUserID(t *testing.T) { + id, err := ParseUserID(" user-123 ") + require.NoError(t, err) + assert.Equal(t, "user-123", id) + + _, err = ParseUserID(" \t ") + assert.EqualError(t, err, "missing user id") +} + +func TestParseRole(t *testing.T) { + role, err := ParseRole(" admin ") + require.NoError(t, err) + assert.Equal(t, client.GroupRoleAdmin, role) + + role, err = ParseRole("member") + require.NoError(t, err) + assert.Equal(t, client.GroupRoleMember, role) + + _, err = ParseRole("owner") + assert.EqualError(t, err, `invalid --role "owner": want admin or member`) +} + func TestParsePermissions(t *testing.T) { permissions, err := ParsePermissions("", "") require.NoError(t, err)