diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a1fd9079..dc2d0bb4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -97,6 +97,42 @@ jobs: - name: Run integration tests run: mvn -B -pl ${{ matrix.product }} -am verify + setup-tests: + needs: unit-tests + + runs-on: ubuntu-24.04 + + strategy: + fail-fast: false + matrix: + product: + - confluence + - crowd + - jira + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: 21 + distribution: temurin + cache: 'maven' + + # The Jira and Confluence tests skip without a license secret; the Crowd + # test then still verifies the wizard mechanics against a real instance. + # Secret names are case-insensitive, so the product name resolves the + # SETUP_IT_LICENSE_ secret directly. + - name: Run setup CLI integration tests + env: + BOOTSTRAPI_SETUP_IT: true + BOOTSTRAPI_SETUP_IT_LICENSE: ${{ secrets[format('SETUP_IT_LICENSE_{0}', matrix.product)] }} + run: mvn -B -pl ${{ matrix.product }} -am test -Dtest=SetupCliIT -Dsurefire.failIfNoSpecifiedTests=false + apcc: needs: build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64bbfd56..79e9ac44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,6 +55,14 @@ Models are Lombok beans annotated with `@Data`: - Expected status codes are asserted as plain integer literals (`assertEquals(200, ...)`). - Test fixtures reuse the models' `EXAMPLE_*` constants and derive keys via `FieldNames` instead of repeating literals. +### Setup CLI integration tests + +The setup CLIs (`java -jar .jar` drives the product setup wizard) have integration tests that boot the official product container images together with a PostgreSQL container. They are excluded from normal builds and run explicitly: + + BOOTSTRAPI_SETUP_IT=true BOOTSTRAPI_SETUP_IT_LICENSE= mvn -pl test -Dtest=SetupCliIT + +The Jira and Confluence tests require a license and skip without one (the public Data Center timebomb licenses work). The Crowd test runs without a license by verifying that the CLI fails loud when the wizard rejects input; with a license it verifies the complete setup. + ## Pull requests - Keep pull requests focused on one topic. diff --git a/README.md b/README.md index 1c2054f7..872b5ac5 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ The startup configuration is cluster-safe: only one node applies the file at a t A configuration that cannot be read, parsed or fully applied stops the application, so an instance never comes up with a configuration it could not reach - in a rolling deployment this blocks the rollout instead of hiding the failure. Since only successful applies are recorded, the configuration is retried when the instance is started again. +## Instance setup + +The plugin JARs double as setup tools: running `java -jar bootstrapi--plugin.jar` drives the product setup wizard of a freshly installed instance over HTTP, configured through `BOOTSTRAPI_SETUP_*` environment variables (base URL, license, database connection, administrator account). Together with the startup configuration this makes a complete instance bootstrap declarative: a deployment hook runs the setup from the same artifact that is installed as the plugin, and the `bootstrapi.yaml` applies everything else the moment the setup completes. + +The Crowd and Jira wizards are driven completely, including the database step. For Confluence the database connection and license must already be configured, e.g. through the `ATL_*` environment variables of the official container images. + ## Installation Download the plugin for your product from the [releases](https://github.com/deftdevs/bootstrapi/releases) and upload it in the product's administration under *Manage apps* → *Upload app*. The endpoints require a user with system administrator permissions. diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupEnv.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupEnv.java new file mode 100644 index 00000000..b4e554fe --- /dev/null +++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupEnv.java @@ -0,0 +1,35 @@ +package com.deftdevs.bootstrapi.commons.cli; + +/** + * Environment access for the setup CLIs. System properties take precedence + * over environment variables so tests (and ad hoc runs) can override values + * without touching the process environment. + */ +public final class SetupEnv { + + public static String require( + final String name) { + + final String value = get(name, null); + if (value == null || value.isBlank()) { + throw new SetupException("Required environment variable '" + name + "' is not set"); + } + return value; + } + + public static String get( + final String name, + final String defaultValue) { + + final String property = System.getProperty(name); + if (property != null) { + return property; + } + + final String env = System.getenv(name); + return env != null ? env : defaultValue; + } + + private SetupEnv() { + } +} diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupException.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupException.java new file mode 100644 index 00000000..c7dd489f --- /dev/null +++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupException.java @@ -0,0 +1,17 @@ +package com.deftdevs.bootstrapi.commons.cli; + +public class SetupException extends RuntimeException { + + public SetupException( + final String message) { + + super(message); + } + + public SetupException( + final String message, + final Throwable cause) { + + super(message, cause); + } +} diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSession.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSession.java new file mode 100644 index 00000000..6abc5883 --- /dev/null +++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSession.java @@ -0,0 +1,247 @@ +package com.deftdevs.bootstrapi.commons.cli; + +import java.io.IOException; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * A cookie-aware HTTP session for driving the product setup wizards from the + * command line. Deliberately built on the JDK HTTP client only: the setup + * CLIs run the plain plugin JAR outside the application, where none of the + * provided dependencies are available. + */ +public class SetupHttpSession { + + // single wizard steps can take very long, e.g. Jira initialising the database + private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(10); + + static { + // the JDK HTTP client follows at most five redirects by default, + // but e.g. the Confluence setup redirect chain is longer + if (System.getProperty("jdk.httpclient.redirects.retrylimit") == null) { + System.setProperty("jdk.httpclient.redirects.retrylimit", "10"); + } + } + + private final URI baseUri; + private final HttpClient followingClient; + private final HttpClient plainClient; + + public SetupHttpSession( + final String baseUrl) { + + this.baseUri = URI.create(baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl); + final CookieManager cookieManager = new CookieManager(null, CookiePolicy.ACCEPT_ALL); + this.followingClient = HttpClient.newBuilder() + .cookieHandler(cookieManager) + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + this.plainClient = HttpClient.newBuilder() + .cookieHandler(cookieManager) + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** + * Polls the given path until it answers with a success status. + */ + public void waitUntilAvailable( + final String path, + final Duration timeout, + final Duration pollInterval) { + + final long deadline = System.nanoTime() + timeout.toNanos(); + while (true) { + try { + final HttpResponse response = send(followingClient, getRequest(path)); + if (response.statusCode() < 300) { + return; + } + } catch (SetupException e) { + // not reachable yet + } + + if (System.nanoTime() > deadline) { + throw new SetupException("Timed out waiting for " + baseUri + path + " to become available"); + } + + System.out.println("Waiting for " + baseUri + " to become available..."); + try { + Thread.sleep(pollInterval.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SetupException("Interrupted while waiting for " + baseUri, e); + } + } + } + + /** + * Polls the given path until its body contains one of the expected + * markers, e.g. the application state in the status endpoint of Jira and + * Confluence, which answers long before the application is ready. + */ + public String waitForAnyState( + final String path, + final Duration timeout, + final Duration pollInterval, + final String... states) { + + final long deadline = System.nanoTime() + timeout.toNanos(); + while (true) { + try { + final String body = get(path); + for (final String state : states) { + if (body.contains(state)) { + return body; + } + } + } catch (SetupException e) { + // not reachable yet + } + + if (System.nanoTime() > deadline) { + throw new SetupException("Timed out waiting for " + baseUri + path + + " to report one of " + String.join(", ", states)); + } + + System.out.println("Waiting for " + baseUri + " to become ready..."); + try { + Thread.sleep(pollInterval.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SetupException("Interrupted while waiting for " + baseUri, e); + } + } + } + + /** + * GET following redirects; fails on a non-success status. + */ + public String get( + final String path) { + + final HttpResponse response = send(followingClient, getRequest(path)); + requireSuccess("GET", path, response); + return response.body(); + } + + /** + * GET without following redirects, returning the redirect target if any. + */ + public Optional getLocation( + final String path) { + + final HttpResponse response = send(plainClient, getRequest(path)); + return response.headers().firstValue("location"); + } + + /** + * POST a form following redirects; fails on a non-success status. + */ + public String postForm( + final String path, + final Map form) { + + final String body = form.entrySet().stream() + .map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue())) + .collect(Collectors.joining("&")); + final HttpRequest request = HttpRequest.newBuilder(resolve(path)) + .timeout(REQUEST_TIMEOUT) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + final HttpResponse response = send(followingClient, request); + requireSuccess("POST", path, response); + return response.body(); + } + + /** + * Extracts the value of a named form input from an HTML page, e.g. the + * {@code atl_token} XSRF token of a setup wizard step. + */ + public static String parseFormInput( + final String html, + final String name) { + + // attributes may be spread over multiple lines + final String stripped = html.replaceAll("\\s", ""); + final String marker = "name=\"" + name + "\""; + + final int nameIndex = stripped.indexOf(marker); + if (nameIndex < 0) { + throw new SetupException("No form input '" + name + "' found in the response page"); + } + + final int tagStart = Math.max(stripped.lastIndexOf('<', nameIndex), 0); + final int tagEnd = stripped.indexOf('>', nameIndex); + final String tag = stripped.substring(tagStart, tagEnd > 0 ? tagEnd : stripped.length()); + + final Matcher matcher = Pattern.compile("value=\"([^\"]*)\"").matcher(tag); + if (!matcher.find()) { + throw new SetupException("No value found for form input '" + name + "'"); + } + return matcher.group(1); + } + + private HttpRequest getRequest( + final String path) { + + return HttpRequest.newBuilder(resolve(path)) + .timeout(REQUEST_TIMEOUT) + .GET() + .build(); + } + + private URI resolve( + final String path) { + + return URI.create(baseUri + path); + } + + private HttpResponse send( + final HttpClient client, + final HttpRequest request) { + + try { + return client.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (IOException e) { + throw new SetupException("Request to " + request.uri() + " failed: " + e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SetupException("Request to " + request.uri() + " was interrupted", e); + } + } + + private static void requireSuccess( + final String method, + final String path, + final HttpResponse response) { + + if (response.statusCode() >= 300) { + final String body = response.body() != null ? response.body() : ""; + throw new SetupException(method + " " + path + " failed with status " + response.statusCode() + + (body.isBlank() ? "" : ": " + body.substring(0, Math.min(body.length(), 500)))); + } + } + + private static String encode( + final String value) { + + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/commons/src/test/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSessionTest.java b/commons/src/test/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSessionTest.java new file mode 100644 index 00000000..d46ce5e2 --- /dev/null +++ b/commons/src/test/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSessionTest.java @@ -0,0 +1,38 @@ +package com.deftdevs.bootstrapi.commons.cli; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SetupHttpSessionTest { + + @Test + void testParseFormInput() { + final String html = "
"; + assertEquals("TOKEN123", SetupHttpSession.parseFormInput(html, "atl_token")); + } + + @Test + void testParseFormInputWithAttributeBetweenNameAndValue() { + final String html = ""; + assertEquals("TOKEN456", SetupHttpSession.parseFormInput(html, "atl_token")); + } + + @Test + void testParseFormInputAcrossMultipleLines() { + final String html = ""; + assertEquals("BTKU-QFB7-2EPG-9S8L", SetupHttpSession.parseFormInput(html, "sid")); + } + + @Test + void testParseFormInputWithValueBeforeName() { + final String html = ""; + assertEquals("TOKEN789", SetupHttpSession.parseFormInput(html, "atl_token")); + } + + @Test + void testParseFormInputMissingField() { + assertThrows(SetupException.class, () -> SetupHttpSession.parseFormInput("", "atl_token")); + } +} diff --git a/confluence/pom.xml b/confluence/pom.xml index fbf783cd..83073d70 100644 --- a/confluence/pom.xml +++ b/confluence/pom.xml @@ -331,6 +331,24 @@ test + + org.postgresql + postgresql + test + + + + org.testcontainers + testcontainers-junit-jupiter + test + + + + org.testcontainers + testcontainers-postgresql + test + + org.glassfish.jersey.core jersey-common @@ -364,6 +382,8 @@ ${atlassian.plugin.key} + + com.deftdevs.bootstrapi.confluence.cli.SetupCli *;resolution:="optional" diff --git a/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/cli/SetupCli.java b/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/cli/SetupCli.java new file mode 100644 index 00000000..e23cd901 --- /dev/null +++ b/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/cli/SetupCli.java @@ -0,0 +1,160 @@ +package com.deftdevs.bootstrapi.confluence.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupEnv; +import com.deftdevs.bootstrapi.commons.cli.SetupException; +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Drives the Confluence setup wizard over HTTP so a fresh instance can be + * set up unattended, e.g. from a deployment hook job. Run it directly from + * the plugin JAR: {@code java -jar bootstrapi-confluence-plugin.jar}. + *

