diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/KcAdmMain.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/KcAdmMain.java index 4a3f03ec7fd..5b39904f344 100644 --- a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/KcAdmMain.java +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/KcAdmMain.java @@ -73,7 +73,7 @@ public class KcAdmMain { KcAdmV2Completer.complete(stripArgs(v2Args, COMPLETE_FLAG), new PrintWriter(System.out, true)); } else { - Globals.main(v2Args, new KcAdmV2Cmd(), CMD, DEFAULT_CONFIG_FILE_STRING); + Globals.main(v2Args, new KcAdmV2Cmd(v2Args), CMD, DEFAULT_CONFIG_FILE_STRING); } } diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCmd.java index 3c0bb06aaf4..5c5331b4a7c 100644 --- a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCmd.java +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCmd.java @@ -22,6 +22,7 @@ import java.io.StringWriter; import picocli.CommandLine.Command; import static org.keycloak.client.admin.cli.KcAdmMain.CMD; +import static org.keycloak.client.admin.cli.KcAdmMain.V2_FLAG; /** @@ -36,6 +37,16 @@ public class ConfigCmd extends AbstractAuthOptionsCmd { public static final String NAME = "config"; + private final boolean v2; + + public ConfigCmd() { + this.v2 = false; + } + + public ConfigCmd(boolean v2) { + this.v2 = v2; + } + @Override protected void process() { @@ -48,19 +59,27 @@ public class ConfigCmd extends AbstractAuthOptionsCmd { @Override protected String help() { - return usage(); + return usage(v2); } public static String usage() { + return usage(false); + } + + private static String usage(boolean v2) { + String cmd = v2 ? CMD + " " + V2_FLAG : CMD; + String subcommands = v2 ? "'credentials', 'truststore', 'openapi'" : "'credentials', 'truststore'"; + String helpHint = v2 ? cmd + " config SUB_COMMAND --help" : CMD + " help config SUB_COMMAND"; + StringWriter sb = new StringWriter(); PrintWriter out = new PrintWriter(sb); - out.println("Usage: " + CMD + " config SUB_COMMAND [ARGUMENTS]"); + out.println("Usage: " + cmd + " config SUB_COMMAND [ARGUMENTS]"); out.println(); - out.println("Where SUB_COMMAND is one of: 'credentials', 'truststore'"); + out.println("Where SUB_COMMAND is one of: " + subcommands); out.println(); out.println(); - out.println("Use '" + CMD + " help config SUB_COMMAND' for more info."); - out.println("Use '" + CMD + " help' for general information and a list of commands."); + out.println("Use '" + helpHint + "' for more info."); + out.println("Use '" + cmd + " help' for general information and a list of commands."); return sb.toString(); } } diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2Cmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2Cmd.java index 0aedd78dee5..d56d8dfd993 100644 --- a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2Cmd.java +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2Cmd.java @@ -1,11 +1,17 @@ package org.keycloak.client.admin.cli.v2; +import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; +import java.nio.file.Path; +import org.keycloak.client.admin.cli.KcAdmMain; import org.keycloak.client.admin.cli.commands.ConfigCmd; import org.keycloak.client.cli.common.BaseGlobalOptionsCmd; +import org.keycloak.client.cli.config.ConfigData; +import org.keycloak.util.JsonSerialization; import picocli.CommandLine; import picocli.CommandLine.Command; @@ -17,15 +23,41 @@ import static org.keycloak.client.admin.cli.KcAdmMain.V2_FLAG; import static org.keycloak.client.cli.util.OsUtil.PROMPT; @Command(name = "kcadm", - description = "%nCOMMAND [ARGUMENTS]" + description = "%nCOMMAND [ARGUMENTS]", + footer = {"%nEnable tab completion:%n source <(kcadm.sh --v2 completion)"} ) public class KcAdmV2Cmd extends BaseGlobalOptionsCmd { private static final String BUNDLED_DESCRIPTOR = "/kcadm-v2-commands.json"; + private static final String CONFIG_FILE_NAME = Path.of(KcAdmMain.DEFAULT_CONFIG_FILE_PATH).getFileName().toString(); + private static final String CONFIG_OPTION = "--config"; + + private static final Path DEFAULT_CACHE_DIR = + Path.of(KcAdmMain.DEFAULT_CONFIG_FILE_PATH).getParent().resolve("command-descriptors").resolve("v2"); + + private final Path cacheDir; + private final String configFilePath; @Spec CommandSpec spec; + public KcAdmV2Cmd() { + this(DEFAULT_CACHE_DIR); + } + + public KcAdmV2Cmd(Path cacheDir) { + this(cacheDir, null); + } + + public KcAdmV2Cmd(String[] args) { + this(DEFAULT_CACHE_DIR, args); + } + + public KcAdmV2Cmd(Path cacheDir, String[] args) { + this.cacheDir = cacheDir; + this.configFilePath = findConfigPath(args); + } + @Override protected boolean nothingToDo() { return true; @@ -57,20 +89,72 @@ public class KcAdmV2Cmd extends BaseGlobalOptionsCmd { @Override protected void configureCommandLine(CommandLine cli) { cli.getCommandSpec().name(CMD + " " + V2_FLAG); - CommandLine configCmd = new CommandLine(new ConfigCmd()); + CommandLine configCmd = new CommandLine(new ConfigCmd(true)); configCmd.getCommandSpec().usageMessage().description("Configuration management"); + configCmd.getCommandSpec().removeSubcommand("credentials"); + configCmd.addSubcommand("credentials", new CommandLine(new KcAdmV2ConfigCredentialsCmd(cacheDir))); + configCmd.addSubcommand("openapi", new CommandLine(new KcAdmV2ConfigOpenApiCmd(cacheDir))); cli.addSubcommand(configCmd); KcAdmV2CommandDescriptor descriptor = loadDescriptor(); KcAdmV2CommandBuilder.addCommands(cli, descriptor); } private KcAdmV2CommandDescriptor loadDescriptor() { - // TODO: fetch and cache server-specific descriptor (follow-up PR) + KcAdmV2DescriptorCache cache = new KcAdmV2DescriptorCache(cacheDir); + + String serverUrl = readServerUrlFromConfig(); + if (serverUrl != null) { + KcAdmV2CommandDescriptor cached = cache.loadForServer(serverUrl); + if (cached != null) { + return cached; + } + } + return loadBundledDescriptor(); } - private KcAdmV2CommandDescriptor loadBundledDescriptor() { - try (InputStream is = getClass().getResourceAsStream(BUNDLED_DESCRIPTOR)) { + private String readServerUrlFromConfig() { + if (configFilePath != null) { + String fromConfig = readServerUrlFrom(configFilePath); + if (fromConfig != null) { + return fromConfig; + } + } + String fromCacheDir = readServerUrlFrom(cacheDir.resolve(CONFIG_FILE_NAME).toString()); + if (fromCacheDir != null) { + return fromCacheDir; + } + return readServerUrlFrom(KcAdmMain.DEFAULT_CONFIG_FILE_PATH); + } + + static String readServerUrlFrom(String configFilePath) { + try { + File configFile = new File(configFilePath); + if (!configFile.isFile()) { + return null; + } + try (FileInputStream is = new FileInputStream(configFile)) { + ConfigData config = JsonSerialization.readValue(is, ConfigData.class); + return config.getServerUrl(); + } + } catch (Exception e) { + return null; + } + } + + private static String findConfigPath(String[] args) { + if (args != null) { + for (int i = 0; i < args.length - 1; i++) { + if (CONFIG_OPTION.equals(args[i])) { + return args[i + 1]; + } + } + } + return null; + } + + public static KcAdmV2CommandDescriptor loadBundledDescriptor() { + try (InputStream is = KcAdmV2Cmd.class.getResourceAsStream(BUNDLED_DESCRIPTOR)) { if (is == null) { throw new RuntimeException("Bundled command descriptor not found: " + BUNDLED_DESCRIPTOR); } diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2Completer.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2Completer.java index 56f5e559fa7..3606671bd7d 100644 --- a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2Completer.java +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2Completer.java @@ -1,6 +1,7 @@ package org.keycloak.client.admin.cli.v2; import java.io.PrintWriter; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -16,10 +17,20 @@ public class KcAdmV2Completer { private static final String LONG_OPTION_PREFIX = "--"; public static void complete(String[] args, PrintWriter out) { - KcAdmV2Cmd rootCmd = new KcAdmV2Cmd(); + completeWith(buildCli(new KcAdmV2Cmd(args)), args, out); + } + + public static void complete(String[] args, PrintWriter out, Path cacheDir) { + completeWith(buildCli(new KcAdmV2Cmd(cacheDir, args)), args, out); + } + + private static CommandLine buildCli(KcAdmV2Cmd rootCmd) { CommandLine cli = new CommandLine(rootCmd); rootCmd.configureCommandLine(cli); + return cli; + } + private static void completeWith(CommandLine cli, String[] args, PrintWriter out) { String partial = args.length > 0 ? args[args.length - 1] : ""; if (LONG_OPTION_PREFIX.equals(partial)) { diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2ConfigCredentialsCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2ConfigCredentialsCmd.java new file mode 100644 index 00000000000..64552ecd94b --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2ConfigCredentialsCmd.java @@ -0,0 +1,98 @@ +package org.keycloak.client.admin.cli.v2; + +import java.io.PrintWriter; +import java.net.URI; +import java.net.URL; +import java.nio.file.Path; + +import org.keycloak.client.admin.cli.commands.ConfigCredentialsCmd; + +import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Option; +import picocli.CommandLine.Spec; + +import static org.keycloak.client.admin.cli.KcAdmMain.CMD; +import static org.keycloak.client.admin.cli.KcAdmMain.V2_FLAG; + +@Command(name = "credentials", description = "--server SERVER_URL --realm REALM [ARGUMENTS]") +class KcAdmV2ConfigCredentialsCmd extends ConfigCredentialsCmd { + + private static final int DEFAULT_MANAGEMENT_PORT = 9000; + private static final String OPENAPI_PATH = "/openapi"; + + @Option(names = "--openapi-url", description = "URL of the OpenAPI endpoint for auto-fetching the server descriptor " + + "(default: ://:" + DEFAULT_MANAGEMENT_PORT + OPENAPI_PATH + ")") + String openApiUrl; + + @Spec + CommandSpec spec; + + private final Path cacheDir; + + KcAdmV2ConfigCredentialsCmd(Path cacheDir) { + this.cacheDir = cacheDir; + } + + @Override + protected String getCommand() { + return CMD + " " + V2_FLAG; + } + + @Override + protected void printExtraOptions(PrintWriter out) { + out.println(" --openapi-url URL URL of the OpenAPI endpoint for auto-fetching the server descriptor"); + out.println(" (default: ://:" + DEFAULT_MANAGEMENT_PORT + OPENAPI_PATH + ")"); + } + + @Override + public void process() { + super.process(); + tryAutoFetchOpenApi(); + } + + private void tryAutoFetchOpenApi() { + if (server == null) { + // --status without --server — no login happened, nothing to fetch + return; + } + + String url = openApiUrl; + if (url == null) { + try { + url = deriveDefaultOpenApiUrl(); + } catch (Exception e) { + warnAutoFetchFailed("could not determine OpenAPI URL from server " + server + + (e.getMessage() != null ? ": " + e.getMessage() : "")); + return; + } + } + + try { + KcAdmV2CommandDescriptor descriptor = KcAdmV2ConfigOpenApiCmd.fetchDescriptorFromUrl(url); + KcAdmV2DescriptorCache cache = new KcAdmV2DescriptorCache(cacheDir); + cache.save(server, descriptor); + printToErr("OpenAPI descriptor fetched from " + url + " and cached for " + server + + " (version " + descriptor.getVersion() + ")"); + } catch (Exception e) { + warnAutoFetchFailed(url + (e.getMessage() != null ? ": " + e.getMessage() : "")); + } + } + + private void warnAutoFetchFailed(String detail) { + printToErr("Failed to fetch OpenAPI descriptor from " + detail + ". " + + "CLI commands may not match your server version. " + + "You can use 'config openapi' to manually load the descriptor."); + } + + private void printToErr(String message) { + spec.commandLine().getErr().println(message); + } + + private String deriveDefaultOpenApiUrl() throws Exception { + URL serverParsed = new URL(server); + URI managementUri = new URI(serverParsed.getProtocol(), null, + serverParsed.getHost(), DEFAULT_MANAGEMENT_PORT, OPENAPI_PATH, null, null); + return managementUri.toString(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2ConfigOpenApiCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2ConfigOpenApiCmd.java new file mode 100644 index 00000000000..abcb2b5daaa --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2ConfigOpenApiCmd.java @@ -0,0 +1,106 @@ +package org.keycloak.client.admin.cli.v2; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; + +import org.keycloak.client.admin.cli.KcAdmMain; +import org.keycloak.client.cli.common.BaseAuthOptionsCmd; +import org.keycloak.client.cli.util.Headers; +import org.keycloak.client.cli.util.HeadersBody; +import org.keycloak.client.cli.util.HeadersBodyStatus; +import org.keycloak.client.cli.util.HttpUtil; + +import org.eclipse.microprofile.openapi.models.OpenAPI; +import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.Spec; + +@Command(name = "openapi", description = "Fetch the server's OpenAPI spec to get commands, options, and help " + + "that match your server version — instead of the built-in defaults.%n%n" + + "The source can be a URL (e.g., https://localhost:9000/openapi) or a local file path.%n%n" + + "The OpenAPI endpoint is served on the management interface, which may use a different host and port " + + "than the main server. Requires 'config credentials' to be run first.") +class KcAdmV2ConfigOpenApiCmd implements Runnable { + + @Parameters(index = "0", paramLabel = "", + description = "URL of the OpenAPI endpoint (e.g., http://localhost:9000/openapi) or path to a local OpenAPI JSON file") + String openApiSource; + + @Option(names = "--config", description = "Path to the config file (${sys:" + BaseAuthOptionsCmd.DEFAULT_CONFIG_PATH_STRING_KEY + "} by default)") + String config; + + @Option(names = { "-h", "--help" }, usageHelp = true, hidden = true) + boolean help; + + @Spec + CommandSpec spec; + + private final Path cacheDir; + + KcAdmV2ConfigOpenApiCmd(Path cacheDir) { + this.cacheDir = cacheDir; + } + + @Override + public void run() { + String configPath = config != null ? config : KcAdmMain.DEFAULT_CONFIG_FILE_PATH; + String serverUrl = KcAdmV2Cmd.readServerUrlFrom(configPath); + if (serverUrl == null) { + throw new RuntimeException("No server configured. Run 'config credentials' first."); + } + + try { + KcAdmV2CommandDescriptor descriptor = loadDescriptor(openApiSource); + KcAdmV2DescriptorCache cache = new KcAdmV2DescriptorCache(cacheDir); + cache.save(serverUrl, descriptor); + spec.commandLine().getErr().println( + "OpenAPI descriptor cached for " + serverUrl + " (version " + descriptor.getVersion() + ")"); + } catch (Exception e) { + String cause = e.getMessage() != null ? ": " + e.getMessage() : ""; + throw new RuntimeException("Failed to load OpenAPI from " + openApiSource + cause, e); + } + } + + static KcAdmV2CommandDescriptor loadDescriptor(String source) throws IOException { + KcAdmV2CommandDescriptor descriptor; + if (isUrl(source)) { + descriptor = fetchDescriptorFromUrl(source); + } else { + File file = new File(source); + if (!file.isFile()) { + throw new RuntimeException("Not a valid URL (must start with http:// or https://) and no file found at: " + source); + } + descriptor = loadDescriptorFromFile(file); + } + + if (descriptor.getResources() == null || descriptor.getResources().isEmpty()) { + throw new RuntimeException("OpenAPI spec contains no resources — the file may not be a valid OpenAPI spec"); + } + + return descriptor; + } + + private static boolean isUrl(String source) { + return source.startsWith("http://") || source.startsWith("https://"); + } + + static KcAdmV2CommandDescriptor fetchDescriptorFromUrl(String url) throws IOException { + HeadersBodyStatus response = HttpUtil.doRequest("get", url, new HeadersBody(new Headers())); + response.checkSuccess(); + InputStream body = response.getBody(); + OpenAPI openApi = KcAdmV2DescriptorBuilder.parseOpenApi(() -> body); + return KcAdmV2DescriptorBuilder.convert(openApi); + } + + private static KcAdmV2CommandDescriptor loadDescriptorFromFile(File file) throws IOException { + try (InputStream is = new FileInputStream(file)) { + OpenAPI openApi = KcAdmV2DescriptorBuilder.parseOpenApi(() -> is); + return KcAdmV2DescriptorBuilder.convert(openApi); + } + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2DescriptorBuilder.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2DescriptorBuilder.java index f2bb28bbe7e..5397980b2f6 100644 --- a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2DescriptorBuilder.java +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2DescriptorBuilder.java @@ -11,9 +11,8 @@ import java.util.Map; import java.util.Optional; import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.OptionDescriptor; +import org.keycloak.client.cli.util.OutputUtil; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; import io.smallrye.openapi.api.SmallRyeOpenAPI; import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.ConfigValue; @@ -37,8 +36,6 @@ public class KcAdmV2DescriptorBuilder { static final String ID_PATH_PARAM = "{id}"; - private static final ObjectMapper MAPPER = new ObjectMapper() - .enable(SerializationFeature.INDENT_OUTPUT); private static final Map HTTP_METHOD_TO_COMMAND = Map.of( PathItem.HttpMethod.GET, "get", @@ -120,11 +117,11 @@ public class KcAdmV2DescriptorBuilder { public static void writeDescriptor(KcAdmV2CommandDescriptor descriptor, Path outputFile) throws IOException { Files.createDirectories(outputFile.getParent()); - MAPPER.writeValue(outputFile.toFile(), descriptor); + OutputUtil.MAPPER.writeValue(outputFile.toFile(), descriptor); } public static KcAdmV2CommandDescriptor readDescriptor(InputStream is) throws IOException { - return MAPPER.readValue(is, KcAdmV2CommandDescriptor.class); + return OutputUtil.MAPPER.readValue(is, KcAdmV2CommandDescriptor.class); } private static String extractResourceName(PathItem pathItem) { diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2DescriptorCache.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2DescriptorCache.java new file mode 100644 index 00000000000..1ff6a7fac95 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/v2/KcAdmV2DescriptorCache.java @@ -0,0 +1,136 @@ +package org.keycloak.client.admin.cli.v2; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.keycloak.client.cli.util.OutputUtil; + +public final class KcAdmV2DescriptorCache { + + public static final String REGISTRY_FILENAME = "registry.json"; + public static final String DESCRIPTOR_PREFIX = "descriptor-"; + + private final Path cacheDir; + + public KcAdmV2DescriptorCache(Path cacheDir) { + this.cacheDir = cacheDir; + } + + public KcAdmV2CommandDescriptor loadForServer(String serverUrl) { + if (!Files.isDirectory(cacheDir)) { + return null; + } + Registry registry = readRegistry(); + if (registry == null) { + return null; + } + ServerEntry entry = registry.servers.get(serverUrl); + if (entry == null || entry.version == null) { + return null; + } + Path descriptorFile = descriptorPath(entry.version); + if (!Files.isRegularFile(descriptorFile)) { + return null; + } + try { + return OutputUtil.MAPPER.readValue(descriptorFile.toFile(), KcAdmV2CommandDescriptor.class); + } catch (IOException e) { + return null; + } + } + + public void save(String serverUrl, KcAdmV2CommandDescriptor descriptor) { + String newVersion = descriptor.getVersion(); + if (newVersion == null || newVersion.isBlank()) { + throw new IllegalArgumentException("Descriptor version must not be null or blank"); + } + + try { + Files.createDirectories(cacheDir); + } catch (IOException e) { + throw new RuntimeException("Failed to create cache directory: " + cacheDir, e); + } + + Registry registry = readRegistryOrEmpty(); + String oldVersion = versionForServer(registry, serverUrl); + + try { + OutputUtil.MAPPER.writeValue(descriptorPath(newVersion).toFile(), descriptor); + } catch (IOException e) { + throw new RuntimeException("Failed to write descriptor: " + descriptorPath(newVersion), e); + } + + registry.servers.put(serverUrl, new ServerEntry(newVersion)); + writeRegistry(registry); + + if (oldVersion != null && !oldVersion.equals(newVersion)) { + deleteOrphanedDescriptor(registry, oldVersion); + } + } + + private void deleteOrphanedDescriptor(Registry registry, String version) { + boolean stillReferenced = registry.servers.values().stream().anyMatch(e -> version.equals(e.version)); + if (!stillReferenced) { + try { + Files.deleteIfExists(descriptorPath(version)); + } catch (IOException ignored) { + } + } + } + + private Path descriptorPath(String version) { + String sanitized = version.replaceAll("[^a-zA-Z0-9_-]", "_"); + return cacheDir.resolve(DESCRIPTOR_PREFIX + sanitized + ".json"); + } + + private String versionForServer(Registry registry, String serverUrl) { + ServerEntry entry = registry.servers.get(serverUrl); + return entry != null ? entry.version : null; + } + + private Registry readRegistry() { + Path registryFile = cacheDir.resolve(REGISTRY_FILENAME); + if (!Files.isRegularFile(registryFile)) { + return null; + } + try { + Registry registry = OutputUtil.MAPPER.readValue(registryFile.toFile(), Registry.class); + if (registry == null || registry.servers == null) { + return null; + } + return registry; + } catch (IOException e) { + return null; + } + } + + private Registry readRegistryOrEmpty() { + Registry registry = readRegistry(); + return registry != null ? registry : new Registry(); + } + + private void writeRegistry(Registry registry) { + try { + OutputUtil.MAPPER.writeValue(cacheDir.resolve(REGISTRY_FILENAME).toFile(), registry); + } catch (IOException e) { + throw new RuntimeException("Failed to write registry: " + cacheDir.resolve(REGISTRY_FILENAME), e); + } + } + + static class Registry { + public Map servers = new LinkedHashMap<>(); + } + + static class ServerEntry { + public String version; + + ServerEntry() {} + + ServerEntry(String version) { + this.version = version; + } + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/cli/common/BaseConfigCredentialsCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/cli/common/BaseConfigCredentialsCmd.java index c88dab97186..f31a4b500bf 100644 --- a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/cli/common/BaseConfigCredentialsCmd.java +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/cli/common/BaseConfigCredentialsCmd.java @@ -226,6 +226,7 @@ public class BaseConfigCredentialsCmd extends BaseAuthOptionsCmd { out.println(" otherwise defaults to keystore password)"); out.println(" --alias ALIAS Alias of the key inside a keystore (defaults to the value of ClientId)"); out.println(" --status Checks the validity of the existing connection (Note: It does not update the config)"); + printExtraOptions(out); out.println(); out.println(); out.println("Examples:"); @@ -261,4 +262,7 @@ public class BaseConfigCredentialsCmd extends BaseAuthOptionsCmd { out.println("Use '" + getCommand() + " help' for general information and a list of commands"); return sb.toString(); } + + protected void printExtraOptions(PrintWriter out) { + } } diff --git a/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2CachedDescriptorHelpTest.java b/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2CachedDescriptorHelpTest.java new file mode 100644 index 00000000000..626716198d9 --- /dev/null +++ b/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2CachedDescriptorHelpTest.java @@ -0,0 +1,216 @@ +package org.keycloak.client.admin.cli.commands.v2; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; + +import org.keycloak.client.admin.cli.KcAdmMain; +import org.keycloak.client.admin.cli.v2.KcAdmV2Cmd; +import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor; +import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.CommandDescriptor; +import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.OptionDescriptor; +import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.ResourceDescriptor; +import org.keycloak.client.admin.cli.v2.KcAdmV2Completer; +import org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache; +import org.keycloak.client.cli.common.Globals; +import org.keycloak.client.cli.config.ConfigData; +import org.keycloak.client.cli.config.FileConfigHandler; +import org.keycloak.client.cli.util.ConfigUtil; +import org.keycloak.util.JsonSerialization; + +import org.junit.After; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import picocli.CommandLine; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class KcAdmV2CachedDescriptorHelpTest { + + private static final String TEST_SERVER = "http://test-server:8080"; + private static final String CONFIG_FILE_NAME = Path.of(KcAdmMain.DEFAULT_CONFIG_FILE_PATH).getFileName().toString(); + + private static Path tempDir; + + @BeforeClass + public static void setUpClass() throws IOException { + tempDir = Files.createTempDirectory("kcadm-help-test"); + setUpCachedDescriptor(); + ConfigUtil.setHandler(null); + FileConfigHandler.setConfigFile(null); + } + + @AfterClass + public static void tearDownClass() throws IOException { + if (tempDir != null && Files.exists(tempDir)) { + deleteRecursively(tempDir); + } + } + + @After + public void tearDown() { + ConfigUtil.setHandler(null); + FileConfigHandler.setConfigFile(null); + } + + @Test + public void helpShowsCachedResource() { + String help = createCli().getUsageMessage(); + assertTrue("'widget' not found in: " + help, help.contains("widget")); + assertFalse("bundled 'client' should not appear in: " + help, help.contains("client")); + } + + @Test + public void helpShowsCachedSubcommands() { + CommandLine widgetCli = createCli().getSubcommands().get("widget"); + assertNotNull("'widget' should be a subcommand", widgetCli); + + String help = widgetCli.getUsageMessage(); + assertTrue("'list' not found in: " + help, help.contains("list")); + assertTrue("'create' not found in: " + help, help.contains("create")); + } + + @Test + public void helpShowsCachedOptions() { + CommandLine createCli = createCli().getSubcommands().get("widget").getSubcommands().get("create"); + String help = createCli.getUsageMessage(); + assertTrue("'--widget-name' not found in: " + help, help.contains("--widget-name")); + assertTrue("'Name of the widget' not found in: " + help, help.contains("Name of the widget")); + } + + @Test + public void autocompleteShowsCachedResourceAndNotBundled() { + List candidates = complete(""); + assertTrue("'widget' not found in: " + candidates, candidates.contains("widget")); + assertFalse("bundled 'client' should not appear in: " + candidates, candidates.contains("client")); + } + + @Test + public void autocompleteShowsCachedSubcommands() { + List candidates = complete("widget", ""); + assertTrue("'list' not found in: " + candidates, candidates.contains("list")); + assertTrue("'create' not found in: " + candidates, candidates.contains("create")); + } + + @Test + public void autocompleteShowsCachedOptions() { + List candidates = complete("widget", "create", "--"); + assertTrue("'--widget-name' not found in: " + candidates, candidates.contains("--widget-name")); + } + + @Test + public void fallsBackToBundledWhenCacheHasNoEntryForServer() throws IOException { + Path emptyCache = Files.createTempDirectory("kcadm-empty-cache"); + try { + CommandLine cli = createCliWithCacheDir(emptyCache, "http://unknown-server:8080"); + String help = cli.getUsageMessage(); + assertTrue("should fall back to bundled 'client': " + help, help.contains("client")); + assertFalse("cached 'widget' should not appear: " + help, help.contains("widget")); + } finally { + deleteRecursively(emptyCache); + } + } + + @Test + public void fallsBackToBundledWhenConfigHasNoServerUrl() throws IOException { + Path emptyCache = Files.createTempDirectory("kcadm-empty-cache"); + try { + CommandLine cli = createCliWithCacheDir(emptyCache, null); + String help = cli.getUsageMessage(); + assertTrue("should fall back to bundled 'client': " + help, help.contains("client")); + } finally { + deleteRecursively(emptyCache); + } + } + + private static void setUpCachedDescriptor() throws IOException { + KcAdmV2DescriptorCache cache = new KcAdmV2DescriptorCache(tempDir); + cache.save(TEST_SERVER, widgetDescriptor()); + + Path configFile = tempDir.resolve(CONFIG_FILE_NAME); + ConfigData config = new ConfigData(); + config.setServerUrl(TEST_SERVER); + Files.writeString(configFile, JsonSerialization.writeValueAsPrettyString(config)); + } + + private CommandLine createCli() { + return createCliWithCacheDir(tempDir, TEST_SERVER); + } + + private static CommandLine createCliWithCacheDir(Path cacheDir, String serverUrl) { + try { + Path configFile = cacheDir.resolve(CONFIG_FILE_NAME); + ConfigData config = new ConfigData(); + if (serverUrl != null) { + config.setServerUrl(serverUrl); + } + Files.writeString(configFile, JsonSerialization.writeValueAsPrettyString(config)); + FileConfigHandler.setConfigFile(configFile.toString()); + ConfigUtil.setHandler(new FileConfigHandler()); + } catch (IOException e) { + throw new RuntimeException("Failed to set up config", e); + } + + return Globals.createCommandLine(new KcAdmV2Cmd(cacheDir), KcAdmMain.CMD, + new PrintWriter(System.err, true)); + } + + private List complete(String... args) { + StringWriter sw = new StringWriter(); + KcAdmV2Completer.complete(args, new PrintWriter(sw), tempDir); + String output = sw.toString().trim(); + if (output.isEmpty()) { + return List.of(); + } + return List.of(output.split(System.lineSeparator())); + } + + private static void deleteRecursively(Path dir) throws IOException { + try (var paths = Files.walk(dir)) { + paths.sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { Files.delete(p); } catch (IOException ignored) {} + }); + } + } + + private static KcAdmV2CommandDescriptor widgetDescriptor() { + OptionDescriptor opt = new OptionDescriptor(); + opt.setName("widget-name"); + opt.setFieldName("widgetName"); + opt.setType(OptionDescriptor.TYPE_STRING); + opt.setDescription("Name of the widget"); + + CommandDescriptor listCmd = new CommandDescriptor(); + listCmd.setName("list"); + listCmd.setResourceName("widget"); + listCmd.setHttpMethod("GET"); + listCmd.setPath("/admin/api/{realmName}/widgets/{version}"); + listCmd.setDescription("List widgets"); + listCmd.setOptions(List.of()); + + CommandDescriptor createCmd = new CommandDescriptor(); + createCmd.setName("create"); + createCmd.setResourceName("widget"); + createCmd.setHttpMethod("POST"); + createCmd.setPath("/admin/api/{realmName}/widgets/{version}"); + createCmd.setDescription("Create a widget"); + createCmd.setOptions(List.of(opt)); + + ResourceDescriptor resource = new ResourceDescriptor(); + resource.setName("widget"); + resource.setCommands(List.of(listCmd, createCmd)); + + KcAdmV2CommandDescriptor descriptor = new KcAdmV2CommandDescriptor(); + descriptor.setVersion("99.0.0"); + descriptor.setResources(List.of(resource)); + return descriptor; + } +} diff --git a/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2CompleterTest.java b/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2CompleterTest.java index fb2eb7780c8..befd1300c78 100644 --- a/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2CompleterTest.java +++ b/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2CompleterTest.java @@ -140,6 +140,28 @@ public class KcAdmV2CompleterTest { assertFalse("Should not suggest '--sign-documents'", candidates.contains("--sign-documents")); } + @Test + public void testConfigShowsSubcommands() { + List candidates = complete("config", ""); + assertTrue("Should suggest 'credentials'", candidates.contains("credentials")); + assertTrue("Should suggest 'openapi'", candidates.contains("openapi")); + } + + @Test + public void testConfigOpenApiOptionsInAutocomplete() { + List candidates = complete("config", "openapi", "--"); + assertTrue("Should suggest '--config': " + candidates, candidates.contains("--config")); + assertTrue("Should suggest '--help': " + candidates, candidates.contains("--help")); + } + + @Test + public void testConfigCredentialsShowsOpenApiUrlOption() { + List candidates = complete("config", "credentials", "--"); + assertTrue("Should suggest '--openapi-url': " + candidates, candidates.contains("--openapi-url")); + assertTrue("Should suggest '--server': " + candidates, candidates.contains("--server")); + assertTrue("Should suggest '--realm': " + candidates, candidates.contains("--realm")); + } + @Test public void testUnknownSubcommandStaysAtCurrentLevel() { List candidates = complete("client", "nonexistent", ""); diff --git a/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2DescriptorCacheTest.java b/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2DescriptorCacheTest.java new file mode 100644 index 00000000000..b27ee1801d1 --- /dev/null +++ b/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2DescriptorCacheTest.java @@ -0,0 +1,189 @@ +package org.keycloak.client.admin.cli.commands.v2; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; + +import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor; +import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.CommandDescriptor; +import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.ResourceDescriptor; +import org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache; +import org.keycloak.client.cli.config.FileConfigHandler; +import org.keycloak.client.cli.util.ConfigUtil; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache.DESCRIPTOR_PREFIX; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class KcAdmV2DescriptorCacheTest { + + private Path tempDir; + private KcAdmV2DescriptorCache cache; + + @Before + public void setUp() throws IOException { + ConfigUtil.setHandler(null); + FileConfigHandler.setConfigFile(null); + tempDir = Files.createTempDirectory("kcadm-cache-test"); + cache = new KcAdmV2DescriptorCache(tempDir); + } + + @After + public void tearDown() throws IOException { + if (tempDir != null && Files.exists(tempDir)) { + deleteRecursively(tempDir); + } + ConfigUtil.setHandler(null); + FileConfigHandler.setConfigFile(null); + } + + @Test + public void loadReturnsNullForUnknownServer() { + assertNull(cache.loadForServer("http://unknown:8080")); + } + + @Test + public void saveAndLoadRoundTrip() { + KcAdmV2CommandDescriptor descriptor = descriptorWithVersion("26.0.0"); + + cache.save("http://localhost:8080", descriptor); + + KcAdmV2CommandDescriptor loaded = cache.loadForServer("http://localhost:8080"); + assertNotNull(loaded); + assertEquals("26.0.0", loaded.getVersion()); + assertEquals(1, loaded.getResources().size()); + assertEquals("client", loaded.getResources().get(0).getName()); + } + + @Test + public void twoServersWithSameVersionShareOneDescriptorFile() { + KcAdmV2CommandDescriptor descriptor = descriptorWithVersion("26.0.0"); + + cache.save("http://server-a:8080", descriptor); + cache.save("http://server-b:8080", descriptor); + + KcAdmV2CommandDescriptor loadedA = cache.loadForServer("http://server-a:8080"); + KcAdmV2CommandDescriptor loadedB = cache.loadForServer("http://server-b:8080"); + assertNotNull(loadedA); + assertNotNull(loadedB); + assertEquals("26.0.0", loadedA.getVersion()); + assertEquals("26.0.0", loadedB.getVersion()); + + long descriptorFileCount = countDescriptorFiles(); + assertEquals("two servers of same version should share one descriptor file", + 1, descriptorFileCount); + } + + @Test + public void differentVersionsCreateSeparateDescriptorFiles() { + cache.save("http://server-a:8080", descriptorWithVersion("26.0.0")); + cache.save("http://server-b:8080", descriptorWithVersion("27.0.0-SNAPSHOT")); + + assertEquals(2, countDescriptorFiles()); + + assertEquals("26.0.0", cache.loadForServer("http://server-a:8080").getVersion()); + assertEquals("27.0.0-SNAPSHOT", cache.loadForServer("http://server-b:8080").getVersion()); + } + + @Test + public void saveUpdatesVersionWhenServerUpgraded() { + cache.save("http://localhost:8080", descriptorWithVersion("26.0.0")); + assertEquals("26.0.0", cache.loadForServer("http://localhost:8080").getVersion()); + assertEquals(1, countDescriptorFiles()); + + cache.save("http://localhost:8080", descriptorWithVersion("27.0.0")); + assertEquals("27.0.0", cache.loadForServer("http://localhost:8080").getVersion()); + assertEquals("old descriptor file should be removed", 1, countDescriptorFiles()); + } + + @Test + public void saveKeepsOldDescriptorIfReferencedByOtherServer() { + cache.save("http://server-a:8080", descriptorWithVersion("26.0.0")); + cache.save("http://server-b:8080", descriptorWithVersion("26.0.0")); + assertEquals(1, countDescriptorFiles()); + + cache.save("http://server-a:8080", descriptorWithVersion("27.0.0")); + assertEquals("old descriptor kept because server-b still uses it", + 2, countDescriptorFiles()); + } + + @Test + public void versionIsSanitizedInFilename() { + String maliciousVersion = "../../etc/passwd"; + Path sanitizedFile = tempDir.resolve(DESCRIPTOR_PREFIX + "______etc_passwd.json"); + + assertFalse("sanitized file should not exist before save", Files.exists(sanitizedFile)); + + cache.save("http://localhost:8080", descriptorWithVersion(maliciousVersion)); + + assertTrue("descriptor should be saved with sanitized filename", Files.exists(sanitizedFile)); + assertNotNull(cache.loadForServer("http://localhost:8080")); + } + + @Test + public void loadReturnsNullWhenCacheDirDoesNotExist() throws IOException { + deleteRecursively(tempDir); + + assertNull(cache.loadForServer("http://localhost:8080")); + } + + @Test + public void saveCreatesDirectoryIfNeeded() { + Path nested = tempDir.resolve("sub").resolve("dir"); + KcAdmV2DescriptorCache nestedCache = new KcAdmV2DescriptorCache(nested); + + nestedCache.save("http://localhost:8080", descriptorWithVersion("26.0.0")); + + assertTrue(Files.isDirectory(nested)); + assertNotNull(nestedCache.loadForServer("http://localhost:8080")); + } + + private KcAdmV2CommandDescriptor descriptorWithVersion(String version) { + CommandDescriptor cmd = new CommandDescriptor(); + cmd.setName("list"); + cmd.setResourceName("client"); + cmd.setHttpMethod("GET"); + cmd.setPath("/admin/api/{realmName}/clients/{version}"); + cmd.setDescription("List clients"); + cmd.setOptions(List.of()); + + ResourceDescriptor resource = new ResourceDescriptor(); + resource.setName("client"); + resource.setCommands(List.of(cmd)); + + KcAdmV2CommandDescriptor descriptor = new KcAdmV2CommandDescriptor(); + descriptor.setVersion(version); + descriptor.setResources(List.of(resource)); + return descriptor; + } + + private long countDescriptorFiles() { + try (var files = Files.list(tempDir)) { + return files + .filter(p -> p.getFileName().toString().startsWith(DESCRIPTOR_PREFIX)) + .count(); + } catch (IOException e) { + throw new RuntimeException("Failed to list descriptor files", e); + } + } + + private static void deleteRecursively(Path dir) throws IOException { + try (var paths = Files.walk(dir)) { + paths.sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { Files.delete(p); } catch (IOException ignored) {} + }); + } + } + +} diff --git a/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2HelpTest.java b/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2HelpTest.java index 1b6b05c0be9..5459a99cb62 100644 --- a/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2HelpTest.java +++ b/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/commands/v2/KcAdmV2HelpTest.java @@ -8,6 +8,7 @@ import java.util.List; import org.keycloak.client.admin.cli.KcAdmMain; import org.keycloak.client.admin.cli.v2.KcAdmV2Cmd; +import org.keycloak.client.cli.common.BaseConfigCredentialsCmd; import org.keycloak.client.cli.common.Globals; import org.junit.Test; @@ -28,6 +29,12 @@ public class KcAdmV2HelpTest { assertTrue("Help should list 'config' command", help.contains("config")); } + @Test + public void testHelpShowsCompletionInstructions() { + String help = createCli().getUsageMessage(); + assertTrue("Help should mention tab completion setup", help.contains("completion")); + } + @Test public void testHelpShowsConsistentDescriptions() { String help = createCli().getUsageMessage(); @@ -383,6 +390,31 @@ public class KcAdmV2HelpTest { } } + @Test + public void testConfigOpenApiHelpShowsUrl() { + CommandLine cli = createCli(); + StringWriter out = new StringWriter(); + StringWriter err = new StringWriter(); + cli.setOut(new PrintWriter(out)); + cli.setErr(new PrintWriter(err)); + + int exitCode = cli.execute("config", "openapi", "--help"); + assertEquals("--help on config openapi should exit with 0", 0, exitCode); + String output = out.toString(); + assertTrue("should show parameter: " + output, output.contains("")); + assertTrue("should show --config option: " + output, output.contains("--config")); + assertTrue("should mention 'config credentials': " + output, output.contains("config credentials")); + } + + @Test + public void testConfigCredentialsHelpShowsOpenApiUrl() { + CommandLine cli = createCli(); + String help = ((BaseConfigCredentialsCmd) cli.getSubcommands().get("config") + .getSubcommands().get("credentials").getCommand()).help(); + assertTrue("should show --openapi-url option: " + help, help.contains("--openapi-url")); + assertTrue("should show --v2 in command: " + help, help.contains("--v2")); + } + private String getVariantHelp(String command, String variant) { CommandLine cli = createCli(); return cli.getSubcommands().get("client").getSubcommands().get(command) diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/AbstractKcAdmV2CLITest.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/AbstractKcAdmV2CLITest.java new file mode 100644 index 00000000000..991695a304d --- /dev/null +++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/AbstractKcAdmV2CLITest.java @@ -0,0 +1,61 @@ +package org.keycloak.tests.admin.cli.v2; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Path; + +import org.keycloak.client.admin.cli.KcAdmMain; +import org.keycloak.client.admin.cli.v2.KcAdmV2Cmd; +import org.keycloak.client.cli.common.Globals; +import org.keycloak.testframework.annotations.InjectKeycloakUrls; +import org.keycloak.testframework.server.KeycloakUrls; + +import picocli.CommandLine; + +abstract class AbstractKcAdmV2CLITest { + + @InjectKeycloakUrls + KeycloakUrls keycloakUrls; + + protected CommandResult kcAdmV2Cmd(Path cacheDir, String configFile, String... args) { + String[] fullArgs = new String[args.length + 2]; + System.arraycopy(args, 0, fullArgs, 0, args.length); + fullArgs[args.length] = "--config"; + fullArgs[args.length + 1] = configFile; + + KcAdmV2Cmd cmd = cacheDir != null ? new KcAdmV2Cmd(cacheDir, fullArgs) : new KcAdmV2Cmd(fullArgs); + return execute(cmd, fullArgs); + } + + protected CommandResult kcAdmV2CmdNoConfig(String... args) { + String[] fullArgs = new String[args.length + 1]; + System.arraycopy(args, 0, fullArgs, 0, args.length); + fullArgs[args.length] = "--no-config"; + + return execute(new KcAdmV2Cmd(fullArgs), fullArgs); + } + + protected CommandResult kcAdmV2CmdRaw(String... args) { + return execute(new KcAdmV2Cmd(args), args); + } + + private CommandResult execute(KcAdmV2Cmd cmd, String[] args) { + CommandLine cli = Globals.createCommandLine(cmd, KcAdmMain.CMD, new PrintWriter(System.err, true)); + + StringWriter out = new StringWriter(); + StringWriter err = new StringWriter(); + cli.setOut(new PrintWriter(out)); + cli.setErr(new PrintWriter(err)); + + int exitCode = cli.execute(args); + return new CommandResult(exitCode, out.toString(), err.toString()); + } + + protected String managementBaseUrl() { + String metricUrl = keycloakUrls.getMetric(); + return metricUrl.substring(0, metricUrl.lastIndexOf("/metrics")); + } + + record CommandResult(int exitCode, String out, String err) { + } +} diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/KcAdmV2ClientCLITest.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/KcAdmV2ClientCLITest.java index ca25577ab9c..45621a8e0e8 100644 --- a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/KcAdmV2ClientCLITest.java +++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/KcAdmV2ClientCLITest.java @@ -1,18 +1,13 @@ package org.keycloak.tests.admin.cli.v2; import java.io.File; -import java.io.PrintWriter; -import java.io.StringWriter; import java.nio.file.Files; import java.nio.file.Path; import org.keycloak.client.admin.cli.KcAdmMain; -import org.keycloak.client.admin.cli.v2.KcAdmV2Cmd; -import org.keycloak.client.cli.common.Globals; import org.keycloak.client.cli.config.FileConfigHandler; import org.keycloak.client.cli.util.HttpUtil; import org.keycloak.common.Profile; -import org.keycloak.testframework.annotations.InjectKeycloakUrls; import org.keycloak.testframework.annotations.InjectRealm; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.annotations.TestSetup; @@ -20,13 +15,12 @@ import org.keycloak.testframework.config.Config; import org.keycloak.testframework.realm.ManagedRealm; import org.keycloak.testframework.server.KeycloakServerConfig; import org.keycloak.testframework.server.KeycloakServerConfigBuilder; -import org.keycloak.testframework.server.KeycloakUrls; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import picocli.CommandLine; +import static org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache.REGISTRY_FILENAME; import static org.keycloak.client.cli.config.FileConfigHandler.setConfigFile; import static org.hamcrest.MatcherAssert.assertThat; @@ -34,16 +28,14 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertFalse; @KeycloakIntegrationTest(config = KcAdmV2ClientCLITest.V2ApiServerConfig.class) -public class KcAdmV2ClientCLITest { +public class KcAdmV2ClientCLITest extends AbstractKcAdmV2CLITest { @InjectRealm ManagedRealm realm; - @InjectKeycloakUrls - KeycloakUrls keycloakUrls; - @TempDir static File tempDir; @@ -583,6 +575,33 @@ public class KcAdmV2ClientCLITest { assertThat(getResult.err(), containsString("Could not find client")); } + @Test + void testLoginAutoFetchFailsGracefully() { + // This server does NOT have OPENAPI enabled, so auto-fetch on login should fail gracefully + Path cacheDir = tempDir.toPath().resolve("auto-fetch-fail"); + String autoFetchConfigFile = new File(tempDir, "auto-fetch-fail.config").getAbsolutePath(); + HttpUtil.clearHttpClient(); + + CommandResult result = kcAdmV2Cmd(cacheDir, autoFetchConfigFile, + "config", "credentials", + "--server", keycloakUrls.getBase(), + "--realm", "master", + "--client", Config.getAdminClientId(), + "--secret", Config.getAdminClientSecret()); + + assertThat("login should succeed even when auto-fetch fails: " + result.err(), result.exitCode(), is(0)); + assertFalse(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)), + "no registry should be created when auto-fetch fails"); + + // Warning should explain: what failed, why it matters, and how to fix it + assertThat("should mention fetch failure: " + result.err(), + result.err(), containsString("Failed to fetch OpenAPI")); + assertThat("should explain why it matters: " + result.err(), + result.err(), containsString("CLI commands may not match your server version")); + assertThat("should suggest manual fallback: " + result.err(), + result.err(), containsString("config openapi")); + } + private String createClientWithAllParams(String clientId) { CommandResult result = kcAdmV2Cmd("client", "create", "oidc", "--client-id", clientId, @@ -613,48 +632,7 @@ public class KcAdmV2ClientCLITest { } private CommandResult kcAdmV2Cmd(String... args) { - CommandLine cli = Globals.createCommandLine(new KcAdmV2Cmd(), KcAdmMain.CMD, new PrintWriter(System.err, true)); - - StringWriter out = new StringWriter(); - StringWriter err = new StringWriter(); - cli.setOut(new PrintWriter(out)); - cli.setErr(new PrintWriter(err)); - - String[] fullArgs = new String[args.length + 2]; - System.arraycopy(args, 0, fullArgs, 0, args.length); - fullArgs[args.length] = "--config"; - fullArgs[args.length + 1] = configFilePath; - - int exitCode = cli.execute(fullArgs); - return new CommandResult(exitCode, out.toString(), err.toString()); - } - - private CommandResult kcAdmV2CmdRaw(String... args) { - CommandLine cli = Globals.createCommandLine(new KcAdmV2Cmd(), KcAdmMain.CMD, new PrintWriter(System.err, true)); - - StringWriter out = new StringWriter(); - StringWriter err = new StringWriter(); - cli.setOut(new PrintWriter(out)); - cli.setErr(new PrintWriter(err)); - - int exitCode = cli.execute(args); - return new CommandResult(exitCode, out.toString(), err.toString()); - } - - private CommandResult kcAdmV2CmdNoConfig(String... args) { - CommandLine cli = Globals.createCommandLine(new KcAdmV2Cmd(), KcAdmMain.CMD, new PrintWriter(System.err, true)); - - StringWriter out = new StringWriter(); - StringWriter err = new StringWriter(); - cli.setOut(new PrintWriter(out)); - cli.setErr(new PrintWriter(err)); - - String[] fullArgs = new String[args.length + 1]; - System.arraycopy(args, 0, fullArgs, 0, args.length); - fullArgs[args.length] = "--no-config"; - - int exitCode = cli.execute(fullArgs); - return new CommandResult(exitCode, out.toString(), err.toString()); + return kcAdmV2Cmd(null, configFilePath, args); } private String getTokenFromConfig() { @@ -676,9 +654,6 @@ public class KcAdmV2ClientCLITest { } } - record CommandResult(int exitCode, String out, String err) { - } - public static class V2ApiServerConfig implements KeycloakServerConfig { @Override public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/KcAdmV2OpenApiFetchCLITest.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/KcAdmV2OpenApiFetchCLITest.java new file mode 100644 index 00000000000..1335d0d1137 --- /dev/null +++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/KcAdmV2OpenApiFetchCLITest.java @@ -0,0 +1,254 @@ +package org.keycloak.tests.admin.cli.v2; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.keycloak.client.admin.cli.v2.KcAdmV2Cmd; +import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor; +import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.CommandDescriptor; +import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.OptionDescriptor; +import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.ResourceDescriptor; +import org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache; +import org.keycloak.client.cli.util.Headers; +import org.keycloak.client.cli.util.HeadersBody; +import org.keycloak.client.cli.util.HeadersBodyStatus; +import org.keycloak.client.cli.util.HttpUtil; +import org.keycloak.common.Profile; +import org.keycloak.config.OpenApiOptions; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.config.Config; +import org.keycloak.testframework.server.KeycloakServerConfig; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache.REGISTRY_FILENAME; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest(config = KcAdmV2OpenApiFetchCLITest.OpenApiServerConfig.class) +public class KcAdmV2OpenApiFetchCLITest extends AbstractKcAdmV2CLITest { + + @TempDir + File tempDir; + + @Test + void testWrongOpenApiUrlFailsHard() { + Path cacheDir = cacheDir("wrong-url"); + String configFile = configFile("wrong-url.config"); + + login(cacheDir, configFile); + + String wrongUrl = keycloakUrls.getBase() + "/wrong/openapi"; + CommandResult result = kcAdmV2Cmd(cacheDir, configFile, + "config", "openapi", wrongUrl); + + assertThat("should fail when OpenAPI URL is wrong: " + result.err(), + result.exitCode(), is(not(0))); + assertThat("error should mention the URL: " + result.err(), + result.err(), containsString(wrongUrl)); + } + + @Test + void testOpenApiFetched() throws Exception { + Path cacheDir = cacheDir("fetched"); + String configFile = configFile("fetched.config"); + + CommandResult loginResult = login(cacheDir, configFile); + + assertTrue(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)), + "registry should exist after login (auto-fetched)"); + // we must assert that user is informed where we took the OpenAPI document from + // as we assume that on the same base URL but with a different port, + // there runs our Keycloak server management interface, but theoretically, it could be some other service + assertThat("auto-fetch should report the URL it fetched from: " + loginResult.err(), + loginResult.err(), containsString("fetched from ")); + + // Delete the registry to simulate auto-fetch failure, then verify explicit fetch recovers + Files.delete(cacheDir.resolve(REGISTRY_FILENAME)); + assertFalse(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)), + "registry should be gone after delete"); + + CommandResult fetchResult = fetchOpenApi(cacheDir, configFile); + assertThat("config openapi should succeed: " + fetchResult.err(), + fetchResult.exitCode(), is(0)); + assertThat("should confirm descriptor was cached: " + fetchResult.err(), + fetchResult.err(), containsString("OpenAPI descriptor cached for")); + + assertTrue(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)), + "registry should be recreated by explicit config openapi"); + + CommandResult listResult = kcAdmV2Cmd(cacheDir, configFile, "client", "list", "-c"); + assertThat("client list should succeed: " + listResult.err(), + listResult.exitCode(), is(0)); + assertThat("should return JSON array: " + listResult.out(), + listResult.out().trim(), startsWith("[")); + } + + @Test + void testServerSpecificOpenApiUsed() { + Path cacheDir = cacheDir("server-specific"); + String configFile = configFile("server-specific.config"); + + login(cacheDir, configFile); + + KcAdmV2CommandDescriptor descriptor = KcAdmV2Cmd.loadBundledDescriptor(); + + OptionDescriptor opt = new OptionDescriptor(); + opt.setName("whatever"); + opt.setFieldName("whatever"); + opt.setType(OptionDescriptor.TYPE_STRING); + + CommandDescriptor fineTestCmd = new CommandDescriptor(); + fineTestCmd.setName("fine-test-operation"); + fineTestCmd.setResourceName("client"); + fineTestCmd.setHttpMethod("POST"); + fineTestCmd.setPath("/no-such-endpoint-at-all"); + fineTestCmd.setDescription("A test operation that does not exist on the server"); + fineTestCmd.setOptions(List.of(opt)); + + ResourceDescriptor clientResource = descriptor.getResources().stream() + .filter(r -> "client".equals(r.getName())) + .findFirst() + .orElseThrow(); + + List commands = new ArrayList<>(clientResource.getCommands()); + commands.add(fineTestCmd); + clientResource.setCommands(commands); + + KcAdmV2DescriptorCache cache = new KcAdmV2DescriptorCache(cacheDir); + cache.save(keycloakUrls.getBase(), descriptor); + + CommandResult result = kcAdmV2Cmd(cacheDir, configFile, + "client", "fine-test-operation", "--whatever", "test"); + assertThat("server should reject the fake operation: " + result.err(), + result.exitCode(), is(not(0))); + assertThat("server should reject the unknown operation: " + result.err(), + result.err(), containsString("Unable to find matching target resource method")); + } + + @Test + void testOpenApiFetchedFromFile() throws Exception { + Path cacheDir = cacheDir("from-file"); + String configFile = configFile("from-file.config"); + + login(cacheDir, configFile); + + // Fetch raw OpenAPI JSON from the server + String openApiUrl = managementBaseUrl() + "/openapi"; + HeadersBodyStatus response = HttpUtil.doRequest("get", openApiUrl, new HeadersBody(new Headers())); + response.checkSuccess(); + + // Save to a local file + Path openApiFile = tempDir.toPath().resolve("openapi-spec.json"); + Files.write(openApiFile, response.getBody().readAllBytes()); + + // Delete the registry so we can verify file loading recreates it + Files.delete(cacheDir.resolve(REGISTRY_FILENAME)); + assertFalse(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)), + "registry should be gone after delete"); + + // Load from file via config openapi + CommandResult result = kcAdmV2Cmd(cacheDir, configFile, + "config", "openapi", openApiFile.toString()); + + assertThat("config openapi from file should succeed: " + result.err(), + result.exitCode(), is(0)); + assertThat("should confirm descriptor was cached: " + result.err(), + result.err(), containsString("OpenAPI descriptor cached for")); + assertTrue(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)), + "registry should be recreated by config openapi from file"); + + // Verify the cached descriptor is usable + CommandResult listResult = kcAdmV2Cmd(cacheDir, configFile, "client", "list", "-c"); + assertThat("client list should succeed with file-loaded descriptor: " + listResult.err(), + listResult.exitCode(), is(0)); + assertThat("should return JSON array: " + listResult.out(), + listResult.out().trim(), startsWith("[")); + } + + @Test + void testOpenApiInvalidFileContent() throws Exception { + Path cacheDir = cacheDir("invalid-content"); + String configFile = configFile("invalid-content.config"); + + login(cacheDir, configFile); + + Path invalidFile = tempDir.toPath().resolve("not-openapi.yaml"); + Files.writeString(invalidFile, """ + openapi: 3.0.0 + info: + title: not real + """); + + CommandResult result = kcAdmV2Cmd(cacheDir, configFile, + "config", "openapi", invalidFile.toString()); + + assertThat("should fail for invalid OpenAPI content: " + result.err(), + result.exitCode(), is(not(0))); + assertThat("error should mention the source: " + result.err(), + result.err(), containsString(invalidFile.toString())); + assertThat("error should explain why: " + result.err(), + result.err(), containsString("no resources")); + } + + @Test + void testOpenApiInvalidSource() { + Path cacheDir = cacheDir("invalid-source"); + String configFile = configFile("invalid-source.config"); + + login(cacheDir, configFile); + + CommandResult result = kcAdmV2Cmd(cacheDir, configFile, + "config", "openapi", "/nonexistent/openapi.json"); + + assertThat("should fail for invalid source: " + result.err(), + result.exitCode(), is(not(0))); + assertThat("error should mention it's not a URL and file not found: " + result.err(), + result.err(), containsString("no file found at")); + } + + private CommandResult login(Path cacheDir, String configFile) { + HttpUtil.clearHttpClient(); + + CommandResult result = kcAdmV2Cmd(cacheDir, configFile, + "config", "credentials", + "--server", keycloakUrls.getBase(), + "--realm", "master", + "--client", Config.getAdminClientId(), + "--secret", Config.getAdminClientSecret()); + assertThat("login should succeed: " + result.err(), result.exitCode(), is(0)); + return result; + } + + private CommandResult fetchOpenApi(Path cacheDir, String configFile) { + String openApiUrl = managementBaseUrl() + "/openapi"; + return kcAdmV2Cmd(cacheDir, configFile, "config", "openapi", openApiUrl); + } + + private Path cacheDir(String name) { + return tempDir.toPath().resolve(name); + } + + private String configFile(String name) { + return new File(tempDir, name).getAbsolutePath(); + } + + public static class OpenApiServerConfig implements KeycloakServerConfig { + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return config.features(Profile.Feature.CLIENT_ADMIN_API_V2, Profile.Feature.OPENAPI) + .option(OpenApiOptions.OPENAPI_ENABLED.getKey(), "true"); + } + } +} diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/KcAdmV2OpenApiUrlCLITest.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/KcAdmV2OpenApiUrlCLITest.java new file mode 100644 index 00000000000..3c0d3835ed9 --- /dev/null +++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/cli/v2/KcAdmV2OpenApiUrlCLITest.java @@ -0,0 +1,125 @@ +package org.keycloak.tests.admin.cli.v2; + +import java.io.File; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.keycloak.client.cli.util.HttpUtil; +import org.keycloak.common.Profile; +import org.keycloak.config.ManagementOptions; +import org.keycloak.config.OpenApiOptions; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.config.Config; +import org.keycloak.testframework.server.KeycloakServerConfig; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache.REGISTRY_FILENAME; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest(config = KcAdmV2OpenApiUrlCLITest.NonDefaultManagementPortConfig.class) +public class KcAdmV2OpenApiUrlCLITest extends AbstractKcAdmV2CLITest { + + private static final String NON_DEFAULT_MANAGEMENT_PORT = "9004"; + + @TempDir + File tempDir; + + @Test + void testLoginWithOpenApiUrl() { + Path cacheDir = tempDir.toPath().resolve("openapi-url"); + String configFile = new File(tempDir, "openapi-url.config").getAbsolutePath(); + + HttpUtil.clearHttpClient(); + + // Login with --openapi-url pointing to the actual (non-default) management URL + String openApiUrl = managementOpenApiUrl(); + CommandResult result = kcAdmV2Cmd(cacheDir, configFile, + "config", "credentials", + "--server", keycloakUrls.getBase(), + "--realm", "master", + "--client", Config.getAdminClientId(), + "--secret", Config.getAdminClientSecret(), + "--openapi-url", openApiUrl); + + assertThat("login should succeed: " + result.err(), result.exitCode(), is(0)); + assertTrue(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)), + "registry should exist — auto-fetch should use the provided --openapi-url"); + assertThat("should report the URL it fetched from: " + result.err(), + result.err(), containsString("fetched from " + openApiUrl)); + + // Verify the auto-fetched descriptor is usable + CommandResult listResult = kcAdmV2Cmd(cacheDir, configFile, "client", "list", "-c"); + assertThat("client list should succeed: " + listResult.err(), + listResult.exitCode(), is(0)); + assertThat("should return JSON array: " + listResult.out(), + listResult.out().trim(), startsWith("[")); + } + + @Test + void testLoginWithoutOpenApiUrlFailsOnNonDefaultPort() { + Path cacheDir = tempDir.toPath().resolve("no-openapi-url"); + String configFile = new File(tempDir, "no-openapi-url.config").getAbsolutePath(); + + HttpUtil.clearHttpClient(); + + // Login WITHOUT --openapi-url — auto-fetch tries default port 9000, but server is on NON_DEFAULT_MANAGEMENT_PORT + CommandResult result = kcAdmV2Cmd(cacheDir, configFile, + "config", "credentials", + "--server", keycloakUrls.getBase(), + "--realm", "master", + "--client", Config.getAdminClientId(), + "--secret", Config.getAdminClientSecret()); + + assertThat("login should succeed even when auto-fetch fails: " + result.err(), result.exitCode(), is(0)); + assertFalse(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)), + "no registry should be created — auto-fetch should fail on wrong port"); + assertThat("should warn about auto-fetch failure: " + result.err(), + result.err(), containsString("Failed to fetch OpenAPI")); + + // Explicit config openapi with the correct URL should recover + String openApiUrl = managementOpenApiUrl(); + CommandResult fetchResult = kcAdmV2Cmd(cacheDir, configFile, + "config", "openapi", openApiUrl); + assertThat("explicit config openapi should succeed: " + fetchResult.err(), + fetchResult.exitCode(), is(0)); + assertTrue(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)), + "registry should exist after explicit config openapi"); + + // Verify the descriptor is usable + CommandResult listResult = kcAdmV2Cmd(cacheDir, configFile, "client", "list", "-c"); + assertThat("client list should succeed: " + listResult.err(), + listResult.exitCode(), is(0)); + assertThat("should return JSON array: " + listResult.out(), + listResult.out().trim(), startsWith("[")); + } + + private String managementOpenApiUrl() { + // FIXME: drop this when https://github.com/keycloak/keycloak/issues/47673 is fixed + try { + URL managementUrl = new URL(managementBaseUrl()); + return managementUrl.getProtocol() + "://" + managementUrl.getHost() + + ":" + NON_DEFAULT_MANAGEMENT_PORT + "/openapi"; + } catch (Exception e) { + throw new AssertionError("Could not parse management base URL", e); + } + } + + public static class NonDefaultManagementPortConfig implements KeycloakServerConfig { + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return config.features(Profile.Feature.CLIENT_ADMIN_API_V2, Profile.Feature.OPENAPI) + .option(OpenApiOptions.OPENAPI_ENABLED.getKey(), "true") + .option(ManagementOptions.HTTP_MANAGEMENT_PORT.getKey(), NON_DEFAULT_MANAGEMENT_PORT); + } + } +}