diff --git a/cli/exp_chat.go b/cli/exp_chat.go index 1246db3851..d1cdb52833 100644 --- a/cli/exp_chat.go +++ b/cli/exp_chat.go @@ -1,18 +1,14 @@ package cli import ( - "context" "fmt" "os" "path/filepath" - "strings" "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/agent/agentcontextconfig" - "github.com/coder/coder/v2/cli/cliui" - "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/serpent" ) @@ -32,209 +28,6 @@ func (r *RootCmd) chatCommand() *serpent.Command { } } -func (r *RootCmd) chatShareCommand() *serpent.Command { - return &serpent.Command{ - Use: "share", - Short: "Manage chat sharing", - Long: "Share chats with users and groups.", - Handler: func(i *serpent.Invocation) error { - return i.Command.HelpHandler(i) - }, - Children: []*serpent.Command{ - r.chatShareAddCommand(), - r.chatShareRemoveCommand(), - }, - } -} - -func (r *RootCmd) chatShareAddCommand() *serpent.Command { - var users []string - var groups []string - - return &serpent.Command{ - Use: "add --user : --group :", - Short: "Share a chat with a user or group.", - Options: serpent.OptionSet{ - { - Name: "user", - Description: "A comma separated list of users to share the chat with.", - Flag: "user", - Value: serpent.StringArrayOf(&users), - }, { - Name: "group", - Description: "A comma separated list of groups to share the chat with.", - Flag: "group", - Value: serpent.StringArrayOf(&groups), - }, - }, - Middleware: serpent.Chain( - serpent.RequireNArgs(1), - ), - Handler: func(inv *serpent.Invocation) error { - if len(users) == 0 && len(groups) == 0 { - return xerrors.New("at least one user or group must be provided") - } - - chatID, err := parseChatShareID(inv.Args[0]) - if err != nil { - return err - } - - client, err := r.InitClient(inv) - if err != nil { - return err - } - experimentalClient := codersdk.NewExperimentalClient(client) - - chat, err := experimentalClient.GetChat(inv.Context(), chatID) - if err != nil { - return xerrors.Errorf("unable to fetch chat %s: %w", inv.Args[0], err) - } - - userRoleStrings := make([][2]string, len(users)) - for i, user := range users { - parsed, err := parseChatShareActorRole(user) - if err != nil { - return xerrors.Errorf("invalid user format %q: %w", user, err) - } - userRoleStrings[i] = parsed - } - - groupRoleStrings := make([][2]string, len(groups)) - for i, group := range groups { - parsed, err := parseChatShareActorRole(group) - if err != nil { - return xerrors.Errorf("invalid group format %q: %w", group, err) - } - groupRoleStrings[i] = parsed - } - - userRoles, groupRoles, err := fetchChatUsersAndGroups(inv.Context(), chatRoleLookupParams{ - Client: client, - OrgID: chat.OrganizationID, - Users: userRoleStrings, - Groups: groupRoleStrings, - DefaultRole: codersdk.ChatRoleRead, - }) - if err != nil { - return err - } - - if err := experimentalClient.UpdateChatACL(inv.Context(), chat.ID, codersdk.UpdateChatACL{ - UserRoles: userRoles, - GroupRoles: groupRoles, - }); err != nil { - return err - } - - acl, err := experimentalClient.GetChatACL(inv.Context(), chat.ID) - if err != nil { - return xerrors.Errorf("could not fetch current chat ACL after sharing: %w", err) - } - out, err := chatACLToTable(inv.Context(), &acl) - if err != nil { - return err - } - _, err = fmt.Fprintln(inv.Stdout, out) - return err - }, - } -} - -func (r *RootCmd) chatShareRemoveCommand() *serpent.Command { - var users []string - var groups []string - - return &serpent.Command{ - Use: "remove --user --group ", - Short: "Remove shared access for users or groups from a chat.", - Options: serpent.OptionSet{ - { - Name: "user", - Description: "A comma separated list of users to remove shared chat access from.", - Flag: "user", - Value: serpent.StringArrayOf(&users), - }, { - Name: "group", - Description: "A comma separated list of groups to remove shared chat access from.", - Flag: "group", - Value: serpent.StringArrayOf(&groups), - }, - }, - Middleware: serpent.Chain( - serpent.RequireNArgs(1), - ), - Handler: func(inv *serpent.Invocation) error { - if len(users) == 0 && len(groups) == 0 { - return xerrors.New("at least one user or group must be provided") - } - - chatID, err := parseChatShareID(inv.Args[0]) - if err != nil { - return err - } - - userRoleStrings := make([][2]string, len(users)) - for i, user := range users { - parsed, err := parseChatShareActor(user) - if err != nil { - return xerrors.Errorf("invalid user format %q: %w", user, err) - } - userRoleStrings[i] = parsed - } - - groupRoleStrings := make([][2]string, len(groups)) - for i, group := range groups { - parsed, err := parseChatShareActor(group) - if err != nil { - return xerrors.Errorf("invalid group format %q: %w", group, err) - } - groupRoleStrings[i] = parsed - } - - client, err := r.InitClient(inv) - if err != nil { - return err - } - experimentalClient := codersdk.NewExperimentalClient(client) - - chat, err := experimentalClient.GetChat(inv.Context(), chatID) - if err != nil { - return xerrors.Errorf("unable to fetch chat %s: %w", inv.Args[0], err) - } - - userRoles, groupRoles, err := fetchChatUsersAndGroups(inv.Context(), chatRoleLookupParams{ - Client: client, - OrgID: chat.OrganizationID, - Users: userRoleStrings, - Groups: groupRoleStrings, - DefaultRole: codersdk.ChatRoleDeleted, - }) - if err != nil { - return err - } - - if err := experimentalClient.UpdateChatACL(inv.Context(), chat.ID, codersdk.UpdateChatACL{ - UserRoles: userRoles, - GroupRoles: groupRoles, - }); err != nil { - return err - } - - acl, err := experimentalClient.GetChatACL(inv.Context(), chat.ID) - if err != nil { - return xerrors.Errorf("could not fetch current chat ACL after sharing: %w", err) - } - out, err := chatACLToTable(inv.Context(), &acl) - if err != nil { - return err - } - _, err = fmt.Fprintln(inv.Stdout, out) - return err - }, - } -} - func (r *RootCmd) chatContextCommand() *serpent.Command { return &serpent.Command{ Use: "context", @@ -387,173 +180,6 @@ func (*RootCmd) chatContextClearCommand() *serpent.Command { return cmd } -const chatShareDefaultGroupDisplay = "-" - -type chatRoleLookupParams struct { - Client *codersdk.Client - OrgID uuid.UUID - OrgName string - Users [][2]string - Groups [][2]string - DefaultRole codersdk.ChatRole -} - -func parseChatShareID(raw string) (uuid.UUID, error) { - parsed, err := uuid.Parse(raw) - if err != nil { - return uuid.Nil, xerrors.Errorf("invalid chat ID %q: %w", raw, err) - } - return parsed, nil -} - -func parseChatShareActorRole(raw string) ([2]string, error) { - if strings.Count(raw, ":") > 1 { - return [2]string{}, xerrors.New("must match pattern 'name:role'") - } - parts := strings.SplitN(raw, ":", 2) - name := parts[0] - if name == "" || !codersdk.UsernameValidRegex.MatchString(name) { - return [2]string{}, xerrors.New("invalid name") - } - if len(parts) == 1 { - return [2]string{name, ""}, nil - } - if parts[1] == "" { - return [2]string{}, xerrors.New("role cannot be empty") - } - return [2]string{name, parts[1]}, nil -} - -func parseChatShareActor(raw string) ([2]string, error) { - if strings.Contains(raw, ":") { - return [2]string{}, xerrors.New("roles are only accepted by chat share add") - } - if raw == "" || !codersdk.UsernameValidRegex.MatchString(raw) { - return [2]string{}, xerrors.New("invalid name") - } - return [2]string{raw, ""}, nil -} - -func stringToChatRole(role string) (codersdk.ChatRole, error) { - switch role { - case string(codersdk.ChatRoleRead): - return codersdk.ChatRoleRead, nil - case string(codersdk.ChatRoleDeleted): - return codersdk.ChatRoleDeleted, nil - default: - return "", xerrors.Errorf("invalid role %q: expected %q", role, codersdk.ChatRoleRead) - } -} - -func fetchChatUsersAndGroups(ctx context.Context, params chatRoleLookupParams) (map[string]codersdk.ChatRole, map[string]codersdk.ChatRole, error) { - userRoles := make(map[string]codersdk.ChatRole, len(params.Users)) - if len(params.Users) > 0 { - orgMembers, err := params.Client.OrganizationMembers(ctx, params.OrgID) - if err != nil { - return nil, nil, err - } - - for _, user := range params.Users { - username := user[0] - role := user[1] - if role == "" { - role = string(params.DefaultRole) - } - - userID := "" - for _, member := range orgMembers { - if member.Username == username { - userID = member.UserID.String() - break - } - } - if userID == "" { - return nil, nil, xerrors.Errorf("could not find user %s in the organization %s", username, params.OrgName) - } - - chatRole, err := stringToChatRole(role) - if err != nil { - return nil, nil, err - } - userRoles[userID] = chatRole - } - } - - groupRoles := make(map[string]codersdk.ChatRole, len(params.Groups)) - if len(params.Groups) > 0 { - orgGroups, err := params.Client.Groups(ctx, codersdk.GroupArguments{ - Organization: params.OrgID.String(), - }) - if err != nil { - return nil, nil, err - } - - for _, group := range params.Groups { - groupName := group[0] - role := group[1] - if role == "" { - role = string(params.DefaultRole) - } - - var orgGroup *codersdk.Group - for _, candidate := range orgGroups { - if candidate.Name == groupName { - orgGroup = &candidate - break - } - } - if orgGroup == nil { - return nil, nil, xerrors.Errorf("could not find group named %s belonging to the organization %s", groupName, params.OrgName) - } - - chatRole, err := stringToChatRole(role) - if err != nil { - return nil, nil, err - } - groupRoles[orgGroup.ID.String()] = chatRole - } - } - - return userRoles, groupRoles, nil -} - -func chatACLToTable(ctx context.Context, acl *codersdk.ChatACL) (string, error) { - type chatShareRow struct { - User string `table:"user"` - Group string `table:"group,default_sort"` - Role codersdk.ChatRole `table:"role"` - } - - formatter := cliui.NewOutputFormatter( - cliui.TableFormat( - []chatShareRow{}, []string{"User", "Group", "Role"}), - cliui.JSONFormat()) - - outputRows := make([]chatShareRow, 0, len(acl.Users)+len(acl.Groups)) - for _, user := range acl.Users { - if user.Role == codersdk.ChatRoleDeleted { - continue - } - outputRows = append(outputRows, chatShareRow{ - User: user.Username, - Group: chatShareDefaultGroupDisplay, - Role: user.Role, - }) - } - for _, group := range acl.Groups { - if group.Role == codersdk.ChatRoleDeleted { - continue - } - outputRows = append(outputRows, chatShareRow{ - User: "", - Group: group.Name, - Role: group.Role, - }) - } - - return formatter.Format(ctx, outputRows) -} - // parseChatID returns the chat UUID from the flag value (which // serpent already populates from --chat or CODER_CHAT_ID). Returns // uuid.Nil if empty (the server will auto-detect). diff --git a/cli/exp_chat_share.go b/cli/exp_chat_share.go new file mode 100644 index 0000000000..fd14622a40 --- /dev/null +++ b/cli/exp_chat_share.go @@ -0,0 +1,195 @@ +package cli + +import ( + "context" + "strings" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) chatShareCommand() *serpent.Command { + return &serpent.Command{ + Use: "share", + Short: "Manage chat sharing", + Long: "Share chats with users and groups.", + Handler: func(i *serpent.Invocation) error { + return i.Command.HelpHandler(i) + }, + Children: []*serpent.Command{ + r.chatShareAddCommand(), + r.chatShareRemoveCommand(), + }, + } +} + +const chatShareDefaultGroupDisplay = "-" + +type chatRoleLookupParams struct { + Client *codersdk.Client + OrgID uuid.UUID + OrgName string + Users [][2]string + Groups [][2]string + DefaultRole codersdk.ChatRole +} + +func parseChatShareID(raw string) (uuid.UUID, error) { + parsed, err := uuid.Parse(raw) + if err != nil { + return uuid.Nil, xerrors.Errorf("invalid chat ID %q: %w", raw, err) + } + return parsed, nil +} + +func parseChatShareActorRole(raw string) ([2]string, error) { + if strings.Count(raw, ":") > 1 { + return [2]string{}, xerrors.New("must match pattern 'name:role'") + } + parts := strings.SplitN(raw, ":", 2) + name := parts[0] + if name == "" || !codersdk.UsernameValidRegex.MatchString(name) { + return [2]string{}, xerrors.New("invalid name") + } + if len(parts) == 1 { + return [2]string{name, ""}, nil + } + if parts[1] == "" { + return [2]string{}, xerrors.New("role cannot be empty") + } + return [2]string{name, parts[1]}, nil +} + +func parseChatShareActor(raw string) ([2]string, error) { + if strings.Contains(raw, ":") { + return [2]string{}, xerrors.New("roles are only accepted by chat share add") + } + if raw == "" || !codersdk.UsernameValidRegex.MatchString(raw) { + return [2]string{}, xerrors.New("invalid name") + } + return [2]string{raw, ""}, nil +} + +func stringToChatRole(role string) (codersdk.ChatRole, error) { + switch role { + case string(codersdk.ChatRoleRead): + return codersdk.ChatRoleRead, nil + case string(codersdk.ChatRoleDeleted): + return codersdk.ChatRoleDeleted, nil + default: + return "", xerrors.Errorf("invalid role %q: expected %q", role, codersdk.ChatRoleRead) + } +} + +func fetchChatUsersAndGroups(ctx context.Context, params chatRoleLookupParams) (map[string]codersdk.ChatRole, map[string]codersdk.ChatRole, error) { + userRoles := make(map[string]codersdk.ChatRole, len(params.Users)) + if len(params.Users) > 0 { + orgMembers, err := params.Client.OrganizationMembers(ctx, params.OrgID) + if err != nil { + return nil, nil, err + } + + for _, user := range params.Users { + username := user[0] + role := user[1] + if role == "" { + role = string(params.DefaultRole) + } + + userID := "" + for _, member := range orgMembers { + if member.Username == username { + userID = member.UserID.String() + break + } + } + if userID == "" { + return nil, nil, xerrors.Errorf("could not find user %s in the organization %s", username, params.OrgName) + } + + chatRole, err := stringToChatRole(role) + if err != nil { + return nil, nil, err + } + userRoles[userID] = chatRole + } + } + + groupRoles := make(map[string]codersdk.ChatRole, len(params.Groups)) + if len(params.Groups) > 0 { + orgGroups, err := params.Client.Groups(ctx, codersdk.GroupArguments{ + Organization: params.OrgID.String(), + }) + if err != nil { + return nil, nil, err + } + + for _, group := range params.Groups { + groupName := group[0] + role := group[1] + if role == "" { + role = string(params.DefaultRole) + } + + var orgGroup *codersdk.Group + for _, candidate := range orgGroups { + if candidate.Name == groupName { + orgGroup = &candidate + break + } + } + if orgGroup == nil { + return nil, nil, xerrors.Errorf("could not find group named %s belonging to the organization %s", groupName, params.OrgName) + } + + chatRole, err := stringToChatRole(role) + if err != nil { + return nil, nil, err + } + groupRoles[orgGroup.ID.String()] = chatRole + } + } + + return userRoles, groupRoles, nil +} + +func chatACLToTable(ctx context.Context, acl *codersdk.ChatACL) (string, error) { + type chatShareRow struct { + User string `table:"user"` + Group string `table:"group,default_sort"` + Role codersdk.ChatRole `table:"role"` + } + + formatter := cliui.NewOutputFormatter( + cliui.TableFormat( + []chatShareRow{}, []string{"User", "Group", "Role"}), + cliui.JSONFormat()) + + outputRows := make([]chatShareRow, 0, len(acl.Users)+len(acl.Groups)) + for _, user := range acl.Users { + if user.Role == codersdk.ChatRoleDeleted { + continue + } + outputRows = append(outputRows, chatShareRow{ + User: user.Username, + Group: chatShareDefaultGroupDisplay, + Role: user.Role, + }) + } + for _, group := range acl.Groups { + if group.Role == codersdk.ChatRoleDeleted { + continue + } + outputRows = append(outputRows, chatShareRow{ + User: "", + Group: group.Name, + Role: group.Role, + }) + } + + return formatter.Format(ctx, outputRows) +} diff --git a/cli/exp_chat_share_add.go b/cli/exp_chat_share_add.go new file mode 100644 index 0000000000..b7c97261aa --- /dev/null +++ b/cli/exp_chat_share_add.go @@ -0,0 +1,104 @@ +package cli + +import ( + "fmt" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) chatShareAddCommand() *serpent.Command { + var users []string + var groups []string + + return &serpent.Command{ + Use: "add --user : --group :", + Short: "Share a chat with a user or group.", + Options: serpent.OptionSet{ + { + Name: "user", + Description: "A comma separated list of users to share the chat with.", + Flag: "user", + Value: serpent.StringArrayOf(&users), + }, { + Name: "group", + Description: "A comma separated list of groups to share the chat with.", + Flag: "group", + Value: serpent.StringArrayOf(&groups), + }, + }, + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Handler: func(inv *serpent.Invocation) error { + if len(users) == 0 && len(groups) == 0 { + return xerrors.New("at least one user or group must be provided") + } + + chatID, err := parseChatShareID(inv.Args[0]) + if err != nil { + return err + } + + client, err := r.InitClient(inv) + if err != nil { + return err + } + experimentalClient := codersdk.NewExperimentalClient(client) + + chat, err := experimentalClient.GetChat(inv.Context(), chatID) + if err != nil { + return xerrors.Errorf("unable to fetch chat %s: %w", inv.Args[0], err) + } + + userRoleStrings := make([][2]string, len(users)) + for i, user := range users { + parsed, err := parseChatShareActorRole(user) + if err != nil { + return xerrors.Errorf("invalid user format %q: %w", user, err) + } + userRoleStrings[i] = parsed + } + + groupRoleStrings := make([][2]string, len(groups)) + for i, group := range groups { + parsed, err := parseChatShareActorRole(group) + if err != nil { + return xerrors.Errorf("invalid group format %q: %w", group, err) + } + groupRoleStrings[i] = parsed + } + + userRoles, groupRoles, err := fetchChatUsersAndGroups(inv.Context(), chatRoleLookupParams{ + Client: client, + OrgID: chat.OrganizationID, + Users: userRoleStrings, + Groups: groupRoleStrings, + DefaultRole: codersdk.ChatRoleRead, + }) + if err != nil { + return err + } + + if err := experimentalClient.UpdateChatACL(inv.Context(), chat.ID, codersdk.UpdateChatACL{ + UserRoles: userRoles, + GroupRoles: groupRoles, + }); err != nil { + return err + } + + acl, err := experimentalClient.GetChatACL(inv.Context(), chat.ID) + if err != nil { + return xerrors.Errorf("could not fetch current chat ACL after sharing: %w", err) + } + out, err := chatACLToTable(inv.Context(), &acl) + if err != nil { + return err + } + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } +} diff --git a/cli/exp_chat_share_add_test.go b/cli/exp_chat_share_add_test.go new file mode 100644 index 0000000000..051ed25910 --- /dev/null +++ b/cli/exp_chat_share_add_test.go @@ -0,0 +1,188 @@ +package cli_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestExpChatShareAdd(t *testing.T) { + t.Parallel() + + t.Run("ShareWithUserExplicitReadRole", func(t *testing.T) { + t.Parallel() + + client, db := coderdtest.NewWithDatabase(t, nil) + firstUser := coderdtest.CreateFirstUser(t, client) + _, toShareWithUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: firstUser.OrganizationID, + OwnerID: firstUser.UserID, + LastModelConfigID: modelConfig.ID, + Title: "share add user", + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, "exp", "chat", "share", "add", chat.ID.String(), "--user", toShareWithUser.Username+":read") + clitest.SetupConfig(t, client, root) + + out := new(bytes.Buffer) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := codersdk.NewExperimentalClient(client).GetChatACL(ctx, chat.ID) + require.NoError(t, err) + assert.Contains(t, acl.Users, codersdk.ChatUser{ + MinimalUser: codersdk.MinimalUser{ + ID: toShareWithUser.ID, + Username: toShareWithUser.Username, + Name: toShareWithUser.Name, + AvatarURL: toShareWithUser.AvatarURL, + }, + Role: codersdk.ChatRoleRead, + }) + assert.Contains(t, out.String(), toShareWithUser.Username) + assert.Contains(t, out.String(), string(codersdk.ChatRoleRead)) + }) + + t.Run("ShareWithUserDefaultReadRole", func(t *testing.T) { + t.Parallel() + + client, db := coderdtest.NewWithDatabase(t, nil) + firstUser := coderdtest.CreateFirstUser(t, client) + _, toShareWithUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: firstUser.OrganizationID, + OwnerID: firstUser.UserID, + LastModelConfigID: modelConfig.ID, + Title: "share add user default role", + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, "exp", "chat", "share", "add", chat.ID.String(), "--user", toShareWithUser.Username) + clitest.SetupConfig(t, client, root) + + out := new(bytes.Buffer) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := codersdk.NewExperimentalClient(client).GetChatACL(ctx, chat.ID) + require.NoError(t, err) + assert.Contains(t, acl.Users, codersdk.ChatUser{ + MinimalUser: codersdk.MinimalUser{ + ID: toShareWithUser.ID, + Username: toShareWithUser.Username, + Name: toShareWithUser.Name, + AvatarURL: toShareWithUser.AvatarURL, + }, + Role: codersdk.ChatRoleRead, + }) + }) + + t.Run("ShareWithMultipleUsers", func(t *testing.T) { + t.Parallel() + + client, db := coderdtest.NewWithDatabase(t, nil) + firstUser := coderdtest.CreateFirstUser(t, client) + _, toShareWithUser1 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + _, toShareWithUser2 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: firstUser.OrganizationID, + OwnerID: firstUser.UserID, + LastModelConfigID: modelConfig.ID, + Title: "share add multiple users", + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, + "exp", "chat", "share", "add", chat.ID.String(), + fmt.Sprintf("--user=%s:read,%s:read", toShareWithUser1.Username, toShareWithUser2.Username), + ) + clitest.SetupConfig(t, client, root) + + out := new(bytes.Buffer) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := codersdk.NewExperimentalClient(client).GetChatACL(ctx, chat.ID) + require.NoError(t, err) + assert.Contains(t, acl.Users, codersdk.ChatUser{ + MinimalUser: codersdk.MinimalUser{ + ID: toShareWithUser1.ID, + Username: toShareWithUser1.Username, + Name: toShareWithUser1.Name, + AvatarURL: toShareWithUser1.AvatarURL, + }, + Role: codersdk.ChatRoleRead, + }) + assert.Contains(t, acl.Users, codersdk.ChatUser{ + MinimalUser: codersdk.MinimalUser{ + ID: toShareWithUser2.ID, + Username: toShareWithUser2.Username, + Name: toShareWithUser2.Name, + AvatarURL: toShareWithUser2.AvatarURL, + }, + Role: codersdk.ChatRoleRead, + }) + }) + + t.Run("RejectsUnknownRole", func(t *testing.T) { + t.Parallel() + + client, db := coderdtest.NewWithDatabase(t, nil) + firstUser := coderdtest.CreateFirstUser(t, client) + _, toShareWithUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: firstUser.OrganizationID, + OwnerID: firstUser.UserID, + LastModelConfigID: modelConfig.ID, + Title: "share add invalid role", + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, "exp", "chat", "share", "add", chat.ID.String(), "--user", toShareWithUser.Username+":write") + clitest.SetupConfig(t, client, root) + + err := inv.WithContext(ctx).Run() + require.Error(t, err) + require.Contains(t, err.Error(), "invalid role \"write\"") + }) + + t.Run("RequiresActor", func(t *testing.T) { + t.Parallel() + + chatID := "00000000-0000-0000-0000-000000000001" + inv, _ := clitest.New(t, "exp", "chat", "share", "add", chatID) + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "at least one user or group must be provided") + }) + + t.Run("RejectsInvalidChatID", func(t *testing.T) { + t.Parallel() + + inv, _ := clitest.New(t, "exp", "chat", "share", "add", "not-a-uuid", "--user", "alice:read") + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "invalid chat ID") + }) +} diff --git a/cli/exp_chat_share_remove.go b/cli/exp_chat_share_remove.go new file mode 100644 index 0000000000..88610f6e7b --- /dev/null +++ b/cli/exp_chat_share_remove.go @@ -0,0 +1,104 @@ +package cli + +import ( + "fmt" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) chatShareRemoveCommand() *serpent.Command { + var users []string + var groups []string + + return &serpent.Command{ + Use: "remove --user --group ", + Short: "Remove shared access for users or groups from a chat.", + Options: serpent.OptionSet{ + { + Name: "user", + Description: "A comma separated list of users to remove shared chat access from.", + Flag: "user", + Value: serpent.StringArrayOf(&users), + }, { + Name: "group", + Description: "A comma separated list of groups to remove shared chat access from.", + Flag: "group", + Value: serpent.StringArrayOf(&groups), + }, + }, + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Handler: func(inv *serpent.Invocation) error { + if len(users) == 0 && len(groups) == 0 { + return xerrors.New("at least one user or group must be provided") + } + + chatID, err := parseChatShareID(inv.Args[0]) + if err != nil { + return err + } + + userRoleStrings := make([][2]string, len(users)) + for i, user := range users { + parsed, err := parseChatShareActor(user) + if err != nil { + return xerrors.Errorf("invalid user format %q: %w", user, err) + } + userRoleStrings[i] = parsed + } + + groupRoleStrings := make([][2]string, len(groups)) + for i, group := range groups { + parsed, err := parseChatShareActor(group) + if err != nil { + return xerrors.Errorf("invalid group format %q: %w", group, err) + } + groupRoleStrings[i] = parsed + } + + client, err := r.InitClient(inv) + if err != nil { + return err + } + experimentalClient := codersdk.NewExperimentalClient(client) + + chat, err := experimentalClient.GetChat(inv.Context(), chatID) + if err != nil { + return xerrors.Errorf("unable to fetch chat %s: %w", inv.Args[0], err) + } + + userRoles, groupRoles, err := fetchChatUsersAndGroups(inv.Context(), chatRoleLookupParams{ + Client: client, + OrgID: chat.OrganizationID, + Users: userRoleStrings, + Groups: groupRoleStrings, + DefaultRole: codersdk.ChatRoleDeleted, + }) + if err != nil { + return err + } + + if err := experimentalClient.UpdateChatACL(inv.Context(), chat.ID, codersdk.UpdateChatACL{ + UserRoles: userRoles, + GroupRoles: groupRoles, + }); err != nil { + return err + } + + acl, err := experimentalClient.GetChatACL(inv.Context(), chat.ID) + if err != nil { + return xerrors.Errorf("could not fetch current chat ACL after sharing: %w", err) + } + out, err := chatACLToTable(inv.Context(), &acl) + if err != nil { + return err + } + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } +} diff --git a/cli/exp_chat_share_remove_test.go b/cli/exp_chat_share_remove_test.go new file mode 100644 index 0000000000..6db332c2f3 --- /dev/null +++ b/cli/exp_chat_share_remove_test.go @@ -0,0 +1,88 @@ +package cli_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestExpChatShareRemove(t *testing.T) { + t.Parallel() + + t.Run("RemoveSharedUser", func(t *testing.T) { + t.Parallel() + + client, db := coderdtest.NewWithDatabase(t, nil) + firstUser := coderdtest.CreateFirstUser(t, client) + _, sharedUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: firstUser.OrganizationID, + OwnerID: firstUser.UserID, + LastModelConfigID: modelConfig.ID, + Title: "share remove user", + }) + experimentalClient := codersdk.NewExperimentalClient(client) + ctx := testutil.Context(t, testutil.WaitMedium) + err := experimentalClient.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{ + UserRoles: map[string]codersdk.ChatRole{sharedUser.ID.String(): codersdk.ChatRoleRead}, + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "exp", "chat", "share", "remove", chat.ID.String(), "--user", sharedUser.Username) + clitest.SetupConfig(t, client, root) + + out := new(bytes.Buffer) + inv.Stdout = out + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := experimentalClient.GetChatACL(ctx, chat.ID) + require.NoError(t, err) + for _, user := range acl.Users { + assert.NotEqual(t, sharedUser.ID, user.ID) + } + assert.NotContains(t, out.String(), sharedUser.Username) + }) + + t.Run("RequiresActor", func(t *testing.T) { + t.Parallel() + + chatID := "00000000-0000-0000-0000-000000000001" + inv, _ := clitest.New(t, "exp", "chat", "share", "remove", chatID) + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "at least one user or group must be provided") + }) + + t.Run("RejectsRoleSyntax", func(t *testing.T) { + t.Parallel() + + chatID := "00000000-0000-0000-0000-000000000001" + inv, _ := clitest.New(t, "exp", "chat", "share", "remove", chatID, "--user", "alice:read") + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "roles are only accepted by chat share add") + }) + + t.Run("RejectsInvalidChatID", func(t *testing.T) { + t.Parallel() + + inv, _ := clitest.New(t, "exp", "chat", "share", "remove", "not-a-uuid", "--user", "alice") + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "invalid chat ID") + }) +} diff --git a/cli/exp_chat_test.go b/cli/exp_chat_test.go index a4b5d2f544..30696c6eca 100644 --- a/cli/exp_chat_test.go +++ b/cli/exp_chat_test.go @@ -1,264 +1,13 @@ package cli_test import ( - "bytes" - "fmt" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/clitest" - "github.com/coder/coder/v2/coderd/coderdtest" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/testutil" ) -func TestExpChatShareAdd(t *testing.T) { - t.Parallel() - - t.Run("ShareWithUserExplicitReadRole", func(t *testing.T) { - t.Parallel() - - client, db := coderdtest.NewWithDatabase(t, nil) - firstUser := coderdtest.CreateFirstUser(t, client) - _, toShareWithUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) - modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) - chat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: firstUser.OrganizationID, - OwnerID: firstUser.UserID, - LastModelConfigID: modelConfig.ID, - Title: "share add user", - }) - - ctx := testutil.Context(t, testutil.WaitMedium) - inv, root := clitest.New(t, "exp", "chat", "share", "add", chat.ID.String(), "--user", toShareWithUser.Username+":read") - clitest.SetupConfig(t, client, root) - - out := new(bytes.Buffer) - inv.Stdout = out - err := inv.WithContext(ctx).Run() - require.NoError(t, err) - - acl, err := codersdk.NewExperimentalClient(client).GetChatACL(ctx, chat.ID) - require.NoError(t, err) - assert.Contains(t, acl.Users, codersdk.ChatUser{ - MinimalUser: codersdk.MinimalUser{ - ID: toShareWithUser.ID, - Username: toShareWithUser.Username, - Name: toShareWithUser.Name, - AvatarURL: toShareWithUser.AvatarURL, - }, - Role: codersdk.ChatRoleRead, - }) - assert.Contains(t, out.String(), toShareWithUser.Username) - assert.Contains(t, out.String(), string(codersdk.ChatRoleRead)) - }) - - t.Run("ShareWithUserDefaultReadRole", func(t *testing.T) { - t.Parallel() - - client, db := coderdtest.NewWithDatabase(t, nil) - firstUser := coderdtest.CreateFirstUser(t, client) - _, toShareWithUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) - modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) - chat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: firstUser.OrganizationID, - OwnerID: firstUser.UserID, - LastModelConfigID: modelConfig.ID, - Title: "share add user default role", - }) - - ctx := testutil.Context(t, testutil.WaitMedium) - inv, root := clitest.New(t, "exp", "chat", "share", "add", chat.ID.String(), "--user", toShareWithUser.Username) - clitest.SetupConfig(t, client, root) - - out := new(bytes.Buffer) - inv.Stdout = out - err := inv.WithContext(ctx).Run() - require.NoError(t, err) - - acl, err := codersdk.NewExperimentalClient(client).GetChatACL(ctx, chat.ID) - require.NoError(t, err) - assert.Contains(t, acl.Users, codersdk.ChatUser{ - MinimalUser: codersdk.MinimalUser{ - ID: toShareWithUser.ID, - Username: toShareWithUser.Username, - Name: toShareWithUser.Name, - AvatarURL: toShareWithUser.AvatarURL, - }, - Role: codersdk.ChatRoleRead, - }) - }) - - t.Run("ShareWithMultipleUsers", func(t *testing.T) { - t.Parallel() - - client, db := coderdtest.NewWithDatabase(t, nil) - firstUser := coderdtest.CreateFirstUser(t, client) - _, toShareWithUser1 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) - _, toShareWithUser2 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) - modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) - chat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: firstUser.OrganizationID, - OwnerID: firstUser.UserID, - LastModelConfigID: modelConfig.ID, - Title: "share add multiple users", - }) - - ctx := testutil.Context(t, testutil.WaitMedium) - inv, root := clitest.New(t, - "exp", "chat", "share", "add", chat.ID.String(), - fmt.Sprintf("--user=%s:read,%s:read", toShareWithUser1.Username, toShareWithUser2.Username), - ) - clitest.SetupConfig(t, client, root) - - out := new(bytes.Buffer) - inv.Stdout = out - err := inv.WithContext(ctx).Run() - require.NoError(t, err) - - acl, err := codersdk.NewExperimentalClient(client).GetChatACL(ctx, chat.ID) - require.NoError(t, err) - assert.Contains(t, acl.Users, codersdk.ChatUser{ - MinimalUser: codersdk.MinimalUser{ - ID: toShareWithUser1.ID, - Username: toShareWithUser1.Username, - Name: toShareWithUser1.Name, - AvatarURL: toShareWithUser1.AvatarURL, - }, - Role: codersdk.ChatRoleRead, - }) - assert.Contains(t, acl.Users, codersdk.ChatUser{ - MinimalUser: codersdk.MinimalUser{ - ID: toShareWithUser2.ID, - Username: toShareWithUser2.Username, - Name: toShareWithUser2.Name, - AvatarURL: toShareWithUser2.AvatarURL, - }, - Role: codersdk.ChatRoleRead, - }) - }) - - t.Run("RejectsUnknownRole", func(t *testing.T) { - t.Parallel() - - client, db := coderdtest.NewWithDatabase(t, nil) - firstUser := coderdtest.CreateFirstUser(t, client) - _, toShareWithUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) - modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) - chat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: firstUser.OrganizationID, - OwnerID: firstUser.UserID, - LastModelConfigID: modelConfig.ID, - Title: "share add invalid role", - }) - - ctx := testutil.Context(t, testutil.WaitMedium) - inv, root := clitest.New(t, "exp", "chat", "share", "add", chat.ID.String(), "--user", toShareWithUser.Username+":write") - clitest.SetupConfig(t, client, root) - - err := inv.WithContext(ctx).Run() - require.Error(t, err) - require.Contains(t, err.Error(), "invalid role \"write\"") - }) - - t.Run("RequiresActor", func(t *testing.T) { - t.Parallel() - - chatID := "00000000-0000-0000-0000-000000000001" - inv, _ := clitest.New(t, "exp", "chat", "share", "add", chatID) - - err := inv.Run() - require.Error(t, err) - require.Contains(t, err.Error(), "at least one user or group must be provided") - }) - - t.Run("RejectsInvalidChatID", func(t *testing.T) { - t.Parallel() - - inv, _ := clitest.New(t, "exp", "chat", "share", "add", "not-a-uuid", "--user", "alice:read") - - err := inv.Run() - require.Error(t, err) - require.Contains(t, err.Error(), "invalid chat ID") - }) -} - -func TestExpChatShareRemove(t *testing.T) { - t.Parallel() - - t.Run("RemoveSharedUser", func(t *testing.T) { - t.Parallel() - - client, db := coderdtest.NewWithDatabase(t, nil) - firstUser := coderdtest.CreateFirstUser(t, client) - _, sharedUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) - modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) - chat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: firstUser.OrganizationID, - OwnerID: firstUser.UserID, - LastModelConfigID: modelConfig.ID, - Title: "share remove user", - }) - experimentalClient := codersdk.NewExperimentalClient(client) - ctx := testutil.Context(t, testutil.WaitMedium) - err := experimentalClient.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{ - UserRoles: map[string]codersdk.ChatRole{sharedUser.ID.String(): codersdk.ChatRoleRead}, - }) - require.NoError(t, err) - - inv, root := clitest.New(t, "exp", "chat", "share", "remove", chat.ID.String(), "--user", sharedUser.Username) - clitest.SetupConfig(t, client, root) - - out := new(bytes.Buffer) - inv.Stdout = out - err = inv.WithContext(ctx).Run() - require.NoError(t, err) - - acl, err := experimentalClient.GetChatACL(ctx, chat.ID) - require.NoError(t, err) - for _, user := range acl.Users { - assert.NotEqual(t, sharedUser.ID, user.ID) - } - assert.NotContains(t, out.String(), sharedUser.Username) - }) - - t.Run("RequiresActor", func(t *testing.T) { - t.Parallel() - - chatID := "00000000-0000-0000-0000-000000000001" - inv, _ := clitest.New(t, "exp", "chat", "share", "remove", chatID) - - err := inv.Run() - require.Error(t, err) - require.Contains(t, err.Error(), "at least one user or group must be provided") - }) - - t.Run("RejectsRoleSyntax", func(t *testing.T) { - t.Parallel() - - chatID := "00000000-0000-0000-0000-000000000001" - inv, _ := clitest.New(t, "exp", "chat", "share", "remove", chatID, "--user", "alice:read") - - err := inv.Run() - require.Error(t, err) - require.Contains(t, err.Error(), "roles are only accepted by chat share add") - }) - - t.Run("RejectsInvalidChatID", func(t *testing.T) { - t.Parallel() - - inv, _ := clitest.New(t, "exp", "chat", "share", "remove", "not-a-uuid", "--user", "alice") - - err := inv.Run() - require.Error(t, err) - require.Contains(t, err.Error(), "invalid chat ID") - }) -} - func TestExpChatContextAdd(t *testing.T) { t.Parallel()