+ * The license and database connection must already be configured (a + * pre-seeded {@code confluence.cfg.xml}); the wizard then starts at the + * data step. + */ +public class SetupCli { + + private final SetupHttpSession session; + + // the setup steps in wizard order with the redirect markers used to resume + private final List steps = List.of( + new Step("/setup/setupcluster-start.action", this::setupCluster), + new Step("/setup/setupdata-start.action", this::setupData), + new Step("/setup/setupusermanagementchoice-start.action", this::setupUserManagement), + new Step("/setup/setupadministrator-start.action", this::setupAdministrator), + new Step("/setup/finishsetup-start.action", this::finishSetup)); + + public static void main( + final String[] args) { + + try { + System.exit(new SetupCli(new SetupHttpSession(SetupEnv.require("BOOTSTRAPI_SETUP_BASE_URL"))).run()); + } catch (SetupException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(1); + } + } + + SetupCli( + final SetupHttpSession session) { + + this.session = session; + } + + int run() { + final Duration timeout = Duration.ofSeconds(Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "300"))); + final Duration pollInterval = Duration.ofSeconds(Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_POLL_SECONDS", "5"))); + + // the status endpoint answers with STARTING long before the application is + // ready; FIRST_RUN means the setup wizard is pending + final String status = session.waitForAnyState("/status", timeout, pollInterval, "RUNNING", "FIRST_RUN"); + if (status.contains("RUNNING")) { + System.out.println("Confluence is already set up, nothing to do."); + return 0; + } + + // the initial page request creates the session cookie + session.get("/"); + + final String stepLocation = session.getLocation("/bootstrap/selectsetupstep.action") + .orElseThrow(() -> new SetupException("The setup step selection did not redirect to a setup step")); + + int stepIndex = -1; + for (int i = 0; i < steps.size(); i++) { + if (stepLocation.contains(steps.get(i).marker)) { + stepIndex = i; + break; + } + } + if (stepIndex < 0) { + throw new SetupException("Unknown setup step: " + stepLocation); + } + + for (int i = stepIndex; i < steps.size(); i++) { + steps.get(i).action.run(); + } + + // the wizard answers invalid input with a 200 error page and stays on the + // current step, so only the resulting state proves the setup went through + if (!session.get("/status").contains("RUNNING")) { + throw new SetupException("The setup did not complete; the application does not report state RUNNING" + + " (most likely a submitted value was rejected)"); + } + + System.out.println("Setting up Confluence done."); + return 0; + } + + private void setupCluster() { + System.out.println("Skipping the clustering setup..."); + + // a cluster is configured through confluence.cfg.xml, not through the wizard + + final Map form = new LinkedHashMap<>(); + form.put("newCluster", "skipCluster"); + form.put("atl_token", token()); + session.postForm("/setup/setupcluster.action", form); + } + + private void setupData() { + System.out.println("Setting up data..."); + final Map form = new LinkedHashMap<>(); + form.put("dbchoiceSelect", "Empty Site"); + form.put("contentChoice", "blank"); + form.put("atl_token", token()); + session.postForm("/setup/setupdata.action", form); + } + + private void setupUserManagement() { + System.out.println("Setting up user management..."); + final Map form = new LinkedHashMap<>(); + form.put("userManagementChoice", "internal"); + form.put("internal", "Manage users and groups within Confluence"); + form.put("atl_token", token()); + session.postForm("/setup/setupusermanagementchoice.action", form); + } + + private void setupAdministrator() { + System.out.println("Setting up administrator..."); + final String password = SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_PASSWORD"); + + final Map form = new LinkedHashMap<>(); + form.put("username", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_USERNAME")); + form.put("fullName", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_FULL_NAME")); + form.put("email", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_EMAIL")); + form.put("password", password); + form.put("confirm", password); + form.put("setup-next-button", "Next"); + form.put("atl_token", token()); + session.postForm("/setup/setupadministrator.action", form); + } + + private void finishSetup() { + System.out.println("Finishing the setup..."); + session.get("/setup/finishsetup.action"); + } + + private String token() { + return SetupHttpSession.parseFormInput(session.get("/"), "atl_token"); + } + + private static class Step { + + private final String marker; + private final Runnable action; + + private Step( + final String marker, + final Runnable action) { + + this.marker = marker; + this.action = action; + } + } +} diff --git a/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliIT.java b/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliIT.java new file mode 100644 index 00000000..29f27280 --- /dev/null +++ b/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliIT.java @@ -0,0 +1,89 @@ +package com.deftdevs.bootstrapi.confluence.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupEnv; +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Sets up a pristine Confluence from the official container image by driving + * the setup wizard, backed by a PostgreSQL container. The image templates the + * database connection and the license into {@code confluence.cfg.xml}, so the + * wizard starts at the data step. Gated behind + * {@code BOOTSTRAPI_SETUP_IT=true} and a license in + * {@code BOOTSTRAPI_SETUP_IT_LICENSE} (the public Confluence Data Center + * timebomb license works). + */ +@Testcontainers +@EnabledIfEnvironmentVariable(named = "BOOTSTRAPI_SETUP_IT", matches = "true") +class SetupCliIT { + + static { + // the bundled docker-java client defaults to API version 1.32, which recent + // Docker daemons reject (Docker 29 requires at least 1.40); 1.41 is accepted + // by every daemon since Docker 20.10 + if (System.getProperty("api.version") == null && System.getenv("DOCKER_API_VERSION") == null) { + System.setProperty("api.version", "1.41"); + } + } + + private static final Network NETWORK = Network.newNetwork(); + + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine") + .withNetwork(NETWORK) + .withNetworkAliases("postgres") + .withDatabaseName("confluence") + .withUsername("confluence") + .withPassword("confluence"); + + @Container + private static final GenericContainer CONFLUENCE = new GenericContainer<>( + SetupEnv.get("BOOTSTRAPI_SETUP_IT_IMAGE", "atlassian/confluence:10.2.14")) + .withNetwork(NETWORK) + .withExposedPorts(8090) + .withEnv("ATL_DB_TYPE", "postgresql") + .withEnv("ATL_JDBC_URL", "jdbc:postgresql://postgres:5432/confluence") + .withEnv("ATL_JDBC_USER", "confluence") + .withEnv("ATL_JDBC_PASSWORD", "confluence") + .withEnv("ATL_LICENSE_KEY", SetupEnv.get("BOOTSTRAPI_SETUP_IT_LICENSE", "")) + .withEnv("JVM_MINIMUM_MEMORY", "1g") + .withEnv("JVM_MAXIMUM_MEMORY", "2g"); + + @AfterAll + static void teardown() { + System.getProperties().keySet().removeIf(key -> key.toString().startsWith("BOOTSTRAPI_SETUP_")); + } + + @Test + void testSetup() { + assumeTrue(SetupEnv.get("BOOTSTRAPI_SETUP_IT_LICENSE", null) != null, + "BOOTSTRAPI_SETUP_IT_LICENSE is not set"); + + final String baseUrl = "http://" + CONFLUENCE.getHost() + ":" + CONFLUENCE.getMappedPort(8090); + System.setProperty("BOOTSTRAPI_SETUP_BASE_URL", baseUrl); + System.setProperty("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "900"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_USERNAME", "admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_PASSWORD", "admin-secret-1"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_FULL_NAME", "Admin Admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_EMAIL", "admin@example.com"); + + final SetupHttpSession session = new SetupHttpSession(baseUrl); + assertEquals(0, new SetupCli(session).run()); + + assertTrue(session.get("/status").contains("RUNNING")); + + // a second run is a no-op + assertEquals(0, new SetupCli(session).run()); + } +} diff --git a/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliTest.java b/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliTest.java new file mode 100644 index 00000000..e1c33a8d --- /dev/null +++ b/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliTest.java @@ -0,0 +1,151 @@ +package com.deftdevs.bootstrapi.confluence.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SetupCliTest { + + private HttpServer server; + private String baseUrl; + private final Map> capturedForms = new LinkedHashMap<>(); + private final AtomicBoolean finishCalled = new AtomicBoolean(); + + @BeforeEach + void setup() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/confluence"; + + System.setProperty("BOOTSTRAPI_SETUP_BASE_URL", baseUrl); + System.setProperty("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "5"); + System.setProperty("BOOTSTRAPI_SETUP_POLL_SECONDS", "1"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_USERNAME", "admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_PASSWORD", "secret"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_FULL_NAME", "Admin Admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_EMAIL", "admin@example.com"); + } + + @AfterEach + void teardown() { + server.stop(0); + System.getProperties().keySet().removeIf(key -> key.toString().startsWith("BOOTSTRAPI_SETUP_")); + } + + @Test + void testFullSetupRun() { + stubWizard("/setup/setupcluster-start.action"); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertEquals("skipCluster", capturedForms.get("/confluence/setup/setupcluster.action").get("newCluster")); + assertEquals("Empty Site", capturedForms.get("/confluence/setup/setupdata.action").get("dbchoiceSelect")); + assertEquals("internal", capturedForms.get("/confluence/setup/setupusermanagementchoice.action").get("userManagementChoice")); + assertEquals("admin", capturedForms.get("/confluence/setup/setupadministrator.action").get("username")); + assertEquals("secret", capturedForms.get("/confluence/setup/setupadministrator.action").get("confirm")); + assertEquals("BASE-TOKEN", capturedForms.get("/confluence/setup/setupadministrator.action").get("atl_token")); + assertTrue(finishCalled.get()); + } + + @Test + void testResumesFromAdministratorStep() { + stubWizard("/setup/setupadministrator-start.action"); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertFalse(capturedForms.containsKey("/confluence/setup/setupdata.action")); + assertFalse(capturedForms.containsKey("/confluence/setup/setupusermanagementchoice.action")); + assertTrue(capturedForms.containsKey("/confluence/setup/setupadministrator.action")); + assertTrue(finishCalled.get()); + } + + @Test + void testAlreadySetUpDoesNothing() { + server.createContext("/confluence", exchange -> { + if (exchange.getRequestURI().getPath().equals("/confluence/status")) { + respond(exchange, 200, "{\"state\":\"RUNNING\"}"); + } else { + respond(exchange, 404, "unexpected"); + } + }); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertTrue(capturedForms.isEmpty()); + assertFalse(finishCalled.get()); + } + + private void stubWizard( + final String currentStep) { + + server.createContext("/confluence", exchange -> { + final String path = exchange.getRequestURI().getPath(); + if (path.equals("/confluence/status")) { + // finishing the setup flips the application state to RUNNING + respond(exchange, 200, finishCalled.get() + ? "{\"state\":\"RUNNING\"}" + : "{\"state\":\"FIRST_RUN\"}"); + } else if (path.equals("/confluence/bootstrap/selectsetupstep.action")) { + exchange.getResponseHeaders().add("Location", "/confluence" + currentStep); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + } else if (path.equals("/confluence/setup/finishsetup.action")) { + finishCalled.set(true); + respond(exchange, 200, "done"); + } else if (path.startsWith("/confluence/setup/")) { + capture(exchange, path); + respond(exchange, 200, "ok"); + } else { + // the base page carries the session cookie and the XSRF token + respond(exchange, 200, ""); + } + }); + } + + private void capture( + final HttpExchange exchange, + final String path) throws IOException { + + final String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + final Map form = new LinkedHashMap<>(); + for (final String pair : body.split("&")) { + final String[] parts = pair.split("=", 2); + form.put(URLDecoder.decode(parts[0], StandardCharsets.UTF_8), + parts.length > 1 ? URLDecoder.decode(parts[1], StandardCharsets.UTF_8) : ""); + } + capturedForms.put(path, form); + } + + private static void respond( + final HttpExchange exchange, + final int status, + final String body) throws IOException { + + final byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } +} diff --git a/crowd/pom.xml b/crowd/pom.xml index ac318ce5..e8cb863d 100644 --- a/crowd/pom.xml +++ b/crowd/pom.xml @@ -292,6 +292,24 @@ test + + org.postgresql + postgresql + test + + + + org.testcontainers + testcontainers-junit-jupiter + test + + + + org.testcontainers + testcontainers-postgresql + test + + org.glassfish.jersey.core jersey-common @@ -319,6 +337,8 @@ ${atlassian.plugin.key} + + com.deftdevs.bootstrapi.crowd.cli.SetupCli *;resolution:="optional" diff --git a/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/cli/SetupCli.java b/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/cli/SetupCli.java new file mode 100644 index 00000000..d0bfd582 --- /dev/null +++ b/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/cli/SetupCli.java @@ -0,0 +1,215 @@ +package com.deftdevs.bootstrapi.crowd.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupEnv; +import com.deftdevs.bootstrapi.commons.cli.SetupException; +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Drives the Crowd setup wizard over HTTP so a fresh instance can be set up + * unattended, e.g. from a deployment hook job. Run it directly from the + * plugin JAR: {@code java -jar bootstrapi-crowd-plugin.jar}. + *

+ * The wizard flow is based on the crowd-init script of the ldap-crowd-adapter + * project (Apache License 2.0, ASERVO Software GmbH): + * https://github.com/aservo/ldap-crowd-adapter + */ +public class SetupCli { + + private final SetupHttpSession session; + + // the setup steps in wizard order with the redirect markers used to resume + private final List steps = List.of( + new Step("/console/setup/setuplicense.action", this::setupLicense), + new Step("/console/setup/installtype.action", this::setupInstallType), + new Step("/console/setup/setupdatabase.action", this::setupDatabase), + new Step("/console/setup/setupoptions.action", this::setupOptions), + new Step("/console/setup/directoryinternalsetup.action", this::setupInternalDirectory), + new Step("/console/setup/defaultadministrator.action", this::setupAdministrator), + new Step("/console/setup/integration.action", this::setupIntegration)); + + public static void main( + final String[] args) { + + try { + System.exit(new SetupCli(new SetupHttpSession(SetupEnv.require("BOOTSTRAPI_SETUP_BASE_URL"))).run()); + } catch (SetupException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(1); + } + } + + SetupCli( + final SetupHttpSession session) { + + this.session = session; + } + + int run() { + final Duration timeout = Duration.ofSeconds(Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "300"))); + final Duration pollInterval = Duration.ofSeconds(Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_POLL_SECONDS", "5"))); + session.waitUntilAvailable("/", timeout, pollInterval); + + final Optional loginLocation = session.getLocation("/console/login.action"); + if (loginLocation.isEmpty() || !loginLocation.get().contains("/console/setup/")) { + System.out.println("Crowd is already set up, nothing to do."); + return 0; + } + + final String stepLocation = session.getLocation("/console/setup/selectsetupstep.action") + .orElseThrow(() -> new SetupException("The setup step selection did not redirect to a setup step")); + + int stepIndex = -1; + for (int i = 0; i < steps.size(); i++) { + if (stepLocation.contains(steps.get(i).marker)) { + stepIndex = i; + break; + } + } + if (stepIndex < 0) { + throw new SetupException("Unknown setup step: " + stepLocation); + } + + for (int i = stepIndex; i < steps.size(); i++) { + steps.get(i).action.run(); + } + + // the wizard answers invalid input with a 200 error page and stays on the + // current step, so only the resulting state proves the setup went through + final Optional verification = session.getLocation("/console/login.action"); + if (verification.isPresent() && verification.get().contains("/console/setup/")) { + throw new SetupException("The setup did not complete; the wizard is still at " + verification.get() + + " (most likely a submitted value was rejected, e.g. an invalid license)"); + } + + System.out.println("Setting up Crowd done."); + return 0; + } + + private void setupLicense() { + System.out.println("Setting up license..."); + final String page = session.get("/console/setup/setuplicense.action"); + final String serverId = SetupEnv.get("BOOTSTRAPI_SETUP_SERVER_ID", + SetupHttpSession.parseFormInput(page, "sid")); + + final Map form = new LinkedHashMap<>(); + form.put("atl_token", SetupHttpSession.parseFormInput(page, "atl_token")); + form.put("sid", serverId); + form.put("key", SetupEnv.require("BOOTSTRAPI_SETUP_LICENSE")); + session.postForm("/console/setup/setuplicense!update.action", form); + } + + private void setupInstallType() { + System.out.println("Setting up install type..."); + final Map form = new LinkedHashMap<>(); + form.put("atl_token", stepToken("/console/setup/installtype.action")); + form.put("installOption", "install.new"); + session.postForm("/console/setup/installtype!update.action", form); + } + + private void setupDatabase() { + System.out.println("Setting up database..."); + final Map form = new LinkedHashMap<>(); + form.put("atl_token", stepToken("/console/setup/setupdatabase.action")); + + if ("embedded".equals(SetupEnv.get("BOOTSTRAPI_SETUP_DATABASE_OPTION", "jdbc"))) { + form.put("databaseOption", "db.embedded"); + } else { + form.put("databaseOption", "db.jdbc"); + form.put("jdbcDatabaseType", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_TYPE")); + form.put("jdbcDriverClassName", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_DRIVER")); + form.put("jdbcUrl", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_URL")); + form.put("jdbcUsername", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_USERNAME")); + form.put("jdbcPassword", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_PASSWORD")); + form.put("jdbcHibernateDialect", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_DIALECT")); + } + session.postForm("/console/setup/setupdatabase!update.action", form); + } + + private void setupOptions() { + System.out.println("Setting up options..."); + final Map form = new LinkedHashMap<>(); + form.put("atl_token", stepToken("/console/setup/setupoptions.action")); + form.put("title", SetupEnv.require("BOOTSTRAPI_SETUP_TITLE")); + form.put("sessionTime", SetupEnv.get("BOOTSTRAPI_SETUP_SESSION_TIME", "30")); + form.put("baseURL", SetupEnv.require("BOOTSTRAPI_SETUP_BASE_URL")); + session.postForm("/console/setup/setupoptions!update.action", form); + } + + private void setupInternalDirectory() { + System.out.println("Setting up internal directory..."); + + // the password policy is deliberately left open so setting up the administrator + // cannot fail; policies can be applied afterwards through the REST API + + final Map form = new LinkedHashMap<>(); + form.put("atl_token", stepToken("/console/setup/directoryinternalsetup.action")); + form.put("name", SetupEnv.get("BOOTSTRAPI_SETUP_DIRECTORY_NAME", "Internal directory")); + form.put("description", SetupEnv.get("BOOTSTRAPI_SETUP_DIRECTORY_DESCRIPTION", "")); + form.put("passwordRegex", ""); + form.put("passwordComplexityMessage", ""); + form.put("passwordMaxAttempts", SetupEnv.get("BOOTSTRAPI_SETUP_DIRECTORY_PASSWORD_MAX_ATTEMPTS", "0")); + form.put("passwordHistoryCount", SetupEnv.get("BOOTSTRAPI_SETUP_DIRECTORY_PASSWORD_HISTORY_COUNT", "0")); + form.put("passwordMaxChangeTime", SetupEnv.get("BOOTSTRAPI_SETUP_DIRECTORY_PASSWORD_MAX_CHANGE_TIME", "0")); + form.put("userEncryptionMethod", SetupEnv.get("BOOTSTRAPI_SETUP_DIRECTORY_PASSWORD_ENCRYPTION_METHOD", "atlassian-security")); + session.postForm("/console/setup/directoryinternalsetup!update.action", form); + } + + private void setupAdministrator() { + System.out.println("Setting up administrator..."); + final String password = SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_PASSWORD"); + + final Map form = new LinkedHashMap<>(); + form.put("atl_token", stepToken("/console/setup/defaultadministrator.action")); + form.put("email", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_EMAIL")); + form.put("name", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_USERNAME")); + form.put("firstname", SetupEnv.get("BOOTSTRAPI_SETUP_ADMIN_FIRST_NAME", "Admin")); + form.put("lastname", SetupEnv.get("BOOTSTRAPI_SETUP_ADMIN_LAST_NAME", "Admin")); + form.put("password", password); + form.put("passwordConfirm", password); + session.postForm("/console/setup/defaultadministrator!update.action", form); + } + + private void setupIntegration() { + System.out.println("Setting up integration..."); + + // the only available integration is the OpenID server, which stays disabled; + // newer Crowd versions no longer have this step and finish after the administrator + final String token; + try { + token = stepToken("/console/setup/integration.action"); + } catch (SetupException e) { + System.out.println("The integration step is not present, skipping."); + return; + } + + final Map form = new LinkedHashMap<>(); + form.put("atl_token", token); + session.postForm("/console/setup/integration!update.action", form); + } + + private String stepToken( + final String stepPath) { + + return SetupHttpSession.parseFormInput(session.get(stepPath), "atl_token"); + } + + private static class Step { + + private final String marker; + private final Runnable action; + + private Step( + final String marker, + final Runnable action) { + + this.marker = marker; + this.action = action; + } + } +} diff --git a/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliIT.java b/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliIT.java new file mode 100644 index 00000000..75168637 --- /dev/null +++ b/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliIT.java @@ -0,0 +1,98 @@ +package com.deftdevs.bootstrapi.crowd.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupEnv; +import com.deftdevs.bootstrapi.commons.cli.SetupException; +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Sets up a pristine Crowd from the official container image by driving the + * complete setup wizard, backed by a PostgreSQL container. Gated behind + * {@code BOOTSTRAPI_SETUP_IT=true} because it downloads and boots real + * product containers. + *

+ * Without a license in {@code BOOTSTRAPI_SETUP_IT_LICENSE} only the wizard + * mechanics are verified: the CLI must walk the wizard up to the license + * validation and fail loud, not silently. + */ +@Testcontainers +@EnabledIfEnvironmentVariable(named = "BOOTSTRAPI_SETUP_IT", matches = "true") +class SetupCliIT { + + static { + // the bundled docker-java client defaults to API version 1.32, which recent + // Docker daemons reject (Docker 29 requires at least 1.40); 1.41 is accepted + // by every daemon since Docker 20.10 + if (System.getProperty("api.version") == null && System.getenv("DOCKER_API_VERSION") == null) { + System.setProperty("api.version", "1.41"); + } + } + + private static final Network NETWORK = Network.newNetwork(); + + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine") + .withNetwork(NETWORK) + .withNetworkAliases("postgres") + .withDatabaseName("crowd") + .withUsername("crowd") + .withPassword("crowd"); + + @Container + private static final GenericContainer CROWD = new GenericContainer<>( + SetupEnv.get("BOOTSTRAPI_SETUP_IT_IMAGE", "atlassian/crowd:7.2.1")) + .withNetwork(NETWORK) + .withExposedPorts(8095); + + @AfterAll + static void teardown() { + System.getProperties().keySet().removeIf(key -> key.toString().startsWith("BOOTSTRAPI_SETUP_")); + } + + @Test + void testSetup() { + final String license = SetupEnv.get("BOOTSTRAPI_SETUP_IT_LICENSE", null); + final String baseUrl = "http://" + CROWD.getHost() + ":" + CROWD.getMappedPort(8095) + "/crowd"; + + System.setProperty("BOOTSTRAPI_SETUP_BASE_URL", baseUrl); + System.setProperty("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "600"); + System.setProperty("BOOTSTRAPI_SETUP_LICENSE", license != null ? license : "INVALID-LICENSE-KEY"); + System.setProperty("BOOTSTRAPI_SETUP_TITLE", "Setup IT"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_TYPE", "PostgreSQL"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_DRIVER", "org.postgresql.Driver"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_URL", "jdbc:postgresql://postgres:5432/crowd"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_USERNAME", "crowd"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_PASSWORD", "crowd"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_DIALECT", "org.hibernate.dialect.PostgreSQLDialect"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_USERNAME", "admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_PASSWORD", "admin-secret-1"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_EMAIL", "admin@example.com"); + + if (license == null) { + // without a real license the wizard silently stays behind (it answers + // invalid input with an error page, not an error status), so the only + // acceptable outcome is that the CLI fails loud at whatever step the + // rejection surfaces + assertThrows(SetupException.class, () -> new SetupCli(new SetupHttpSession(baseUrl)).run()); + return; + } + + assertEquals(0, new SetupCli(new SetupHttpSession(baseUrl)).run()); + + // the wizard is gone and a second run is a no-op + final SetupHttpSession session = new SetupHttpSession(baseUrl); + assumeTrue(session.getLocation("/console/login.action").isEmpty()); + assertEquals(0, new SetupCli(session).run()); + } +} diff --git a/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliTest.java b/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliTest.java new file mode 100644 index 00000000..8967ab24 --- /dev/null +++ b/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliTest.java @@ -0,0 +1,156 @@ +package com.deftdevs.bootstrapi.crowd.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SetupCliTest { + + private HttpServer server; + private String baseUrl; + private final Map> capturedForms = new LinkedHashMap<>(); + + @BeforeEach + void setup() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/crowd"; + + System.setProperty("BOOTSTRAPI_SETUP_BASE_URL", baseUrl); + System.setProperty("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "5"); + System.setProperty("BOOTSTRAPI_SETUP_POLL_SECONDS", "1"); + System.setProperty("BOOTSTRAPI_SETUP_LICENSE", "LICENSE-KEY"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_OPTION", "embedded"); + System.setProperty("BOOTSTRAPI_SETUP_TITLE", "Test Crowd"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_USERNAME", "admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_PASSWORD", "secret"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_EMAIL", "admin@example.com"); + } + + @AfterEach + void teardown() { + server.stop(0); + System.getProperties().keySet().removeIf(key -> key.toString().startsWith("BOOTSTRAPI_SETUP_")); + } + + @Test + void testFullSetupRun() { + stubWizard("/crowd/console/setup/setuplicense.action"); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertEquals("LICENSE-KEY", capturedForms.get("/crowd/console/setup/setuplicense!update.action").get("key")); + assertEquals("SERVER-ID", capturedForms.get("/crowd/console/setup/setuplicense!update.action").get("sid")); + assertEquals("install.new", capturedForms.get("/crowd/console/setup/installtype!update.action").get("installOption")); + assertEquals("db.embedded", capturedForms.get("/crowd/console/setup/setupdatabase!update.action").get("databaseOption")); + assertEquals("Test Crowd", capturedForms.get("/crowd/console/setup/setupoptions!update.action").get("title")); + assertEquals("Internal directory", capturedForms.get("/crowd/console/setup/directoryinternalsetup!update.action").get("name")); + assertEquals("admin", capturedForms.get("/crowd/console/setup/defaultadministrator!update.action").get("name")); + assertEquals("secret", capturedForms.get("/crowd/console/setup/defaultadministrator!update.action").get("passwordConfirm")); + assertEquals("STEP-TOKEN", capturedForms.get("/crowd/console/setup/integration!update.action").get("atl_token")); + } + + @Test + void testResumesFromLaterStep() { + stubWizard("/crowd/console/setup/defaultadministrator.action"); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertFalse(capturedForms.containsKey("/crowd/console/setup/setuplicense!update.action")); + assertFalse(capturedForms.containsKey("/crowd/console/setup/setupdatabase!update.action")); + assertTrue(capturedForms.containsKey("/crowd/console/setup/defaultadministrator!update.action")); + assertTrue(capturedForms.containsKey("/crowd/console/setup/integration!update.action")); + } + + @Test + void testAlreadySetUpDoesNothing() throws IOException { + // a set up instance serves the login page without redirecting to the wizard + server.createContext("/crowd", exchange -> respond(exchange, 200, "login")); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertTrue(capturedForms.isEmpty()); + } + + private void stubWizard( + final String currentStep) { + + final java.util.concurrent.atomic.AtomicBoolean wizardComplete = new java.util.concurrent.atomic.AtomicBoolean(); + server.createContext("/crowd", exchange -> { + final String path = exchange.getRequestURI().getPath(); + if (path.equals("/crowd/console/login.action") || path.equals("/crowd/console/setup/selectsetupstep.action")) { + // once the wizard has completed, the login page no longer redirects to it + if (wizardComplete.get()) { + respond(exchange, 200, "login"); + } else { + redirect(exchange, currentStep); + } + } else if (path.endsWith("!update.action")) { + capturedForms.put(path, parseForm(exchange)); + if (path.endsWith("/integration!update.action")) { + wizardComplete.set(true); + } + respond(exchange, 200, "ok"); + } else if (path.startsWith("/crowd/console/setup/")) { + respond(exchange, 200, "" + + ""); + } else { + respond(exchange, 200, "base"); + } + }); + } + + private static void redirect( + final HttpExchange exchange, + final String location) throws IOException { + + exchange.getResponseHeaders().add("Location", location); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + } + + private static void respond( + final HttpExchange exchange, + final int status, + final String body) throws IOException { + + final byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } + + private static Map parseForm( + final HttpExchange exchange) throws IOException { + + final String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + final Map form = new LinkedHashMap<>(); + for (final String pair : body.split("&")) { + final String[] parts = pair.split("=", 2); + form.put(URLDecoder.decode(parts[0], StandardCharsets.UTF_8), + parts.length > 1 ? URLDecoder.decode(parts[1], StandardCharsets.UTF_8) : ""); + } + return form; + } +} diff --git a/jira/pom.xml b/jira/pom.xml index 1d849d61..80eb7bf3 100644 --- a/jira/pom.xml +++ b/jira/pom.xml @@ -259,6 +259,24 @@ test + + org.postgresql + postgresql + test + + + + org.testcontainers + testcontainers-junit-jupiter + test + + + + org.testcontainers + testcontainers-postgresql + test + + org.glassfish.jersey.core jersey-common @@ -297,6 +315,8 @@ ${atlassian.plugin.key} + + com.deftdevs.bootstrapi.jira.cli.SetupCli *;resolution:="optional" diff --git a/jira/src/main/java/com/deftdevs/bootstrapi/jira/cli/SetupCli.java b/jira/src/main/java/com/deftdevs/bootstrapi/jira/cli/SetupCli.java new file mode 100644 index 00000000..a8bdd9e4 --- /dev/null +++ b/jira/src/main/java/com/deftdevs/bootstrapi/jira/cli/SetupCli.java @@ -0,0 +1,168 @@ +package com.deftdevs.bootstrapi.jira.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupEnv; +import com.deftdevs.bootstrapi.commons.cli.SetupException; +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Drives the Jira setup wizard over HTTP so a fresh instance can be set up + * unattended, e.g. from a deployment hook job. Run it directly from the + * plugin JAR: {@code java -jar bootstrapi-jira-plugin.jar}. + *

+ * On an empty database the wizard starts with the database step, which is + * driven from the {@code BOOTSTRAPI_SETUP_DATABASE_*} variables; afterwards + * (or when the database is already initialised) the wizard continues at the + * application properties step. + */ +public class SetupCli { + + private final SetupHttpSession session; + + public static void main( + final String[] args) { + + try { + System.exit(new SetupCli(new SetupHttpSession(SetupEnv.require("BOOTSTRAPI_SETUP_BASE_URL"))).run()); + } catch (SetupException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(1); + } + } + + SetupCli( + final SetupHttpSession session) { + + this.session = session; + } + + int run() { + final Duration timeout = Duration.ofSeconds(Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "300"))); + final Duration pollInterval = Duration.ofSeconds(Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_POLL_SECONDS", "5"))); + + // the status endpoint answers with STARTING long before the application is + // ready; FIRST_RUN means the setup wizard is pending + final String status = session.waitForAnyState("/status", timeout, pollInterval, "RUNNING", "FIRST_RUN"); + if (status.contains("RUNNING")) { + System.out.println("Jira is already set up, nothing to do."); + return 0; + } + + // the initial page request creates the session and shows the current setup step + String page = session.get("/"); + if (page.contains("SetupDatabase.jspa")) { + setupDatabase(page); + page = session.get("/"); + } + + String token = SetupHttpSession.parseFormInput(page, "atl_token"); + token = setupApplicationProperties(token); + token = setupLicense(token); + token = setupAdministrator(token); + setupMailNotifications(token); + + // the wizard answers invalid input with a 200 error page and stays on the + // current step, so only the resulting state proves the setup went through + if (!session.get("/status").contains("RUNNING")) { + throw new SetupException("The setup did not complete; the application does not report state RUNNING" + + " (most likely a submitted value was rejected, e.g. an invalid license)"); + } + + System.out.println("Setting up Jira done."); + return 0; + } + + private void setupDatabase( + final String page) { + + System.out.println("Setting up the database (this initialises the database and can take a while)..."); + final Map form = new LinkedHashMap<>(); + form.put("atl_token", SetupHttpSession.parseFormInput(page, "atl_token")); + form.put("databaseOption", "external"); + form.put("databaseType", SetupEnv.get("BOOTSTRAPI_SETUP_DATABASE_TYPE", "postgres72")); + form.put("jdbcHostname", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_HOSTNAME")); + form.put("jdbcPort", SetupEnv.get("BOOTSTRAPI_SETUP_DATABASE_PORT", "5432")); + form.put("jdbcDatabase", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_NAME")); + form.put("jdbcUsername", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_USERNAME")); + form.put("jdbcPassword", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_PASSWORD")); + form.put("schemaName", SetupEnv.get("BOOTSTRAPI_SETUP_DATABASE_SCHEMA", "public")); + form.put("testingConnection", "false"); + session.postForm("/secure/SetupDatabase.jspa", form); + } + + private String setupApplicationProperties( + final String token) { + + System.out.println("Setting up application properties..."); + final Map form = new LinkedHashMap<>(); + form.put("baseURL", SetupEnv.require("BOOTSTRAPI_SETUP_BASE_URL")); + form.put("title", SetupEnv.require("BOOTSTRAPI_SETUP_TITLE")); + form.put("mode", SetupEnv.get("BOOTSTRAPI_SETUP_MODE", "private")); + form.put("nextStep", "true"); + form.put("atl_token", token); + return SetupHttpSession.parseFormInput( + session.postForm("/secure/SetupApplicationProperties.jspa", form), "atl_token"); + } + + private String setupLicense( + final String token) { + + System.out.println("Setting up license (this continues initialising the database and can take a while)..."); + final int maxRetries = Integer.parseInt(SetupEnv.get("BOOTSTRAPI_SETUP_MAX_RETRIES", "3")); + final long retryDelayMillis = Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_RETRY_SECONDS", "10")) * 1000; + + final Map form = new LinkedHashMap<>(); + form.put("setupLicenseKey", SetupEnv.require("BOOTSTRAPI_SETUP_LICENSE")); + form.put("atl_token", token); + + // freshly started instances occasionally answer with an error; retry a few times + for (int attempt = 0; ; attempt++) { + try { + return SetupHttpSession.parseFormInput(session.postForm("/secure/SetupLicense.jspa", form), "atl_token"); + } catch (SetupException e) { + if (attempt >= maxRetries) { + throw e; + } + System.out.println("Setting up the license failed, retrying: " + e.getMessage()); + try { + Thread.sleep(retryDelayMillis); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new SetupException("Interrupted while retrying the license setup", ie); + } + } + } + } + + private String setupAdministrator( + final String token) { + + System.out.println("Setting up administrator..."); + final String password = SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_PASSWORD"); + + final Map form = new LinkedHashMap<>(); + form.put("fullname", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_FULL_NAME")); + form.put("email", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_EMAIL")); + form.put("username", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_USERNAME")); + form.put("password", password); + form.put("confirm", password); + form.put("atl_token", token); + return SetupHttpSession.parseFormInput(session.postForm("/secure/SetupAdminAccount.jspa", form), "atl_token"); + } + + private void setupMailNotifications( + final String token) { + + System.out.println("Finishing the setup (skipping mail notifications)..."); + + // the mail server is skipped here; it can be configured through the REST API + + final Map form = new LinkedHashMap<>(); + form.put("noemail", "true"); + form.put("atl_token", token); + session.postForm("/secure/SetupMailNotifications.jspa", form); + } +} diff --git a/jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliIT.java b/jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliIT.java new file mode 100644 index 00000000..a4cdc65f --- /dev/null +++ b/jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliIT.java @@ -0,0 +1,93 @@ +package com.deftdevs.bootstrapi.jira.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupEnv; +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Sets up a pristine Jira from the official container image by driving the + * setup wizard, backed by a PostgreSQL container whose connection the image + * templates into {@code dbconfig.xml}. Gated behind + * {@code BOOTSTRAPI_SETUP_IT=true} and a license in + * {@code BOOTSTRAPI_SETUP_IT_LICENSE} (the public Jira Data Center timebomb + * license works). + */ +@Testcontainers +@EnabledIfEnvironmentVariable(named = "BOOTSTRAPI_SETUP_IT", matches = "true") +class SetupCliIT { + + static { + // the bundled docker-java client defaults to API version 1.32, which recent + // Docker daemons reject (Docker 29 requires at least 1.40); 1.41 is accepted + // by every daemon since Docker 20.10 + if (System.getProperty("api.version") == null && System.getenv("DOCKER_API_VERSION") == null) { + System.setProperty("api.version", "1.41"); + } + } + + private static final Network NETWORK = Network.newNetwork(); + + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine") + .withNetwork(NETWORK) + .withNetworkAliases("postgres") + .withDatabaseName("jira") + .withUsername("jira") + .withPassword("jira"); + + @Container + private static final GenericContainer JIRA = new GenericContainer<>( + SetupEnv.get("BOOTSTRAPI_SETUP_IT_IMAGE", "atlassian/jira-software:11.3.8")) + .withNetwork(NETWORK) + .withExposedPorts(8080) + .withEnv("ATL_DB_TYPE", "postgres72") + .withEnv("ATL_JDBC_URL", "jdbc:postgresql://postgres:5432/jira") + .withEnv("ATL_JDBC_USER", "jira") + .withEnv("ATL_JDBC_PASSWORD", "jira") + .withEnv("JVM_MINIMUM_MEMORY", "1g") + .withEnv("JVM_MAXIMUM_MEMORY", "2g"); + + @AfterAll + static void teardown() { + System.getProperties().keySet().removeIf(key -> key.toString().startsWith("BOOTSTRAPI_SETUP_")); + } + + @Test + void testSetup() { + final String license = SetupEnv.get("BOOTSTRAPI_SETUP_IT_LICENSE", null); + assumeTrue(license != null, "BOOTSTRAPI_SETUP_IT_LICENSE is not set"); + + final String baseUrl = "http://" + JIRA.getHost() + ":" + JIRA.getMappedPort(8080); + System.setProperty("BOOTSTRAPI_SETUP_BASE_URL", baseUrl); + System.setProperty("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "900"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_HOSTNAME", "postgres"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_NAME", "jira"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_USERNAME", "jira"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_PASSWORD", "jira"); + System.setProperty("BOOTSTRAPI_SETUP_TITLE", "Setup IT"); + System.setProperty("BOOTSTRAPI_SETUP_LICENSE", license); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_FULL_NAME", "Admin Admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_EMAIL", "admin@example.com"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_USERNAME", "admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_PASSWORD", "admin-secret-1"); + + final SetupHttpSession session = new SetupHttpSession(baseUrl); + assertEquals(0, new SetupCli(session).run()); + + assertTrue(session.get("/status").contains("RUNNING")); + + // a second run is a no-op + assertEquals(0, new SetupCli(session).run()); + } +} diff --git a/jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliTest.java b/jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliTest.java new file mode 100644 index 00000000..76555ada --- /dev/null +++ b/jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliTest.java @@ -0,0 +1,180 @@ +package com.deftdevs.bootstrapi.jira.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SetupCliTest { + + private HttpServer server; + private String baseUrl; + private final Map> capturedForms = new LinkedHashMap<>(); + private final List capturedPaths = new ArrayList<>(); + + @BeforeEach + void setup() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/jira"; + + System.setProperty("BOOTSTRAPI_SETUP_BASE_URL", baseUrl); + System.setProperty("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "5"); + System.setProperty("BOOTSTRAPI_SETUP_POLL_SECONDS", "1"); + System.setProperty("BOOTSTRAPI_SETUP_RETRY_SECONDS", "0"); + System.setProperty("BOOTSTRAPI_SETUP_TITLE", "Test Jira"); + System.setProperty("BOOTSTRAPI_SETUP_LICENSE", "LICENSE-KEY"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_FULL_NAME", "Admin Admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_EMAIL", "admin@example.com"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_USERNAME", "admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_PASSWORD", "secret"); + } + + @AfterEach + void teardown() { + server.stop(0); + System.getProperties().keySet().removeIf(key -> key.toString().startsWith("BOOTSTRAPI_SETUP_")); + } + + @Test + void testFullSetupRunWithLicenseRetry() { + final AtomicInteger licenseAttempts = new AtomicInteger(); + final java.util.concurrent.atomic.AtomicBoolean databaseInitialized = new java.util.concurrent.atomic.AtomicBoolean(); + final java.util.concurrent.atomic.AtomicBoolean wizardComplete = new java.util.concurrent.atomic.AtomicBoolean(); + + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_HOSTNAME", "postgres"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_NAME", "jira"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_USERNAME", "jira"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_PASSWORD", "jira"); + + server.createContext("/jira", exchange -> { + final String path = exchange.getRequestURI().getPath(); + switch (path) { + case "/jira/status": + respond(exchange, 200, wizardComplete.get() + ? "{\"state\":\"RUNNING\"}" + : "{\"state\":\"FIRST_RUN\"}"); + break; + case "/jira/": + case "/jira": + // an empty database puts the wizard on the database step first + respond(exchange, 200, databaseInitialized.get() + ? tokenPage("TOKEN-0") + : "

