From 1beee932aed04b9d7d45285b7885a5d290f1efb6 Mon Sep 17 00:00:00 2001 From: "slominskir-coding-agent[bot]" <335168828+slominskir-coding-agent[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:22:41 -0400 Subject: [PATCH 1/2] Add integration tests Add an integration source set and an integrationTest task that test the running app, as started by docker compose -f build.yaml up. Like other JLab projects, the task is not part of build: it needs the containers, which take minutes to build and start. The tests log in to Keycloak with the demo users and with the adm client's service account, which uses client credentials as CI deploys do, then call the app over HTTPS: - DeployIT: deploys to the sshd container as an admin and as the service account, and checks each job on the log page: a failing command keeps its exit code and output, a prompt gets no input, and an unknown host is recorded. Other users, invalid and missing versions, unknown envs, and requests without a token are refused. - InventoryIT: only admins can add app envs. The tests add their own app envs with unique names and remove them afterwards, removing their deploy jobs too. They wait up to 5 minutes for the services to start. ADM_URL and KEYCLOAK_URL point them at other ports. To get tokens for demo users, the test realm's adm client now allows the password grant. container/keycloak/initdb.d/03_customize.sh, which does that, is now executable: the Keycloak image runs only executable scripts, so it was skipped before, and the deployer-group role it creates never existed. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 + build.gradle | 30 ++ container/keycloak/initdb.d/03_customize.sh | 8 +- src/integration/java/org/jlab/adm/Adm.java | 316 ++++++++++++++++++ .../java/org/jlab/adm/DeployIT.java | 129 +++++++ .../java/org/jlab/adm/InventoryIT.java | 46 +++ 6 files changed, 535 insertions(+), 1 deletion(-) mode change 100644 => 100755 container/keycloak/initdb.d/03_customize.sh create mode 100644 src/integration/java/org/jlab/adm/Adm.java create mode 100644 src/integration/java/org/jlab/adm/DeployIT.java create mode 100644 src/integration/java/org/jlab/adm/InventoryIT.java diff --git a/README.md b/README.md index bd2db6f..549b450 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,13 @@ The user you use to run Wildfly needs to have an SSH public/private key pair (ss The unit tests run with `gradlew build`, or alone with `gradlew test`. They need no database, Wildfly, or containers: the SSH tests start their own in-process SSH server. +The integration tests run against the app and its services in containers, built from source, and are not part of `gradlew build`: +``` +docker compose -f build.yaml up -d --build +gradlew integrationTest +``` +They wait up to 5 minutes for the services to start (set `ADM_READY_TIMEOUT_SECONDS` to change that), then log in with the demo users and the `adm` client's service account, as CI deploys do. They add their own app envs to deploy to, and remove them afterwards. To test an app on other ports, set `ADM_URL` (default `https://localhost:8443/adm`) and `KEYCLOAK_URL` (default `http://localhost:8081/auth`). + ## Release 1. Bump the version number in the VERSION file and commit and push to GitHub (using [Semantic Versioning](https://semver.org/)). 2. The [CD](https://github.com/JeffersonLab/adm/blob/main/.github/workflows/cd.yaml) GitHub Action should run automatically invoking: diff --git a/build.gradle b/build.gradle index 2028282..28cb0d2 100644 --- a/build.gradle +++ b/build.gradle @@ -18,6 +18,15 @@ tasks.withType(JavaCompile).configureEach { repositories { mavenCentral() } +sourceSets { + integration { + java.srcDir "${projectDir}/src/integration/java" + } +} +configurations { + integrationImplementation.extendsFrom testImplementation + integrationRuntimeOnly.extendsFrom testRuntimeOnly +} dependencies { // No matter what smoothness weblib MUST be included in WAR (https://github.com/JeffersonLab/smoothness/issues/4) implementation "org.jlab:smoothness-weblib:${smoothnessVersion}" @@ -35,10 +44,31 @@ dependencies { testImplementation platform('org.junit:junit-bom:6.1.3') testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + integrationImplementation 'org.eclipse.parsson:parsson:1.1.9' } tasks.named('test') { useJUnitPlatform() } +// Not part of build: needs the app and its services running, see README +tasks.register('integrationTest', Test) { + description = 'Runs integration tests against a running app (docker compose -f build.yaml up).' + group = 'verification' + + testClassesDirs = sourceSets.integration.output.classesDirs + classpath = sourceSets.integration.runtimeClasspath + + useJUnitPlatform() + shouldRunAfter tasks.named('test') + + // The services may still be starting, and they are not up to date with the source files + outputs.upToDateWhen { false } + + testLogging { + events "passed", "skipped", "failed" + exceptionFormat = "full" + } +} tasks.named('jar') { enabled = false } diff --git a/container/keycloak/initdb.d/03_customize.sh b/container/keycloak/initdb.d/03_customize.sh old mode 100644 new mode 100755 index 5968d96..ca1e186 --- a/container/keycloak/initdb.d/03_customize.sh +++ b/container/keycloak/initdb.d/03_customize.sh @@ -13,4 +13,10 @@ echo "----------------" echo "| Assign Roles |" echo "----------------" KC_USERNAME=jsmith -assign_role \ No newline at end of file +assign_role + +echo "------------------------------------" +echo "| Allow Password Grant (Test Only) |" +echo "------------------------------------" +# Integration tests get tokens for test users such as tbrown with their passwords +${KC_HOME}/bin/kcadm.sh update clients/${KC_CLIENT_NAME} -r "${KC_REALM}" -s directAccessGrantsEnabled=true diff --git a/src/integration/java/org/jlab/adm/Adm.java b/src/integration/java/org/jlab/adm/Adm.java new file mode 100644 index 0000000..e0eb9c9 --- /dev/null +++ b/src/integration/java/org/jlab/adm/Adm.java @@ -0,0 +1,316 @@ +package org.jlab.adm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import java.io.IOException; +import java.io.StringReader; +import java.math.BigInteger; +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.security.GeneralSecurityException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +/** + * The running app and its Keycloak, as started by {@code docker compose -f build.yaml up}. + * + *

The environment variables ADM_URL and KEYCLOAK_URL point the tests at other ports. + */ +final class Adm { + + static final String ADM_URL = env("ADM_URL", "https://localhost:8443/adm"); + static final String KEYCLOAK_URL = env("KEYCLOAK_URL", "http://localhost:8081/auth"); + + /** The app in the demo data */ + static final String APP = "testapp"; + + /** The env in the demo data, deploying to the sshd container */ + static final String DEMO_ENV = "local-demo"; + + /** The demo users (container/keycloak) all have this password */ + static final String PASSWORD = "password"; + + /** The adm client's service account, which deploys like CI does */ + static final String SERVICE_ACCOUNT = "service-account-adm"; + + private static final String CLIENT_ID = "adm"; + private static final String CLIENT_SECRET = "yHi6W2raPmLvPXoxqMA7VWbLAA2WN0eB"; + private static final String TOKEN_URL = + KEYCLOAK_URL + "/realms/test-realm/protocol/openid-connect/token"; + + private static final Duration READY_TIMEOUT = + Duration.ofSeconds(Long.parseLong(env("ADM_READY_TIMEOUT_SECONDS", "300"))); + private static final Duration JOB_TIMEOUT = Duration.ofSeconds(60); + + private static final HttpClient HTTP = + HttpClient.newBuilder().sslContext(trustAll()).connectTimeout(Duration.ofSeconds(5)).build(); + + private static boolean ready = false; + + private Adm() {} + + /** A finished deploy job, as shown on the log page */ + record Job(Integer exitCode, String out, String err, String stackTrace) {} + + /** Waits for the app, its database, and Keycloak, as they may still be starting. */ + static synchronized void awaitReady() throws InterruptedException { + Instant end = Instant.now().plus(READY_TIMEOUT); + String problem = null; + + while (!ready && Instant.now().isBefore(end)) { + try { + HttpResponse response = get("/log", serviceAccountToken()); + ready = response.statusCode() == 200; + problem = "GET /log returned " + response.statusCode(); + } catch (IOException | RuntimeException e) { + problem = e.toString(); + } + + if (!ready) { + Thread.sleep(5_000); + } + } + + if (!ready) { + fail("ADM at " + ADM_URL + " not ready after " + READY_TIMEOUT + ": " + problem); + } + } + + /** Gets a token for a demo user with their password. */ + static String userToken(String username) throws IOException, InterruptedException { + return token(Map.of("grant_type", "password", "username", username, "password", PASSWORD)); + } + + /** Gets a token for the adm client's service account, as CI does. */ + static String serviceAccountToken() throws IOException, InterruptedException { + return token(Map.of("grant_type", "client_credentials")); + } + + private static String token(Map form) throws IOException, InterruptedException { + String basic = + Base64.getEncoder() + .encodeToString((CLIENT_ID + ":" + CLIENT_SECRET).getBytes(StandardCharsets.UTF_8)); + + HttpRequest request = + HttpRequest.newBuilder(URI.create(TOKEN_URL)) + .header("Authorization", "Basic " + basic) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(encode(form))) + .build(); + + HttpResponse response = HTTP.send(request, HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode(), "token request failed: " + response.body()); + + return json(response).getString("access_token"); + } + + /** Requests a deploy; null values leave the parameter out. */ + static JsonObject deploy(String token, String env, String app, String ver) + throws IOException, InterruptedException { + Map form = new LinkedHashMap<>(); + form.put("env", env); + form.put("app", app); + form.put("ver", ver); + form.values().removeIf(v -> v == null); + + HttpResponse response = post("/deploy", token, form); + + assertEquals(200, response.statusCode(), response.body()); + + return json(response); + } + + /** Requests a deploy that is expected to start, and waits for its job to finish. */ + static Job deployAndWait(String token, String env, String ver) + throws IOException, InterruptedException { + JsonObject result = deploy(token, env, APP, ver); + + assertTrue(result.containsKey("jobId"), result.toString()); + + return awaitJob(result.getJsonNumber("jobId").bigIntegerValue(), token); + } + + /** Waits for a deploy job to finish, reading it from the log page. */ + static Job awaitJob(BigInteger jobId, String token) throws IOException, InterruptedException { + Pattern row = + Pattern.compile( + "(.*?)", + Pattern.DOTALL); + // The period shows an end time once the job has finished + Pattern finished = Pattern.compile("\\d{2}:\\d{2}:\\d{2} -\\s+\\d{2}-"); + Pattern exitCode = Pattern.compile("Exit Code:\\s*(\\S+)"); + + Instant end = Instant.now().plus(JOB_TIMEOUT); + + while (Instant.now().isBefore(end)) { + HttpResponse response = get("/log?jobId=" + jobId, token); + + assertEquals(200, response.statusCode(), response.body()); + + Matcher m = row.matcher(response.body()); + + if (m.find() && finished.matcher(m.group(4)).find()) { + Matcher code = exitCode.matcher(m.group(4)); + assertTrue(code.find(), m.group(4)); + + return new Job( + "None".equals(code.group(1)) ? null : Integer.valueOf(code.group(1)), + unescape(m.group(1)), + unescape(m.group(2)), + unescape(m.group(3))); + } + + Thread.sleep(500); + } + + return fail("Job " + jobId + " did not finish within " + JOB_TIMEOUT); + } + + /** + * Adds an env of the demo app, as an admin, with a unique name. + * + * @return the env name + */ + static String addAppEnv(String requestUsername, String hostname, String deployCommand) + throws IOException, InterruptedException { + String envName = "it-" + UUID.randomUUID().toString().substring(0, 8); + + Map form = new LinkedHashMap<>(); + form.put("appName", APP); + form.put("envName", envName); + form.put("requestUsername", requestUsername); + form.put("deployUsername", "testuser"); + form.put("deployHostname", hostname); + form.put("deployPort", "22"); + form.put("deployCommand", deployCommand); + + JsonObject result = json(post("/inventory/ajax/add-app-env", adminToken(), form)); + + assertEquals("ok", result.getString("stat"), result.toString()); + + return envName; + } + + /** Removes an env added by addAppEnv, with its deploy jobs. */ + static void removeAppEnv(String envName) throws IOException, InterruptedException { + String token = adminToken(); + + HttpResponse page = + get("/inventory/app-envs?appName=" + APP + "&envName=" + envName, token); + Matcher id = Pattern.compile("data-id=\"(\\d+)\"").matcher(page.body()); + + assertTrue(id.find(), "env not found: " + envName); + + JsonObject result = + json(post("/inventory/ajax/remove-app-env", token, Map.of("appEnvId", id.group(1)))); + + assertEquals("ok", result.getString("stat"), result.toString()); + } + + /** tbrown has the adm-admin role */ + static String adminToken() throws IOException, InterruptedException { + return userToken("tbrown"); + } + + static HttpResponse get(String path, String token) + throws IOException, InterruptedException { + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(ADM_URL + path)); + + if (token != null) { + builder.header("Authorization", "Bearer " + token); + } + + return HTTP.send(builder.GET().build(), HttpResponse.BodyHandlers.ofString()); + } + + static HttpResponse post(String path, String token, Map form) + throws IOException, InterruptedException { + HttpRequest.Builder builder = + HttpRequest.newBuilder(URI.create(ADM_URL + path)) + .header("Content-Type", "application/x-www-form-urlencoded"); + + if (token != null) { + builder.header("Authorization", "Bearer " + token); + } + + return HTTP.send( + builder.POST(HttpRequest.BodyPublishers.ofString(encode(form))).build(), + HttpResponse.BodyHandlers.ofString()); + } + + static JsonObject json(HttpResponse response) { + try (JsonReader reader = Json.createReader(new StringReader(response.body()))) { + return reader.readObject(); + } + } + + private static String encode(Map form) { + return form.entrySet().stream() + .map( + e -> + URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8) + + "=" + + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); + } + + /** Reverses the JSTL fn:escapeXml used on the log page */ + private static String unescape(String text) { + return text.replace("<", "<") + .replace(">", ">") + .replace("'", "'") + .replace(""", "\"") + .replace("&", "&"); + } + + /** The app's certificate is self-signed */ + private static SSLContext trustAll() { + TrustManager trustAll = + new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + + try { + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[] {trustAll}, null); + return context; + } catch (GeneralSecurityException e) { + throw new IllegalStateException(e); + } + } + + private static String env(String name, String defaultValue) { + String value = System.getenv(name); + return value == null || value.isBlank() ? defaultValue : value; + } +} diff --git a/src/integration/java/org/jlab/adm/DeployIT.java b/src/integration/java/org/jlab/adm/DeployIT.java new file mode 100644 index 0000000..b244a38 --- /dev/null +++ b/src/integration/java/org/jlab/adm/DeployIT.java @@ -0,0 +1,129 @@ +package org.jlab.adm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.json.JsonObject; +import java.net.http.HttpResponse; +import java.util.Map; +import org.jlab.adm.Adm.Job; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** Deploys to the sshd container through the app, as users and as CI's service account. */ +class DeployIT { + + private static String echoEnv; + private static String failEnv; + private static String promptEnv; + private static String badHostEnv; + + @BeforeAll + static void addAppEnvs() throws Exception { + Adm.awaitReady(); + + echoEnv = Adm.addAppEnv(Adm.SERVICE_ACCOUNT, "sshd", "echo deployed"); + failEnv = + Adm.addAppEnv(Adm.SERVICE_ACCOUNT, "sshd", "sh -c 'echo out; echo err >&2; exit 3' #"); + promptEnv = + Adm.addAppEnv( + Adm.SERVICE_ACCOUNT, + "sshd", + "sh -c 'printf \"Accept? \"; read -r x || { echo no answer >&2; exit 1; }' #"); + badHostEnv = Adm.addAppEnv(Adm.SERVICE_ACCOUNT, "nosuchhost", "echo"); + } + + @AfterAll + static void removeAppEnvs() throws Exception { + for (String env : new String[] {echoEnv, failEnv, promptEnv, badHostEnv}) { + if (env != null) { + Adm.removeAppEnv(env); + } + } + } + + @Test + void adminDeploysDemoEnv() throws Exception { + // The demo env's command is "touch /tmp/hello && echo" + Job job = Adm.deployAndWait(Adm.adminToken(), Adm.DEMO_ENV, "1.0.0"); + + assertEquals(new Job(0, "1.0.0\n", "", ""), job); + } + + @Test + void serviceAccountDeploysLikeCi() throws Exception { + Job job = Adm.deployAndWait(Adm.serviceAccountToken(), echoEnv, "2.3.4-rc.1"); + + assertEquals(new Job(0, "deployed 2.3.4-rc.1\n", "", ""), job); + } + + @Test + void failedCommandKeepsExitCodeAndOutput() throws Exception { + Job job = Adm.deployAndWait(Adm.serviceAccountToken(), failEnv, "1.0.0"); + + assertEquals(new Job(3, "out\n", "err\n", ""), job); + } + + @Test + void promptGetsNoInput() throws Exception { + Job job = Adm.deployAndWait(Adm.serviceAccountToken(), promptEnv, "1.0.0"); + + assertEquals(new Job(1, "Accept? ", "no answer\n", ""), job); + } + + @Test + void unknownHostIsRecorded() throws Exception { + Job job = Adm.deployAndWait(Adm.serviceAccountToken(), badHostEnv, "1.0.0"); + + assertNull(job.exitCode()); + assertEquals("", job.out()); + assertTrue(job.stackTrace().contains("nosuchhost"), job.stackTrace()); + } + + @Test + void otherUserIsRefused() throws Exception { + // jadams has only the adm-user role, and is not the env's request user + JsonObject result = Adm.deploy(Adm.userToken("jadams"), echoEnv, Adm.APP, "1.0.0"); + + assertEquals( + "User jadams is not authorized to deploy app testapp to env " + echoEnv, + result.getString("exception")); + } + + @Test + void invalidVersionIsRefused() throws Exception { + JsonObject result = + Adm.deploy(Adm.serviceAccountToken(), echoEnv, Adm.APP, "1.0.0; touch /tmp/pwned"); + + assertEquals("Version string must be semver formatted", result.getString("exception")); + } + + @Test + void missingVersionIsRefused() throws Exception { + JsonObject result = Adm.deploy(Adm.serviceAccountToken(), echoEnv, Adm.APP, null); + + assertEquals("Version string must be semver formatted", result.getString("exception")); + } + + @Test + void unknownEnvIsRefused() throws Exception { + JsonObject result = Adm.deploy(Adm.serviceAccountToken(), "no-such-env", Adm.APP, "1.0.0"); + + assertEquals( + "AppEnv not found for app testapp and env no-such-env", result.getString("exception")); + } + + @Test + void noTokenRedirectsToLogin() throws Exception { + HttpResponse response = + Adm.post("/deploy", null, Map.of("env", echoEnv, "app", Adm.APP, "ver", "1.0.0")); + + assertEquals(302, response.statusCode()); + String location = response.headers().firstValue("Location").orElse(null); + assertNotNull(location); + assertTrue(location.contains("/protocol/openid-connect/auth"), location); + } +} diff --git a/src/integration/java/org/jlab/adm/InventoryIT.java b/src/integration/java/org/jlab/adm/InventoryIT.java new file mode 100644 index 0000000..4a72ff9 --- /dev/null +++ b/src/integration/java/org/jlab/adm/InventoryIT.java @@ -0,0 +1,46 @@ +package org.jlab.adm; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jakarta.json.JsonObject; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** Changing the inventory needs the adm-admin role. */ +class InventoryIT { + + @BeforeAll + static void awaitReady() throws Exception { + Adm.awaitReady(); + } + + @Test + void adminAddsAndRemovesAppEnv() throws Exception { + // Each step asserts that it succeeded + String env = Adm.addAppEnv(Adm.SERVICE_ACCOUNT, "sshd", "echo"); + Adm.removeAppEnv(env); + } + + @Test + void userCannotAddAppEnv() throws Exception { + JsonObject result = + Adm.json( + Adm.post( + "/inventory/ajax/add-app-env", + Adm.userToken("jadams"), + Map.of( + "appName", Adm.APP, + "envName", "it-refused", + "requestUsername", Adm.SERVICE_ACCOUNT, + "deployUsername", "testuser", + "deployHostname", "sshd", + "deployPort", "22", + "deployCommand", "echo"))); + + assertEquals("fail", result.getString("stat")); + assertEquals( + "Unable to add App: Not authenticated / authorized (do you need to re-login?)", + result.getString("error")); + } +} From 48e22e226516ab23a3ee66d7c359e774d34d4ab8 Mon Sep 17 00:00:00 2001 From: "slominskir-coding-agent[bot]" <335168828+slominskir-coding-agent[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:33:52 -0400 Subject: [PATCH 2/2] Trust only the app's certificate in integration tests CodeQL flagged the TrustManager that accepted any certificate. Trust the JDK's CAs plus the self-signed certificate of the jeffersonlab/wildfly image instead, saved as a test resource. A different certificate now fails the TLS handshake. Co-Authored-By: Claude Opus 5.5 --- src/integration/java/org/jlab/adm/Adm.java | 50 +++++++++++-------- .../resources/wildfly-localhost.crt | 20 ++++++++ 2 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 src/integration/resources/wildfly-localhost.crt diff --git a/src/integration/java/org/jlab/adm/Adm.java b/src/integration/java/org/jlab/adm/Adm.java index e0eb9c9..7b27ca6 100644 --- a/src/integration/java/org/jlab/adm/Adm.java +++ b/src/integration/java/org/jlab/adm/Adm.java @@ -8,6 +8,7 @@ import jakarta.json.JsonObject; import jakarta.json.JsonReader; import java.io.IOException; +import java.io.InputStream; import java.io.StringReader; import java.math.BigInteger; import java.net.URI; @@ -16,8 +17,11 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.GeneralSecurityException; -import java.security.cert.X509Certificate; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; import java.time.Duration; import java.time.Instant; import java.util.Base64; @@ -28,8 +32,7 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; +import javax.net.ssl.TrustManagerFactory; /** * The running app and its Keycloak, as started by {@code docker compose -f build.yaml up}. @@ -63,7 +66,10 @@ final class Adm { private static final Duration JOB_TIMEOUT = Duration.ofSeconds(60); private static final HttpClient HTTP = - HttpClient.newBuilder().sslContext(trustAll()).connectTimeout(Duration.ofSeconds(5)).build(); + HttpClient.newBuilder() + .sslContext(sslContext()) + .connectTimeout(Duration.ofSeconds(5)) + .build(); private static boolean ready = false; @@ -284,27 +290,31 @@ private static String unescape(String text) { .replace("&", "&"); } - /** The app's certificate is self-signed */ - private static SSLContext trustAll() { - TrustManager trustAll = - new X509TrustManager() { - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) {} + /** + * Trusts the usual CAs, plus the self-signed certificate of the jeffersonlab/wildfly image that + * the app runs in. If that image changes its certificate, save the new one with: {@code openssl + * s_client -connect localhost:8443 + * src/integration/resources/wildfly-localhost.crt} + */ + private static SSLContext sslContext() { + Path cacerts = Path.of(System.getProperty("java.home"), "lib", "security", "cacerts"); - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) {} + try (InputStream defaults = Files.newInputStream(cacerts); + InputStream wildfly = Adm.class.getResourceAsStream("/wildfly-localhost.crt")) { + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(defaults, null); + trustStore.setCertificateEntry( + "wildfly-localhost", + CertificateFactory.getInstance("X.509").generateCertificate(wildfly)); - @Override - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - }; + TrustManagerFactory trustManagers = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagers.init(trustStore); - try { SSLContext context = SSLContext.getInstance("TLS"); - context.init(null, new TrustManager[] {trustAll}, null); + context.init(null, trustManagers.getTrustManagers(), null); return context; - } catch (GeneralSecurityException e) { + } catch (IOException | GeneralSecurityException e) { throw new IllegalStateException(e); } } diff --git a/src/integration/resources/wildfly-localhost.crt b/src/integration/resources/wildfly-localhost.crt new file mode 100644 index 0000000..edc2454 --- /dev/null +++ b/src/integration/resources/wildfly-localhost.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUTCCAjkCFGFfxF4Z4k/YWJ1XysQSXDCdYUunMA0GCSqGSIb3DQEBCwUAMGQx +CzAJBgNVBAYTAlVTMREwDwYDVQQIDAhWaXJnaW5pYTEWMBQGA1UECgwNbG9jYWxo +b3N0IGRldjEWMBQGA1UECwwNSVQgRGVwYXJ0bWVudDESMBAGA1UEAwwJbG9jYWxo +b3N0MCAXDTI2MDIxOTEzNTQwOFoYDzIyOTkxMjA0MTM1NDA4WjBkMQswCQYDVQQG +EwJVUzERMA8GA1UECAwIVmlyZ2luaWExFjAUBgNVBAoMDWxvY2FsaG9zdCBkZXYx +FjAUBgNVBAsMDUlUIERlcGFydG1lbnQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL61OfXPi7VuzMJ4+SRf1oCpjz8j +tIaujTpggXMFmQ1QEIWpstwNl24lwhG/yXgpOpFLv8pGOVDJp/cR9+766EI6nqpJ +TQVt7qEjAJwaH6U8gEE2RfboyzyysVry03K899dEV/FN9wJagGJldSryeu0glEJ9 +Zi0rjbWYQ/x5AIB3fF8pu33BrNa+h62Qzz8AIOlA5YoZr+JNnUUEx4tIqLXOajOE +Ho9nqTKjbOXcYPwWijKjkbB91g1Q/06e5Ze8wAEzo7zkGZ05qwKRii/OZ6vg5GoW +1wVaoMQh1nbw0Hi+mDTfIBUQZwKtVj/HyNIZTm5mC60dGS4ikgtRDU03K/8CAwEA +ATANBgkqhkiG9w0BAQsFAAOCAQEAEW2AcwVYOgk3K50JY4y8Yalfecv58pHsSJO4 +ckGBPFiACDBvWvlue7rVIUnEa751QjtvafxFAtoxqDi9J81LjxT4ssppZbJh5Q2U +lRi28dxv+sau7tLq4emBuJ9m7NXiZHiJWhM8P3Iipz3EJov+vFHoHXoN8/8mUlxx +HoZ3MTQLeQMmWQq/L7H91vW0+lo7Puzwj2VvHP4mEV1s2hj0ju+N5YcYqb6G6udF +twE41wPKUXiEdlNlldW4qckhPNArgpwBjypR5mQjgjH6l6mSfDInl6YU1ov94NFF +SqJ1uaDQ1AraMgXjb2cQIKqGWiuMBYfxglLJ169SilLbCY/hyQ== +-----END CERTIFICATE-----