From b812b1bbd85680c61bb1adfd97ea3b77ab07c623 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Wed, 29 Jul 2026 14:44:46 +0300 Subject: [PATCH] STX-211 Org CRUD commands --- .../streamx/cli/commands/StreamxCommand.java | 8 +- .../completion/CompleteClusterIdsCommand.java | 46 +++ .../completion/CompleteOrgIdsCommand.java | 35 ++ .../completion/ZshCompletionGenerator.java | 9 + .../cli/commands/context/ContextCommand.java | 2 + .../cli/commands/context/org/OrgCommand.java | 19 ++ .../context/org/current/CurrentCommand.java | 28 ++ .../context/org/unset/UnsetCommand.java | 35 ++ .../commands/context/org/use/UseCommand.java | 39 +++ .../streamx/cli/commands/org/OrgCommand.java | 23 ++ .../org/clusters/ClustersCommand.java | 15 + .../org/clusters/list/ListCommand.java | 72 ++++ .../commands/org/create/CreateCommand.java | 27 ++ .../commands/org/delete/DeleteCommand.java | 45 +++ .../cli/commands/org/get/GetCommand.java | 53 +++ .../cli/commands/org/list/ListCommand.java | 62 ++++ .../com/streamx/cli/platform/Cluster.java | 27 ++ .../ClusterIdCompletionCandidates.java | 11 + .../platform/OrgIdCompletionCandidates.java | 16 + .../cli/platform/OrganizationClustersApi.java | 50 +++ .../cli/platform/OrganizationsApi.java | 36 ++ .../commands/context/ContextCommandIT.java | 1 + .../commands/org/InsecureTlsCommandIT.java | 107 ++++++ .../commands/org/OrgClustersCommandIT.java | 78 +++++ .../cli/commands/org/OrgCommandIT.java | 320 ++++++++++++++++++ .../cli/commands/org/StubPlatformServer.java | 255 ++++++++++++++ src/test/resources/tls/selfsigned.p12 | Bin 0 -> 2608 bytes 27 files changed, 1418 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/streamx/cli/commands/completion/CompleteClusterIdsCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/completion/CompleteOrgIdsCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/context/org/OrgCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/context/org/current/CurrentCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/context/org/unset/UnsetCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/context/org/use/UseCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/OrgCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/clusters/ClustersCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/clusters/list/ListCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/create/CreateCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/delete/DeleteCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/get/GetCommand.java create mode 100644 src/main/java/com/streamx/cli/commands/org/list/ListCommand.java create mode 100644 src/main/java/com/streamx/cli/platform/Cluster.java create mode 100644 src/main/java/com/streamx/cli/platform/ClusterIdCompletionCandidates.java create mode 100644 src/main/java/com/streamx/cli/platform/OrgIdCompletionCandidates.java create mode 100644 src/main/java/com/streamx/cli/platform/OrganizationClustersApi.java create mode 100644 src/main/java/com/streamx/cli/platform/OrganizationsApi.java create mode 100644 src/test/java/com/streamx/cli/commands/org/InsecureTlsCommandIT.java create mode 100644 src/test/java/com/streamx/cli/commands/org/OrgClustersCommandIT.java create mode 100644 src/test/java/com/streamx/cli/commands/org/OrgCommandIT.java create mode 100644 src/test/java/com/streamx/cli/commands/org/StubPlatformServer.java create mode 100644 src/test/resources/tls/selfsigned.p12 diff --git a/src/main/java/com/streamx/cli/commands/StreamxCommand.java b/src/main/java/com/streamx/cli/commands/StreamxCommand.java index 49c89dbf..64e4afff 100644 --- a/src/main/java/com/streamx/cli/commands/StreamxCommand.java +++ b/src/main/java/com/streamx/cli/commands/StreamxCommand.java @@ -1,8 +1,10 @@ package com.streamx.cli.commands; 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.CompleteNonDefaultTemplateIdsCommand; +import com.streamx.cli.commands.completion.CompleteOrgIdsCommand; import com.streamx.cli.commands.completion.CompleteRegisteredTemplateIdsCommand; import com.streamx.cli.commands.completion.CompleteSettingsKeysCommand; import com.streamx.cli.commands.completion.CompleteSettingsSetKeysCommand; @@ -11,6 +13,7 @@ import com.streamx.cli.commands.context.ContextCommand; import com.streamx.cli.commands.info.InfoCommand; import com.streamx.cli.commands.local.LocalCommand; +import com.streamx.cli.commands.org.OrgCommand; import com.streamx.cli.commands.publish.PublishCommand; import com.streamx.cli.commands.settings.SettingsCommand; import com.streamx.cli.framework.AbstractCommandGroup; @@ -22,6 +25,7 @@ subcommands = { AuthCommand.class, ContextCommand.class, + OrgCommand.class, LocalCommand.class, SettingsCommand.class, PublishCommand.class, @@ -32,7 +36,9 @@ CompleteNonDefaultTemplateIdsCommand.class, CompleteSettingsKeysCommand.class, CompleteSettingsSetKeysCommand.class, - CompleteContextNamesCommand.class + CompleteContextNamesCommand.class, + CompleteOrgIdsCommand.class, + CompleteClusterIdsCommand.class } ) public class StreamxCommand extends AbstractCommandGroup { diff --git a/src/main/java/com/streamx/cli/commands/completion/CompleteClusterIdsCommand.java b/src/main/java/com/streamx/cli/commands/completion/CompleteClusterIdsCommand.java new file mode 100644 index 00000000..a1bc84f8 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/completion/CompleteClusterIdsCommand.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.Cluster; +import com.streamx.cli.platform.OrganizationClustersApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import java.util.List; +import java.util.Objects; +import picocli.CommandLine; + +@CommandLine.Command( + name = "__complete-cluster-ids", + hidden = true, + header = "Internal: list cluster IDs available to an organization for shell completion" +) +public class CompleteClusterIdsCommand 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 OrganizationClustersApi(client).list(org).stream() + .map(Cluster::id) + .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/CompleteOrgIdsCommand.java b/src/main/java/com/streamx/cli/commands/completion/CompleteOrgIdsCommand.java new file mode 100644 index 00000000..ba72b755 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/completion/CompleteOrgIdsCommand.java @@ -0,0 +1,35 @@ +package com.streamx.cli.commands.completion; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.OrganizationsApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.generated.model.Organization; +import java.util.List; +import java.util.Objects; +import picocli.CommandLine; + +@CommandLine.Command( + name = "__complete-org-ids", + hidden = true, + header = "Internal: list organization IDs for shell completion, one per line" +) +public class CompleteOrgIdsCommand extends AbstractCommand> { + + @Override + public CommandResult> runCommand() { + try (PlatformClients client = PlatformClients.completion()) { + return new CommandResult<>(new OrganizationsApi(client).list().stream() + .map(Organization::getId) + .filter(Objects::nonNull) + .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 3e1624df..2241d978 100644 --- a/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java +++ b/src/main/java/com/streamx/cli/commands/completion/ZshCompletionGenerator.java @@ -6,6 +6,8 @@ import com.streamx.cli.commands.settings.eventtemplates.RegisteredTemplateIdCompletionCandidates; 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.OrgIdCompletionCandidates; import java.io.File; import java.nio.file.Path; import java.util.ArrayList; @@ -18,6 +20,7 @@ public final class ZshCompletionGenerator { + private static final String ORG_FROM_WORDS = "\"${words[${words[(i)--org]}+1]}\""; private ZshCompletionGenerator() { } @@ -215,6 +218,12 @@ private static String getCompletionAction( if (completionCandidates instanceof ContextNameCompletionCandidates) { return "($(streamx __complete-context-names 2>/dev/null))"; } + if (completionCandidates instanceof OrgIdCompletionCandidates) { + return "($(streamx __complete-org-ids 2>/dev/null))"; + } + if (completionCandidates instanceof ClusterIdCompletionCandidates) { + return "($(streamx __complete-cluster-ids " + ORG_FROM_WORDS + " 2>/dev/null))"; + } // Any remaining candidates are a fixed list (e.g. roles); the dynamic ones are handled above. if (completionCandidates != null) { String values = renderCandidates(completionCandidates); diff --git a/src/main/java/com/streamx/cli/commands/context/ContextCommand.java b/src/main/java/com/streamx/cli/commands/context/ContextCommand.java index ed919ee9..05240a02 100644 --- a/src/main/java/com/streamx/cli/commands/context/ContextCommand.java +++ b/src/main/java/com/streamx/cli/commands/context/ContextCommand.java @@ -4,6 +4,7 @@ import com.streamx.cli.commands.context.current.CurrentCommand; import com.streamx.cli.commands.context.delete.DeleteCommand; import com.streamx.cli.commands.context.list.ListCommand; +import com.streamx.cli.commands.context.org.OrgCommand; import com.streamx.cli.commands.context.use.UseCommand; import com.streamx.cli.framework.AbstractCommandGroup; import picocli.CommandLine; @@ -17,6 +18,7 @@ CreateCommand.class, UseCommand.class, CurrentCommand.class, + OrgCommand.class, DeleteCommand.class } ) diff --git a/src/main/java/com/streamx/cli/commands/context/org/OrgCommand.java b/src/main/java/com/streamx/cli/commands/context/org/OrgCommand.java new file mode 100644 index 00000000..78ff24ae --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/org/OrgCommand.java @@ -0,0 +1,19 @@ +package com.streamx.cli.commands.context.org; + +import com.streamx.cli.commands.context.org.current.CurrentCommand; +import com.streamx.cli.commands.context.org.unset.UnsetCommand; +import com.streamx.cli.commands.context.org.use.UseCommand; +import com.streamx.cli.framework.AbstractCommandGroup; +import picocli.CommandLine; + +@CommandLine.Command( + name = "org", + header = "Manage the current organization of the active context", + subcommands = { + UseCommand.class, + CurrentCommand.class, + UnsetCommand.class + } +) +public class OrgCommand extends AbstractCommandGroup { +} diff --git a/src/main/java/com/streamx/cli/commands/context/org/current/CurrentCommand.java b/src/main/java/com/streamx/cli/commands/context/org/current/CurrentCommand.java new file mode 100644 index 00000000..4f4db583 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/org/current/CurrentCommand.java @@ -0,0 +1,28 @@ +package com.streamx.cli.commands.context.org.current; + +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.PlatformContext; +import picocli.CommandLine; + +@CommandLine.Command( + name = "current", + header = "Print the current organization", + description = "The effective value: STREAMX_ORG if set, otherwise the active context's " + + "current-org." +) +public class CurrentCommand extends AbstractSilentCommand { + + @Override + public CommandResult runCommand() { + String org = PlatformContext.effectiveOrg(); + if (org == null) { + throw new CliException(msg.noCurrentOrg()); + } + System.out.println(org); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/context/org/unset/UnsetCommand.java b/src/main/java/com/streamx/cli/commands/context/org/unset/UnsetCommand.java new file mode 100644 index 00000000..91d74900 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/org/unset/UnsetCommand.java @@ -0,0 +1,35 @@ +package com.streamx.cli.commands.context.org.unset; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.config.StreamxHome; +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import java.io.IOException; +import picocli.CommandLine; + +@CommandLine.Command( + name = "unset", + header = "Clear the current organization of the active context", + description = "Also clears the current project (it cannot exist without an organization). " + + "Idempotent. A STREAMX_ORG environment variable is not affected." +) +public class UnsetCommand extends AbstractSilentCommand { + + @Override + public CommandResult runCommand() { + try { + final boolean hadProject = StreamxHome.readCurrentProject() != null; + StreamxHome.clearCurrentOrg(); + StreamxHome.clearCurrentProject(); + System.out.println(msg.orgUnset()); + if (hadProject) { + System.out.println(msg.projectUnset()); + } + } catch (IOException e) { + throw new CliException(e.getMessage(), e); + } + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/context/org/use/UseCommand.java b/src/main/java/com/streamx/cli/commands/context/org/use/UseCommand.java new file mode 100644 index 00000000..f919d9b8 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/context/org/use/UseCommand.java @@ -0,0 +1,39 @@ +package com.streamx.cli.commands.context.org.use; + +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.PlatformContext; +import picocli.CommandLine; + +@CommandLine.Command( + name = "use", + header = "Set the current organization for the active context", + description = "Commands taking an organization fall back to it when the argument is " + + "omitted. Stored per context; STREAMX_ORG overrides it for a single invocation." +) +public class UseCommand extends AbstractSilentCommand { + + @CommandLine.Parameters( + index = "0", + description = "Organization ID", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @Override + public CommandResult runCommand() { + if (orgId.isBlank()) { + throw new CliException(msg.noOrgContext()); + } + String clearedProject = PlatformContext.setCurrentOrg(orgId.strip()); + if (clearedProject != null) { + System.err.println(msg.orgUseClearedProject(clearedProject)); + } + System.out.println(msg.orgUseSet(orgId.strip())); + return new CommandResult<>(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 new file mode 100644 index 00000000..94ed1b31 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/OrgCommand.java @@ -0,0 +1,23 @@ +package com.streamx.cli.commands.org; + +import com.streamx.cli.commands.org.clusters.ClustersCommand; +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.list.ListCommand; +import com.streamx.cli.framework.AbstractCommandGroup; +import picocli.CommandLine; + +@CommandLine.Command( + name = "org", + header = "Manage StreamX organizations", + subcommands = { + ClustersCommand.class, + CreateCommand.class, + DeleteCommand.class, + GetCommand.class, + ListCommand.class + } +) +public class OrgCommand extends AbstractCommandGroup { +} diff --git a/src/main/java/com/streamx/cli/commands/org/clusters/ClustersCommand.java b/src/main/java/com/streamx/cli/commands/org/clusters/ClustersCommand.java new file mode 100644 index 00000000..4b8ea979 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/clusters/ClustersCommand.java @@ -0,0 +1,15 @@ +package com.streamx.cli.commands.org.clusters; + +import com.streamx.cli.commands.org.clusters.list.ListCommand; +import com.streamx.cli.framework.AbstractCommandGroup; +import picocli.CommandLine; + +@CommandLine.Command( + name = "clusters", + header = "Inspect organization clusters", + subcommands = { + ListCommand.class + } +) +public class ClustersCommand extends AbstractCommandGroup { +} diff --git a/src/main/java/com/streamx/cli/commands/org/clusters/list/ListCommand.java b/src/main/java/com/streamx/cli/commands/org/clusters/list/ListCommand.java new file mode 100644 index 00000000..02522069 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/clusters/list/ListCommand.java @@ -0,0 +1,72 @@ +package com.streamx.cli.commands.org.clusters.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.Cluster; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationClustersApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +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 clusters available to an organization" +) +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 cluster IDs, one per line (for piping to xargs)" + ) + public boolean quiet; + + @Override + public String getTextOutput(CommandResult> result) { + List clusters = result.getData(); + + if (quiet) { + return clusters.stream() + .map(Cluster::id) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + } + + if (clusters.isEmpty()) { + return msg.orgClustersListEmpty(); + } + + return TextTable.render( + List.of("ID", "TYPE", "NAME", "ENABLED"), + clusters.stream() + .map(cluster -> Arrays.asList( + cluster.id(), + cluster.type(), + cluster.name(), + String.valueOf(cluster.enabled()))) + .toList()); + } + + @Override + public CommandResult> runCommand() { + orgId = PlatformContext.requireOrg(orgId); + try (PlatformClients client = PlatformClients.fromConfig()) { + return new CommandResult<>(new OrganizationClustersApi(client).list(orgId)); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/org/create/CreateCommand.java b/src/main/java/com/streamx/cli/commands/org/create/CreateCommand.java new file mode 100644 index 00000000..c9008530 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/create/CreateCommand.java @@ -0,0 +1,27 @@ +package com.streamx.cli.commands.org.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.OrganizationsApi; +import com.streamx.cli.platform.PlatformClients; +import picocli.CommandLine; + +@CommandLine.Command( + name = "create", + header = "Create an organization" +) +public class CreateCommand extends AbstractSilentCommand { + @CommandLine.Parameters(index = "0", description = "Organization name") + public String name; + + @Override + public CommandResult runCommand() { + try (PlatformClients client = PlatformClients.fromConfig()) { + new OrganizationsApi(client).create(name); + } + System.out.println(msg.orgCreated(name)); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/org/delete/DeleteCommand.java b/src/main/java/com/streamx/cli/commands/org/delete/DeleteCommand.java new file mode 100644 index 00000000..bd856215 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/delete/DeleteCommand.java @@ -0,0 +1,45 @@ +package com.streamx.cli.commands.org.delete; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.framework.DeleteConfirmation; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationsApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import picocli.CommandLine; + +@CommandLine.Command( + name = "delete", + header = "Delete an organization", + description = "Asks to type the organization ID back as confirmation; " + + "--force deletes without asking." +) +public class DeleteCommand extends AbstractSilentCommand { + @CommandLine.Parameters( + index = "0", + arity = "0..1", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @CommandLine.Option( + names = {"-f", "--force"}, + description = "Skip the confirmation prompt (required in non-interactive environments)" + ) + public boolean force; + + @Override + public CommandResult runCommand() { + orgId = PlatformContext.requireOrg(orgId); + DeleteConfirmation.require(force, orgId); + try (PlatformClients client = PlatformClients.fromConfig()) { + new OrganizationsApi(client).delete(orgId); + } + System.out.println(msg.orgDeleted(orgId)); + return new CommandResult<>(null); + } +} diff --git a/src/main/java/com/streamx/cli/commands/org/get/GetCommand.java b/src/main/java/com/streamx/cli/commands/org/get/GetCommand.java new file mode 100644 index 00000000..b3b4a034 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/get/GetCommand.java @@ -0,0 +1,53 @@ +package com.streamx.cli.commands.org.get; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.platform.OrgIdCompletionCandidates; +import com.streamx.cli.platform.OrganizationsApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.PlatformContext; +import com.streamx.cli.platform.generated.model.Organization; +import picocli.CommandLine; + +@CommandLine.Command( + name = "get", + header = "Display an organization" +) +public class GetCommand extends AbstractCommand { + @CommandLine.Parameters( + index = "0", + arity = "0..1", + description = "Organization ID (defaults to the current organization)", + completionCandidates = OrgIdCompletionCandidates.class + ) + public String orgId; + + @Override + public String getTextOutput(CommandResult result) { + Organization organization = result.getData(); + return """ + id = %s + name = %s + role = %s + projectsNumber = %s + state = %s""" + .formatted( + orDash(organization.getId()), + orDash(organization.getName()), + orDash(organization.getRole() == null ? null : organization.getRole().getName()), + orDash(organization.getProjectsNumber()), + orDash(organization.getState())); + } + + private static String orDash(String value) { + return value == null ? "-" : value; + } + + @Override + public CommandResult runCommand() { + orgId = PlatformContext.requireOrg(orgId); + try (PlatformClients client = PlatformClients.fromConfig()) { + return new CommandResult<>(new OrganizationsApi(client).get(orgId)); + } + } +} diff --git a/src/main/java/com/streamx/cli/commands/org/list/ListCommand.java b/src/main/java/com/streamx/cli/commands/org/list/ListCommand.java new file mode 100644 index 00000000..1a7e2463 --- /dev/null +++ b/src/main/java/com/streamx/cli/commands/org/list/ListCommand.java @@ -0,0 +1,62 @@ +package com.streamx.cli.commands.org.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.OrganizationsApi; +import com.streamx.cli.platform.PlatformClients; +import com.streamx.cli.platform.generated.model.Organization; +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 organizations you have access to" +) +public class ListCommand extends AbstractCommand> { + + @CommandLine.Option( + names = {"-q", "--quiet"}, + description = "Only display organization IDs, one per line (for piping to xargs)" + ) + public boolean quiet; + + @Override + public String getTextOutput(CommandResult> result) { + List organizations = result.getData(); + + if (quiet) { + return organizations.stream() + .map(Organization::getId) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + } + + if (organizations.isEmpty()) { + return msg.orgListEmpty(); + } + + return TextTable.render( + List.of("ID", "NAME", "ROLE", "PROJECTS", "STATE"), + organizations.stream() + .map(organization -> Arrays.asList( + organization.getId(), + organization.getName(), + organization.getRole() == null ? null : organization.getRole().getName(), + organization.getProjectsNumber(), + organization.getState())) + .toList()); + } + + @Override + public CommandResult> runCommand() { + try (PlatformClients client = PlatformClients.fromConfig()) { + return new CommandResult<>(new OrganizationsApi(client).list()); + } + } +} diff --git a/src/main/java/com/streamx/cli/platform/Cluster.java b/src/main/java/com/streamx/cli/platform/Cluster.java new file mode 100644 index 00000000..14c0d88a --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/Cluster.java @@ -0,0 +1,27 @@ +package com.streamx.cli.platform; + +import com.streamx.cli.platform.generated.model.ClusterLocation; +import com.streamx.cli.platform.generated.model.ClustersProcessingInner; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record Cluster( + String id, + String type, + String name, + boolean enabled, + Double latitude, + Double longitude +) { + + public static Cluster from(ClustersProcessingInner node, String type) { + ClusterLocation location = node.getLocation(); + return new Cluster( + node.getId(), + type, + node.getName(), + Boolean.TRUE.equals(node.getEnabled()), + location == null ? null : location.getLatitude(), + location == null ? null : location.getLongitude()); + } +} diff --git a/src/main/java/com/streamx/cli/platform/ClusterIdCompletionCandidates.java b/src/main/java/com/streamx/cli/platform/ClusterIdCompletionCandidates.java new file mode 100644 index 00000000..635cdf79 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/ClusterIdCompletionCandidates.java @@ -0,0 +1,11 @@ +package com.streamx.cli.platform; + +import java.util.Collections; +import java.util.Iterator; + +public class ClusterIdCompletionCandidates implements Iterable { + @Override + public Iterator iterator() { + return Collections.emptyIterator(); + } +} diff --git a/src/main/java/com/streamx/cli/platform/OrgIdCompletionCandidates.java b/src/main/java/com/streamx/cli/platform/OrgIdCompletionCandidates.java new file mode 100644 index 00000000..e1759411 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/OrgIdCompletionCandidates.java @@ -0,0 +1,16 @@ +package com.streamx.cli.platform; + +import java.util.Collections; +import java.util.Iterator; + +/** + * Marker for dynamic organization-ID completion. The zsh completion script resolves the + * values at TAB time via the hidden {@code __complete-org-ids} command; iterating here on + * purpose yields nothing so that script generation never triggers a network call. + */ +public class OrgIdCompletionCandidates implements Iterable { + @Override + public Iterator iterator() { + return Collections.emptyIterator(); + } +} diff --git a/src/main/java/com/streamx/cli/platform/OrganizationClustersApi.java b/src/main/java/com/streamx/cli/platform/OrganizationClustersApi.java new file mode 100644 index 00000000..6e3bc3f7 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/OrganizationClustersApi.java @@ -0,0 +1,50 @@ +package com.streamx.cli.platform; + +import com.streamx.cli.platform.generated.api.ClusterApi; +import com.streamx.cli.platform.generated.model.Clusters; +import com.streamx.cli.platform.generated.model.ClustersProcessingInner; +import java.util.ArrayList; +import java.util.List; + +public class OrganizationClustersApi { + + private final PlatformClients clients; + private final ClusterApi api; + + public OrganizationClustersApi(PlatformClients clients) { + this.clients = clients; + this.api = clients.api(ClusterApi.class); + } + + public List list(String orgId) { + return flatten( + clients.call(() -> api.listOrganizationClusters(orgId, null, null), Clusters.class)); + } + + public List listForProject(String orgId, String projectId) { + return flatten( + clients.call(() -> api.listProjectClusters(orgId, projectId, null, null), Clusters.class)); + } + + public void setForProject(String orgId, String projectId, List clusterIds) { + clients.call(() -> api.updateProjectClusters(orgId, projectId, clusterIds, null, null)); + } + + private static List flatten(Clusters clusters) { + List result = new ArrayList<>(); + if (clusters == null) { + return result; + } + for (ClustersProcessingInner node : orEmpty(clusters.getProcessing())) { + result.add(Cluster.from(node, "processing")); + } + for (ClustersProcessingInner node : orEmpty(clusters.getEdge())) { + result.add(Cluster.from(node, "edge")); + } + return result; + } + + private static List orEmpty(List list) { + return list == null ? List.of() : list; + } +} diff --git a/src/main/java/com/streamx/cli/platform/OrganizationsApi.java b/src/main/java/com/streamx/cli/platform/OrganizationsApi.java new file mode 100644 index 00000000..17dd5805 --- /dev/null +++ b/src/main/java/com/streamx/cli/platform/OrganizationsApi.java @@ -0,0 +1,36 @@ +package com.streamx.cli.platform; + +import com.streamx.cli.platform.generated.api.OrganizationsResourceApi; +import com.streamx.cli.platform.generated.model.Name; +import com.streamx.cli.platform.generated.model.Organization; +import java.util.Comparator; +import java.util.List; + +public class OrganizationsApi { + + private final PlatformClients clients; + private final OrganizationsResourceApi api; + + public OrganizationsApi(PlatformClients clients) { + this.clients = clients; + this.api = clients.api(OrganizationsResourceApi.class); + } + + public List list() { + return clients.callList(() -> api.listOrganizations(null, null), Organization.class).stream() + .sorted(Comparator.comparing(Organization::getId, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + public Organization get(String orgId) { + return clients.call(() -> api.getOrganization(orgId, null, null), Organization.class); + } + + public void create(String name) { + clients.call(() -> api.createOrganization(new Name().name(name), null, null)); + } + + public void delete(String orgId) { + clients.call(() -> api.deleteOrganization(orgId, null, null)); + } +} diff --git a/src/test/java/com/streamx/cli/commands/context/ContextCommandIT.java b/src/test/java/com/streamx/cli/commands/context/ContextCommandIT.java index 8e02c944..33f33dba 100644 --- a/src/test/java/com/streamx/cli/commands/context/ContextCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/context/ContextCommandIT.java @@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.streamx.cli.commands.auth.StubOidcServer; +import com.streamx.cli.commands.org.StubPlatformServer; import com.streamx.cli.test.CliBaseIT; import io.quarkus.test.junit.QuarkusTest; import java.io.IOException; diff --git a/src/test/java/com/streamx/cli/commands/org/InsecureTlsCommandIT.java b/src/test/java/com/streamx/cli/commands/org/InsecureTlsCommandIT.java new file mode 100644 index 00000000..a9527215 --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/org/InsecureTlsCommandIT.java @@ -0,0 +1,107 @@ +package com.streamx.cli.commands.org; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.streamx.cli.platform.PlatformConfig; +import com.streamx.cli.test.CliBaseIT; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; +import io.quarkus.test.junit.QuarkusTest; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.time.Instant; +import java.util.Properties; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class InsecureTlsCommandIT extends CliBaseIT { + + private static final String ORGS = """ + [{"id":"acme","name":"Acme","role":{"name":"owner","displayName":"Owner"}, + "projectsNumber":"1","state":"ACTIVE"}]"""; + + private HttpsServer server; + private String baseUrl; + + @BeforeEach + void setUp() throws Exception { + server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.setHttpsConfigurator(new HttpsConfigurator(selfSignedContext())); + server.createContext("/api/v1/organizations", exchange -> { + byte[] body = ORGS.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + server.start(); + baseUrl = "https://127.0.0.1:" + server.getAddress().getPort(); + + Path credentials = streamxHome.resolve("contexts/default/config/credentials.json"); + 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 (server != null) { + server.stop(0); + } + Files.deleteIfExists(streamxHome.resolve("contexts/default/config/credentials.json")); + } + + private void writeConfig(boolean insecure) throws IOException { + Properties properties = new Properties(); + properties.setProperty(PlatformConfig.STREAMX_PLATFORM_URL, baseUrl); + properties.setProperty(PlatformConfig.STREAMX_PLATFORM_INSECURE, String.valueOf(insecure)); + try (OutputStream out = Files.newOutputStream(getConfigPath())) { + properties.store(out, null); + } + } + + private static SSLContext selfSignedContext() throws Exception { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (var in = InsecureTlsCommandIT.class.getResourceAsStream("/tls/selfsigned.p12")) { + keyStore.load(in, "changeit".toCharArray()); + } + KeyManagerFactory keyManagers = KeyManagerFactory.getInstance("SunX509"); + keyManagers.init(keyStore, "changeit".toCharArray()); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(keyManagers.getKeyManagers(), null, null); + return context; + } + + @Test + void trustsSelfSignedCertificateWhenInsecure() throws Exception { + writeConfig(true); + + ProcessResult result = exec("org", "list", "-q"); + + result.assertSuccess(); + assertThat(result.stdout().strip()).isEqualTo("acme"); + } + + @Test + void rejectsSelfSignedCertificateWhenSecure() throws Exception { + writeConfig(false); + + ProcessResult result = exec("org", "list", "-q"); + + result.assertExitCode(1); + assertThat(result.stderr()).containsIgnoringCase("SSL"); + } +} diff --git a/src/test/java/com/streamx/cli/commands/org/OrgClustersCommandIT.java b/src/test/java/com/streamx/cli/commands/org/OrgClustersCommandIT.java new file mode 100644 index 00000000..cd3d66d6 --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/org/OrgClustersCommandIT.java @@ -0,0 +1,78 @@ +package com.streamx.cli.commands.org; + +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 OrgClustersCommandIT 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()); + } + + /** processing and edge arrive as separate arrays; the CLI flattens them with a TYPE column. */ + @Test + void shouldListProcessingAndEdgeClustersTogether() throws Exception { + ProcessResult result = exec("org", "clusters", "list", "--org", ORG); + + result.assertSuccess(); + assertThat(platform.getRequests()) + .containsExactly("GET /api/v1/organizations/" + ORG + "/clusters"); + assertThat(result.stdout()).contains("ID", "TYPE", "NAME", "ENABLED"); + assertThat(result.stdout()).contains("processing-eu-central", "processing", "EU Central"); + assertThat(result.stdout()).contains("edge-us-east", "edge", "US East"); + } + + @Test + void shouldListOnlyClusterIdsWhenQuiet() throws Exception { + ProcessResult result = exec("org", "clusters", "list", "--org", ORG, "-q"); + + result.assertSuccess(); + assertThat(result.stdout()).isEqualTo("processing-eu-central\nedge-us-east\n"); + } +} diff --git a/src/test/java/com/streamx/cli/commands/org/OrgCommandIT.java b/src/test/java/com/streamx/cli/commands/org/OrgCommandIT.java new file mode 100644 index 00000000..046d6473 --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/org/OrgCommandIT.java @@ -0,0 +1,320 @@ +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.commands.auth.StubOidcServer; +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 OrgCommandIT extends CliBaseIT { + private StubPlatformServer platform; + + private Path getCredentialsPath() { + return streamxHome.resolve("contexts/default/config/credentials.json"); + } + + private void writeCredentials(Instant expiresAt) throws IOException { + Path path = getCredentialsPath(); + Files.createDirectories(path.getParent()); + Files.writeString(path, """ + {"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(expiresAt.getEpochSecond())); + } + + @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); + } + + writeCredentials(Instant.now().plusSeconds(300)); + } + + @AfterEach + void tearDown() throws IOException { + if (platform != null) { + platform.close(); + } + Files.deleteIfExists(getCredentialsPath()); + Files.deleteIfExists(streamxHome.resolve("contexts/default/current-org")); + Files.deleteIfExists(streamxHome.resolve("contexts/default/current-project")); + } + + @Test + void shouldListOrganizations() throws Exception { + ProcessResult result = exec("org", "list"); + + result.assertSuccess(); + assertThat(result.stdout()).contains("acme", "Acme", "owner", "globex", "Globex", "view"); + assertThat(result.stdout()).contains("ID", "NAME", "ROLE", "PROJECTS", "STATE"); + } + + @Test + void shouldSendBearerTokenOnEveryRequest() throws Exception { + exec("org", "list").assertSuccess(); + + assertThat(platform.getAuthorizationHeaders()) + .containsExactly("Bearer test-access-token"); + } + + @Test + void completeOrgIdsListsIdsOnePerLine() throws Exception { + ProcessResult result = exec("__complete-org-ids"); + + result.assertSuccess(); + assertThat(result.stdout().strip().lines()).containsExactly("acme", "globex"); + } + + @Test + void completeOrgIdsIsSilentWhenNotLoggedIn() throws Exception { + Files.deleteIfExists(getCredentialsPath()); + + ProcessResult result = exec("__complete-org-ids"); + + result.assertSuccess(); + assertThat(result.stdout().strip()).isEmpty(); + } + + @Test + void shouldListOnlyOrganizationIdsWhenQuiet() throws Exception { + ProcessResult result = exec("org", "list", "--quiet"); + + result.assertSuccess(); + assertThat(result.stdout()).isEqualTo("acme\nglobex\n"); + } + + @Test + void shouldPrintNothingWhenQuietAndNoOrganizations() throws Exception { + platform.returnNoOrganizations(); + + ProcessResult result = exec("org", "list", "-q"); + + result.assertSuccess(); + assertThat(result.stdout()).isEmpty(); + } + + @Test + void shouldListOrganizationsAsJson() throws Exception { + ProcessResult result = exec("org", "list", "--output", "json"); + + result.assertSuccess(); + assertThat(result.stdout()).contains("\"id\" : \"acme\"", "\"name\" : \"owner\""); + } + + @Test + void shouldGetOrganization() throws Exception { + ProcessResult result = exec("org", "get", "acme"); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly("GET /api/v1/organizations/acme"); + assertThat(result.stdout()).contains("id = acme"); + } + + @Test + void shouldCreateOrganization() throws Exception { + ProcessResult result = exec("org", "create", "my-org"); + + result.assertSuccess(); + assertThat(platform.getCreatedNames()).containsExactly("my-org"); + assertThat(result.stdout()).contains(msg.orgCreated("my-org")); + } + + @Test + void shouldDeleteOrganizationAfterTypedConfirmation() throws Exception { + ProcessResult result = execWithStdin("acme\n", "org", "delete", "acme"); + + result.assertSuccess(); + assertThat(platform.getDeletedIds()).containsExactly("acme"); + assertThat(result.stdout()).contains(msg.orgDeleted("acme")); + } + + @Test + void shouldDeleteOrganizationWithForceWithoutPrompting() throws Exception { + ProcessResult result = exec("org", "delete", "-f", "acme"); + + result.assertSuccess(); + assertThat(platform.getDeletedIds()).containsExactly("acme"); + } + + @Test + void shouldCancelDeletionWhenConfirmationDoesNotMatch() throws Exception { + ProcessResult result = execWithStdin("wrong-id\n", "org", "delete", "acme"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.deleteConfirmMismatch("acme")); + assertThat(platform.getDeletedIds()).isEmpty(); + } + + @Test + void shouldRequireForceWhenNoInputIsAvailable() throws Exception { + ProcessResult result = exec("org", "delete", "acme"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains("--force"); + assertThat(platform.getDeletedIds()).isEmpty(); + } + + @Test + void shouldFailWhenNotLoggedIn() throws Exception { + Files.deleteIfExists(getCredentialsPath()); + + ProcessResult result = exec("org", "list"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.platformNotLoggedIn()); + } + + @Test + void refreshesTokenAndRetriesOnce401() throws Exception { + try (StubOidcServer oidc = new StubOidcServer("streamx", 0)) { + Files.writeString(getCredentialsPath(), """ + {"access_token":"stale-token","refresh_token":"%s", + "expires_at":%d,"issuer_url":"%s/realms/streamx","client_id":"streamx-cli"} + """.formatted(StubOidcServer.REFRESH_TOKEN, + Instant.now().plusSeconds(300).getEpochSecond(), oidc.getServerUrl())); + platform.failFirstRequestWith(401); + + ProcessResult result = exec("org", "list", "-q"); + + result.assertSuccess(); + assertThat(result.stdout().strip().lines()).containsExactly("acme", "globex"); + assertThat(platform.getRequests()).hasSize(2); + assertThat(platform.getAuthorizationHeaders()).containsExactly( + "Bearer stale-token", "Bearer " + StubOidcServer.ACCESS_TOKEN); + } + } + + @Test + void shouldFailWhenPlatformUrlNotConfigured() throws Exception { + try (OutputStream out = Files.newOutputStream(getConfigPath())) { + new Properties().store(out, null); + } + + ProcessResult result = exec("org", "list"); + + result.assertExitCode(1); + assertThat(result.stderr()) + .contains(msg.platformUrlNotConfigured(PlatformConfig.STREAMX_PLATFORM_URL)); + } + + @Test + void shouldEncodeOrganizationIdIntoASinglePathSegment() throws Exception { + exec("org", "delete", "-f", "so-x/users/alice@example.com"); + + assertThat(platform.getRawRequests()).containsExactly( + "DELETE /api/v1/organizations/so-x%2Fusers%2Falice@example.com"); + } + + @Test + void shouldReportMissingOrForbiddenOrganizationFor404() throws Exception { + platform.failWith(404, ""); + + ProcessResult result = exec("org", "get", "nope"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.platformNotFound()); + } + + @Test + void shouldSurfaceServerValidationViolations() throws Exception { + platform.failWith(400, """ + {"errorMessage":"Validation failed","errorCode":400, + "violations":[{"field":"name","message":"must not be blank"}]} + """); + + ProcessResult result = exec("org", "create", "bad name"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains("Validation failed"); + assertThat(result.stderr()).contains("name: must not be blank"); + } + + @Test + void shouldRefuseToUseAnExpiredSessionThatCannotBeRefreshed() throws Exception { + writeCredentials(Instant.now().minusSeconds(60)); + + ProcessResult result = exec("org", "list"); + + result.assertExitCode(1); + assertThat(result.stderr()).isNotEmpty(); + assertThat(platform.getRequests()).isEmpty(); + } + + @Test + void orgUseCurrentLifecycle() throws Exception { + ProcessResult use = exec("context", "org", "use", "acme"); + use.assertSuccess(); + assertThat(use.stdout()).contains(msg.orgUseSet("acme")); + assertThat(streamxHome.resolve("contexts/default/current-org")).content() + .isEqualToIgnoringNewLines("acme"); + + ProcessResult current = exec("context", "org", "current"); + current.assertSuccess(); + assertThat(current.stdout().strip()).isEqualTo("acme"); + } + + @Test + void orgCurrentFailsWhenUnset() throws Exception { + ProcessResult result = exec("context", "org", "current"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.noCurrentOrg()); + } + + @Test + void orgGetFallsBackToCurrentOrg() throws Exception { + exec("context", "org", "use", "acme").assertSuccess(); + + ProcessResult result = exec("org", "get"); + + result.assertSuccess(); + assertThat(platform.getRequests()).containsExactly("GET /api/v1/organizations/acme"); + } + + @Test + void orgGetFailsWithoutAnyOrgContext() throws Exception { + ProcessResult result = exec("org", "get"); + + result.assertExitCode(1); + assertThat(result.stderr()).contains(msg.noOrgContext()); + assertThat(platform.getRequests()).isEmpty(); + } + + @Test + void envVarOverridesCurrentOrgFile() throws Exception { + exec("context", "org", "use", "acme").assertSuccess(); + setEnv("STREAMX_ORG", "globex"); + try { + ProcessResult current = exec("context", "org", "current"); + current.assertSuccess(); + assertThat(current.stdout().strip()).isEqualTo("globex"); + + ProcessResult get = exec("org", "get"); + get.assertSuccess(); + assertThat(platform.getRequests()).containsExactly("GET /api/v1/organizations/globex"); + } finally { + clearEnv("STREAMX_ORG"); + } + } + +} diff --git a/src/test/java/com/streamx/cli/commands/org/StubPlatformServer.java b/src/test/java/com/streamx/cli/commands/org/StubPlatformServer.java new file mode 100644 index 00000000..a5c40f23 --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/org/StubPlatformServer.java @@ -0,0 +1,255 @@ +package com.streamx.cli.commands.org; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public class StubPlatformServer implements AutoCloseable { + private final HttpServer server; + private final List authorizationHeaders = new ArrayList<>(); + private final List requests = new ArrayList<>(); + private final List createdNames = new ArrayList<>(); + private final List deletedIds = new ArrayList<>(); + + private volatile int forcedStatus; + private volatile String forcedBody = ""; + private volatile boolean empty; + private volatile int failFirstStatus; + private final java.util.concurrent.atomic.AtomicBoolean firstFailConsumed = + new java.util.concurrent.atomic.AtomicBoolean(); + + private final List requestBodies = new ArrayList<>(); + + /** + * Raw, still-encoded request paths. {@code getRequestURI().getPath()} decodes, which makes an + * escaped segment indistinguishable from one that injected extra path segments. + */ + private final List rawRequests = new ArrayList<>(); + + public List getRawRequests() { + return rawRequests; + } + + public StubPlatformServer() throws IOException { + this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/v1/organizations", this::route); + server.start(); + } + + public List getRequestBodies() { + return requestBodies; + } + + /** Sub-resources are matched before the plain organization paths. */ + private void route(HttpExchange exchange) throws IOException { + String path = exchange.getRequestURI().getPath(); + if (path.contains("/users")) { + handleUsers(exchange); + } else if (path.contains("/invitations")) { + handleInvitations(exchange); + } else if (path.endsWith("/clusters")) { + handleClusters(exchange); + } else if (path.endsWith("/projects")) { + handleProjects(exchange); + } else { + handleOrganizations(exchange); + } + } + + private void handleUsers(HttpExchange exchange) throws IOException { + String method = exchange.getRequestMethod(); + String path = exchange.getRequestURI().getPath(); + record(exchange, method, path); + if (forcedStatus != 0) { + respond(exchange, forcedStatus, forcedBody); + return; + } + + if ("GET".equals(method)) { + respond(exchange, 200, """ + [ + {"id":"user1@streamx.com","displayName":"User First", + "role":{"name":"owner","displayName":"Owner"},"status":"ACTIVE","isCaller":true}, + {"id":"active@streamx.com","displayName":"Active Member", + "role":{"name":"edit","displayName":"Editor"},"status":"ACTIVE","isCaller":false}, + {"id":"pending@streamx.com","displayName":"Pending Invitee", + "role":{"name":"view","displayName":"Viewer"},"status":"PENDING","isCaller":false} + ] + """); + } else { + respond(exchange, 204, ""); + } + } + + private void handleInvitations(HttpExchange exchange) throws IOException { + String method = exchange.getRequestMethod(); + String path = exchange.getRequestURI().getPath(); + record(exchange, method, path); + if (forcedStatus != 0) { + respond(exchange, forcedStatus, forcedBody); + return; + } + + if ("GET".equals(method)) { + respond(exchange, 200, """ + [ + {"email":"invited@streamx.com","role":{"name":"edit","displayName":"Editor"}, + "status":"PENDING"} + ] + """); + } else { + respond(exchange, 204, ""); + } + } + + private void handleProjects(HttpExchange exchange) throws IOException { + record(exchange, exchange.getRequestMethod(), exchange.getRequestURI().getPath()); + if (forcedStatus != 0) { + respond(exchange, forcedStatus, forcedBody); + return; + } + respond(exchange, 200, """ + [ + {"id":"so-acme-shop-a1b2c","name":"shop","state":"Ready"}, + {"id":"so-acme-site-d3e4f","name":"site","state":"Ready"} + ] + """); + } + + private void handleClusters(HttpExchange exchange) throws IOException { + record(exchange, exchange.getRequestMethod(), exchange.getRequestURI().getPath()); + if (forcedStatus != 0) { + respond(exchange, forcedStatus, forcedBody); + return; + } + respond(exchange, 200, """ + { + "processing": [ + {"id":"processing-eu-central","enabled":true,"name":"EU Central", + "location":{"latitude":50.1,"longitude":8.6}} + ], + "edge": [ + {"id":"edge-us-east","enabled":false,"name":"US East", + "location":{"latitude":40.7,"longitude":-74.0}} + ] + } + """); + } + + private void record(HttpExchange exchange, String method, String path) throws IOException { + requests.add(method + " " + path); + rawRequests.add(method + " " + exchange.getRequestURI().getRawPath()); + authorizationHeaders.add( + String.valueOf(exchange.getRequestHeaders().getFirst("Authorization"))); + requestBodies.add(new String(readBody(exchange), StandardCharsets.UTF_8)); + } + + public String getUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + public List getAuthorizationHeaders() { + return authorizationHeaders; + } + + public List getRequests() { + return requests; + } + + public List getCreatedNames() { + return createdNames; + } + + public List getDeletedIds() { + return deletedIds; + } + + public void failWith(int status, String body) { + this.forcedStatus = status; + this.forcedBody = body; + } + + public void failFirstRequestWith(int status) { + this.failFirstStatus = status; + } + + public void returnNoOrganizations() { + this.empty = true; + } + + private void handleOrganizations(HttpExchange exchange) throws IOException { + String method = exchange.getRequestMethod(); + String path = exchange.getRequestURI().getPath(); + requests.add(method + " " + path); + rawRequests.add(method + " " + exchange.getRequestURI().getRawPath()); + authorizationHeaders.add( + String.valueOf(exchange.getRequestHeaders().getFirst("Authorization"))); + + if (failFirstStatus != 0 && firstFailConsumed.compareAndSet(false, true)) { + respond(exchange, failFirstStatus, ""); + return; + } + + if (forcedStatus != 0) { + respond(exchange, forcedStatus, forcedBody); + return; + } + + String id = path.substring("/api/v1/organizations".length()).replaceAll("^/", ""); + + if ("GET".equals(method) && id.isEmpty() && empty) { + respond(exchange, 200, "[]"); + } else if ("GET".equals(method) && id.isEmpty()) { + respond(exchange, 200, """ + [ + {"id":"acme","name":"Acme","projectsNumber":"3", + "role":{"name":"owner","displayName":"Owner"},"state":"ready"}, + {"id":"globex","name":"Globex","projectsNumber":"0", + "role":{"name":"view","displayName":"Viewer"},"state":"ready"} + ] + """); + } else if ("GET".equals(method)) { + respond(exchange, 200, """ + {"id":"%s","name":"Acme","projectsNumber":"3", + "role":{"name":"owner","displayName":"Owner"},"state":"ready"} + """.formatted(id)); + } else if ("POST".equals(method)) { + String body = new String(readBody(exchange), StandardCharsets.UTF_8); + createdNames.add(body.replaceAll(".*\"name\"\\s*:\\s*\"([^\"]+)\".*", "$1")); + respond(exchange, 204, ""); + } else if ("DELETE".equals(method)) { + deletedIds.add(id); + respond(exchange, 204, ""); + } else { + respond(exchange, 405, ""); + } + } + + private static byte[] readBody(HttpExchange exchange) throws IOException { + try (InputStream in = exchange.getRequestBody()) { + return in.readAllBytes(); + } + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + if (bytes.length == 0) { + exchange.sendResponseHeaders(status, -1); + } else { + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + } + exchange.close(); + } + + @Override + public void close() { + server.stop(0); + } +} diff --git a/src/test/resources/tls/selfsigned.p12 b/src/test/resources/tls/selfsigned.p12 new file mode 100644 index 0000000000000000000000000000000000000000..215f5b98af07d3f95c79940f19dc8575cc340eda GIT binary patch literal 2608 zcma)8XEYoN7M?DnP7uP2D5E?H-VB2w@d%>#n&`pkU5IWB88bvn2+_hKF-nMDf(TwD z-poXz1W!%$k|2z3?b$tfXMgSfxaT|f`|iEx+#mNl7e!!}0s+A&0(=Apze3a@GT4Aj zz#;z5cRXB<@NdifBhB+0!bi1Kp_+>?A*U6g5Xd9N&p6TBI*Ef zU}{dG`M9WQA|}QAn1Nwr2R(Z)9((m* z*U{dnzCL}?17jd*9?X7xaupc&;6-;^<|QuG(qvbFFAs7{=svo}fxo&~=W$%wpg2XL zC{U1lM(Luo-mL39Z=CF|#fsR?R|W-6B43VHjs5$@9o{eoVfD-B74%u ziYL?n_TnRWmIU>i4XJD>^8vob16}owcdX#BJL|sM=2Nbik0-*r1Tkni7R%?fLE`?* z2hM#3(Y)BKV>VUUmE!C)niU zY(!VGu(%^wBAQ5dZzxk(?x_zR5K9h9`CTErrU37DxN`6EN_*47OWqgux15Pjb>q3P zB;{BgIH4|p(z@}7zXu_Ta`e~tMtXVy3gULB(zjl8u*W0P=V6nTYkBy=XZPBy@NIK8 z3yn?c1#6IDN7=NcR_oQK^l zV`-9@#b{+oF^-tD041u zp}jqVkEWTH^q#pb{`I^v;}^d^#u8ub&hm&|QVFL5jYC^%pQVCBPOOtd)($Th)oJ!E ziK;e zxFy@KohGYj!~^R3-Xtb_dW0{Y+2fW<+b7rGv-ZZ5#Mn&q>&Q%%xhDH6Z21jGR)zA- z)}D*jN!ATxt+ow0T<^{mQyvtSnHit$2W0kI#F9})~2PHu4`mT}8ia?bZQ0lCMwOvy^8x^eKA)1u;!bBA^# z1ljzrO($_GG{Rh7!Q>C5t#Ly9i3}RYS1PH@R`(y5IpyvNCf9Fdjwai3tc5<&uM4Nk zEv74fZ$ayzkU#Ab&W+}|%nb+z-~c`Vw=?tvxC8voGFO!F-$fVD+z?I+PhXr6S`mxE zD$2>rDaoTzC;|ldvxo^+L;!E0&K3ay0cWuIw*vfMcKsP9(Ediyu7_=Yxo{#b4?Hle6yH6%4ueLGb`4&SkbaIxkmSLU$1G>eY17d=EM7i zi+z@T3+44M;%sn!ExbHcfaV-kC_we*(qr|yYwCU1IT<9y&yk{Au z{4*lvf@Gu#s|F=A>^#@YWksP$pwvLFSjMHm1v(_p;_fyqUyG>A_b$>DvAk<1a7oa>~GvlhYR*Zb-6${*aroQR}2x zX=zKyt=r#lbY8L%HkRREv~cAf*SpIIN-;_Ma?FnKtJcJ4pOE~fw_JEwiR|9wR*=QF z0utM4=AHc%!Bu0h8)l;8J#QkY?18sPLVW&M0CTS>UE=j9-^eLv%f2YSkvXW4@GZJ0 zQ+F%`lxa7yWCXJfnB~d7^W9mMkp<1|h%jL@1cV(@N!=IF_X9=#@Iy=6Y+n0bVI*%}SGLAxw%V!ixE=W_h=i$#Bn#VfBdVE`pgQdGNX!c=|zxGLzGVpF=swMRATg^`*Y%iu=a?8X{9cJ;XuEnb(H zh*H5D*>fdqGbZZ=={Ye>+}QW3C_@w)#rE@y0s)}_h!En3eg`Jac}aBMTWh;~sOZx! rJ;2mokZp!?@ikXlOe%!Ckh$&