" + tokenPage("DB-TOKEN") + "
"); + break; + case "/jira/secure/SetupDatabase.jspa": + capture(exchange, path); + databaseInitialized.set(true); + respond(exchange, 200, "database ready"); + break; + case "/jira/secure/SetupApplicationProperties.jspa": + capture(exchange, path); + respond(exchange, 200, tokenPage("TOKEN-1")); + break; + case "/jira/secure/SetupLicense.jspa": + capture(exchange, path); + if (licenseAttempts.incrementAndGet() == 1) { + // a freshly started instance may answer with an error once + respond(exchange, 500, "not ready yet"); + } else { + respond(exchange, 200, tokenPage("TOKEN-2")); + } + break; + case "/jira/secure/SetupAdminAccount.jspa": + capture(exchange, path); + respond(exchange, 200, tokenPage("TOKEN-3")); + break; + case "/jira/secure/SetupMailNotifications.jspa": + capture(exchange, path); + wizardComplete.set(true); + respond(exchange, 200, "done"); + break; + default: + respond(exchange, 404, "unknown " + path); + } + }); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertEquals(2, licenseAttempts.get()); + assertEquals("DB-TOKEN", capturedForms.get("/jira/secure/SetupDatabase.jspa").get("atl_token")); + assertEquals("postgres", capturedForms.get("/jira/secure/SetupDatabase.jspa").get("jdbcHostname")); + assertEquals("postgres72", capturedForms.get("/jira/secure/SetupDatabase.jspa").get("databaseType")); + assertEquals("TOKEN-0", capturedForms.get("/jira/secure/SetupApplicationProperties.jspa").get("atl_token")); + assertEquals("Test Jira", capturedForms.get("/jira/secure/SetupApplicationProperties.jspa").get("title")); + assertEquals("TOKEN-1", capturedForms.get("/jira/secure/SetupLicense.jspa").get("atl_token")); + assertEquals("LICENSE-KEY", capturedForms.get("/jira/secure/SetupLicense.jspa").get("setupLicenseKey")); + assertEquals("TOKEN-2", capturedForms.get("/jira/secure/SetupAdminAccount.jspa").get("atl_token")); + assertEquals("Admin Admin", capturedForms.get("/jira/secure/SetupAdminAccount.jspa").get("fullname")); + assertEquals("TOKEN-3", capturedForms.get("/jira/secure/SetupMailNotifications.jspa").get("atl_token")); + assertEquals("true", capturedForms.get("/jira/secure/SetupMailNotifications.jspa").get("noemail")); + } + + @Test + void testAlreadySetUpDoesNothing() { + server.createContext("/jira", exchange -> { + if (exchange.getRequestURI().getPath().equals("/jira/status")) { + respond(exchange, 200, "{\"state\":\"RUNNING\"}"); + } else { + capturedPaths.add(exchange.getRequestURI().getPath()); + respond(exchange, 404, "unexpected"); + } + }); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertTrue(capturedForms.isEmpty()); + assertTrue(capturedPaths.isEmpty()); + } + + private static String tokenPage( + final String token) { + + return ""; + } + + private void capture( + final HttpExchange exchange, + final String path) throws IOException { + + final String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + final Map form = new LinkedHashMap<>(); + for (final String pair : body.split("&")) { + final String[] parts = pair.split("=", 2); + form.put(URLDecoder.decode(parts[0], StandardCharsets.UTF_8), + parts.length > 1 ? URLDecoder.decode(parts[1], StandardCharsets.UTF_8) : ""); + } + capturedForms.put(path, form); + } + + private static void respond( + final HttpExchange exchange, + final int status, + final String body) throws IOException { + + final byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } +} diff --git a/pom.xml b/pom.xml index 126541f0..59ef9bd9 100644 --- a/pom.xml +++ b/pom.xml @@ -100,6 +100,8 @@ 3.1.12 6.1.1 5.23.0 + 42.7.7 + 2.0.5 true published @@ -179,6 +181,24 @@ ${jersey-common.version}
+ + org.postgresql + postgresql + ${postgresql.version} + + + + org.testcontainers + testcontainers-junit-jupiter + ${testcontainers.version} + + + + org.testcontainers + testcontainers-postgresql + ${testcontainers.version} + + org.glassfish jakarta.el