From f232361d47e51c191aa71681d932aa092cc3cb8c Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Wed, 29 Jul 2026 14:44:46 +0300 Subject: [PATCH] STX-211 Org membership commands --- .../streamx/cli/commands/StreamxCommand.java | 4 + .../CompleteInvitedEmailsCommand.java | 46 +++++ .../CompleteOrgMemberIdsCommand.java | 47 +++++ .../completion/ZshCompletionGenerator.java | 8 + .../streamx/cli/commands/org/OrgCommand.java | 6 +- .../org/invitations/InvitationsCommand.java | 21 +++ .../org/invitations/accept/AcceptCommand.java | 74 ++++++++ .../org/invitations/cancel/CancelCommand.java | 44 +++++ .../org/invitations/create/CreateCommand.java | 51 ++++++ .../org/invitations/list/ListCommand.java | 71 ++++++++ .../commands/org/members/MembersCommand.java | 21 +++ .../commands/org/members/add/AddCommand.java | 54 ++++++ .../org/members/list/ListCommand.java | 72 ++++++++ .../org/members/remove/RemoveCommand.java | 56 ++++++ .../org/members/setrole/SetRoleCommand.java | 65 +++++++ .../InvitedEmailCompletionCandidates.java | 16 ++ .../OrgMemberIdCompletionCandidates.java | 17 ++ .../platform/OrganizationInvitationsApi.java | 43 +++++ .../cli/platform/OrganizationUsersApi.java | 47 +++++ .../java/com/streamx/cli/platform/Roles.java | 18 ++ .../completion/CompletionCommandIT.java | 9 + .../cli/commands/org/OrgCommandIT.java | 32 ++++ .../commands/org/OrgInvitationsCommandIT.java | 140 +++++++++++++++ .../cli/commands/org/OrgMembersCommandIT.java | 163 ++++++++++++++++++ 24 files changed, 1124 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/streamx/cli/commands/completion/CompleteInvitedEmailsCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/completion/CompleteOrgMemberIdsCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/invitations/InvitationsCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/invitations/accept/AcceptCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/invitations/cancel/CancelCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/invitations/create/CreateCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/invitations/list/ListCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/members/MembersCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/members/add/AddCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/members/list/ListCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/members/remove/RemoveCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/members/setrole/SetRoleCommand.java create mode 100644 src/main/java/com/streamx/cli/platform/InvitedEmailCompletionCandidates.java create mode 100644 src/main/java/com/streamx/cli/platform/OrgMemberIdCompletionCandidates.java create mode 100644 src/main/java/com/streamx/cli/platform/OrganizationInvitationsApi.java create mode 100644 src/main/java/com/streamx/cli/platform/OrganizationUsersApi.java create mode 100644 src/main/java/com/streamx/cli/platform/Roles.java create mode 100644 src/test/java/com/streamx/cli/commands/org/OrgInvitationsCommandIT.java create mode 100644 src/test/java/com/streamx/cli/commands/org/OrgMembersCommandIT.java diff --git a/src/main/java/com/streamx/cli/commands/StreamxCommand.java b/src/main/java/com/streamx/cli/commands/StreamxCommand.java index 64e4afff..87327263 100644 --- a/src/main/java/com/streamx/cli/commands/StreamxCommand.java +++ b/src/main/java/com/streamx/cli/commands/StreamxCommand.java @@ -3,8 +3,10 @@ import com.streamx.cli.commands.auth.AuthCommand; import com.streamx.cli.commands.completion.CompleteClusterIdsCommand; import com.streamx.cli.commands.completion.CompleteContextNamesCommand; +import com.streamx.cli.commands.completion.CompleteInvitedEmailsCommand; import com.streamx.cli.commands.completion.CompleteNonDefaultTemplateIdsCommand; import com.streamx.cli.commands.completion.CompleteOrgIdsCommand; +import com.streamx.cli.commands.completion.CompleteOrgMemberIdsCommand; import com.streamx.cli.commands.completion.CompleteRegisteredTemplateIdsCommand; import com.streamx.cli.commands.completion.CompleteSettingsKeysCommand; import com.streamx.cli.commands.completion.CompleteSettingsSetKeysCommand; @@ -38,6 +40,8 @@ CompleteSettingsSetKeysCommand.class, CompleteContextNamesCommand.class, CompleteOrgIdsCommand.class, + CompleteOrgMemberIdsCommand.class, + CompleteInvitedEmailsCommand.class, CompleteClusterIdsCommand.class } ) diff --git a/src/main/java/com/streamx/cli/commands/completion/CompleteInvitedEmailsCommand.java b/src/main/java/com/streamx/cli/commands/completion/CompleteInvitedEmailsCommand.java new file mode 100644 index 00000000..146abcd9 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/completion/CompleteInvitedEmailsCommand.java @@ -0,0 +1,46 @@ +package com.streamx.cli.commands.completion; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.OrganizationInvitationsApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.generated.model.Invitation; +import java.util.List; +import java.util.Objects; +import picocli.CommandLine; + +@CommandLine.Command( + name = "__complete-invited-emails", + hidden = true, + header = "Internal: list invited email addresses of an organization for shell completion" +) +public class CompleteInvitedEmailsCommand extends AbstractCommand> { + + @CommandLine.Parameters(index = "0", arity = "0..1", description = "Organization ID") + public String orgId; + + @Override + public CommandResult> runCommand() { + String org = orgId == null || orgId.isBlank() || orgId.startsWith("-") + ? PlatformContext.effectiveOrg() + : orgId; + if (org == null) { + return new CommandResult<>(List.of()); + } + try (PlatformClients client = PlatformClients.completion()) { + return new CommandResult<>(new OrganizationInvitationsApi(client).list(org).stream() + .map(Invitation::getEmail) + .filter(Objects::nonNull) + .sorted() + .toList()); + } catch (RuntimeException anyFailure) { + return new CommandResult<>(List.of()); + } + } + + @Override + public String getTextOutput(CommandResult> result) { + return String.join("\n", result.getData()); + } +} diff --git a/src/main/java/com/streamx/cli/commands/completion/CompleteOrgMemberIdsCommand.java b/src/main/java/com/streamx/cli/commands/completion/CompleteOrgMemberIdsCommand.java new file mode 100644 index 00000000..714f619f --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/completion/CompleteOrgMemberIdsCommand.java @@ -0,0 +1,47 @@ +package com.streamx.cli.commands.completion; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.OrganizationUsersApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.generated.model.User; +import java.util.List; +import java.util.Objects; +import picocli.CommandLine; + +@CommandLine.Command( + name = "__complete-org-member-ids", + hidden = true, + header = "Internal: list ACTIVE member IDs of an organization for shell completion" +) +public class CompleteOrgMemberIdsCommand extends AbstractCommand> { + + @CommandLine.Parameters(index = "0", arity = "0..1", description = "Organization ID") + public String orgId; + + @Override + public CommandResult> runCommand() { + String org = orgId == null || orgId.isBlank() || orgId.startsWith("-") + ? PlatformContext.effectiveOrg() + : orgId; + if (org == null) { + return new CommandResult<>(List.of()); + } + try (PlatformClients client = PlatformClients.completion()) { + return new CommandResult<>(new OrganizationUsersApi(client).list(org).stream() + .filter(user -> user.getStatus() == User.StatusEnum.ACTIVE) + .map(User::getId) + .filter(Objects::nonNull) + .sorted() + .toList()); + } catch (RuntimeException anyFailure) { + return new CommandResult<>(List.of()); + } + } + + @Override + public String getTextOutput(CommandResult> result) { + return String.join("\n", result.getData()); + } +} diff --git a/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java b/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java index 2241d978..ce88717a 100644 --- a/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java +++ b/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java @@ -7,7 +7,9 @@ import com.streamx.cli.commands.settings.eventtemplates.TemplateIdCompletionCandidates; import com.streamx.cli.config.ContextNameCompletionCandidates; import com.streamx.cli.platform.ClusterIdCompletionCandidates; +import com.streamx.cli.platform.InvitedEmailCompletionCandidates; import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrgMemberIdCompletionCandidates; import java.io.File; import java.nio.file.Path; import java.util.ArrayList; @@ -221,6 +223,12 @@ private static String getCompletionAction( if (completionCandidates instanceof OrgIdCompletionCandidates) { return "($(streamx __complete-org-ids 2>/dev/null))"; } + if (completionCandidates instanceof OrgMemberIdCompletionCandidates) { + return "($(streamx __complete-org-member-ids " + ORG_FROM_WORDS + " 2>/dev/null))"; + } + if (completionCandidates instanceof InvitedEmailCompletionCandidates) { + return "($(streamx __complete-invited-emails " + ORG_FROM_WORDS + " 2>/dev/null))"; + } if (completionCandidates instanceof ClusterIdCompletionCandidates) { return "($(streamx __complete-cluster-ids " + ORG_FROM_WORDS + " 2>/dev/null))"; } diff --git a/src/main/java/com/streamx/cli/commands/org/OrgCommand.java b/src/main/java/com/streamx/cli/commands/org/OrgCommand.java index 94ed1b31..4d7cfa77 100644 --- a/src/main/java/com/streamx/cli/commands/org/OrgCommand.java +++ b/src/main/java/com/streamx/cli/commands/org/OrgCommand.java @@ -4,7 +4,9 @@ import com.streamx.cli.commands.org.create.CreateCommand; import com.streamx.cli.commands.org.delete.DeleteCommand; import com.streamx.cli.commands.org.get.GetCommand; +import com.streamx.cli.commands.org.invitations.InvitationsCommand; import com.streamx.cli.commands.org.list.ListCommand; +import com.streamx.cli.commands.org.members.MembersCommand; import com.streamx.cli.framework.AbstractCommandGroup; import picocli.CommandLine; @@ -16,7 +18,9 @@ CreateCommand.class, DeleteCommand.class, GetCommand.class, - ListCommand.class + InvitationsCommand.class, + ListCommand.class, + MembersCommand.class } ) public class OrgCommand extends AbstractCommandGroup { diff --git a/src/main/java/com/streamx/cli/commands/org/invitations/InvitationsCommand.java b/src/main/java/com/streamx/cli/commands/org/invitations/InvitationsCommand.java new file mode 100644 index 00000000..2ae86ba1 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/invitations/InvitationsCommand.java @@ -0,0 +1,21 @@ +package com.streamx.cli.commands.org.invitations; + +import com.streamx.cli.commands.org.invitations.accept.AcceptCommand; +import com.streamx.cli.commands.org.invitations.cancel.CancelCommand; +import com.streamx.cli.commands.org.invitations.create.CreateCommand; +import com.streamx.cli.commands.org.invitations.list.ListCommand; +import com.streamx.cli.framework.AbstractCommandGroup; +import picocli.CommandLine; + +@CommandLine.Command( + name = "invitations", + header = "Manage organization invitations", + subcommands = { + AcceptCommand.class, + CancelCommand.class, + CreateCommand.class, + ListCommand.class + } +) +public class InvitationsCommand extends AbstractCommandGroup { +} diff --git a/src/main/java/com/streamx/cli/commands/org/invitations/accept/AcceptCommand.java b/src/main/java/com/streamx/cli/commands/org/invitations/accept/AcceptCommand.java new file mode 100644 index 00000000..5caf72ed --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/invitations/accept/AcceptCommand.java @@ -0,0 +1,74 @@ +package com.streamx.cli.commands.org.invitations.accept; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationInvitationsApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import picocli.CommandLine; + +@CommandLine.Command( + name = "accept", + header = "Accept an invitation to an organization", + description = { + "The invitation token is read from standard input, or from --token-file.", + "It is not taken as an argument: that would leave a credential in the shell", + "history and expose it to anyone listing processes.", + "", + " streamx org invitations accept --org --token-file ./token.txt", + " pbpaste | streamx org invitations accept --org " + } +) +public class AcceptCommand extends AbstractSilentCommand { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Option( + names = "--token-file", + description = "File holding the invitation token; defaults to reading standard input" + ) + public Path tokenFile; + + @Override + public CommandResult runCommand() { + orgId = PlatformContext.requireOrg(orgId); + String token = readToken(); + + try (PlatformClients client = PlatformClients.fromConfig()) { + new OrganizationInvitationsApi(client).accept(orgId, token); + } + System.out.println(msg.orgInvitationAccepted()); + return new CommandResult<>(null); + } + + private String readToken() { + String token; + try { + token = tokenFile != null + ? Files.readString(tokenFile, StandardCharsets.UTF_8) + : new String(System.in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new CliException(msg.orgInvitationTokenRequired(), e); + } + + token = token.strip(); + if (token.isEmpty()) { + throw new CliException(msg.orgInvitationTokenRequired()); + } + return token; + } +} diff --git a/src/main/java/com/streamx/cli/commands/org/invitations/cancel/CancelCommand.java b/src/main/java/com/streamx/cli/commands/org/invitations/cancel/CancelCommand.java new file mode 100644 index 00000000..11b4cecf --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/invitations/cancel/CancelCommand.java @@ -0,0 +1,44 @@ +package com.streamx.cli.commands.org.invitations.cancel; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.InvitedEmailCompletionCandidates; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationInvitationsApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import picocli.CommandLine; + +@CommandLine.Command( + name = "cancel", + header = "Cancel a pending organization invitation" +) +public class CancelCommand extends AbstractSilentCommand { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Parameters( + index = "0", + description = "Invited email address", + completionCandidates = InvitedEmailCompletionCandidates.class + ) + public String email; + + @Override + public CommandResult runCommand() { + orgId = PlatformContext.requireOrg(orgId); + try (PlatformClients client = PlatformClients.fromConfig()) { + new OrganizationInvitationsApi(client).cancel(orgId, email); + } + System.out.println(msg.orgInvitationCancelled(email)); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/org/invitations/create/CreateCommand.java b/src/main/java/com/streamx/cli/commands/org/invitations/create/CreateCommand.java new file mode 100644 index 00000000..79d5135b --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/invitations/create/CreateCommand.java @@ -0,0 +1,51 @@ +package com.streamx.cli.commands.org.invitations.create; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationInvitationsApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.Roles; +import picocli.CommandLine; + +@CommandLine.Command( + name = "create", + header = "Invite a user to an organization" +) +public class CreateCommand extends AbstractSilentCommand { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Parameters( + index = "0", + description = "Email address to invite" + ) + public String email; + + @CommandLine.Option( + names = {"-r", "--role"}, + required = true, + description = "Role to grant: ${COMPLETION-CANDIDATES}", + completionCandidates = Roles.class + ) + public String role; + + @Override + public CommandResult runCommand() { + orgId = PlatformContext.requireOrg(orgId); + try (PlatformClients client = PlatformClients.fromConfig()) { + new OrganizationInvitationsApi(client).create(orgId, email, role); + } + System.out.println(msg.orgInvitationCreated(email, role)); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/org/invitations/list/ListCommand.java b/src/main/java/com/streamx/cli/commands/org/invitations/list/ListCommand.java new file mode 100644 index 00000000..53406e1f --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/invitations/list/ListCommand.java @@ -0,0 +1,71 @@ +package com.streamx.cli.commands.org.invitations.list; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.framework.TextTable; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationInvitationsApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.generated.model.Invitation; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import picocli.CommandLine; + +@CommandLine.Command( + name = "list", + header = "List pending organization invitations" +) +public class ListCommand extends AbstractCommand> { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Option( + names = {"-q", "--quiet"}, + description = "Only display invited emails, one per line (for piping to xargs)" + ) + public boolean quiet; + + @Override + public String getTextOutput(CommandResult> result) { + List invitations = result.getData(); + + if (quiet) { + return invitations.stream() + .map(Invitation::getEmail) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + } + + if (invitations.isEmpty()) { + return msg.orgInvitationsListEmpty(); + } + + return TextTable.render( + List.of("EMAIL", "ROLE", "STATUS"), + invitations.stream() + .map(invitation -> Arrays.asList( + invitation.getEmail(), + invitation.getRole() == null ? null : invitation.getRole().getName(), + invitation.getStatus() == null ? null : invitation.getStatus().value())) + .toList()); + } + + @Override + public CommandResult> runCommand() { + orgId = PlatformContext.requireOrg(orgId); + try (PlatformClients client = PlatformClients.fromConfig()) { + return new CommandResult<>(new OrganizationInvitationsApi(client).list(orgId)); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/org/members/MembersCommand.java b/src/main/java/com/streamx/cli/commands/org/members/MembersCommand.java new file mode 100644 index 00000000..760e3670 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/members/MembersCommand.java @@ -0,0 +1,21 @@ +package com.streamx.cli.commands.org.members; + +import com.streamx.cli.commands.org.members.add.AddCommand; +import com.streamx.cli.commands.org.members.list.ListCommand; +import com.streamx.cli.commands.org.members.remove.RemoveCommand; +import com.streamx.cli.commands.org.members.setrole.SetRoleCommand; +import com.streamx.cli.framework.AbstractCommandGroup; +import picocli.CommandLine; + +@CommandLine.Command( + name = "members", + header = "Manage organization members", + subcommands = { + AddCommand.class, + ListCommand.class, + RemoveCommand.class, + SetRoleCommand.class + } +) +public class MembersCommand extends AbstractCommandGroup { +} diff --git a/src/main/java/com/streamx/cli/commands/org/members/add/AddCommand.java b/src/main/java/com/streamx/cli/commands/org/members/add/AddCommand.java new file mode 100644 index 00000000..2524e037 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/members/add/AddCommand.java @@ -0,0 +1,54 @@ +package com.streamx.cli.commands.org.members.add; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationUsersApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.Roles; +import picocli.CommandLine; + +@CommandLine.Command( + name = "add", + header = "Add an existing user to an organization", + description = "The account must already exist in the identity provider; it is looked up by " + + "email. To bring in a new user, send an invitation instead: " + + "streamx org invitations create" +) +public class AddCommand extends AbstractSilentCommand { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Parameters( + index = "0", + description = "Email of an existing account" + ) + public String email; + + @CommandLine.Option( + names = {"-r", "--role"}, + required = true, + description = "Role to grant: ${COMPLETION-CANDIDATES}", + completionCandidates = Roles.class + ) + public String role; + + @Override + public CommandResult runCommand() { + orgId = PlatformContext.requireOrg(orgId); + try (PlatformClients client = PlatformClients.fromConfig()) { + new OrganizationUsersApi(client).add(orgId, email, role); + } + System.out.println(msg.orgMemberAdded(email, role)); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/org/members/list/ListCommand.java b/src/main/java/com/streamx/cli/commands/org/members/list/ListCommand.java new file mode 100644 index 00000000..2c0660da --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/members/list/ListCommand.java @@ -0,0 +1,72 @@ +package com.streamx.cli.commands.org.members.list; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.framework.TextTable; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationUsersApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.generated.model.User; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import picocli.CommandLine; + +@CommandLine.Command( + name = "list", + header = "List organization members" +) +public class ListCommand extends AbstractCommand> { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Option( + names = {"-q", "--quiet"}, + description = "Only display member IDs, one per line (for piping to xargs)" + ) + public boolean quiet; + + @Override + public String getTextOutput(CommandResult> result) { + List users = result.getData(); + + if (quiet) { + return users.stream() + .map(User::getId) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + } + + if (users.isEmpty()) { + return msg.orgMembersListEmpty(); + } + + return TextTable.render( + List.of("ID", "DISPLAY NAME", "ROLE", "STATUS", ""), + users.stream() + .map(user -> java.util.Arrays.asList( + user.getId(), + user.getDisplayName(), + user.getRole() == null ? null : user.getRole().getName(), + user.getStatus() == null ? null : user.getStatus().value(), + Boolean.TRUE.equals(user.getIsCaller()) ? "(you)" : "")) + .toList()); + } + + @Override + public CommandResult> runCommand() { + orgId = PlatformContext.requireOrg(orgId); + try (PlatformClients client = PlatformClients.fromConfig()) { + return new CommandResult<>(new OrganizationUsersApi(client).list(orgId)); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/org/members/remove/RemoveCommand.java b/src/main/java/com/streamx/cli/commands/org/members/remove/RemoveCommand.java new file mode 100644 index 00000000..469c1907 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/members/remove/RemoveCommand.java @@ -0,0 +1,56 @@ +package com.streamx.cli.commands.org.members.remove; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrgMemberIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationUsersApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.generated.model.User; +import picocli.CommandLine; + +@CommandLine.Command( + name = "remove", + header = "Remove a member from an organization" +) +public class RemoveCommand extends AbstractSilentCommand { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Parameters( + index = "0", + description = "ID of an ACTIVE member, as shown by 'streamx org members list'", + completionCandidates = OrgMemberIdCompletionCandidates.class + ) + public String userId; + + @Override + public CommandResult runCommand() { + orgId = PlatformContext.requireOrg(orgId); + try (PlatformClients client = PlatformClients.fromConfig()) { + OrganizationUsersApi users = new OrganizationUsersApi(client); + + User member = users.find(orgId, userId) + .orElseThrow(() -> new CliException(msg.orgMemberNotFound(userId, orgId))); + if (member.getStatus() != User.StatusEnum.ACTIVE) { + String status = member.getStatus() == null ? "" : member.getStatus().value(); + throw new CliException( + msg.orgMemberNotActiveForRemoval(userId, status, orgId, userId)); + } + + users.remove(orgId, userId); + } + System.out.println(msg.orgMemberRemoved(userId)); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/org/members/setrole/SetRoleCommand.java b/src/main/java/com/streamx/cli/commands/org/members/setrole/SetRoleCommand.java new file mode 100644 index 00000000..6fcd140b --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/members/setrole/SetRoleCommand.java @@ -0,0 +1,65 @@ +package com.streamx.cli.commands.org.members.setrole; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrgMemberIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationUsersApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.Roles; +import com.streamx.cli.platform.generated.model.User; +import picocli.CommandLine; + +@CommandLine.Command( + name = "set-role", + header = "Change the role of an organization member" +) +public class SetRoleCommand extends AbstractSilentCommand { + + @CommandLine.Option( + names = "--org", + paramLabel = "", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Parameters( + index = "0", + description = "ID of an ACTIVE member, as shown by 'streamx org members list'", + completionCandidates = OrgMemberIdCompletionCandidates.class + ) + public String userId; + + @CommandLine.Option( + names = {"-r", "--role"}, + required = true, + description = "New role: ${COMPLETION-CANDIDATES}", + completionCandidates = Roles.class + ) + public String role; + + @Override + public CommandResult runCommand() { + orgId = PlatformContext.requireOrg(orgId); + try (PlatformClients client = PlatformClients.fromConfig()) { + OrganizationUsersApi users = new OrganizationUsersApi(client); + + User member = users.find(orgId, userId) + .orElseThrow(() -> new CliException(msg.orgMemberNotFound(userId, orgId))); + if (member.getStatus() != User.StatusEnum.ACTIVE) { + String status = member.getStatus() == null ? "" : member.getStatus().value(); + throw new CliException( + msg.orgMemberNotActiveForRoleChange(userId, status, orgId, userId)); + } + + users.editRole(orgId, userId, role); + } + System.out.println(msg.orgMemberRoleChanged(userId, role)); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/platform/InvitedEmailCompletionCandidates.java b/src/main/java/com/streamx/cli/platform/InvitedEmailCompletionCandidates.java new file mode 100644 index 00000000..25eba9c2 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/InvitedEmailCompletionCandidates.java @@ -0,0 +1,16 @@ +package com.streamx.cli.platform; + +import java.util.Collections; +import java.util.Iterator; + +/** + * Marker for dynamic invited-email completion. The zsh completion script resolves the values + * at TAB time via the hidden {@code __complete-invited-emails } command, passing the + * organization ID already typed on the command line. + */ +public class InvitedEmailCompletionCandidates implements Iterable { + @Override + public Iterator iterator() { + return Collections.emptyIterator(); + } +} diff --git a/src/main/java/com/streamx/cli/platform/OrgMemberIdCompletionCandidates.java b/src/main/java/com/streamx/cli/platform/OrgMemberIdCompletionCandidates.java new file mode 100644 index 00000000..0d1742f0 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/OrgMemberIdCompletionCandidates.java @@ -0,0 +1,17 @@ +package com.streamx.cli.platform; + +import java.util.Collections; +import java.util.Iterator; + +/** + * Marker for dynamic member-ID completion. The zsh completion script resolves the values at + * TAB time via the hidden {@code __complete-org-member-ids } command, passing the + * organization ID already typed on the command line. Only ACTIVE members are offered, since + * the commands using this refuse to act on pending invitees. + */ +public class OrgMemberIdCompletionCandidates implements Iterable { + @Override + public Iterator iterator() { + return Collections.emptyIterator(); + } +} diff --git a/src/main/java/com/streamx/cli/platform/OrganizationInvitationsApi.java b/src/main/java/com/streamx/cli/platform/OrganizationInvitationsApi.java new file mode 100644 index 00000000..aaf68f75 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/OrganizationInvitationsApi.java @@ -0,0 +1,43 @@ +package com.streamx.cli.platform; + +import com.streamx.cli.platform.generated.api.InvitationsResourceApi; +import com.streamx.cli.platform.generated.model.Invitation; +import com.streamx.cli.platform.generated.model.InvitationAccept; +import com.streamx.cli.platform.generated.model.InvitationRequest; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; + +public class OrganizationInvitationsApi { + + private final PlatformClients clients; + private final InvitationsResourceApi api; + + public OrganizationInvitationsApi(PlatformClients clients) { + this.clients = clients; + this.api = clients.api(InvitationsResourceApi.class); + } + + public List list(String orgId) { + return clients.callList(() -> api.listInvitations(orgId, null, null), Invitation.class).stream() + .sorted(Comparator.comparing(Invitation::getEmail, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + public void create(String orgId, String email, String role) { + clients.call(() -> api.createInvitation( + orgId, new InvitationRequest().email(email).role(role), null, null)); + } + + public void accept(String orgId, String token) { + clients.call(() -> api.acceptInvitation( + orgId, new InvitationAccept().token(token), null, null)); + } + + public void cancel(String orgId, String email) { + String emailBase64 = Base64.getEncoder() + .encodeToString(email.getBytes(StandardCharsets.UTF_8)); + clients.call(() -> api.cancelInvitation(emailBase64, orgId, null, null)); + } +} diff --git a/src/main/java/com/streamx/cli/platform/OrganizationUsersApi.java b/src/main/java/com/streamx/cli/platform/OrganizationUsersApi.java new file mode 100644 index 00000000..6320ad15 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/OrganizationUsersApi.java @@ -0,0 +1,47 @@ +package com.streamx.cli.platform; + +import com.streamx.cli.platform.generated.api.UsersResourceApi; +import com.streamx.cli.platform.generated.model.NameAndRole; +import com.streamx.cli.platform.generated.model.RoleChange; +import com.streamx.cli.platform.generated.model.User; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class OrganizationUsersApi { + + private final PlatformClients clients; + private final UsersResourceApi api; + + public OrganizationUsersApi(PlatformClients clients) { + this.clients = clients; + this.api = clients.api(UsersResourceApi.class); + } + + public List list(String orgId) { + return clients.callList(() -> api.listUsers(orgId, null, null), User.class).stream() + .sorted(Comparator.comparing(User::getId, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + public Optional find(String orgId, String userId) { + return list(orgId).stream() + .filter(user -> Objects.equals(user.getId(), userId)) + .findFirst(); + } + + public void add(String orgId, String email, String role) { + clients.call(() -> api.addUserToOrganization( + orgId, new NameAndRole().name(email).role(role), null, null)); + } + + public void remove(String orgId, String userId) { + clients.call(() -> api.removeUserFromOrganization(orgId, userId, null, null)); + } + + public void editRole(String orgId, String userId, String newRoleId) { + clients.call(() -> api.editUserRoles( + orgId, userId, new RoleChange().newRoleId(newRoleId), null, null)); + } +} diff --git a/src/main/java/com/streamx/cli/platform/Roles.java b/src/main/java/com/streamx/cli/platform/Roles.java new file mode 100644 index 00000000..9b944e86 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/Roles.java @@ -0,0 +1,18 @@ +package com.streamx.cli.platform; + +import java.util.Iterator; +import java.util.List; + +public class Roles implements Iterable { + + public static final String OWNER = "owner"; + public static final String EDIT = "edit"; + public static final String VIEW = "view"; + + private static final List ALL = List.of(OWNER, EDIT, VIEW); + + @Override + public Iterator iterator() { + return ALL.iterator(); + } +} diff --git a/src/test/java/com/streamx/cli/commands/completion/CompletionCommandIT.java b/src/test/java/com/streamx/cli/commands/completion/CompletionCommandIT.java index 97d0b538..be49ac1a 100644 --- a/src/test/java/com/streamx/cli/commands/completion/CompletionCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/completion/CompletionCommandIT.java @@ -28,6 +28,15 @@ void shouldGenerateZshCompletionScript() throws Exception { assertThat(stdout).contains("compdef _streamx streamx"); } + /** Option candidates were previously dropped, so --role offered nothing on TAB. */ + @Test + void shouldEmitRoleCandidatesForZshOptions() throws Exception { + ProcessResult result = exec("completion", "zsh"); + + result.assertSuccess(); + assertThat(result.stdout()).contains("(owner edit view)"); + } + @Test void shouldEmitDynamicTemplateIdCompletionForPublishEvent() throws Exception { ProcessResult result = exec("completion", "zsh"); diff --git a/src/test/java/com/streamx/cli/commands/org/OrgCommandIT.java b/src/test/java/com/streamx/cli/commands/org/OrgCommandIT.java index 046d6473..8f5a84d2 100644 --- a/src/test/java/com/streamx/cli/commands/org/OrgCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/org/OrgCommandIT.java @@ -317,4 +317,36 @@ void envVarOverridesCurrentOrgFile() throws Exception { } } + @Test + void membersAddUsesCurrentOrgContext() throws Exception { + exec("context", "org", "use", "acme").assertSuccess(); + + ProcessResult result = exec("org", "members", "add", "alice@example.com", "-r", "edit"); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("POST /api/v1/organizations/acme/users"); + } + + @Test + void membersAddHonorsExplicitOrgOverContext() throws Exception { + exec("context", "org", "use", "acme").assertSuccess(); + + ProcessResult result = + exec("org", "members", "add", "alice@example.com", "--org", "globex", "-r", "edit"); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("POST /api/v1/organizations/globex/users"); + } + + @Test + void membersAddRequiresEmailArgument() throws Exception { + ProcessResult result = exec("org", "members", "add", "--org", "acme", "-r", "edit"); + + result.assertExitCode(2); + assertThat(result.stderr()).contains(""); + assertThat(platform.getRequests()).isEmpty(); + } + } diff --git a/src/test/java/com/streamx/cli/commands/org/OrgInvitationsCommandIT.java b/src/test/java/com/streamx/cli/commands/org/OrgInvitationsCommandIT.java new file mode 100644 index 00000000..6caf81c7 --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/org/OrgInvitationsCommandIT.java @@ -0,0 +1,140 @@ +package com.streamx.cli.commands.org; + +import static com.streamx.cli.i18n.MessageProvider.msg; +import static org.assertj.core.api.Assertions.assertThat; + +import com.streamx.cli.platform.PlatformConfig; +import com.streamx.cli.test.CliBaseIT; +import io.quarkus.test.junit.QuarkusTest; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Base64; +import java.util.Properties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class OrgInvitationsCommandIT extends CliBaseIT { + + private static final String ORG = "so-testorg"; + + private StubPlatformServer platform; + + private Path getCredentialsPath() { + return streamxHome.resolve("contexts/default/config/credentials.json"); + } + + @BeforeEach + void setUp() throws IOException { + platform = new StubPlatformServer(); + + Properties properties = new Properties(); + properties.setProperty(PlatformConfig.STREAMX_PLATFORM_URL, platform.getUrl()); + Path configFile = getConfigPath(); + Files.createDirectories(configFile.getParent()); + try (OutputStream out = Files.newOutputStream(configFile)) { + properties.store(out, null); + } + + Path credentials = getCredentialsPath(); + Files.createDirectories(credentials.getParent()); + Files.writeString(credentials, """ + {"access_token":"test-access-token","refresh_token":"test-refresh-token", + "expires_at":%d,"issuer_url":"http://127.0.0.1:1/realms/streamx", + "client_id":"streamx-cli"} + """.formatted(Instant.now().plusSeconds(300).getEpochSecond())); + } + + @AfterEach + void tearDown() throws IOException { + if (platform != null) { + platform.close(); + } + Files.deleteIfExists(getCredentialsPath()); + } + + @Test + void shouldListInvitations() throws Exception { + ProcessResult result = exec("org", "invitations", "list", "--org", ORG); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("GET /api/v1/organizations/" + ORG + "/invitations"); + assertThat(result.stdout()).contains("EMAIL", "ROLE", "STATUS"); + assertThat(result.stdout()).contains("invited@streamx.com", "edit", "PENDING"); + } + + @Test + void completeInvitedEmailsListsPendingInvitations() throws Exception { + ProcessResult result = exec("__complete-invited-emails", ORG); + + result.assertSuccess(); + assertThat(result.stdout().strip().lines()).containsExactly("invited@streamx.com"); + } + + @Test + void shouldCreateInvitation() throws Exception { + ProcessResult result = + exec("org", "invitations", "create", "new@streamx.com", "--org", ORG, "--role", "view"); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("POST /api/v1/organizations/" + ORG + "/invitations"); + assertThat(platform.getRequestBodies().get(0)) + .contains("\"email\":\"new@streamx.com\"", "\"role\":\"view\""); + assertThat(result.stdout()).contains(msg.orgInvitationCreated("new@streamx.com", "view")); + } + + /** The token is a credential, so it is read from stdin rather than taken from argv. */ + @Test + void shouldAcceptInvitationWithTokenFromStdin() throws Exception { + ProcessResult result = + execWithStdin("token-abc\n", "org", "invitations", "accept", "--org", ORG); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("PATCH /api/v1/organizations/" + ORG + "/invitations"); + assertThat(platform.getRequestBodies().get(0)).contains("\"token\":\"token-abc\""); + assertThat(result.stdout()).contains(msg.orgInvitationAccepted()); + } + + @Test + void shouldAcceptInvitationWithTokenFromFile() throws Exception { + Path tokenFile = streamxHome.resolve("token.txt"); + Files.writeString(tokenFile, "token-from-file\n"); + + ProcessResult result = exec( + "org", "invitations", "accept", "--org", ORG, "--token-file", tokenFile.toString()); + + result.assertSuccess(); + assertThat(platform.getRequestBodies().get(0)).contains("\"token\":\"token-from-file\""); + } + + @Test + void shouldFailWhenNoInvitationTokenSupplied() throws Exception { + ProcessResult result = execWithStdin("", "org", "invitations", "accept", "--org", ORG); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.orgInvitationTokenRequired()); + assertThat(platform.getRequests()).isEmpty(); + } + + /** The server takes the email base64-encoded in the path; the CLI must hide that. */ + @Test + void shouldCancelInvitationSendingBase64EncodedEmailInPath() throws Exception { + String email = "invited@streamx.com"; + String expected = Base64.getEncoder().encodeToString(email.getBytes(StandardCharsets.UTF_8)); + + ProcessResult result = exec("org", "invitations", "cancel", email, "--org", ORG); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("DELETE /api/v1/organizations/" + ORG + "/invitations/" + expected); + assertThat(result.stdout()).contains(msg.orgInvitationCancelled(email)); + } +} diff --git a/src/test/java/com/streamx/cli/commands/org/OrgMembersCommandIT.java b/src/test/java/com/streamx/cli/commands/org/OrgMembersCommandIT.java new file mode 100644 index 00000000..63ffd116 --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/org/OrgMembersCommandIT.java @@ -0,0 +1,163 @@ +package com.streamx.cli.commands.org; + +import static com.streamx.cli.i18n.MessageProvider.msg; +import static org.assertj.core.api.Assertions.assertThat; + +import com.streamx.cli.platform.PlatformConfig; +import com.streamx.cli.test.CliBaseIT; +import io.quarkus.test.junit.QuarkusTest; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Properties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class OrgMembersCommandIT extends CliBaseIT { + + private static final String ORG = "so-testorg"; + + private StubPlatformServer platform; + + private Path getCredentialsPath() { + return streamxHome.resolve("contexts/default/config/credentials.json"); + } + + @BeforeEach + void setUp() throws IOException { + platform = new StubPlatformServer(); + + Properties properties = new Properties(); + properties.setProperty(PlatformConfig.STREAMX_PLATFORM_URL, platform.getUrl()); + Path configFile = getConfigPath(); + Files.createDirectories(configFile.getParent()); + try (OutputStream out = Files.newOutputStream(configFile)) { + properties.store(out, null); + } + + Path credentials = getCredentialsPath(); + Files.createDirectories(credentials.getParent()); + Files.writeString(credentials, """ + {"access_token":"test-access-token","refresh_token":"test-refresh-token", + "expires_at":%d,"issuer_url":"http://127.0.0.1:1/realms/streamx", + "client_id":"streamx-cli"} + """.formatted(Instant.now().plusSeconds(300).getEpochSecond())); + } + + @AfterEach + void tearDown() throws IOException { + if (platform != null) { + platform.close(); + } + Files.deleteIfExists(getCredentialsPath()); + } + + @Test + void shouldListMembersMarkingTheCaller() throws Exception { + ProcessResult result = exec("org", "members", "list", "--org", ORG); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("GET /api/v1/organizations/" + ORG + "/users"); + assertThat(result.stdout()).contains("user1@streamx.com", "owner", "ACTIVE", "(you)"); + assertThat(result.stdout()).contains("pending@streamx.com", "view", "PENDING"); + } + + @Test + void completeOrgMemberIdsListsOnlyActiveMembers() throws Exception { + ProcessResult result = exec("__complete-org-member-ids", ORG); + + result.assertSuccess(); + assertThat(result.stdout().strip().lines()) + .containsExactly("active@streamx.com", "user1@streamx.com"); + } + + @Test + void shouldListOnlyMemberIdsWhenQuiet() throws Exception { + ProcessResult result = exec("org", "members", "list", "--org", ORG, "-q"); + + result.assertSuccess(); + assertThat(result.stdout()) + .isEqualTo("active@streamx.com\npending@streamx.com\nuser1@streamx.com\n"); + } + + @Test + void shouldAddMemberByEmailWithRole() throws Exception { + ProcessResult result = + exec("org", "members", "add", "existing@streamx.com", "--org", ORG, "--role", "edit"); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("POST /api/v1/organizations/" + ORG + "/users"); + assertThat(platform.getRequestBodies().get(0)) + .contains("\"name\":\"existing@streamx.com\"", "\"role\":\"edit\""); + assertThat(result.stdout()).contains(msg.orgMemberAdded("existing@streamx.com", "edit")); + } + + @Test + void shouldRemoveActiveMember() throws Exception { + ProcessResult result = exec("org", "members", "remove", "active@streamx.com", "--org", ORG); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly( + "GET /api/v1/organizations/" + ORG + "/users", + "DELETE /api/v1/organizations/" + ORG + "/users/active@streamx.com"); + assertThat(result.stdout()).contains(msg.orgMemberRemoved("active@streamx.com")); + } + + /** The server rejects removing a principal that is not an active member; say so up front. */ + @Test + void shouldRefuseToRemovePendingInvitation() throws Exception { + ProcessResult result = exec("org", "members", "remove", "pending@streamx.com", "--org", ORG); + + result.assertExitCode(1); + assertThat(result.stderr()).contains("invitations cancel"); + assertThat(platform.getRequests()) + .containsExactly("GET /api/v1/organizations/" + ORG + "/users"); + } + + @Test + void shouldChangeRoleOfActiveMember() throws Exception { + ProcessResult result = + exec("org", "members", "set-role", "active@streamx.com", "--org", ORG, "--role", "owner"); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly( + "GET /api/v1/organizations/" + ORG + "/users", + "PUT /api/v1/organizations/" + ORG + "/users/active@streamx.com"); + assertThat(platform.getRequestBodies().get(1)).contains("\"newRoleId\":\"owner\""); + assertThat(result.stdout()).contains(msg.orgMemberRoleChanged("active@streamx.com", "owner")); + } + + /** Server-side the role change is remove-then-add, which would activate a pending invitation. */ + @Test + void shouldRefuseToChangeRoleOfPendingInvitation() throws Exception { + ProcessResult result = + exec("org", "members", "set-role", "pending@streamx.com", "--org", ORG, "--role", "owner"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains("without the invitation being accepted"); + assertThat(platform.getRequests()) + .containsExactly("GET /api/v1/organizations/" + ORG + "/users"); + } + + @Test + void shouldRejectUnknownMember() throws Exception { + ProcessResult result = exec("org", "members", "remove", "nobody@streamx.com", "--org", ORG); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.orgMemberNotFound("nobody@streamx.com", ORG)); + } + + @Test + void shouldRequireRoleWhenAddingMember() throws Exception { + ProcessResult result = exec("org", "members", "add", "someone", "--org", ORG); + + result.assertExitCode(2); + assertThat(platform.getRequests()).isEmpty(); + } +}