diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 84f541f..d5a5258 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -1398,6 +1398,24 @@ Aliases members, ls, l ``` +#### `azdo team member remove [ORGANIZATION/]PROJECT/TEAM [flags]` + +Remove one or more members from a team. + +``` +-q, --jq expression Filter JSON output using a jq expression + --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +-u, --user strings Members to remove. Accepts a descriptor, email, principal name, SID, or identity ID. Pass the flag multiple times to remove several members. +-y, --yes Skip the confirmation prompt. +``` + +Aliases + +``` +r, rm, del, d +``` + ### `azdo team show [ORGANIZATION/]PROJECT/TEAM [flags]` Show details of a team. diff --git a/docs/azdo_team_member.md b/docs/azdo_team_member.md index bf2a6ac..b4d729a 100644 --- a/docs/azdo_team_member.md +++ b/docs/azdo_team_member.md @@ -6,6 +6,7 @@ Manage members of a team. * [azdo team member add](./azdo_team_member_add.md) * [azdo team member list](./azdo_team_member_list.md) +* [azdo team member remove](./azdo_team_member_remove.md) ### See also diff --git a/docs/azdo_team_member_remove.md b/docs/azdo_team_member_remove.md new file mode 100644 index 0000000..0fd16af --- /dev/null +++ b/docs/azdo_team_member_remove.md @@ -0,0 +1,63 @@ +## Command `azdo team member remove` + +``` +azdo team member remove [ORGANIZATION/]PROJECT/TEAM [flags] +``` + +Remove one or more users or groups from a team. + +The positional argument accepts the team's project and team name in the +form [ORGANIZATION/]PROJECT/TEAM. + + +### Options + + +* `-q`, `--jq` `expression` + + Filter JSON output using a jq expression + +* `--json` `fields` + + Output JSON with the specified fields. Prefix a field with '-' to exclude it. + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + +* `-u`, `--user` `strings` + + Members to remove. Accepts a descriptor, email, principal name, SID, or identity ID. Pass the flag multiple times to remove several members. + +* `-y`, `--yes` + + Skip the confirmation prompt. + + +### ALIASES + +- `r` +- `rm` +- `del` +- `d` + +### JSON Fields + +`memberDescriptor`, `memberDisplayName`, `memberOrigin`, `memberOriginId`, `results`, `status`, `teamName` + +### Examples + +```bash +# Remove a user by email +azdo team member remove Fabrikam/FabrikamEngineering/MyTeam --user user@example.com + +# Remove multiple users in a single invocation +azdo team member remove Fabrikam/MyProject/MyTeam -u alice@contoso.com -u bob@contoso.com + +# Remove a user without confirmation prompt +azdo team member remove MyOrg/Fabrikam/MyTeam --user vssgp.Uy0xLTItMw== --yes +``` + +### See also + +* [azdo team member](./azdo_team_member.md) diff --git a/internal/cmd/team/member/member.go b/internal/cmd/team/member/member.go index 1bc74d3..1ea28fe 100644 --- a/internal/cmd/team/member/member.go +++ b/internal/cmd/team/member/member.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/team/member/add" "github.com/tmeckel/azdo-cli/internal/cmd/team/member/list" + "github.com/tmeckel/azdo-cli/internal/cmd/team/member/remove" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -14,6 +15,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { } cmd.AddCommand(add.NewCmd(ctx)) + cmd.AddCommand(remove.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) return cmd diff --git a/internal/cmd/team/member/remove/remove.go b/internal/cmd/team/member/remove/remove.go new file mode 100644 index 0000000..83db28b --- /dev/null +++ b/internal/cmd/team/member/remove/remove.go @@ -0,0 +1,419 @@ +package remove + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/graph" + "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" + "go.uber.org/zap" +) + +type opts struct { + targetArg string + users []string + yes bool + exporter util.Exporter +} + +type removeResultView struct { + MemberDescriptor *string `json:"memberDescriptor,omitempty"` + MemberDisplayName *string `json:"memberDisplayName,omitempty"` + MemberOrigin *string `json:"memberOrigin,omitempty"` + MemberOriginID *string `json:"memberOriginId,omitempty"` + Status *string `json:"status,omitempty"` +} + +type removeView struct { + TeamName *string `json:"teamName,omitempty"` + Results []removeResultView `json:"results"` +} + +type memberState struct { + input string + descriptor string + displayName string + origin string + originID string + statusVal string +} + +const ( + statusNotFound = "not found" + statusNotAMember = "not a member" + statusRemoved = "removed" + statusError = "error" + statusToRemove = "" +) + +func NewCmd(ctx util.CmdContext) *cobra.Command { + o := &opts{} + + cmd := &cobra.Command{ + Use: "remove [ORGANIZATION/]PROJECT/TEAM", + Short: "Remove one or more members from a team.", + Long: heredoc.Doc(` + Remove one or more users or groups from a team. + + The positional argument accepts the team's project and team name in the + form [ORGANIZATION/]PROJECT/TEAM. + `), + Example: heredoc.Doc(` + # Remove a user by email + azdo team member remove Fabrikam/FabrikamEngineering/MyTeam --user user@example.com + + # Remove multiple users in a single invocation + azdo team member remove Fabrikam/MyProject/MyTeam -u alice@contoso.com -u bob@contoso.com + + # Remove a user without confirmation prompt + azdo team member remove MyOrg/Fabrikam/MyTeam --user vssgp.Uy0xLTItMw== --yes + `), + Args: util.ExactArgs(1, "team argument required"), + Aliases: []string{ + "r", + "rm", + "del", + "d", + }, + RunE: func(cmd *cobra.Command, args []string) error { + o.targetArg = args[0] + return runRemove(ctx, o) + }, + } + + cmd.Flags().StringSliceVarP(&o.users, "user", "u", nil, "Members to remove. Accepts a descriptor, email, principal name, SID, or identity ID. Pass the flag multiple times to remove several members.") + _ = cmd.MarkFlagRequired("user") + cmd.Flags().BoolVarP(&o.yes, "yes", "y", false, "Skip the confirmation prompt.") + util.AddJSONFlags(cmd, &o.exporter, []string{ + "teamName", + "results", + "memberDescriptor", + "memberDisplayName", + "memberOrigin", + "memberOriginId", + "status", + }) + + return cmd +} + +func runRemove(ctx util.CmdContext, o *opts) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseProjectTargetWithDefaultOrganization(ctx, o.targetArg) + if err != nil { + return err + } + + zap.L().Debug( + "resolving team for member remove", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.String("team", scope.Targets[0]), + ) + + coreClient, err := ctx.ClientFactory().Core(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create Core client: %w", err) + } + + team, err := coreClient.GetTeam(ctx.Context(), core.GetTeamArgs{ + ProjectId: types.ToPtr(scope.Project), + TeamId: types.ToPtr(scope.Targets[0]), + ExpandIdentity: types.ToPtr(true), + }) + if err != nil { + return fmt.Errorf("failed to resolve team %q in project %q: %w", scope.Targets[0], scope.Project, err) + } + if team == nil || team.Identity == nil || types.GetValue(team.Identity.SubjectDescriptor, "") == "" { + return fmt.Errorf("team has no underlying descriptor (Identity.SubjectDescriptor is empty)") + } + + teamGroupDescriptor := types.GetValue(team.Identity.SubjectDescriptor, "") + teamName := types.GetValue(team.Name, "") + + extensionsClient, err := ctx.ClientFactory().Extensions(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create Extensions client: %w", err) + } + + graphClient, err := ctx.ClientFactory().Graph(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create Graph client: %w", err) + } + + // Deduplicate users preserving input order + seen := make(map[string]struct{}) + uniqueUsers := make([]string, 0, len(o.users)) + for _, raw := range o.users { + t := strings.TrimSpace(raw) + if t == "" { + continue + } + if _, ok := seen[t]; ok { + continue + } + seen[t] = struct{}{} + uniqueUsers = append(uniqueUsers, t) + } + + // Phase 1: resolve all members + members := make([]memberState, 0, len(uniqueUsers)) + for _, input := range uniqueUsers { + subject, err := extensionsClient.ResolveSubject(ctx.Context(), input) + if err != nil { + members = append(members, memberState{ + input: input, + statusVal: statusNotFound, + }) + continue + } + descriptor := types.GetValue(subject.Descriptor, "") + displayName := types.GetValue(subject.DisplayName, descriptor) + members = append(members, memberState{ + input: input, + descriptor: descriptor, + displayName: displayName, + origin: types.GetValue(subject.Origin, ""), + originID: types.GetValue(subject.LegacyDescriptor, ""), + statusVal: statusToRemove, + }) + } + + // Phase 2: check membership for each resolved member + for i := range members { + if members[i].descriptor == "" { + continue + } + + err = graphClient.CheckMembershipExistence(ctx.Context(), graph.CheckMembershipExistenceArgs{ + ContainerDescriptor: types.ToPtr(teamGroupDescriptor), + SubjectDescriptor: types.ToPtr(members[i].descriptor), + }) + if err == nil { + members[i].statusVal = statusToRemove + continue + } + + var wrapped *azuredevops.WrappedError + if errors.As(err, &wrapped) && wrapped != nil && wrapped.StatusCode != nil && *wrapped.StatusCode == http.StatusNotFound { + members[i].statusVal = statusNotAMember + continue + } + + zap.L().Debug( + "membership check failed", + zap.String("member", members[i].input), + zap.Error(err), + ) + members[i].statusVal = statusError + } + + ios.StopProgressIndicator() + + // Phase 3: confirmation prompt + removable := 0 + for _, m := range members { + if m.statusVal == statusToRemove { + removable++ + } + } + + if removable > 0 && !o.yes { + p, err := ctx.Prompter() + if err != nil { + return err + } + + var prompt string + if removable == 1 { + var name string + for _, m := range members { + if m.statusVal == statusToRemove { + name = m.displayName + if name == "" { + name = m.descriptor + } + if name == "" { + name = m.input + } + break + } + } + prompt = fmt.Sprintf("Are you sure you want to remove the member %s from team %s?", name, teamName) + } else { + var sb strings.Builder + fmt.Fprintf(&sb, "Are you sure you want to remove %d members from team %s?", removable, teamName) + listed := 0 + for _, m := range members { + if m.statusVal == statusToRemove { + if listed < 5 { + name := m.displayName + if name == "" { + name = m.descriptor + } + if name == "" { + name = m.input + } + fmt.Fprintf(&sb, "\n - %s", name) + listed++ + } else { + fmt.Fprintf(&sb, "\n ... and %d more", removable-listed) + break + } + } + } + prompt = sb.String() + } + + confirmed, err := p.Confirm(prompt, false) + if err != nil { + return err + } + if !confirmed { + return util.ErrCancel + } + } + + // Phase 4: remove pending members + if removable > 0 { + ios.StartProgressIndicator() + for i := range members { + if members[i].statusVal != statusToRemove { + continue + } + + err = graphClient.RemoveMembership(ctx.Context(), graph.RemoveMembershipArgs{ + ContainerDescriptor: types.ToPtr(teamGroupDescriptor), + SubjectDescriptor: types.ToPtr(members[i].descriptor), + }) + if err == nil { + members[i].statusVal = statusRemoved + continue + } + + var wrapped *azuredevops.WrappedError + if errors.As(err, &wrapped) && wrapped != nil && wrapped.StatusCode != nil && *wrapped.StatusCode == http.StatusNotFound { + members[i].statusVal = statusNotAMember + continue + } + + zap.L().Debug( + "failed to remove membership", + zap.String("member", members[i].input), + zap.Error(err), + ) + members[i].statusVal = statusError + } + ios.StopProgressIndicator() + } + + // Phase 5: assemble results + // Compute exit code + var failureCount int + + if o.exporter != nil { + results := make([]removeResultView, 0, len(members)) + for _, m := range members { + s := m.statusVal + if s == "" { + s = statusError + } + + r := removeResultView{ + MemberDescriptor: nil, + MemberDisplayName: nil, + MemberOrigin: nil, + MemberOriginID: nil, + Status: types.ToPtr(s), + } + if m.descriptor != "" { + r.MemberDescriptor = types.ToPtr(m.descriptor) + r.MemberDisplayName = types.ToPtr(m.descriptor) + } + if m.displayName != "" { + r.MemberDisplayName = types.ToPtr(m.displayName) + } + if m.origin != "" { + r.MemberOrigin = types.ToPtr(m.origin) + } + if m.originID != "" { + r.MemberOriginID = types.ToPtr(m.originID) + } + results = append(results, r) + } + + view := removeView{ + TeamName: types.ToPtr(teamName), + Results: results, + } + + if err := o.exporter.Write(ios, view); err != nil { + return err + } + + for _, r := range results { + s := types.GetValue(r.Status, "") + if s != statusRemoved && s != statusNotAMember { + failureCount++ + } + } + } else { + tp, err := ctx.Printer("list") + if err != nil { + return err + } + + tp.AddColumns("MEMBER", "DESCRIPTOR", "STATUS") + tp.EndRow() + + for _, m := range members { + display := m.displayName + if display == "" { + display = m.descriptor + } + + desc := m.descriptor + if desc == "" { + desc = m.input + } + + s := m.statusVal + if s == "" { + s = statusError + } + + tp.AddField(display) + tp.AddField(desc) + tp.AddField(s) + tp.EndRow() + + if s != statusRemoved && s != statusNotAMember { + failureCount++ + } + } + + if err := tp.Render(); err != nil { + return err + } + } + + if failureCount > 0 { + return fmt.Errorf("remove completed with %d failure(s)", failureCount) + } + + return nil +} diff --git a/internal/cmd/team/member/remove/remove_test.go b/internal/cmd/team/member/remove/remove_test.go new file mode 100644 index 0000000..3530417 --- /dev/null +++ b/internal/cmd/team/member/remove/remove_test.go @@ -0,0 +1,382 @@ +package remove + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/graph" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/printer" + "github.com/tmeckel/azdo-cli/internal/types" + "go.uber.org/mock/gomock" +) + +type fakeDeps struct { + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + coreClient *mocks.MockCoreClient + graphClient *mocks.MockGraphClient + extClient *mocks.MockAzDOExtension + prompter *mocks.MockPrompter + config *mocks.MockConfig + authCfg *mocks.MockAuthConfig +} + +func setupFakeDeps(t *testing.T, organization string) (*fakeDeps, *bytes.Buffer) { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ios, _, out, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStderrTTY(false) + + tp, err := printer.NewTablePrinter(out, false, 200) + require.NoError(t, err) + + deps := &fakeDeps{ + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + coreClient: mocks.NewMockCoreClient(ctrl), + graphClient: mocks.NewMockGraphClient(ctrl), + extClient: mocks.NewMockAzDOExtension(ctrl), + prompter: mocks.NewMockPrompter(ctrl), + config: mocks.NewMockConfig(ctrl), + authCfg: mocks.NewMockAuthConfig(ctrl), + } + + deps.cmd.EXPECT().IOStreams().Return(ios, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + deps.cmd.EXPECT().Printer(gomock.Any()).Return(tp, nil).AnyTimes() + deps.clientFact.EXPECT().Core(gomock.Any(), organization).Return(deps.coreClient, nil).AnyTimes() + deps.clientFact.EXPECT().Graph(gomock.Any(), organization).Return(deps.graphClient, nil).AnyTimes() + deps.clientFact.EXPECT().Extensions(gomock.Any(), organization).Return(deps.extClient, nil).AnyTimes() + + return deps, out +} + +func teamResult(desc, name string) *core.WebApiTeam { + return &core.WebApiTeam{ + Name: &name, + Identity: &identity.Identity{ + SubjectDescriptor: &desc, + }, + } +} + +func subject(desc, displayName, origin, legacyDesc string) *graph.GraphSubject { + return &graph.GraphSubject{ + Descriptor: &desc, + DisplayName: &displayName, + Origin: &origin, + LegacyDescriptor: &legacyDesc, + } +} + +func notFound() error { + return &azuredevops.WrappedError{ + StatusCode: types.ToPtr(http.StatusNotFound), + } +} + +func apiError(code int) error { + return &azuredevops.WrappedError{ + StatusCode: types.ToPtr(code), + } +} + +// --- Single-member tests (1–11) --- + +func TestRemove_SingleMember_HappyPath_YesFlag(t *testing.T) { + deps, buf := setupFakeDeps(t, "myOrg") + + teamDesc := "vssgp.Uy0xLTkt" + deps.coreClient.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(teamResult(teamDesc, "My Team"), nil) + + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "alice@c.com"). + Return(subject("aad.1", "Alice", "aad", "la"), nil) + + deps.graphClient.EXPECT().CheckMembershipExistence(gomock.Any(), gomock.Any()). + Return(nil) + deps.graphClient.EXPECT().RemoveMembership(gomock.Any(), gomock.Any()). + Return(nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "-u", "alice@c.com", "-y"}) + err := cmd.Execute() + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "Alice") + assert.Contains(t, output, "aad.1") + assert.Contains(t, output, "removed") + assert.NotContains(t, output, "Group") +} + +func TestRemove_SingleMember_InteractiveCancel(t *testing.T) { + deps, _ := setupFakeDeps(t, "myOrg") + + deps.coreClient.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(teamResult("vssgp.X", "My Team"), nil) + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "alice@c.com"). + Return(subject("aad.1", "Alice", "aad", "la"), nil) + deps.graphClient.EXPECT().CheckMembershipExistence(gomock.Any(), gomock.Any()). + Return(nil) + deps.cmd.EXPECT().Prompter().Return(deps.prompter, nil).Times(1) + deps.prompter.EXPECT().Confirm(gomock.Any(), false).Return(false, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "-u", "alice@c.com"}) + err := cmd.Execute() + require.Error(t, err) + assert.True(t, errors.Is(err, util.ErrCancel), "expected ErrCancel") +} + +func TestRemove_SingleMember_NotAMember(t *testing.T) { + deps, buf := setupFakeDeps(t, "myOrg") + + deps.coreClient.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(teamResult("vssgp.X", "My Team"), nil) + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "alice@c.com"). + Return(subject("aad.1", "Alice", "aad", "la"), nil) + deps.graphClient.EXPECT().CheckMembershipExistence(gomock.Any(), gomock.Any()). + Return(notFound()) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "-u", "alice@c.com", "-y"}) + err := cmd.Execute() + require.NoError(t, err) + + assert.Contains(t, buf.String(), "not a member") +} + +func TestRemove_SingleMember_Race_404OnRemove(t *testing.T) { + deps, buf := setupFakeDeps(t, "myOrg") + + deps.coreClient.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(teamResult("vssgp.X", "My Team"), nil) + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "alice@c.com"). + Return(subject("aad.1", "Alice", "aad", "la"), nil) + deps.graphClient.EXPECT().CheckMembershipExistence(gomock.Any(), gomock.Any()). + Return(nil) + deps.graphClient.EXPECT().RemoveMembership(gomock.Any(), gomock.Any()). + Return(notFound()) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "-u", "alice@c.com", "-y"}) + err := cmd.Execute() + require.NoError(t, err) + + assert.Contains(t, buf.String(), "not a member") +} + +func TestRemove_TeamNotFound(t *testing.T) { + deps, _ := setupFakeDeps(t, "myOrg") + + deps.coreClient.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(nil, fmt.Errorf("team not found")) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/NoTeam", "-u", "alice@c.com", "-y"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "team not found") +} + +func TestRemove_TeamHasNoIdentity(t *testing.T) { + deps, _ := setupFakeDeps(t, "myOrg") + + deps.coreClient.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(&core.WebApiTeam{Name: types.ToPtr("NoIdentity")}, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "-u", "alice@c.com", "-y"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "no underlying descriptor") + assert.NotContains(t, err.Error(), "Group") +} + +func TestRemove_MemberNotFound(t *testing.T) { + deps, buf := setupFakeDeps(t, "myOrg") + + deps.coreClient.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(teamResult("vssgp.X", "My Team"), nil) + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "ghost@x.com"). + Return(nil, errors.New("not found")) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "-u", "ghost@x.com", "-y"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, buf.String(), "not found") + assert.Contains(t, err.Error(), "failure(s)") +} + +func TestRemove_TargetArg_ParsesOrgSlashProjectSlashTeam(t *testing.T) { + deps, _ := setupFakeDeps(t, "myCustomOrg") + + var captured core.GetTeamArgs + deps.coreClient.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.GetTeamArgs) (*core.WebApiTeam, error) { + captured = args + return teamResult("vssgp.X", "Team"), nil + }) + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "u@x.com"). + Return(subject("aad.Y", "U", "aad", "l"), nil) + deps.graphClient.EXPECT().CheckMembershipExistence(gomock.Any(), gomock.Any()). + Return(nil) + deps.graphClient.EXPECT().RemoveMembership(gomock.Any(), gomock.Any()). + Return(nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myCustomOrg/MyProject/MyTeam", "-u", "u@x.com", "-y"}) + err := cmd.Execute() + require.NoError(t, err) + + assert.Equal(t, "MyProject", *captured.ProjectId) + assert.Equal(t, "MyTeam", *captured.TeamId) +} + +func TestRemove_JSONOutput_EnvelopeShape(t *testing.T) { + deps, buf := setupFakeDeps(t, "myOrg") + + deps.coreClient.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(teamResult("vssgp.Uy0xLTkt", "My Team"), nil) + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "alice@c.com"). + Return(subject("aad.YR5kM", "Alice", "aad", "la"), nil) + deps.graphClient.EXPECT().CheckMembershipExistence(gomock.Any(), gomock.Any()). + Return(nil) + deps.graphClient.EXPECT().RemoveMembership(gomock.Any(), gomock.Any()). + Return(nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "-u", "alice@c.com", "-y", "--json"}) + err := cmd.Execute() + require.NoError(t, err) + + var parsed struct { + TeamName string `json:"teamName"` + Results []json.RawMessage `json:"results"` + } + require.NoError(t, json.Unmarshal(buf.Bytes(), &parsed)) + assert.Equal(t, "My Team", parsed.TeamName) + assert.Len(t, parsed.Results, 1) +} + +// --- Bulk tests (12–22) --- + +func TestRemove_Bulk_DedupePreservesInputOrder(t *testing.T) { + deps, buf := setupFakeDeps(t, "myOrg") + + deps.coreClient.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(teamResult("vssgp.X", "My Team"), nil) + + gomock.InOrder( + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "alice@c.com"). + Return(subject("aad.1", "Alice", "aad", "la"), nil), + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "bob@c.com"). + Return(subject("aad.2", "Bob", "aad", "lb"), nil), + ) + + deps.graphClient.EXPECT().CheckMembershipExistence(gomock.Any(), gomock.Any()). + Return(nil).Times(2) + deps.graphClient.EXPECT().RemoveMembership(gomock.Any(), gomock.Any()). + Return(nil).Times(2) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "-u", "alice@c.com", "-u", "bob@c.com", "-u", "alice@c.com", "-y"}) + err := cmd.Execute() + require.NoError(t, err) + + aliceCount := strings.Count(buf.String(), "Alice") + assert.Equal(t, 1, aliceCount, "Alice should appear only once") + + alicePos := bytes.Index(buf.Bytes(), []byte("Alice")) + bobPos := bytes.Index(buf.Bytes(), []byte("Bob")) + assert.True(t, alicePos < bobPos, "input order not preserved") +} + +func TestRemove_Bulk_ExitCodePartialSuccess(t *testing.T) { + deps, _ := setupFakeDeps(t, "myOrg") + + deps.coreClient.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(teamResult("vssgp.X", "My Team"), nil) + + gomock.InOrder( + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "alice@c.com"). + Return(subject("aad.1", "Alice", "aad", "la"), nil), + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "error@c.com"). + Return(subject("aad.2", "Error", "aad", "le"), nil), + deps.graphClient.EXPECT().CheckMembershipExistence(gomock.Any(), gomock.Any()). + Return(nil), + deps.graphClient.EXPECT().CheckMembershipExistence(gomock.Any(), gomock.Any()). + Return(nil), + deps.graphClient.EXPECT().RemoveMembership(gomock.Any(), gomock.Any()). + Return(nil), + deps.graphClient.EXPECT().RemoveMembership(gomock.Any(), gomock.Any()). + Return(apiError(http.StatusInternalServerError)), + ) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "-u", "alice@c.com", "-u", "error@c.com", "-y"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "failure(s)") +} + +func TestRemove_Bulk_Prompt_NotShownWhenAllNotAMember(t *testing.T) { + deps, buf := setupFakeDeps(t, "myOrg") + + deps.coreClient.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(teamResult("vssgp.X", "My Team"), nil) + + gomock.InOrder( + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "alice@c.com"). + Return(subject("aad.1", "Alice", "aad", "la"), nil), + deps.extClient.EXPECT().ResolveSubject(gomock.Any(), "bob@c.com"). + Return(subject("aad.2", "Bob", "aad", "lb"), nil), + ) + + deps.graphClient.EXPECT().CheckMembershipExistence(gomock.Any(), gomock.Any()). + Return(notFound()).Times(2) + + // No Prompter call expected + // No RemoveMembership calls expected + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "-u", "alice@c.com", "-u", "bob@c.com"}) + err := cmd.Execute() + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "not a member") + assert.Contains(t, output, "Alice") + assert.Contains(t, output, "Bob") +} + +func TestRemove_MissingUserFlag(t *testing.T) { + deps, _ := setupFakeDeps(t, "myOrg") + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "required flag") +}