second first commit

This commit is contained in:
Zakaria
2026-09-22 08:57:00 -04:00
parent e88d7e2ff3
commit 0aef7fb2d8
4766 changed files with 1236986 additions and 0 deletions
@@ -0,0 +1,79 @@
package io.github.hectorvent.floci.compat;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.io.InputStream;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
/**
* Loads AWS API fixtures captured against real AWS endpoints. Each fixture is
* the canonical wire-format response that floci must replicate (or, for error
* fixtures, the canonical exception name + HTTP status code).
*
* <p>Fixtures live in {@code src/test/resources/fixtures/<service>/<name>.json}
* and follow this structure:
* <pre>{@code
* {
* "name": "sign-up.happy",
* "capturedAt": "...",
* "request": { ... raw AWS SDK request body ... },
* "response": { ... raw AWS SDK response body, or null if error ... },
* "error": { "name": "...", "message": "...", "$metadata": {...} } // null if success
* }
* }</pre>
*
* <p>Use {@link #normalize(JsonNode)} before equality comparisons to strip
* volatile fields (RequestId, UserSub, timestamps, opaque Session tokens).
*/
public final class FixtureLoader {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Set<String> VOLATILE_FIELDS = Set.of(
"requestId", "RequestId", "x-amzn-RequestId",
"UserSub",
"Session",
"Timestamp", "SentAt", "capturedAt",
"$metadata"
);
private FixtureLoader() {}
/** Load a fixture by name (without the .json extension). */
public static JsonNode load(String name) throws IOException {
String path = "/fixtures/aws-cognito/" + name + ".json";
try (InputStream in = FixtureLoader.class.getResourceAsStream(path)) {
if (in == null) {
throw new IOException("Fixture not found on classpath: " + path);
}
return MAPPER.readTree(in);
}
}
/**
* Recursively remove volatile fields (RequestId, UserSub, timestamps, Session)
* so two responses can be structurally compared without false negatives.
*/
public static JsonNode normalize(JsonNode node) {
if (node == null || node.isNull() || node.isValueNode()) return node;
if (node.isArray()) {
for (JsonNode child : node) normalize(child);
return node;
}
if (node instanceof ObjectNode obj) {
Iterator<String> fields = obj.fieldNames();
List<String> toRemove = new java.util.ArrayList<>();
while (fields.hasNext()) {
String f = fields.next();
if (VOLATILE_FIELDS.contains(f)) toRemove.add(f);
}
toRemove.forEach(obj::remove);
obj.fieldNames().forEachRemaining(f -> normalize(obj.get(f)));
}
return node;
}
}
@@ -0,0 +1,52 @@
package io.github.hectorvent.floci.compat;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FixtureLoaderTest {
@Test
void load_signUpHappy_returnsCanonicalShape() throws Exception {
JsonNode fixture = FixtureLoader.load("sign-up.happy");
assertEquals("sign-up.happy", fixture.path("name").asText());
JsonNode response = fixture.path("response");
assertFalse(response.isMissingNode(), "response present");
assertFalse(response.path("UserConfirmed").asBoolean(true), "UserConfirmed=false on real AWS");
JsonNode cdd = response.path("CodeDeliveryDetails");
assertEquals("EMAIL", cdd.path("DeliveryMedium").asText());
assertEquals("email", cdd.path("AttributeName").asText());
assertTrue(cdd.path("Destination").asText().contains("@"), "destination is masked email");
}
@Test
void load_codeMismatchError_returnsErrorShape() throws Exception {
JsonNode fixture = FixtureLoader.load("confirm-sign-up.error.code-mismatch");
JsonNode error = fixture.path("error");
assertFalse(error.isMissingNode());
assertEquals("CodeMismatchException", error.path("name").asText());
assertEquals(400, error.path("$metadata").path("httpStatusCode").asInt());
}
@Test
void normalize_stripsVolatileFields() throws Exception {
JsonNode fixture = FixtureLoader.load("sign-up.happy");
JsonNode response = fixture.path("response");
assertNotNull(response.get("$metadata"));
FixtureLoader.normalize(response);
// Volatile fields removed from the response
assertTrue(response.path("$metadata").isMissingNode(), "$metadata removed");
assertTrue(response.path("UserSub").isMissingNode(), "UserSub removed");
// Structural fields preserved
assertFalse(response.path("CodeDeliveryDetails").isMissingNode(), "CDD preserved");
assertFalse(response.path("UserConfirmed").isMissingNode(), "UserConfirmed preserved");
}
}
@@ -0,0 +1,103 @@
package io.github.hectorvent.floci.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import io.smallrye.config.WithDefault;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ApplicationDefaultsTest {
@Test
void productionConfigEnablesCloudTrailByDefault() throws IOException {
try (InputStream configStream = getClass().getClassLoader().getResourceAsStream("application.yml")) {
assertNotNull(configStream, "application.yml should be available on the test classpath");
JsonNode config = new YAMLMapper().readTree(configStream);
assertTrue(config.path("floci")
.path("services")
.path("cloudtrail")
.path("enabled")
.asBoolean(false),
"application.yml should enable CloudTrail by default");
}
}
@Test
void productionConfigUsesExpectedRequestSizeLimit() throws IOException {
JsonNode config = new YAMLMapper().readTree(Path.of("src/main/resources/application.yml").toFile());
assertEquals(2048,
config.path("floci").path("protocols").path("max-request-size").asInt(),
"production application.yml should allow 2048 MB request bodies by default");
}
@Test
void productionConfigUsesTheAwsSqsMaximumMessageSize() throws IOException, NoSuchMethodException {
JsonNode config = new YAMLMapper().readTree(Path.of("src/main/resources/application.yml").toFile());
assertEquals(1048576,
config.path("floci").path("services").path("sqs").path("max-message-size").asInt(),
"production application.yml should use the AWS SQS maximum of 1048576 bytes");
WithDefault fallback = EmulatorConfig.SqsServiceConfig.class
.getMethod("maxMessageSize")
.getAnnotation(WithDefault.class);
assertNotNull(fallback, "maxMessageSize should declare a fallback default");
assertEquals("1048576", fallback.value(),
"the EmulatorConfig fallback should match the AWS SQS maximum too");
}
@Test
void productionConfigListensOnLoopbackWithoutNetworkExposureConsent() throws IOException, NoSuchMethodException {
JsonNode config = new YAMLMapper().readTree(Path.of("src/main/resources/application.yml").toFile());
assertEquals("127.0.0.1", config.path("quarkus").path("http").path("host").asText(),
"production application.yml should listen on loopback by default");
assertFalse(config.path("floci").path("security").path("allow-unsafe-network-exposure").asBoolean(true),
"production application.yml should not allow a non-loopback listener by default");
WithDefault fallback = EmulatorConfig.SecurityConfig.class
.getMethod("allowUnsafeNetworkExposure")
.getAnnotation(WithDefault.class);
assertNotNull(fallback, "allowUnsafeNetworkExposure should declare a fallback default");
assertEquals("false", fallback.value(), "the EmulatorConfig fallback should refuse exposure too");
}
@Test
void productionConfigDoesNotSeedIamDeployerPrincipalByDefault() throws IOException {
JsonNode config = new YAMLMapper().readTree(Path.of("src/main/resources/application.yml").toFile());
assertFalse(config.path("floci")
.path("services")
.path("iam")
.path("seed-deployer-principal")
.asBoolean(true),
"production application.yml should not create default admin credentials unless enabled");
}
@Test
void productionConfigRejectsPrivateJwtTargetsByDefault() throws Exception {
JsonNode config = new YAMLMapper().readTree(Path.of("src/main/resources/application.yml").toFile());
assertFalse(config.path("floci")
.path("security")
.path("allow-private-jwt-targets")
.asBoolean(true),
"production application.yml should reject private JWT issuer and JWKS targets");
WithDefault fallback = EmulatorConfig.SecurityConfig.class
.getMethod("allowPrivateJwtTargets")
.getAnnotation(WithDefault.class);
assertNotNull(fallback, "allowPrivateJwtTargets should declare a fallback default");
assertEquals("false", fallback.value());
}
}
@@ -0,0 +1,55 @@
package io.github.hectorvent.floci.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
class CertificateMetadataTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void learnedHostnamesRoundTripThroughJson() throws Exception {
CertificateMetadata metadata = CertificateMetadata.create(List.of("localhost"), "dev");
metadata.setLearnedHostnames(List.of("api.example.localhost.floci.io"));
CertificateMetadata read = MAPPER.readValue(MAPPER.writeValueAsString(metadata), CertificateMetadata.class);
assertEquals(List.of("api.example.localhost.floci.io"), read.getLearnedHostnames());
assertEquals(List.of("localhost"), read.getHostnames(), "configured names stay apart from learned ones");
assertEquals(metadata, read);
}
@Test
void metadataWrittenBeforeTheFieldExistedReadsAsNoLearnedHostnames() throws Exception {
String legacyJson = "{\"hostnames\":[\"localhost\"],\"generatedAt\":\"2026-01-01T00:00:00Z\",\"flociVersion\":\"dev\"}";
CertificateMetadata read = MAPPER.readValue(legacyJson, CertificateMetadata.class);
assertEquals(List.of(), read.getLearnedHostnames());
}
@Test
void nullLearnedHostnamesReadAsEmpty() {
CertificateMetadata metadata = new CertificateMetadata();
metadata.setLearnedHostnames(null);
assertEquals(List.of(), metadata.getLearnedHostnames());
}
@Test
void learnedHostnamesTakePartInEquality() {
CertificateMetadata a = new CertificateMetadata(List.of("localhost"), "2026-01-01T00:00:00Z", "dev");
CertificateMetadata b = new CertificateMetadata(List.of("localhost"), "2026-01-01T00:00:00Z", "dev");
assertEquals(a, b);
b.setLearnedHostnames(List.of("api.example.localhost.floci.io"));
assertNotEquals(a, b);
assertNotEquals(a.hashCode(), b.hashCode());
}
}
@@ -0,0 +1,215 @@
package io.github.hectorvent.floci.config;
import io.github.hectorvent.floci.services.acm.CertificateGenerator;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* The CA bundle handed to every container Floci launches: the JVM's public roots followed by
* Floci's trust anchor, and the environment entries that make each runtime read it.
*/
class ContainerCaBundleTest {
@TempDir
Path tempDir;
@BeforeEach
@AfterEach
void forgetBootstrapTlsDir() {
// TlsConfigSource.resolvedTlsDir is static: a TLS-on bootstrap run by another test class
// in this JVM would otherwise steer hostPath() at that test's directory.
System.setProperty("floci.tls.enabled", "false");
new TlsConfigSource();
System.clearProperty("floci.tls.enabled");
}
@Test
void writeProducesTheSystemRootsFollowedByTheAnchor() throws Exception {
Path tlsDir = tempDir.resolve("tls");
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tlsDir);
Path bundle = ContainerCaBundle.write(tlsDir, ca.certificatePath());
assertEquals(tlsDir.resolve("floci-ca-bundle.pem"), bundle);
List<X509Certificate> certificates = parseAll(Files.readAllBytes(bundle));
assertTrue(certificates.size() > 50, "expected the JVM's public roots, got " + certificates.size());
assertEquals(ca.certificate(), certificates.get(certificates.size() - 1), "the Floci CA closes the bundle");
assertEquals(1, certificates.stream().filter(ca.certificate()::equals).count());
}
@Test
void writeIsIdempotent() throws Exception {
Path tlsDir = tempDir.resolve("tls");
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tlsDir);
byte[] first = Files.readAllBytes(ContainerCaBundle.write(tlsDir, ca.certificatePath()));
byte[] second = Files.readAllBytes(ContainerCaBundle.write(tlsDir, ca.certificatePath()));
assertTrue(java.util.Arrays.equals(first, second));
try (var files = Files.list(tlsDir)) {
assertTrue(files.noneMatch(file -> file.getFileName().toString().endsWith(".tmp")), "no temp file left behind");
}
}
@Test
void writeKeepsEveryCertificateOfAChainFileAndDropsPrivateKeys() throws Exception {
Path tlsDir = tempDir.resolve("tls");
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tlsDir);
CertificateGenerator.GeneratedCertificate leaf = ca.issueServerCertificate("floci", List.of("floci"),
KeyAlgorithm.RSA_2048, null);
Path chainFile = Files.writeString(tempDir.resolve("user-chain.pem"),
leaf.certificatePem() + leaf.privateKeyPem() + ca.caPem());
String bundle = Files.readString(ContainerCaBundle.write(tlsDir, chainFile));
assertFalse(bundle.contains("PRIVATE KEY"), "a private key must never reach a container");
List<X509Certificate> certificates = parseAll(bundle.getBytes());
int size = certificates.size();
assertEquals(ca.certificate(), certificates.get(size - 1));
assertEquals(new CertificateGenerator().parseCertificate(leaf.certificatePem()), certificates.get(size - 2));
}
@Test
void writeRefusesAnAnchorFileWithoutACertificate() throws Exception {
Path tlsDir = Files.createDirectories(tempDir.resolve("tls"));
Path notACertificate = Files.writeString(tempDir.resolve("empty.pem"), "nothing to trust here\n");
assertThrows(IllegalArgumentException.class, () -> ContainerCaBundle.write(tlsDir, notACertificate));
assertFalse(Files.exists(tlsDir.resolve("floci-ca-bundle.pem")));
}
@Test
void hostPathIsEmptyWhenTlsIsOff() {
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
when(config.tls().enabled()).thenReturn(false);
when(config.storage().persistentPath()).thenReturn(tempDir.toString());
assertEquals(Optional.empty(), ContainerCaBundle.hostPath(config));
}
@Test
void hostPathIsEmptyWhenTheBootWriteDidNotHappen() {
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
when(config.tls().enabled()).thenReturn(true);
when(config.storage().persistentPath()).thenReturn(tempDir.toString());
assertEquals(Optional.empty(), ContainerCaBundle.hostPath(config));
}
@Test
void hostPathFindsTheBundleUnderTheConfiguredPersistentPath() throws Exception {
Path tlsDir = tempDir.resolve("tls");
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tlsDir);
Path bundle = ContainerCaBundle.write(tlsDir, ca.certificatePath());
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
when(config.tls().enabled()).thenReturn(true);
when(config.storage().persistentPath()).thenReturn(tempDir.toString());
assertEquals(Optional.of(bundle), ContainerCaBundle.hostPath(config));
}
@Test
void hostPathFollowsTheDirectoryTheBootstrapUsed() throws Exception {
// application.yml alone may name a persistent path the bootstrap never saw; the bundle
// lives where TlsConfigSource wrote it, next to the CA.
Path bootDir = tempDir.resolve("boot");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", bootDir.toString());
try {
new TlsConfigSource();
} finally {
System.clearProperty("floci.tls.enabled");
System.clearProperty("floci.tls.self-signed");
System.clearProperty("floci.storage.persistent-path");
}
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
when(config.tls().enabled()).thenReturn(true);
when(config.storage().persistentPath()).thenReturn(tempDir.resolve("elsewhere").toString());
assertEquals(Optional.of(bootDir.resolve("tls").resolve("floci-ca-bundle.pem")),
ContainerCaBundle.hostPath(config));
}
@Test
void hostPathFollowsTheDirectoryOfAUserCertificateBoot() throws Exception {
Path bootDir = tempDir.resolve("boot");
CertificateGenerator.GeneratedCertificate user = new CertificateGenerator()
.generateSelfSignedCertificate("localhost", List.of("localhost"), KeyAlgorithm.RSA_2048);
Path userCert = Files.writeString(tempDir.resolve("user.crt"), user.certificatePem());
Path userKey = Files.writeString(tempDir.resolve("user.key"), user.privateKeyPem());
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.cert-path", userCert.toString());
System.setProperty("floci.tls.key-path", userKey.toString());
System.setProperty("floci.storage.persistent-path", bootDir.toString());
try {
new TlsConfigSource();
} finally {
System.clearProperty("floci.tls.enabled");
System.clearProperty("floci.tls.cert-path");
System.clearProperty("floci.tls.key-path");
System.clearProperty("floci.storage.persistent-path");
}
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
when(config.tls().enabled()).thenReturn(true);
when(config.storage().persistentPath()).thenReturn(tempDir.resolve("elsewhere").toString());
assertEquals(Optional.of(bootDir.resolve("tls").resolve("floci-ca-bundle.pem")),
ContainerCaBundle.hostPath(config));
}
@Test
void envPointsEveryRuntimeAtTheContainerPath() {
assertEquals(List.of(
"SSL_CERT_FILE=/etc/floci-ca-bundle.pem",
"CURL_CA_BUNDLE=/etc/floci-ca-bundle.pem",
"REQUESTS_CA_BUNDLE=/etc/floci-ca-bundle.pem",
"NODE_EXTRA_CA_CERTS=/etc/floci-ca-bundle.pem",
"AWS_CA_BUNDLE=/etc/floci-ca-bundle.pem"), ContainerCaBundle.env());
}
@Test
void appendEnvKeepsTheCallerEntriesFirstAndUnchanged() {
List<String> merged = ContainerCaBundle.appendEnv(List.of("FOO=bar", "SSL_CERT_FILE=/my/own.pem", "AWS_CA_BUNDLE"));
assertEquals(List.of("FOO=bar", "SSL_CERT_FILE=/my/own.pem", "AWS_CA_BUNDLE"), merged.subList(0, 3));
assertEquals(List.of(
"CURL_CA_BUNDLE=/etc/floci-ca-bundle.pem",
"REQUESTS_CA_BUNDLE=/etc/floci-ca-bundle.pem",
"NODE_EXTRA_CA_CERTS=/etc/floci-ca-bundle.pem"), merged.subList(3, merged.size()));
}
@Test
void appendEnvOfNothingIsTheTrustEnv() {
assertEquals(ContainerCaBundle.env(), ContainerCaBundle.appendEnv(null));
assertEquals(ContainerCaBundle.env(), ContainerCaBundle.appendEnv(List.of()));
}
private static List<X509Certificate> parseAll(byte[] pem) throws Exception {
List<X509Certificate> certificates = new ArrayList<>();
for (var certificate : CertificateFactory.getInstance("X.509").generateCertificates(new ByteArrayInputStream(pem))) {
certificates.add((X509Certificate) certificate);
}
return certificates;
}
}
@@ -0,0 +1,192 @@
package io.github.hectorvent.floci.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.restassured.http.ContentType;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManagerFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import static io.github.hectorvent.floci.services.cognito.CognitoRestAssuredUtils.cognitoJson;
import static io.github.hectorvent.floci.testing.RestAssuredJsonUtils.awsActionJson;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* End to end, with TLS on and the real certificate manager: an ACM certificate is requested, the
* three custom domain operations are called over the wire with their normal responses, and a
* client that trusts only the chain ACM returned, sends each domain as SNI and verifies the
* server's identity for it completes the handshake, where it failed before the domain existed.
* A reset takes the names back. The fixture leaf carries no 127.0.0.1 SAN and the socket connects
* by IP, so the SNI name is the only one the identity check can match.
*/
@QuarkusTest
@TestProfile(CustomDomainTlsIntegrationTest.Profile.class)
class CustomDomainTlsIntegrationTest {
static final Path DATA_DIR = Path.of("target", "floci-custom-domain-tls-test").toAbsolutePath();
static final Path TLS_DIR = DATA_DIR.resolve("tls");
static final String API_DOMAIN = "api.custom-domain-it.localhost.floci.io";
static final String IOT_DOMAIN = "iot.custom-domain-it.localhost.floci.io";
static final String AUTH_DOMAIN = "auth.custom-domain-it.localhost.floci.io";
@ConfigProperty(name = "quarkus.http.test-ssl-port", defaultValue = "0")
int sslPort;
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void customDomainsAreServedUnderTheAcmChainAsSoonAsTheyExist() throws Exception {
JsonNode requested = awsActionJson("CertificateManager", "RequestCertificate",
"{\"DomainName\": \"*.custom-domain-it.localhost.floci.io\", \"ValidationMethod\": \"DNS\"}");
String certificateArn = requested.path("CertificateArn").asText();
String chain = awsActionJson("CertificateManager", "GetCertificate",
"{\"CertificateArn\": \"" + certificateArn + "\"}").path("CertificateChain").asText();
assertTrue(chain.contains("BEGIN CERTIFICATE"), chain);
for (String domain : List.of(API_DOMAIN, IOT_DOMAIN, AUTH_DOMAIN)) {
assertThrows(SSLHandshakeException.class, () -> handshake(chain, domain, true),
"precondition: " + domain + " is not served before it is created");
}
given().contentType(ContentType.JSON)
.body("{\"domainName\":\"" + API_DOMAIN + "\",\"regionalCertificateArn\":\"" + certificateArn + "\","
+ "\"endpointConfiguration\":{\"types\":[\"REGIONAL\"]}}")
.when().post("/domainnames").then().statusCode(201);
given().contentType(ContentType.JSON)
.body("{\"domainName\":\"" + IOT_DOMAIN + "\",\"serverCertificateArns\":[\"" + certificateArn + "\"]}")
.when().post("/domainConfigurations/custom-domain-it").then().statusCode(200);
String poolId = cognitoJson("CreateUserPool", "{\"PoolName\": \"custom-domain-it\"}")
.path("UserPool").path("Id").asText();
JsonNode created = cognitoJson("CreateUserPoolDomain", "{\"Domain\": \"" + AUTH_DOMAIN + "\", \"UserPoolId\": \""
+ poolId + "\", \"CustomDomainConfig\": {\"CertificateArn\": \"" + certificateArn + "\"}}");
assertTrue(created.path("CloudFrontDomain").asText().endsWith(".cloudfront.net"), created.toString());
for (String domain : List.of(API_DOMAIN, IOT_DOMAIN, AUTH_DOMAIN)) {
X509Certificate served = handshake(chain, domain, true);
assertTrue(sans(served).contains(domain), domain + " not in " + sans(served));
assertEquals(parse(chain).get(0).getSubjectX500Principal(), served.getIssuerX500Principal(),
"the served leaf is issued by the CA in the ACM chain");
}
given().when().post("/_floci/state/reset").then().statusCode(200);
for (String domain : List.of(API_DOMAIN, IOT_DOMAIN, AUTH_DOMAIN)) {
assertThrows(SSLHandshakeException.class, () -> handshake(chain, domain, true),
"reset drops " + domain);
}
assertFalse(sans(handshake(chain, API_DOMAIN, false)).contains(API_DOMAIN));
}
/**
* TLS by IP trusting only {@code chainPem}, sending {@code sniHost} as SNI and, when asked,
* verifying the server's identity for that name the way an SDK does.
*/
private X509Certificate handshake(String chainPem, String sniHost, boolean verifyIdentity) throws Exception {
KeyStore trust = KeyStore.getInstance(KeyStore.getDefaultType());
trust.load(null, null);
int i = 0;
for (X509Certificate ca : parse(chainPem)) {
trust.setCertificateEntry("chain-" + i++, ca);
}
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(trust);
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, tmf.getTrustManagers(), null);
try (SSLSocket socket = (SSLSocket) ctx.getSocketFactory().createSocket()) {
socket.connect(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), sslPort), 5000);
SSLParameters params = socket.getSSLParameters();
params.setServerNames(List.of(new SNIHostName(sniHost)));
if (verifyIdentity) {
params.setEndpointIdentificationAlgorithm("HTTPS");
}
socket.setSSLParameters(params);
socket.startHandshake();
return (X509Certificate) socket.getSession().getPeerCertificates()[0];
}
}
private static List<X509Certificate> parse(String pem) throws Exception {
return CertificateFactory.getInstance("X.509")
.generateCertificates(new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8)))
.stream().map(X509Certificate.class::cast).toList();
}
private static Set<String> sans(X509Certificate leaf) throws Exception {
Set<String> sans = new TreeSet<>();
for (List<?> entry : leaf.getSubjectAlternativeNames()) {
sans.add(String.valueOf(entry.get(1)));
}
return sans;
}
/**
* Boots with TLS on and a CA-issued leaf like the one TlsConfigSource writes, in a directory
* of its own. The wildcard covers one label only, so the three-label names are uncovered
* until the operations add them.
*/
public static final class Profile implements QuarkusTestProfile {
static {
try {
Files.createDirectories(TLS_DIR);
for (String name : List.of("floci-server.crt", "floci-server.key", "floci-server.metadata.json")) {
Files.deleteIfExists(TLS_DIR.resolve(name));
}
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(TLS_DIR);
List<String> sans = List.of("localhost", "*.localhost.floci.io");
var leaf = ca.issueServerCertificate("localhost", sans, KeyAlgorithm.RSA_2048, null);
Files.writeString(TLS_DIR.resolve("floci-server.crt"), leaf.certificatePem());
Files.writeString(TLS_DIR.resolve("floci-server.key"), leaf.privateKeyPem());
Files.writeString(TLS_DIR.resolve("floci-server.metadata.json"),
new ObjectMapper().writeValueAsString(CertificateMetadata.create(sans, "dev")));
} catch (IOException e) {
throw new IllegalStateException("could not prepare the TLS fixtures", e);
}
}
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"floci.tls.enabled", "true",
"floci.tls.self-signed", "true",
"floci.tls.aws-https-port", "0",
"floci.storage.persistent-path", DATA_DIR.toString(),
"quarkus.tls.key-store.pem.0.cert", TLS_DIR.resolve("floci-server.crt").toString(),
"quarkus.tls.key-store.pem.0.key", TLS_DIR.resolve("floci-server.key").toString(),
"quarkus.http.insecure-requests", "enabled");
}
}
}
@@ -0,0 +1,280 @@
package io.github.hectorvent.floci.config;
import io.github.hectorvent.floci.services.acm.CertificateGenerator.GeneratedCertificate;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.vertx.core.MultiMap;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpClient;
import io.vertx.core.http.HttpClientOptions;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.http.RequestOptions;
import io.vertx.core.net.PemTrustOptions;
import io.vertx.core.net.SocketAddress;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Docker-gated integration test verifying OCI Distribution image push and pull
* over TLS against the ECR registry data plane, including hostname verification
* of the regional wildcard SANs and regression coverage for plain HTTP.
*/
@QuarkusTest
@TestProfile(EcrTlsDataPlaneDockerIntegrationTest.Profile.class)
class EcrTlsDataPlaneDockerIntegrationTest {
@ConfigProperty(name = "quarkus.http.test-ssl-port", defaultValue = "0")
int sslPort;
@ConfigProperty(name = "quarkus.http.test-port", defaultValue = "0")
int httpPort;
@Inject
Vertx vertx;
@BeforeAll
static void requireDocker() {
Assumptions.assumeTrue(isDockerAvailable(), "Docker daemon must be available for ECR data plane tests");
}
private static boolean isDockerAvailable() {
try {
Process process = new ProcessBuilder("docker", "version", "--format", "{{.Server.Version}}")
.redirectErrorStream(true)
.start();
int exit = process.waitFor();
return exit == 0;
} catch (Exception e) {
return false;
}
}
@Test
void pushAndPullImageOverTlsAndVerifyPlainHttpRegression() throws Exception {
String tlsHost = "000000000000.dkr.ecr.us-east-1.localhost.floci.io";
HttpClient tlsClient = vertx.createHttpClient(new HttpClientOptions()
.setSsl(true)
.setForceSni(true)
.setTrustOptions(new PemTrustOptions().addCertPath(
FlociCertificateAuthority.loadOrCreate(Profile.TLS_DIR).certificatePath().toString()))
.setVerifyHost(true));
try {
// 1. Verify registry ping over TLS with hostname verification
SimpleResponse pingResponse = execute(tlsClient, HttpMethod.GET, sslPort, tlsHost, "/v2/", null, null, null);
assertEquals(200, pingResponse.statusCode());
assertEquals("registry/2.0", pingResponse.header("Docker-Distribution-Api-Version"));
// 2. Upload layer blob over TLS
byte[] layerBytes = "floci-ecr-tls-layer-content-v1".getBytes(StandardCharsets.UTF_8);
String layerDigest = "sha256:" + sha256Hex(layerBytes);
SimpleResponse startLayerUpload = execute(tlsClient, HttpMethod.POST, sslPort, tlsHost,
"/v2/tls-repo/blobs/uploads/", null, null, null);
assertEquals(202, startLayerUpload.statusCode());
String layerUploadLocation = startLayerUpload.header("Location");
assertNotNull(layerUploadLocation);
String layerUploadUri = layerUploadLocation + (layerUploadLocation.contains("?") ? "&" : "?")
+ "digest=" + layerDigest;
SimpleResponse finishLayerUpload = execute(tlsClient, HttpMethod.PUT, sslPort, tlsHost,
layerUploadUri, "application/octet-stream", null, Buffer.buffer(layerBytes));
assertEquals(201, finishLayerUpload.statusCode());
// 3. Upload config blob over TLS
byte[] configBytes = "{\"architecture\":\"amd64\",\"os\":\"linux\"}".getBytes(StandardCharsets.UTF_8);
String configDigest = "sha256:" + sha256Hex(configBytes);
SimpleResponse startConfigUpload = execute(tlsClient, HttpMethod.POST, sslPort, tlsHost,
"/v2/tls-repo/blobs/uploads/", null, null, null);
assertEquals(202, startConfigUpload.statusCode());
String configUploadLocation = startConfigUpload.header("Location");
assertNotNull(configUploadLocation);
String configUploadUri = configUploadLocation + (configUploadLocation.contains("?") ? "&" : "?")
+ "digest=" + configDigest;
SimpleResponse finishConfigUpload = execute(tlsClient, HttpMethod.PUT, sslPort, tlsHost,
configUploadUri, "application/octet-stream", null, Buffer.buffer(configBytes));
assertEquals(201, finishConfigUpload.statusCode());
// 4. Put manifest over TLS
String manifestJson = """
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"size": %d,
"digest": "%s"
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": %d,
"digest": "%s"
}
]
}
""".formatted(configBytes.length, configDigest, layerBytes.length, layerDigest);
SimpleResponse putManifestResponse = execute(tlsClient, HttpMethod.PUT, sslPort, tlsHost,
"/v2/tls-repo/manifests/v1",
"application/vnd.docker.distribution.manifest.v2+json",
null,
Buffer.buffer(manifestJson));
assertEquals(201, putManifestResponse.statusCode());
String manifestDigest = putManifestResponse.header("Docker-Content-Digest");
assertNotNull(manifestDigest);
// 5. Pull manifest over TLS
String manifestMediaType = "application/vnd.docker.distribution.manifest.v2+json";
SimpleResponse getManifestResponse = execute(tlsClient, HttpMethod.GET, sslPort, tlsHost,
"/v2/tls-repo/manifests/v1", null, manifestMediaType, null);
assertEquals(200, getManifestResponse.statusCode());
assertEquals(manifestDigest, getManifestResponse.header("Docker-Content-Digest"));
// 6. Pull layer blob over TLS
SimpleResponse getLayerResponse = execute(tlsClient, HttpMethod.GET, sslPort, tlsHost,
"/v2/tls-repo/blobs/" + layerDigest, null, null, null);
assertEquals(200, getLayerResponse.statusCode());
assertArrayEquals(layerBytes, getLayerResponse.body().getBytes());
// 7. List tags over TLS
SimpleResponse getTagsResponse = execute(tlsClient, HttpMethod.GET, sslPort, tlsHost,
"/v2/tls-repo/tags/list", null, null, null);
assertEquals(200, getTagsResponse.statusCode());
assertTrue(getTagsResponse.body().toString().contains("v1"));
// 8. Plain HTTP regression: verify the plain HTTP path still works on port
HttpClient httpClient = vertx.createHttpClient(new HttpClientOptions().setSsl(false));
try {
String httpHost = "000000000000.dkr.ecr.us-east-1.localhost:" + httpPort;
SimpleResponse httpPing = execute(httpClient, HttpMethod.GET, httpPort, httpHost,
"/v2/", null, null, null);
assertEquals(200, httpPing.statusCode());
assertEquals("registry/2.0", httpPing.header("Docker-Distribution-Api-Version"));
SimpleResponse httpManifestGet = execute(httpClient, HttpMethod.GET, httpPort, httpHost,
"/v2/tls-repo/manifests/v1", null, manifestMediaType, null);
assertEquals(200, httpManifestGet.statusCode());
assertEquals(manifestDigest, httpManifestGet.header("Docker-Content-Digest"));
} finally {
httpClient.close();
}
} finally {
tlsClient.close();
}
}
private SimpleResponse execute(HttpClient client, HttpMethod method, int port, String host,
String uri, String contentType, String accept, Buffer body) throws Exception {
String pureHost = host.contains(":") ? host.substring(0, host.indexOf(':')) : host;
RequestOptions options = new RequestOptions()
.setServer(SocketAddress.inetSocketAddress(port, "127.0.0.1"))
.setHost(pureHost)
.setPort(port)
.setMethod(method)
.setURI(uri);
return client.request(options)
.compose(request -> {
request.putHeader("Host", host);
if (contentType != null) {
request.putHeader("Content-Type", contentType);
}
if (accept != null) {
request.putHeader("Accept", accept);
}
if (body != null) {
request.putHeader("Content-Length", String.valueOf(body.length()));
return request.send(body);
}
return request.send();
})
.compose(response -> response.body().map(responseBody ->
new SimpleResponse(response.statusCode(), response.headers(), responseBody)))
.toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
}
private static String sha256Hex(byte[] data) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(data);
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
private record SimpleResponse(int statusCode, MultiMap headers, Buffer body) {
String header(String name) {
return headers.get(name);
}
}
public static final class Profile implements QuarkusTestProfile {
static final Path DATA_DIR = Path.of("target", "floci-ecr-tls-dataplane-test").toAbsolutePath();
static final Path TLS_DIR = DATA_DIR.resolve("tls");
static final Path CERT_FILE = TLS_DIR.resolve("floci-server.crt");
static final Path KEY_FILE = TLS_DIR.resolve("floci-server.key");
static {
try {
Files.createDirectories(TLS_DIR);
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(TLS_DIR);
List<String> sans = new ArrayList<>(TlsConfigSource.DEFAULT_SAN_HOSTNAMES);
GeneratedCertificate leaf = ca.issueServerCertificate("localhost", sans, KeyAlgorithm.RSA_2048, null);
Files.writeString(CERT_FILE, leaf.certificatePem());
Files.writeString(KEY_FILE, leaf.privateKeyPem());
ContainerCaBundle.write(TLS_DIR, ca.certificatePath());
} catch (IOException | GeneralSecurityException e) {
throw new IllegalStateException("Failed to prepare TLS test files", e);
}
}
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"floci.tls.enabled", "true",
"floci.tls.self-signed", "true",
"floci.tls.aws-https-port", "0",
"floci.storage.persistent-path", DATA_DIR.toString(),
"quarkus.tls.key-store.pem.0.cert", CERT_FILE.toString(),
"quarkus.tls.key-store.pem.0.key", KEY_FILE.toString(),
"quarkus.http.insecure-requests", "enabled"
);
}
}
}
@@ -0,0 +1,61 @@
package io.github.hectorvent.floci.config;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* {@link EmulatorConfig#iotEndpointAddress()}: the configured address when there is one,
* otherwise the host and port of the effective base URL.
*/
class EmulatorConfigIotEndpointAddressTest {
private static EmulatorConfig config(String effectiveBaseUrl, Optional<String> endpointAddress) {
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
when(config.effectiveBaseUrl()).thenReturn(effectiveBaseUrl);
when(config.services().iot().endpointAddress()).thenReturn(endpointAddress);
when(config.iotEndpointAddress()).thenCallRealMethod();
return config;
}
@Test
void unsetFallsBackToTheHostAndPortOfTheBaseUrl() {
assertEquals("localhost:4566", config("http://localhost:4566", Optional.empty()).iotEndpointAddress());
}
@Test
void fallbackDropsSchemeAndPath() {
assertEquals("floci:4566", config("https://floci:4566/", Optional.empty()).iotEndpointAddress());
}
@Test
void configuredAddressIsReturnedVerbatim() {
assertEquals("iot.example.localhost.floci.io",
config("http://localhost:4566", Optional.of("iot.example.localhost.floci.io")).iotEndpointAddress());
}
@Test
void configuredAddressKeepsAPortTheOperatorAdded() {
assertEquals("iot.example.localhost.floci.io:8443",
config("http://localhost:4566", Optional.of("iot.example.localhost.floci.io:8443")).iotEndpointAddress());
}
@Test
void surroundingWhitespaceIsDropped() {
assertEquals("iot.example.localhost.floci.io",
config("http://localhost:4566", Optional.of(" iot.example.localhost.floci.io\n")).iotEndpointAddress());
}
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t\n"})
void blankAddressCountsAsUnset(String blank) {
assertEquals("localhost:4566", config("http://localhost:4566", Optional.of(blank)).iotEndpointAddress());
}
}
@@ -0,0 +1,59 @@
package io.github.hectorvent.floci.config;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* The producer must hand beans the directory the config bootstrap used, and only fall back to the
* configured persistent path when the bootstrap laid nothing down.
*/
class FlociCertificateAuthorityProducerTest {
@TempDir
Path tempDir;
@AfterEach
void clearBootstrap() {
System.clearProperty("floci.tls.enabled");
System.clearProperty("floci.tls.self-signed");
System.clearProperty("floci.storage.persistent-path");
new TlsConfigSource();
}
private static EmulatorConfig configWithPersistentPath(String path) {
EmulatorConfig config = mock(EmulatorConfig.class);
EmulatorConfig.StorageConfig storage = mock(EmulatorConfig.StorageConfig.class);
when(config.storage()).thenReturn(storage);
when(storage.persistentPath()).thenReturn(path);
return config;
}
@Test
void usesTheBootstrapDirectoryWhenTheBootstrapIssuedTheLeaf() {
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
new TlsConfigSource();
Path dir = FlociCertificateAuthorityProducer.tlsDir(configWithPersistentPath("/elsewhere/from/yaml"));
assertEquals(tempDir.resolve("tls"), dir, "one directory for the served leaf and ca.pem, or two CAs would exist");
}
@Test
void fallsBackToTheConfiguredPathWhenTlsIsOff() {
System.setProperty("floci.tls.enabled", "false");
new TlsConfigSource();
Path dir = FlociCertificateAuthorityProducer.tlsDir(configWithPersistentPath(tempDir.toString()));
assertEquals(tempDir.resolve("tls"), dir);
}
}
@@ -0,0 +1,292 @@
package io.github.hectorvent.floci.config;
import io.github.hectorvent.floci.services.acm.CertificateGenerator;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Set;
import javax.security.auth.x500.X500Principal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FlociCertificateAuthorityTest {
@TempDir
Path tempDir;
@Test
void createsCaFilesWithOwnerOnlyKey() throws Exception {
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tempDir);
assertTrue(Files.exists(tempDir.resolve("floci-root-ca.crt")));
assertTrue(Files.exists(tempDir.resolve("floci-root-ca.key")));
assertEquals(tempDir.resolve("floci-root-ca.crt"), ca.certificatePath());
assertTrue(ca.certificate().getBasicConstraints() >= 0, "CA must be cA=true");
assertTrue(ca.certificate().getKeyUsage()[5], "CA must assert keyCertSign");
assertEquals(ca.certificate().getSubjectX500Principal(), ca.certificate().getIssuerX500Principal());
assertEquals("CN=Floci Local CA", ca.certificate().getSubjectX500Principal().getName());
assertTrue(ca.caPem().startsWith("-----BEGIN CERTIFICATE-----"));
assertEquals(Files.readString(tempDir.resolve("floci-root-ca.crt")), ca.caPem(), "caPem is the file's bytes");
assertTrue(ca.fingerprint().matches("([0-9A-F]{2}:){31}[0-9A-F]{2}"), ca.fingerprint());
assertEquals(ca.fingerprint(), FlociCertificateAuthority.loadOrCreate(tempDir).fingerprint());
Set<PosixFilePermission> keyPerms = Files.getPosixFilePermissions(tempDir.resolve("floci-root-ca.key"));
assertEquals(PosixFilePermissions.fromString("rw-------"), keyPerms);
assertEquals(PosixFilePermissions.fromString("rwx------"), Files.getPosixFilePermissions(tempDir));
}
@Test
void createsTheDirectoryWhenMissing() {
Path tlsDir = tempDir.resolve("nested").resolve("tls");
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tlsDir);
assertTrue(Files.exists(tlsDir.resolve("floci-root-ca.key")));
assertTrue(ca.isIssuedByUs(parse(ca.issueClientCertificate("device").certificatePem())));
}
@Test
void reloadsTheSameCaAcrossRestarts() throws Exception {
FlociCertificateAuthority first = FlociCertificateAuthority.loadOrCreate(tempDir);
Files.setPosixFilePermissions(tempDir.resolve("floci-root-ca.key"), PosixFilePermissions.fromString("rw-r--r--"));
FlociCertificateAuthority second = FlociCertificateAuthority.loadOrCreate(tempDir);
assertEquals(first.certificate(), second.certificate());
assertEquals(first.key(), second.key());
assertEquals(first.caPem(), second.caPem());
assertEquals(PosixFilePermissions.fromString("rw-------"),
Files.getPosixFilePermissions(tempDir.resolve("floci-root-ca.key")), "permissions are re-tightened on load");
}
@Test
void corruptCaIsRegeneratedNotSilentlyKept() throws Exception {
FlociCertificateAuthority first = FlociCertificateAuthority.loadOrCreate(tempDir);
Files.writeString(tempDir.resolve("floci-root-ca.crt"), "-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----\n");
FlociCertificateAuthority second = FlociCertificateAuthority.loadOrCreate(tempDir);
assertNotEquals(first.certificate(), second.certificate());
assertTrue(second.certificate().getBasicConstraints() >= 0);
assertEquals(second.caPem(), Files.readString(tempDir.resolve("floci-root-ca.crt")));
}
@Test
void keyThatDoesNotMatchTheCertificateIsRegenerated() throws Exception {
FlociCertificateAuthority first = FlociCertificateAuthority.loadOrCreate(tempDir);
var stranger = new CertificateGenerator().generateCaCertificate("Other CA");
Files.writeString(tempDir.resolve("floci-root-ca.key"), stranger.privateKeyPem());
FlociCertificateAuthority second = FlociCertificateAuthority.loadOrCreate(tempDir);
assertNotEquals(first.certificate(), second.certificate(), "a pair that cannot sign must not be kept");
assertTrue(second.isIssuedByUs(parse(second.issueClientCertificate("device").certificatePem())));
}
@Test
void expiredCaIsRegenerated() throws Exception {
CertificateGenerator gen = new CertificateGenerator();
KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
X500Name dn = new X500Name("CN=" + FlociCertificateAuthority.COMMON_NAME);
X509Certificate expired = gen.signCertificate(dn, keyPair.getPublic(), dn, keyPair.getPrivate(), List.of(),
true, null, -1);
Files.writeString(tempDir.resolve("floci-root-ca.crt"), gen.toPem(expired));
Files.writeString(tempDir.resolve("floci-root-ca.key"), gen.toPem(keyPair.getPrivate()));
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tempDir);
assertNotEquals(expired, ca.certificate(), "an expired trust anchor is useless and must be replaced");
ca.certificate().checkValidity();
}
@Test
void missingKeyFileRegeneratesThePair() throws Exception {
FlociCertificateAuthority first = FlociCertificateAuthority.loadOrCreate(tempDir);
Files.delete(tempDir.resolve("floci-root-ca.key"));
FlociCertificateAuthority second = FlociCertificateAuthority.loadOrCreate(tempDir);
assertNotEquals(first.certificate(), second.certificate(), "a certificate without its key cannot sign");
assertTrue(Files.exists(tempDir.resolve("floci-root-ca.key")));
assertEquals(second.caPem(), Files.readString(tempDir.resolve("floci-root-ca.crt")));
}
@Test
void aLeafInPlaceOfTheCaIsRegenerated() throws Exception {
var leaf = FlociCertificateAuthority.loadOrCreate(tempDir.resolve("another-ca"))
.issueServerCertificate("localhost", List.of(), KeyAlgorithm.RSA_2048, null);
Files.writeString(tempDir.resolve("floci-root-ca.crt"), leaf.certificatePem());
Files.writeString(tempDir.resolve("floci-root-ca.key"), leaf.privateKeyPem());
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tempDir);
assertTrue(ca.certificate().getBasicConstraints() >= 0, "must be a CA again");
assertEquals(ca.certificate().getSubjectX500Principal(), ca.certificate().getIssuerX500Principal());
}
@Test
void signsAServerLeafThatVerifiesAgainstTheCa() throws Exception {
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tempDir);
CertificateGenerator.GeneratedCertificate leaf = ca.issueServerCertificate(
"localhost", List.of("localhost", "*.localhost.floci.io"), KeyAlgorithm.RSA_2048, null);
X509Certificate cert = parse(leaf.certificatePem());
cert.verify(ca.certificate().getPublicKey());
assertEquals(ca.certificate().getSubjectX500Principal(), cert.getIssuerX500Principal());
assertEquals(-1, cert.getBasicConstraints());
assertEquals(List.of("1.3.6.1.5.5.7.3.1", "1.3.6.1.5.5.7.3.2"), cert.getExtendedKeyUsage());
assertEquals(List.of("localhost", "*.localhost.floci.io"),
cert.getSubjectAlternativeNames().stream().map(san -> san.get(1)).toList());
assertTrue(ca.isIssuedByUs(cert));
}
@Test
void signsAClientLeafWithClientAuthOnly() throws Exception {
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tempDir);
X509Certificate cert = parse(ca.issueClientCertificate("AWS IoT Certificate").certificatePem());
cert.verify(ca.certificate().getPublicKey());
assertEquals("CN=AWS IoT Certificate", cert.getSubjectX500Principal().getName());
assertEquals(List.of("1.3.6.1.5.5.7.3.2"), cert.getExtendedKeyUsage());
assertTrue(ca.isIssuedByUs(cert));
assertEquals(java.time.Instant.parse("2049-12-31T23:59:59Z"), cert.getNotAfter().toInstant(), "AWS's fixed expiry");
assertEquals(java.time.Instant.parse("2050-12-31T23:59:59Z"), ca.certificate().getNotAfter().toInstant(),
"a new CA outlives the device certificates it signs");
}
@Test
void expiryRulesStayValidAfterTheFixedDates() {
java.time.Instant in2051 = java.time.Instant.parse("2051-06-01T00:00:00Z");
assertEquals(in2051.plus(3650, java.time.temporal.ChronoUnit.DAYS), FlociCertificateAuthority.caNotAfter(in2051),
"a CA created after 2050 lives ten years instead of being born expired");
assertEquals(java.time.Instant.parse("2050-12-31T23:59:59Z"),
FlociCertificateAuthority.caNotAfter(java.time.Instant.parse("2026-09-05T00:00:00Z")));
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tempDir);
assertEquals(ca.certificate().getNotAfter().toInstant(),
ca.deviceCertificateNotAfter(java.time.Instant.parse("2050-06-01T00:00:00Z")),
"after 2049 a device certificate lives a year, capped at the CA's expiry: never born expired");
assertTrue(ca.deviceCertificateNotAfter(java.time.Instant.parse("2050-06-01T00:00:00Z"))
.isAfter(java.time.Instant.parse("2050-06-01T00:00:00Z")));
assertEquals(java.time.Instant.parse("2049-12-31T23:59:59Z"),
ca.deviceCertificateNotAfter(java.time.Instant.parse("2026-09-05T00:00:00Z")));
}
@Test
void deviceCertificateNeverOutlivesAShortLivedCa() throws Exception {
CertificateGenerator gen = new CertificateGenerator();
var shortLived = gen.generateCaCertificate(FlociCertificateAuthority.COMMON_NAME,
java.time.Instant.now().plus(30, java.time.temporal.ChronoUnit.DAYS));
Files.writeString(tempDir.resolve("floci-root-ca.crt"), shortLived.certificatePem());
Files.writeString(tempDir.resolve("floci-root-ca.key"), shortLived.privateKeyPem());
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tempDir);
assertEquals(parse(shortLived.certificatePem()), ca.certificate(), "the short-lived CA is kept as is");
X509Certificate leaf = parse(ca.issueClientCertificate("device").certificatePem());
X509Certificate fromCsr = ca.signClientCsr(csrPem("CN=device", rsaKeyPair(2048)));
assertEquals(ca.certificate().getNotAfter(), leaf.getNotAfter(), "capped at the CA's expiry");
assertEquals(ca.certificate().getNotAfter(), fromCsr.getNotAfter(), "the CSR path is capped the same way");
}
@Test
void aSelfSignedLeafIsNotOurs() {
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tempDir);
var stranger = new CertificateGenerator().generateSelfSignedCertificate(
"localhost", List.of("localhost"), KeyAlgorithm.RSA_2048);
assertFalse(ca.isIssuedByUs(parse(stranger.certificatePem())));
}
@Test
void aLeafNamingOurCaButSignedByAnotherKeyIsNotOurs() {
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tempDir);
var impostor = new CertificateGenerator().generateCaCertificate(FlociCertificateAuthority.COMMON_NAME);
var generator = new CertificateGenerator();
var forged = generator.generateIssuedCertificate("localhost", List.of(), KeyAlgorithm.RSA_2048, null,
new CertificateGenerator.Issuer(parse(impostor.certificatePem()),
generator.parsePrivateKey(impostor.privateKeyPem())),
CertificateGenerator.LeafUsage.SERVER);
assertFalse(ca.isIssuedByUs(parse(forged.certificatePem())), "same issuer name, wrong signature");
}
@Test
void signsACsrAsAClientLeafWithTheCsrSubjectAndKey() throws Exception {
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tempDir);
java.security.KeyPair deviceKey = rsaKeyPair(2048);
X509Certificate cert = ca.signClientCsr(csrPem("CN=device-42,O=Example", deviceKey));
cert.verify(ca.certificate().getPublicKey());
assertEquals(deviceKey.getPublic(), cert.getPublicKey());
assertEquals(new X500Principal(
new X500Name("CN=device-42,O=Example").getEncoded()), cert.getSubjectX500Principal());
assertEquals(List.of("1.3.6.1.5.5.7.3.2"), cert.getExtendedKeyUsage());
assertEquals(-1, cert.getBasicConstraints());
assertTrue(ca.isIssuedByUs(cert));
}
@Test
void refusesWhatIsNotACsr() throws Exception {
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tempDir);
IllegalArgumentException notPem = assertThrows(IllegalArgumentException.class,
() -> ca.signClientCsr("hello"));
assertTrue(notPem.getMessage().contains("certificateSigningRequest"), notPem.getMessage());
IllegalArgumentException certificate = assertThrows(IllegalArgumentException.class,
() -> ca.signClientCsr(ca.caPem()));
assertTrue(certificate.getMessage().contains("not a PEM CERTIFICATE REQUEST"), certificate.getMessage());
}
@Test
void refusesAWeakRsaKey() throws Exception {
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tempDir);
String weak = csrPem("CN=weak", rsaKeyPair(1024));
IllegalArgumentException refused = assertThrows(IllegalArgumentException.class,
() -> ca.signClientCsr(weak));
assertTrue(refused.getMessage().contains("2048"), refused.getMessage());
}
private static KeyPair rsaKeyPair(int bits) throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(bits);
return kpg.generateKeyPair();
}
private static String csrPem(String subject, KeyPair keyPair) throws Exception {
var csr = new JcaPKCS10CertificationRequestBuilder(
new X500Name(subject), keyPair.getPublic())
.build(new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()));
StringWriter out = new StringWriter();
try (var writer = new JcaPEMWriter(out)) {
writer.writeObject(csr);
}
return out.toString();
}
private static X509Certificate parse(String pem) {
return new CertificateGenerator().parseCertificate(pem);
}
}
@@ -0,0 +1,74 @@
package io.github.hectorvent.floci.config;
import io.smallrye.config.PropertiesConfigSource;
import io.smallrye.config.SmallRyeConfig;
import io.smallrye.config.SmallRyeConfigBuilder;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static java.util.stream.StreamSupport.stream;
import static org.junit.jupiter.api.Assertions.*;
class FlociConfigRelocationsInterceptorTest {
private static SmallRyeConfig config(Map<String, String> properties) {
return new SmallRyeConfigBuilder()
.addDefaultInterceptors()
.withInterceptors(new FlociConfigRelocationsInterceptor())
.withSources(new PropertiesConfigSource(properties, "test", 250))
.withDefaultValue("floci.protocols.max-request-size", "2048")
.withDefaultValue("floci.services.lambda.ecr-base-uri", "public.ecr.aws")
.build();
}
@Test
void legacyMaxRequestSizeKeyResolvesToTheRelocatedKey() {
SmallRyeConfig config = config(Map.of("floci.max-request-size", "4096"));
assertEquals(4096, config.getValue("floci.protocols.max-request-size", Integer.class));
}
@Test
void legacyEcrBaseUriKeyResolvesToTheRelocatedKey() {
SmallRyeConfig config = config(Map.of("floci.ecr-base-uri", "my.registry.example/mirror"));
assertEquals("my.registry.example/mirror",
config.getValue("floci.services.lambda.ecr-base-uri", String.class));
}
@Test
void theRelocatedKeyWinsOverTheLegacyKeyFromTheSameSource() {
SmallRyeConfig config = config(Map.of(
"floci.max-request-size", "4096",
"floci.protocols.max-request-size", "8192"));
assertEquals(8192, config.getValue("floci.protocols.max-request-size", Integer.class));
}
@Test
void theWithDefaultValueAppliesWhenNeitherKeyIsSet() {
SmallRyeConfig config = config(Map.of());
assertEquals(2048, config.getValue("floci.protocols.max-request-size", Integer.class));
assertEquals("public.ecr.aws",
config.getValue("floci.services.lambda.ecr-base-uri", String.class));
}
@Test
void nameIterationPresentsOnlyTheRelocatedKey() {
SmallRyeConfig config = config(Map.of("floci.max-request-size", "4096"));
var names = stream(config.getPropertyNames().spliterator(), false).toList();
assertTrue(names.contains("floci.protocols.max-request-size"));
assertFalse(names.contains("floci.max-request-size"));
}
@Test
void expressionExpansionSeesTheLegacyKeyWhenResolvingTheRelocatedKey() {
SmallRyeConfig config = config(Map.of(
"quarkus.http.limits.max-body-size", "${floci.protocols.max-request-size}M",
"floci.max-request-size", "4096"));
assertEquals("4096M", config.getValue("quarkus.http.limits.max-body-size", String.class));
}
}
@@ -0,0 +1,43 @@
package io.github.hectorvent.floci.config;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class HotReloadExposureWarningTest {
private static EmulatorConfig configWith(boolean enabled, List<String> allowedPaths) {
EmulatorConfig.LambdaServiceConfig.HotReload hotReload =
mock(EmulatorConfig.LambdaServiceConfig.HotReload.class);
when(hotReload.enabled()).thenReturn(enabled);
when(hotReload.allowedPaths()).thenReturn(Optional.ofNullable(allowedPaths));
EmulatorConfig.LambdaServiceConfig lambda = mock(EmulatorConfig.LambdaServiceConfig.class);
when(lambda.hotReload()).thenReturn(hotReload);
EmulatorConfig.ServicesConfig services = mock(EmulatorConfig.ServicesConfig.class);
when(services.lambda()).thenReturn(lambda);
EmulatorConfig config = mock(EmulatorConfig.class);
when(config.services()).thenReturn(services);
return config;
}
@Test
void warnsWhenHotReloadIsEnabledWithNoAllowList() {
assertTrue(HotReloadExposureWarning.hotReloadAcceptsAnyPath(configWith(true, null)));
}
@Test
void staysQuietWhenAnAllowListIsSet() {
assertFalse(HotReloadExposureWarning.hotReloadAcceptsAnyPath(configWith(true, List.of("/home/ci/code"))));
}
@Test
void staysQuietWhenHotReloadIsDisabled() {
assertFalse(HotReloadExposureWarning.hotReloadAcceptsAnyPath(configWith(false, null)));
}
}
@@ -0,0 +1,250 @@
package io.github.hectorvent.floci.config;
import io.github.hectorvent.floci.services.iot.IotMqttWebSocketIntegrationTest;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
import io.vertx.core.Vertx;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
import org.eclipse.paho.client.mqttv3.MqttCallback;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttMessage;
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* The deployment claims behind {@code wss://<endpoint>:443/mqtt}: in production one port fronts
* both Quarkus servers through {@link TlsProxyServer}, which sniffs the first byte, and a custom
* domain learned at runtime is served by the same HTTPS listener. Neither is proven by tests that
* dial the Quarkus test ports directly.
*/
@QuarkusTest
@TestProfile(IotMqttWebSocketIntegrationTest.Profile.class)
class IotMqttWebSocketProxyIntegrationTest {
static final String LEARNED_HOST = "iot.example.localhost.floci.io";
private static final byte[] CONNACK_ACCEPTED = {0x20, 0x02, 0x00, 0x00};
@ConfigProperty(name = "quarkus.http.test-ssl-port", defaultValue = "0")
int testSslPort;
@ConfigProperty(name = "quarkus.http.test-port", defaultValue = "0")
int testHttpPort;
@Inject
Vertx vertx;
@Inject
TlsCertificateManager certificateManager;
private TlsProxyServer proxy;
@AfterEach
void stopProxy() {
if (proxy != null) {
proxy.stop();
}
}
@Test
void wssAndWsShareOnePortThroughTheTlsProxy() throws Exception {
int publicPort = startProxy();
String topic = "proxy/" + System.nanoTime();
byte[] payload = "through the proxy".getBytes(StandardCharsets.UTF_8);
try (PahoClient subscriber = PahoClient.connect("wss://127.0.0.1:" + publicPort + "/mqtt", "proxy-wss-" + System.nanoTime())) {
subscriber.client.subscribe(topic, 1);
try (PahoClient publisher = PahoClient.connect("ws://127.0.0.1:" + publicPort + "/mqtt", "proxy-ws-" + System.nanoTime())) {
publisher.client.publish(topic, payload, 1, false);
}
assertArrayEquals(payload, subscriber.take(), "a TLS ClientHello and a plain GET on the same port both reach /mqtt");
}
}
@Test
void learnedHostnameServesMqttOverWssOnceEnsureHostRan() throws Exception {
assertThrows(SSLHandshakeException.class, () -> upgradeAs(LEARNED_HOST),
"precondition: the boot leaf covers one label under localhost.floci.io, not two");
certificateManager.ensureHost(LEARNED_HOST);
assertArrayEquals(CONNACK_ACCEPTED, upgradeAs(LEARNED_HOST),
"after ensureHost a client verifying the learned name gets an MQTT session on /mqtt");
}
private int startProxy() throws Exception {
int publicPort;
try (ServerSocket probe = new ServerSocket(0)) {
publicPort = probe.getLocalPort();
}
EmulatorConfig config = mock(EmulatorConfig.class);
EmulatorConfig.TlsConfig tls = mock(EmulatorConfig.TlsConfig.class);
when(config.port()).thenReturn(publicPort);
when(config.tls()).thenReturn(tls);
when(tls.enabled()).thenReturn(true);
when(tls.awsHttpsPort()).thenReturn(0);
proxy = new TlsProxyServer(vertx, config, "127.0.0.1", testHttpPort, testSslPort);
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
while (true) {
try (Socket probe = new Socket("127.0.0.1", publicPort)) {
return publicPort;
} catch (IOException notYet) {
assertTrue(System.nanoTime() < deadline, "the proxy never bound " + publicPort);
Thread.sleep(50);
}
}
}
/**
* Dials 127.0.0.1 the way a client that resolved {@code host} would: the TLS layer is given the
* name, so SNI and HTTPS endpoint identification apply to it, trusting only the Floci CA. Then a hand-written upgrade with
* that Host, one masked binary frame carrying an MQTT CONNECT, and the CONNACK frame back.
*/
private byte[] upgradeAs(String host) throws Exception {
Socket plain = new Socket();
plain.connect(new InetSocketAddress("127.0.0.1", testSslPort), 5000);
try (SSLSocket socket = (SSLSocket) IotMqttWebSocketIntegrationTest.trustOnlyFlociCa().getSocketFactory()
.createSocket(plain, host, testSslPort, true)) {
socket.setSoTimeout(10_000);
SSLParameters parameters = socket.getSSLParameters();
parameters.setServerNames(List.of(new SNIHostName(host)));
parameters.setEndpointIdentificationAlgorithm("HTTPS");
socket.setSSLParameters(parameters);
socket.startHandshake();
OutputStream out = socket.getOutputStream();
out.write(("GET /mqtt HTTP/1.1\r\nHost: " + host + ":" + testSslPort + "\r\nUpgrade: websocket\r\n"
+ "Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n"
+ "Sec-WebSocket-Protocol: mqtt\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
out.flush();
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII));
String status = reader.readLine();
assertNotNull(status);
assertTrue(status.startsWith("HTTP/1.1 101"), status);
boolean echoed = false;
for (String line = reader.readLine(); line != null && !line.isEmpty(); line = reader.readLine()) {
echoed |= line.equalsIgnoreCase("sec-websocket-protocol: mqtt");
}
assertTrue(echoed, "the learned host echoes the mqtt subprotocol");
out.write(maskedBinaryFrame(connectPacket("learned-" + System.nanoTime())));
out.flush();
return readBinaryFramePayload(socket.getInputStream());
}
}
private static byte[] connectPacket(String clientId) {
byte[] id = clientId.getBytes(StandardCharsets.UTF_8);
byte[] variable = new byte[10 + 2 + id.length];
System.arraycopy(new byte[] {0x00, 0x04, 'M', 'Q', 'T', 'T', 0x04, 0x02, 0x00, 0x3C}, 0, variable, 0, 10);
variable[10] = (byte) (id.length >> 8);
variable[11] = (byte) id.length;
System.arraycopy(id, 0, variable, 12, id.length);
byte[] packet = new byte[2 + variable.length];
packet[0] = 0x10;
packet[1] = (byte) variable.length;
System.arraycopy(variable, 0, packet, 2, variable.length);
return packet;
}
/** RFC 6455 client frames are masked; payloads here are under 126 bytes. */
private static byte[] maskedBinaryFrame(byte[] payload) {
byte[] mask = {0x12, 0x34, 0x56, 0x78};
byte[] frame = new byte[6 + payload.length];
frame[0] = (byte) 0x82;
frame[1] = (byte) (0x80 | payload.length);
System.arraycopy(mask, 0, frame, 2, 4);
for (int i = 0; i < payload.length; i++) {
frame[6 + i] = (byte) (payload[i] ^ mask[i % 4]);
}
return frame;
}
private static byte[] readBinaryFramePayload(InputStream in) throws IOException {
int first = in.read();
int second = in.read();
assertEquals(0x82, first, "a final binary frame");
int length = second & 0x7F;
byte[] payload = in.readNBytes(length);
assertEquals(length, payload.length);
return payload;
}
private static final class PahoClient implements AutoCloseable {
final MqttClient client;
private final BlockingQueue<byte[]> payloads = new LinkedBlockingQueue<>();
private PahoClient(MqttClient client) {
this.client = client;
}
static PahoClient connect(String url, String clientId) throws Exception {
MqttClient client = new MqttClient(url, clientId, new MemoryPersistence());
PahoClient paho = new PahoClient(client);
client.setCallback(new MqttCallback() {
@Override
public void connectionLost(Throwable cause) {
}
@Override
public void messageArrived(String topic, MqttMessage message) {
paho.payloads.add(message.getPayload());
}
@Override
public void deliveryComplete(IMqttDeliveryToken token) {
}
});
MqttConnectOptions options = new MqttConnectOptions();
options.setConnectionTimeout(10);
if (url.startsWith("wss://")) {
options.setSocketFactory(IotMqttWebSocketIntegrationTest.trustOnlyFlociCa().getSocketFactory());
}
client.connect(options);
return paho;
}
byte[] take() throws InterruptedException {
byte[] payload = payloads.poll(10, TimeUnit.SECONDS);
assertNotNull(payload, "no publish received within 10s");
return payload;
}
@Override
public void close() throws Exception {
if (client.isConnected()) {
client.disconnect();
}
client.close();
}
}
}
@@ -0,0 +1,143 @@
package io.github.hectorvent.floci.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Guards the GraalVM native-image build against a classpath resource that is read at runtime but
* never packaged.
*
* <p>Quarkus's static analysis finds an inline literal such as
* {@code getResourceAsStream("/redshift/bootstrap-catalog.sql")}, so those need no registration.
* It cannot follow a constant, so {@code getResourceAsStream(CATALOG_RESOURCE_NAME)} leaves the
* file out of the image and the first read fails at runtime with a missing-resource error. The
* remedy is a matching glob under {@code quarkus.native.resources.includes} in
* {@code application.yml}.
*
* <p>Like {@link NativeImageRuntimeInitializationTest}, this failure only surfaces in the slow
* native build and never in the JVM CI jobs, so it moves the check into ordinary CI. Regression
* context: the SageMaker instance type catalog shipped reading
* {@code sagemaker/instance-type-catalog.yaml} through a constant without the matching include,
* which would have failed every GPU training job in the native image while passing every JVM test.
*/
class NativeImageResourceIncludesTest {
private static final Path MAIN_SOURCE_ROOT = Path.of("src", "main", "java");
private static final Path APPLICATION_YML = Path.of("src", "main", "resources", "application.yml");
private static final Path MAIN_RESOURCE_ROOT = Path.of("src", "main", "resources");
private static final String JAVA_SUFFIX = ".java";
/** A {@code static final String NAME = "some/path.ext";} declaration holding a resource path. */
private static final Pattern RESOURCE_CONSTANT = Pattern.compile(
"static\\s+final\\s+String\\s+(\\w+)\\s*=\\s*\"(/?[\\w.\\-]+(?:/[\\w.\\-]+)+\\.[A-Za-z0-9]+)\"");
/**
* Read while the image is built, not at runtime. {@code AwsManagedPolicies} parses the catalog
* during the build and keeps the parsed form in the image heap, so including the 3.5 MB of
* source files would only be a second copy. {@code application.yml} says the same.
*/
private static final Set<String> BUILD_TIME_ONLY = Set.of(
"iam/managed-policies.yaml",
"iam/managed-policy-documents.json",
"iam/managed-policy-versions.json");
@Test
void everyResourceReadThroughAConstantIsIncludedInTheNativeImage() throws IOException {
List<PathMatcher> includes = parseIncludes();
Map<String, String> offenders = new LinkedHashMap<>();
try (Stream<Path> sources = Files.walk(MAIN_SOURCE_ROOT)) {
sources.filter(p -> p.toString().endsWith(JAVA_SUFFIX)).forEach(source -> {
for (String resource : resourcesReadThroughAConstant(source)) {
if (BUILD_TIME_ONLY.contains(resource)) {
continue;
}
// Only a file this repository actually ships can be registered. A constant
// naming something from a dependency is that dependency's problem.
if (!Files.exists(MAIN_RESOURCE_ROOT.resolve(resource))) {
continue;
}
if (includes.stream().noneMatch(m -> m.matches(Path.of(resource)))) {
offenders.put(resource, source.toString());
}
}
});
}
assertTrue(offenders.isEmpty(),
"These resources are read at runtime through a constant, which Quarkus's static analysis "
+ "cannot follow, but no glob in " + APPLICATION_YML + " under "
+ "quarkus.native.resources.includes matches them. The native image will not contain "
+ "them and the first read will fail at runtime. Add an include for each:\n "
+ offenders.entrySet().stream()
.map(e -> e.getKey() + " (read by " + e.getValue() + ")")
.reduce((a, b) -> a + "\n " + b).orElse(""));
}
/** Resource paths declared as a constant in {@code javaFile} and passed to getResourceAsStream there. */
private List<String> resourcesReadThroughAConstant(Path javaFile) {
String source;
try {
source = Files.readString(javaFile);
} catch (IOException e) {
throw new UncheckedIOException("Unable to read source file " + javaFile, e);
}
List<String> found = new ArrayList<>();
if (!source.contains("getResourceAsStream")) {
return found;
}
Matcher constants = RESOURCE_CONSTANT.matcher(source);
while (constants.find()) {
String name = constants.group(1);
String value = constants.group(2);
if (source.contains("getResourceAsStream(" + name + ")")
|| source.contains("getResourceAsStream(\n")
&& source.contains(name + ")")) {
found.add(value.startsWith("/") ? value.substring(1) : value);
}
}
return found;
}
/** The {@code quarkus.native.resources.includes} globs, as matchers over classpath-relative paths. */
private List<PathMatcher> parseIncludes() throws IOException {
List<String> lines = Files.readAllLines(APPLICATION_YML);
List<PathMatcher> matchers = new ArrayList<>();
boolean inIncludes = false;
for (String raw : lines) {
String line = raw.strip();
if (line.startsWith("includes:")) {
inIncludes = true;
continue;
}
if (inIncludes) {
if (!line.startsWith("- ")) {
if (!line.isEmpty() && !line.startsWith("#")) {
break;
}
continue;
}
matchers.add(FileSystems.getDefault().getPathMatcher("glob:" + line.substring(2).strip()));
}
}
assertTrue(!matchers.isEmpty(), "Found no quarkus.native.resources.includes entries in " + APPLICATION_YML);
return matchers;
}
}
@@ -0,0 +1,102 @@
package io.github.hectorvent.floci.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Guards the GraalVM native-image build against the "Random/SecureRandom in the
* image heap" failure mode.
*
* <p>A {@code static} {@link java.security.SecureRandom}/{@link java.util.Random}
* field is initialized at <em>build</em> time, so native-image captures the
* instance — with a frozen seed — into the image heap and aborts with:
*
* <pre>
* Detected an instance of Random/SplittableRandom class in the image heap.
* </pre>
*
* The remedy is to list the owning class under
* {@code quarkus.native.additional-build-args → --initialize-at-run-time} in
* {@code application.yml} so its static initializer runs at image runtime.
*
* <p>This failure only surfaces in the (slow, nightly) native build, never in
* the JVM CI jobs. This test moves the check into ordinary CI: every main-source
* class declaring such a static field MUST be registered for run-time
* initialization. Regression context: CloudMapService (#1217) added a static
* {@code SecureRandom} without the registration and broke the nightly (#1234).
*/
class NativeImageRuntimeInitializationTest {
private static final Path MAIN_SOURCE_ROOT = Path.of("src", "main", "java");
private static final Path APPLICATION_YML = Path.of("src", "main", "resources", "application.yml");
private static final String JAVA_SUFFIX = ".java";
private static final String INIT_AT_RUN_TIME_FLAG = "--initialize-at-run-time=";
/** A {@code static [final] [pkg.]SecureRandom|Random|SplittableRandom <name> ;|=} field declaration. */
private static final Pattern STATIC_RANDOM_FIELD = Pattern.compile(
"\\bstatic\\s+(?:final\\s+)?(?:[\\w.]+\\.)?\\b(?:SecureRandom|SplittableRandom|Random)\\b\\s+\\w+\\s*[;=]");
private static final Pattern PACKAGE_DECLARATION = Pattern.compile("^\\s*package\\s+([\\w.]+)\\s*;", Pattern.MULTILINE);
@Test
void everyClassWithAStaticRandomFieldIsInitializedAtRunTime() throws IOException {
Set<String> runtimeInitialized = parseRuntimeInitializedClasses();
List<String> offenders = new ArrayList<>();
try (Stream<Path> sources = Files.walk(MAIN_SOURCE_ROOT)) {
sources.filter(p -> p.toString().endsWith(JAVA_SUFFIX))
.forEach(p -> {
String fqcn = fullyQualifiedNameIfHoldsStaticRandom(p);
if (fqcn != null && !runtimeInitialized.contains(fqcn)) {
offenders.add(fqcn);
}
});
}
assertTrue(offenders.isEmpty(),
"These classes declare a static Random/SecureRandom field but are NOT registered under "
+ INIT_AT_RUN_TIME_FLAG + " in " + APPLICATION_YML + ". GraalVM native builds will fail "
+ "(instance captured in the image heap). Add an --initialize-at-run-time entry for each:\n "
+ String.join("\n ", offenders));
}
/** @return the top-level FQCN if the source file declares a static Random field, otherwise {@code null}. */
private String fullyQualifiedNameIfHoldsStaticRandom(Path javaFile) {
String source;
try {
source = Files.readString(javaFile);
} catch (IOException e) {
throw new UncheckedIOException("Unable to read source file " + javaFile, e);
}
if (!STATIC_RANDOM_FIELD.matcher(source).find()) {
return null;
}
Matcher pkg = PACKAGE_DECLARATION.matcher(source);
String packageName = pkg.find() ? pkg.group(1) : "";
String fileName = javaFile.getFileName().toString();
String simpleName = fileName.substring(0, fileName.length() - JAVA_SUFFIX.length());
return packageName.isEmpty() ? simpleName : packageName + "." + simpleName;
}
private Set<String> parseRuntimeInitializedClasses() throws IOException {
return Files.readAllLines(APPLICATION_YML).stream()
.map(String::trim)
.filter(line -> line.contains(INIT_AT_RUN_TIME_FLAG))
.map(line -> line.substring(line.indexOf(INIT_AT_RUN_TIME_FLAG) + INIT_AT_RUN_TIME_FLAG.length()).trim())
.collect(Collectors.toSet());
}
}
@@ -0,0 +1,107 @@
package io.github.hectorvent.floci.config;
import io.smallrye.config.PropertiesConfigSource;
import io.smallrye.config.SmallRyeConfig;
import io.smallrye.config.SmallRyeConfigBuilder;
import org.eclipse.microprofile.config.Config;
import org.eclipse.microprofile.config.ConfigValue;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.nio.file.Path;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class NetworkExposureGuardTest {
@TempDir
Path tempDir;
@AfterEach
void cleanup() {
System.clearProperty("floci.tls.enabled");
System.clearProperty("floci.tls.self-signed");
System.clearProperty("floci.storage.persistent-path");
}
private static EmulatorConfig.SecurityConfig security(boolean allowExposure) {
EmulatorConfig.SecurityConfig security = mock(EmulatorConfig.SecurityConfig.class);
when(security.allowUnsafeNetworkExposure()).thenReturn(allowExposure);
return security;
}
@ParameterizedTest
@ValueSource(strings = {"127.0.0.1", "127.10.0.3", "::1", "[::1]", "localhost", "LOCALHOST"})
void loopbackHostNeedsNoConsent(String host) {
assertDoesNotThrow(() -> NetworkExposureGuard.requireConsent(host, security(false)));
}
@ParameterizedTest
@ValueSource(strings = {"0.0.0.0", "::", "192.168.1.20", "172.17.0.1", "floci.internal"})
void nonLoopbackHostWithoutConsentNamesTheSettingToChange(String host) {
IllegalStateException e = assertThrows(IllegalStateException.class,
() -> NetworkExposureGuard.requireConsent(host, security(false)));
assertTrue(e.getMessage().contains(host), e.getMessage());
assertTrue(e.getMessage().contains("floci.security.allow-unsafe-network-exposure"), e.getMessage());
assertTrue(e.getMessage().contains("FLOCI_SECURITY_ALLOW_UNSAFE_NETWORK_EXPOSURE"), e.getMessage());
}
@Test
void nonLoopbackHostWithConsentIsAllowed() {
assertDoesNotThrow(() -> NetworkExposureGuard.requireConsent("0.0.0.0", security(true)));
}
@Test
void startupRefusesMalformedHostEvenWithNetworkExposureConsent() {
Config config = mock(Config.class);
ConfigValue host = mock(ConfigValue.class);
when(host.getValue()).thenReturn("999.999.999.999");
when(config.getConfigValue("quarkus.http.host")).thenReturn(host);
EmulatorConfig emulatorConfig = mock(EmulatorConfig.class);
EmulatorConfig.SecurityConfig security = security(true);
when(emulatorConfig.security()).thenReturn(security);
NetworkExposureGuard guard = new NetworkExposureGuard(emulatorConfig, config);
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> guard.onStart(null));
assertTrue(exception.getMessage().contains("malformed"), exception.getMessage());
}
@Test
void publicHostIsTheConfiguredHost() {
SmallRyeConfig config = new SmallRyeConfigBuilder()
.withSources(new PropertiesConfigSource(Map.of("quarkus.http.host", "127.0.0.1"), "yml", 255))
.withSources(new PropertiesConfigSource(Map.of("quarkus.http.host", "0.0.0.0"), "sysprops", 400))
.build();
assertEquals("0.0.0.0", NetworkExposureGuard.publicHost(config));
}
@Test
void publicHostIgnoresTheLoopbackPinTlsModeAppliesToQuarkus() {
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
SmallRyeConfig config = new SmallRyeConfigBuilder()
.withSources(new TlsConfigSource())
.withSources(new PropertiesConfigSource(Map.of("quarkus.http.host", "0.0.0.0"), "yml", 255))
.build();
assertEquals("127.0.0.1", config.getValue("quarkus.http.host", String.class),
"precondition: TLS mode keeps Quarkus itself on loopback behind the proxy");
assertEquals("0.0.0.0", NetworkExposureGuard.publicHost(config),
"the proxy must listen where the user asked, not on the internal pin");
}
}
@@ -0,0 +1,27 @@
package io.github.hectorvent.floci.config;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@QuarkusTest
class RdsProxyConfigTest {
@Inject
EmulatorConfig config;
@Test
void rdsProxyPortRangeHasDefaults() {
assertEquals(7001, config.services().rds().proxyBasePort());
assertEquals(7099, config.services().rds().proxyMaxPort());
}
@Test
void rdsProxyBoundsHaveDefaults() {
assertEquals(10000, config.services().rds().proxyHandshakeTimeoutMillis());
assertEquals(5000, config.services().rds().proxyBackendConnectTimeoutMillis());
assertEquals(100, config.services().rds().proxyMaxConnections());
}
}
@@ -0,0 +1,34 @@
package io.github.hectorvent.floci.config;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
@QuarkusTest
class RedshiftProxyConfigTest {
@Inject
EmulatorConfig config;
@Test
void redshiftProxyPortRangeHasDefaults() {
assertEquals(7100, config.services().redshift().proxyBasePort());
assertEquals(7199, config.services().redshift().proxyMaxPort());
}
@Test
void redshiftEndpointHostDefaultsToEmpty() {
assertEquals(Optional.empty(), config.services().redshift().endpointHost());
}
@Test
void redshiftProxyBoundsHaveDefaults() {
assertEquals(10000, config.services().redshift().proxyHandshakeTimeoutMillis());
assertEquals(5000, config.services().redshift().proxyBackendConnectTimeoutMillis());
assertEquals(100, config.services().redshift().proxyMaxConnections());
}
}
@@ -0,0 +1,68 @@
package io.github.hectorvent.floci.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import io.smallrye.config.common.utils.StringUtil;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Guards the rule that {@code application.yml} — not an interface {@code @WithDefault} —
* is the source of truth for which services run. A service reachable only through its
* fallback default is invisible to anyone reading the config and cannot be toggled from
* the file, which is how six services drifted out of both YAMLs before this test existed.
*/
class ServiceConfigYamlCoverageTest {
private static final List<Path> CONFIGS = List.of(
Path.of("src/main/resources/application.yml"),
Path.of("src/test/resources/application.yml"));
@Test
void everyToggleableServiceDeclaresEnabledInBothConfigs() throws IOException {
List<String> missing = new ArrayList<>();
for (Path config : CONFIGS) {
JsonNode services = new YAMLMapper().readTree(config.toFile())
.path("floci").path("services");
for (Method accessor : EmulatorConfig.ServicesConfig.class.getDeclaredMethods()) {
if (!declaresEnabledToggle(accessor.getReturnType())) {
continue;
}
String key = StringUtil.skewer(accessor.getName());
if (!services.path(key).path("enabled").isBoolean()) {
missing.add(config + " -> floci.services." + key + ".enabled");
}
}
}
assertTrue(missing.isEmpty(),
"Every service config with an enabled() toggle must declare it explicitly in both "
+ "application.yml files. Note a bare 'service-name:' key with no child does "
+ "not count, since it binds to nothing. Missing: " + missing);
}
/**
* Config records without an {@code enabled()} toggle (for example {@code DuckConfig},
* which only carries an image and an optional URL) are not services that can be turned
* off, so they are exempt.
*/
private static boolean declaresEnabledToggle(Class<?> configInterface) {
for (Method method : configInterface.getDeclaredMethods()) {
if (method.getName().equals("enabled")
&& method.getParameterCount() == 0
&& (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,537 @@
package io.github.hectorvent.floci.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.core.common.AwsRegions;
import io.github.hectorvent.floci.services.acm.CertificateGenerator;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Security;
import java.security.cert.X509Certificate;
import java.util.*;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
/**
* Integration tests for TLS certificate hostname functionality.
*
* Tests that the TLS certificate generation correctly:
* - Includes custom hostnames from FLOCI_HOSTNAME and FLOCI_BASE_URL in certificate SANs
* - Regenerates certificates when hostname configuration changes
* - Preserves default behavior for standard configurations
* - Handles user-provided certificates correctly
* - Manages certificate reuse and regeneration appropriately
*/
class TlsCertificateHostnameTest {
@TempDir
Path tempDir;
@BeforeAll
static void setupBouncyCastle() {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
@AfterEach
void cleanupSystemProperties() {
System.clearProperty("floci.hostname");
System.clearProperty("floci.base-url");
System.clearProperty("floci.tls.enabled");
System.clearProperty("floci.tls.self-signed");
System.clearProperty("floci.tls.cert-path");
System.clearProperty("floci.tls.key-path");
System.clearProperty("floci.storage.persistent-path");
}
// ==================== Custom Hostname Tests ====================
@Test
void testFlociHostnameIncludedInCertificateSans() throws Exception {
// Arrange
System.setProperty("floci.hostname", "floci");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// Act
new TlsConfigSource();
// Assert
Path certFile = tempDir.resolve("tls/floci-server.crt");
assertTrue(Files.exists(certFile), "Certificate file should exist");
X509Certificate cert = parseCertificate(certFile);
List<String> sans = extractSansFromCertificate(cert);
assertTrue(sans.contains("floci"),
"Certificate SANs should contain 'floci' from FLOCI_HOSTNAME. Found: " + sans);
assertTrue(sans.contains("localhost"),
"Certificate SANs should include default 'localhost'");
}
@Test
void testBaseUrlHostnameIncludedInCertificateSans() throws Exception {
// Arrange
System.setProperty("floci.base-url", "https://myhost:4566");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// Act
new TlsConfigSource();
// Assert
Path certFile = tempDir.resolve("tls/floci-server.crt");
X509Certificate cert = parseCertificate(certFile);
List<String> sans = extractSansFromCertificate(cert);
assertTrue(sans.contains("myhost"),
"Certificate SANs should contain 'myhost' from FLOCI_BASE_URL. Found: " + sans);
}
@Test
void testBaseUrlIpAddressIncludedInCertificateSans() throws Exception {
// Arrange
System.setProperty("floci.base-url", "https://192.168.1.100:4566");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// Act
new TlsConfigSource();
// Assert
Path certFile = tempDir.resolve("tls/floci-server.crt");
X509Certificate cert = parseCertificate(certFile);
List<String> sans = extractSansFromCertificate(cert);
assertTrue(sans.contains("192.168.1.100"),
"Certificate SANs should contain IP address from FLOCI_BASE_URL. Found: " + sans);
}
@Test
void testBothHostnamesIncludedInCertificateSans() throws Exception {
// Arrange
System.setProperty("floci.hostname", "newhost");
System.setProperty("floci.base-url", "http://oldhost:4566");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// Act
new TlsConfigSource();
// Assert
Path certFile = tempDir.resolve("tls/floci-server.crt");
X509Certificate cert = parseCertificate(certFile);
List<String> sans = extractSansFromCertificate(cert);
assertTrue(sans.contains("newhost"),
"Certificate SANs should contain 'newhost' from FLOCI_HOSTNAME. Found: " + sans);
assertTrue(sans.contains("oldhost"),
"Certificate SANs should contain 'oldhost' from FLOCI_BASE_URL. Found: " + sans);
}
// ==================== Certificate Regeneration Tests ====================
@Test
void testConfigurationChangeTriggersRegeneration() throws Exception {
// Arrange: Generate initial certificate
System.setProperty("floci.hostname", "host1");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
new TlsConfigSource();
Path certFile = tempDir.resolve("tls/floci-server.crt");
X509Certificate initialCert = parseCertificate(certFile);
List<String> initialSans = extractSansFromCertificate(initialCert);
assertTrue(initialSans.contains("host1"), "Initial certificate should contain 'host1'");
// Act: Change hostname and restart
System.setProperty("floci.hostname", "host2");
new TlsConfigSource();
// Assert: Certificate regenerated with new hostname
X509Certificate regeneratedCert = parseCertificate(certFile);
List<String> regeneratedSans = extractSansFromCertificate(regeneratedCert);
assertTrue(regeneratedSans.contains("host2"),
"Certificate should contain 'host2' after configuration change. Found: " + regeneratedSans);
assertFalse(regeneratedSans.contains("host1"),
"Certificate should not contain old hostname 'host1'");
}
@Test
void testMissingMetadataTriggersRegeneration() throws Exception {
// Arrange: Create certificate without metadata (simulating old version)
System.setProperty("floci.hostname", "floci");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
Path tlsDir = tempDir.resolve("tls");
Files.createDirectories(tlsDir);
Path certFile = tlsDir.resolve("floci-server.crt");
Path keyFile = tlsDir.resolve("floci-server.key");
Path metadataFile = tlsDir.resolve("floci-server.metadata.json");
// Generate certificate without metadata
CertificateGenerator gen = new CertificateGenerator();
CertificateGenerator.GeneratedCertificate generated = gen.generateSelfSignedCertificate(
"localhost",
List.of("localhost", "127.0.0.1", "0.0.0.0", "*.localhost", "localhost.floci.io", "*.localhost.floci.io"),
KeyAlgorithm.RSA_2048);
Files.writeString(certFile, generated.certificatePem());
Files.writeString(keyFile, generated.privateKeyPem());
assertFalse(Files.exists(metadataFile), "Metadata should not exist initially");
// Act: Trigger TlsConfigSource
new TlsConfigSource();
// Assert: Metadata created and certificate regenerated with custom hostname
assertTrue(Files.exists(metadataFile),
"Metadata file should exist after regeneration");
X509Certificate cert = parseCertificate(certFile);
List<String> sans = extractSansFromCertificate(cert);
assertTrue(sans.contains("floci"),
"Regenerated certificate should contain 'floci'. Found: " + sans);
}
// ==================== Default Configuration Tests ====================
@Test
void testDefaultConfigurationGeneratesDefaultSans() throws Exception {
// Arrange: Default configuration (no custom hostnames)
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// Act
new TlsConfigSource();
// Assert
Path certFile = tempDir.resolve("tls/floci-server.crt");
X509Certificate cert = parseCertificate(certFile);
List<String> sans = extractSansFromCertificate(cert);
Set<String> expectedSans = Set.copyOf(TlsConfigSource.DEFAULT_SAN_HOSTNAMES);
Set<String> actualSans = new HashSet<>(sans);
assertEquals(expectedSans, actualSans,
"Default configuration should generate certificate with default SANs only");
}
@ParameterizedTest
@MethodSource("defaultConfigurations")
void testDefaultConfigurationsProduceDefaultSans(Map<String, String> config) throws Exception {
// Arrange
config.forEach(System::setProperty);
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// Act
new TlsConfigSource();
// Assert
Path certFile = tempDir.resolve("tls/floci-server.crt");
X509Certificate cert = parseCertificate(certFile);
List<String> sans = extractSansFromCertificate(cert);
Set<String> expectedSans = Set.copyOf(TlsConfigSource.DEFAULT_SAN_HOSTNAMES);
Set<String> actualSans = new HashSet<>(sans);
assertEquals(expectedSans, actualSans,
"Default configuration should produce default SANs. Config: " + config);
// Cleanup
config.keySet().forEach(System::clearProperty);
}
// ==================== User-Provided Certificate Tests ====================
@Test
void testUserProvidedCertificatesUsedWithoutModification() throws Exception {
// Arrange: Create user-provided certificate
Path userCertFile = tempDir.resolve("user-cert.crt");
Path userKeyFile = tempDir.resolve("user-key.key");
CertificateGenerator gen = new CertificateGenerator();
CertificateGenerator.GeneratedCertificate userCert = gen.generateSelfSignedCertificate(
"user-domain.com",
List.of("user-domain.com", "*.user-domain.com"),
KeyAlgorithm.RSA_2048);
Files.writeString(userCertFile, userCert.certificatePem());
Files.writeString(userKeyFile, userCert.privateKeyPem());
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.cert-path", userCertFile.toString());
System.setProperty("floci.tls.key-path", userKeyFile.toString());
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// Act
new TlsConfigSource();
// Assert: No self-signed certificate generated
Path selfSignedCert = tempDir.resolve("tls/floci-server.crt");
assertFalse(Files.exists(selfSignedCert),
"Self-signed certificate should not be generated when user provides certificates");
}
// ==================== TLS Disabled Tests ====================
@Test
void testTlsDisabledSkipsCertificateGeneration() throws Exception {
// Arrange
System.setProperty("floci.tls.enabled", "false");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// Act
new TlsConfigSource();
// Assert
Path tlsDir = tempDir.resolve("tls");
if (Files.exists(tlsDir)) {
assertFalse(Files.exists(tlsDir.resolve("floci-server.crt")),
"No certificate should be created when TLS is disabled");
}
}
// ==================== Certificate Reuse Tests ====================
@Test
void testUnchangedConfigurationReusesCertificate() throws Exception {
// Arrange: Generate initial certificate
System.setProperty("floci.hostname", "floci");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
new TlsConfigSource();
Path certFile = tempDir.resolve("tls/floci-server.crt");
long initialModifiedTime = Files.getLastModifiedTime(certFile).toMillis();
String initialMetadata = Files.readString(tempDir.resolve("tls/floci-server.metadata.json"));
Thread.sleep(100);
// Act: Restart with same configuration
new TlsConfigSource();
// Assert: Certificate reused (not regenerated)
long newModifiedTime = Files.getLastModifiedTime(certFile).toMillis();
String newMetadata = Files.readString(tempDir.resolve("tls/floci-server.metadata.json"));
assertEquals(initialModifiedTime, newModifiedTime,
"Certificate should be reused when configuration unchanged");
assertEquals(initialMetadata, newMetadata,
"Metadata should be unchanged");
}
// ==================== Local CA Tests ====================
@Test
void serverCertificateIsIssuedByTheLocalCa() throws Exception {
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
TlsConfigSource source = new TlsConfigSource();
Path tlsDir = tempDir.resolve("tls");
X509Certificate ca = parseCertificate(tlsDir.resolve("floci-root-ca.crt"));
X509Certificate leaf = parseCertificate(tlsDir.resolve("floci-server.crt"));
leaf.verify(ca.getPublicKey());
assertEquals(ca.getSubjectX500Principal(), leaf.getIssuerX500Principal());
assertEquals(-1, leaf.getBasicConstraints(), "server leaf must not be a CA");
assertEquals(List.of("1.3.6.1.5.5.7.3.1", "1.3.6.1.5.5.7.3.2"), leaf.getExtendedKeyUsage());
assertEquals(tlsDir.resolve("floci-server.crt").toAbsolutePath().toString(),
source.getValue("quarkus.tls.key-store.pem.0.cert"));
assertEquals(tlsDir.resolve("floci-server.key").toAbsolutePath().toString(),
source.getValue("quarkus.tls.key-store.pem.0.key"));
assertNull(source.getValue("quarkus.http.ssl.certificate.files"), "legacy key no longer published");
assertNull(source.getValue("quarkus.http.ssl.certificate.key-files"), "legacy key no longer published");
assertEquals(PosixFilePermissions.fromString("rw-------"),
Files.getPosixFilePermissions(tlsDir.resolve("floci-server.key")));
assertEquals(tlsDir, TlsConfigSource.resolvedTlsDir());
}
@Test
void resolvedTlsDirFollowsEveryTlsOnBootAndIsClearedWhenTlsIsOff() throws Exception {
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
new TlsConfigSource();
assertEquals(tempDir.resolve("tls"), TlsConfigSource.resolvedTlsDir());
System.setProperty("floci.tls.enabled", "false");
new TlsConfigSource();
assertNull(TlsConfigSource.resolvedTlsDir(), "TLS off: nothing was laid down");
Path userCert = tempDir.resolve("user.crt");
Path userKey = tempDir.resolve("user.key");
var user = new CertificateGenerator().generateSelfSignedCertificate("localhost", List.of("localhost"), KeyAlgorithm.RSA_2048);
Files.writeString(userCert, user.certificatePem());
Files.writeString(userKey, user.privateKeyPem());
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.cert-path", userCert.toString());
System.setProperty("floci.tls.key-path", userKey.toString());
new TlsConfigSource();
assertEquals(tempDir.resolve("tls"), TlsConfigSource.resolvedTlsDir(),
"user certificate: the container CA bundle was laid down there");
}
@Test
void legacySelfSignedLeafIsReplacedByCaIssuedOne() throws Exception {
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
Path tlsDir = Files.createDirectories(tempDir.resolve("tls"));
CertificateGenerator gen = new CertificateGenerator();
// The exact SAN list TlsConfigSource would compute for this configuration, so only the
// issuer check can trigger regeneration here.
List<String> sans = TlsConfigSource.DEFAULT_SAN_HOSTNAMES;
var legacy = gen.generateSelfSignedCertificate("localhost", sans, KeyAlgorithm.RSA_2048);
Files.writeString(tlsDir.resolve("floci-server.crt"), legacy.certificatePem());
Files.writeString(tlsDir.resolve("floci-server.key"), legacy.privateKeyPem());
Files.writeString(tlsDir.resolve("floci-server.metadata.json"),
new ObjectMapper().writeValueAsString(
CertificateMetadata.create(sans, "dev")));
new TlsConfigSource();
X509Certificate ca = parseCertificate(tlsDir.resolve("floci-root-ca.crt"));
X509Certificate leaf = parseCertificate(tlsDir.resolve("floci-server.crt"));
assertEquals(ca.getSubjectX500Principal(), leaf.getIssuerX500Principal());
assertNotEquals(legacy.certificatePem(), Files.readString(tlsDir.resolve("floci-server.crt")));
}
@Test
void expiredServerLeafIsReissued() throws Exception {
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
Path tlsDir = Files.createDirectories(tempDir.resolve("tls"));
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tlsDir);
CertificateGenerator gen = new CertificateGenerator();
List<String> sans = TlsConfigSource.DEFAULT_SAN_HOSTNAMES;
KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
// Issued by the current CA with a validity that ended a day ago: only the expiry check can trigger.
X509Certificate expired = gen.signCertificate(new X500Name("CN=localhost"),
keyPair.getPublic(), X500Name.getInstance(
ca.certificate().getSubjectX500Principal().getEncoded()), ca.key(), sans, false,
CertificateGenerator.LeafUsage.SERVER, -1);
Files.writeString(tlsDir.resolve("floci-server.crt"), gen.toPem(expired));
Files.writeString(tlsDir.resolve("floci-server.key"), gen.toPem(keyPair.getPrivate()));
Files.writeString(tlsDir.resolve("floci-server.metadata.json"),
new ObjectMapper().writeValueAsString(CertificateMetadata.create(sans, "dev")));
assertTrue(ca.isIssuedByUs(expired), "the expired leaf is ours, so only validity can reject it");
new TlsConfigSource();
X509Certificate leaf = parseCertificate(tlsDir.resolve("floci-server.crt"));
leaf.checkValidity();
assertNotEquals(expired.getSerialNumber(), leaf.getSerialNumber());
assertTrue(ca.isIssuedByUs(leaf));
}
@Test
void leafSignedByAPreviousCaIsReplacedWhenTheCaChanges() throws Exception {
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
new TlsConfigSource();
Path tlsDir = tempDir.resolve("tls");
String firstLeaf = Files.readString(tlsDir.resolve("floci-server.crt"));
Files.delete(tlsDir.resolve("floci-root-ca.key"));
new TlsConfigSource();
X509Certificate ca = parseCertificate(tlsDir.resolve("floci-root-ca.crt"));
X509Certificate leaf = parseCertificate(tlsDir.resolve("floci-server.crt"));
assertNotEquals(firstLeaf, Files.readString(tlsDir.resolve("floci-server.crt")));
leaf.verify(ca.getPublicKey());
}
@Test
void defaultSansIncludeEcrRegionalHostnamesForAllAwsRegions() throws Exception {
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
new TlsConfigSource();
Path certFile = tempDir.resolve("tls/floci-server.crt");
X509Certificate cert = parseCertificate(certFile);
List<String> sans = extractSansFromCertificate(cert);
for (String region : AwsRegions.ALL) {
String expectedWildcard = "*.dkr.ecr." + region + ".localhost.floci.io";
assertTrue(TlsConfigSource.DEFAULT_SAN_HOSTNAMES.contains(expectedWildcard),
"DEFAULT_SAN_HOSTNAMES missing ECR wildcard for region " + region);
assertTrue(sans.contains(expectedWildcard),
"Certificate SANs missing ECR wildcard for region " + region);
}
}
// ==================== Helper Methods ====================
private X509Certificate parseCertificate(Path certFile) throws Exception {
String certPem = Files.readString(certFile);
CertificateGenerator gen = new CertificateGenerator();
return gen.parseCertificate(certPem);
}
private List<String> extractSansFromCertificate(X509Certificate cert) throws Exception {
List<String> sans = new ArrayList<>();
Collection<List<?>> subjectAltNames = cert.getSubjectAlternativeNames();
if (subjectAltNames != null) {
for (List<?> san : subjectAltNames) {
Integer type = (Integer) san.get(0);
String value = (String) san.get(1);
if (type == GeneralName.dNSName || type == GeneralName.iPAddress) {
sans.add(value);
}
}
}
return sans;
}
static Stream<Map<String, String>> defaultConfigurations() {
return Stream.of(
Map.of("floci.tls.enabled", "true", "floci.tls.self-signed", "true"),
Map.of("floci.tls.enabled", "true", "floci.tls.self-signed", "true",
"floci.base-url", "http://localhost:4566"),
Map.of("floci.tls.enabled", "true", "floci.tls.self-signed", "true",
"floci.base-url", "http://127.0.0.1:4566"),
Map.of("floci.tls.enabled", "true", "floci.tls.self-signed", "true",
"floci.hostname", "localhost"),
Map.of("floci.tls.enabled", "true", "floci.tls.self-signed", "true",
"floci.base-url", "https://localhost:4566"),
Map.of("floci.tls.enabled", "true", "floci.tls.self-signed", "true",
"floci.base-url", "http://0.0.0.0:4566")
);
}
}
@@ -0,0 +1,119 @@
package io.github.hectorvent.floci.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.services.acm.CertificateGenerator;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import io.quarkus.tls.CertificateUpdatedEvent;
import io.quarkus.tls.TlsConfiguration;
import io.quarkus.tls.TlsConfigurationRegistry;
import jakarta.enterprise.event.Event;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Security;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* A server leaf on disk as {@code TlsConfigSource} leaves it, a mocked configuration, TLS registry
* and event, for the {@link TlsCertificateManager} unit tests.
*/
abstract class TlsCertificateManagerFixture {
static final List<String> CONFIGURED = List.of("localhost", "127.0.0.1", "*.localhost.floci.io", "localhost.floci.io");
static final String NEW_HOST = "api.example.localhost.floci.io";
@TempDir
Path tempDir;
Path tlsDir;
FlociCertificateAuthority ca;
EmulatorConfig config;
TlsConfigurationRegistry registry;
TlsConfiguration defaultTls;
@SuppressWarnings("unchecked")
final Event<CertificateUpdatedEvent> events = mock(Event.class);
@BeforeAll
static void bouncyCastle() {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
@BeforeEach
void serverLeafOnDisk() throws Exception {
forgetBootstrapTlsDir();
tlsDir = Files.createDirectories(tempDir.resolve("tls"));
ca = FlociCertificateAuthority.loadOrCreate(tlsDir);
var leaf = ca.issueServerCertificate("localhost", CONFIGURED, KeyAlgorithm.RSA_2048, null);
Files.writeString(tlsDir.resolve("floci-server.crt"), leaf.certificatePem());
Files.writeString(tlsDir.resolve("floci-server.key"), leaf.privateKeyPem());
Files.writeString(tlsDir.resolve("floci-server.metadata.json"),
new ObjectMapper().writeValueAsString(CertificateMetadata.create(CONFIGURED, "dev")));
config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
when(config.tls().enabled()).thenReturn(true);
when(config.tls().certPath()).thenReturn(Optional.empty());
when(config.storage().persistentPath()).thenReturn(tempDir.toString());
when(config.hostname()).thenReturn(Optional.of("floci"));
when(config.baseUrl()).thenReturn("http://localhost:4566");
when(config.dns().extraSuffixes()).thenReturn(Optional.of(List.of("example.internal")));
registry = mock(TlsConfigurationRegistry.class);
defaultTls = mock(TlsConfiguration.class);
when(registry.getDefault()).thenReturn(Optional.of(defaultTls));
when(defaultTls.reload()).thenReturn(true);
}
TlsCertificateManager manager() {
return new TlsCertificateManager(config, ca, registry, events);
}
/**
* The manager resolves the TLS directory like every CDI consumer: the one the config
* bootstrap laid down, else the configured persistent path. Another test class in this JVM
* may have run a TLS-on bootstrap; a TLS-off bootstrap clears that static, as a real boot does.
*/
private static void forgetBootstrapTlsDir() {
System.setProperty("floci.tls.enabled", "false");
try {
new TlsConfigSource();
} finally {
System.clearProperty("floci.tls.enabled");
}
assertNull(TlsConfigSource.resolvedTlsDir());
}
X509Certificate read(String name) throws Exception {
return new CertificateGenerator().parseCertificate(Files.readString(tlsDir.resolve(name)));
}
byte[] servedCertificate() throws Exception {
return Files.readAllBytes(tlsDir.resolve("floci-server.crt"));
}
CertificateMetadata readMetadata() throws Exception {
return new ObjectMapper().readValue(tlsDir.resolve("floci-server.metadata.json").toFile(), CertificateMetadata.class);
}
static Set<String> sans(X509Certificate cert) throws Exception {
Set<String> out = new TreeSet<>();
for (List<?> entry : cert.getSubjectAlternativeNames()) {
out.add(String.valueOf(entry.get(1)));
}
return out;
}
}
@@ -0,0 +1,159 @@
package io.github.hectorvent.floci.config;
import org.bouncycastle.asn1.x509.GeneralName;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Which names {@code ensureHost} accepts: host name syntax, the one-label wildcard rule that
* X.509 name matching and API Gateway custom domains share, and the local-suffix allow-list.
*/
class TlsCertificateManagerNameRulesTest extends TlsCertificateManagerFixture {
@Test
void knownOrWildcardCoveredHostIsANoOp() throws Exception {
byte[] certBefore = servedCertificate();
TlsCertificateManager m = manager();
m.ensureHost("localhost");
m.ensureHost("LOCALHOST.floci.io.");
m.ensureHost("one-label.localhost.floci.io");
m.ensureHost(" 127.0.0.1 ");
assertArrayEquals(certBefore, servedCertificate());
verify(defaultTls, never()).reload();
verify(events, never()).fire(any());
}
@Test
void aWildcardCoversExactlyOneLabel() throws Exception {
TlsCertificateManager m = manager();
m.ensureHost("one.localhost.floci.io");
verify(defaultTls, never()).reload();
m.ensureHost("two.labels.localhost.floci.io");
verify(defaultTls, times(1)).reload();
m.ensureHost("other.labels.localhost.floci.io");
verify(defaultTls, times(2)).reload();
assertTrue(sans(read("floci-server.crt")).contains("*.localhost.floci.io"), "the wildcard itself is kept");
}
@Test
void wildcardIsAcceptedOnlyAsAWholeLeftmostLabel() throws Exception {
TlsCertificateManager m = manager();
m.ensureHost("*.api.example.localhost.floci.io");
assertTrue(sans(read("floci-server.crt")).contains("*.api.example.localhost.floci.io"),
"one leading *. label is a valid SAN");
verify(defaultTls, times(1)).reload();
m.ensureHost("*");
m.ensureHost("*.");
m.ensureHost("api*.example.localhost.floci.io");
m.ensureHost("*.*.example.localhost.floci.io");
m.ensureHost("example.*.localhost.floci.io");
verify(defaultTls, times(1)).reload();
Set<String> starred = new TreeSet<>(sans(read("floci-server.crt")));
starred.removeIf(n -> !n.contains("*"));
assertEquals(Set.of("*.localhost.floci.io", "*.api.example.localhost.floci.io"), starred,
"malformed wildcards must not reach the certificate");
}
@Test
void namesThatAreNotHostnamesAreRefused() throws Exception {
byte[] certBefore = servedCertificate();
TlsCertificateManager m = manager();
m.ensureHost(null);
m.ensureHost("");
m.ensureHost(" ");
m.ensureHost(".");
m.ensureHost("api.example.localhost.floci.io:443");
m.ensureHost("https://api.example.localhost.floci.io");
m.ensureHost("api example.localhost.floci.io");
m.ensureHost("api_1.example.localhost.floci.io");
m.ensureHost("api..example.localhost.floci.io");
m.ensureHost(".api.example.localhost.floci.io");
m.ensureHost("-api.example.localhost.floci.io");
m.ensureHost("api-.example.localhost.floci.io");
m.ensureHost("a".repeat(64) + ".localhost.floci.io");
m.ensureHost("a".repeat(63) + "." + "b".repeat(63) + "." + "c".repeat(63) + "." + "d".repeat(63) + ".localhost.floci.io");
assertArrayEquals(certBefore, servedCertificate());
verify(defaultTls, never()).reload();
verify(events, never()).fire(any());
}
@Test
void hostOutsideTheAllowListIsRefused() throws Exception {
byte[] certBefore = servedCertificate();
TlsCertificateManager m = manager();
m.ensureHost("accounts.google.com");
m.ensureHost("evil-localhost.floci.io");
m.ensureHost("localhost.floci.io.attacker.example");
m.ensureHost("floci.example");
m.ensureHost("*.google.com");
assertArrayEquals(certBefore, servedCertificate());
verify(defaultTls, never()).reload();
assertEquals(List.of(), readMetadata().getLearnedHostnames());
}
@Test
void allowListIncludesConfiguredHostnameBaseUrlAndExtraSuffixes() throws Exception {
when(config.baseUrl()).thenReturn("https://Floci.Corp.Example:4566");
TlsCertificateManager m = manager();
m.ensureHost("api.floci");
m.ensureHost("floci");
m.ensureHost("iot.example.internal");
m.ensureHost("data.localhost.localstack.cloud");
m.ensureHost("api.floci.corp.example");
Set<String> sans = sans(read("floci-server.crt"));
assertTrue(sans.containsAll(List.of("api.floci", "floci", "iot.example.internal",
"data.localhost.localstack.cloud", "api.floci.corp.example")), sans.toString());
}
@Test
void unusableBaseUrlAndAbsentOptionalConfigStillAllowBuiltinSuffixes() throws Exception {
when(config.baseUrl()).thenReturn("not a url");
when(config.hostname()).thenReturn(Optional.empty());
when(config.dns().extraSuffixes()).thenReturn(Optional.empty());
manager().ensureHost(NEW_HOST);
assertTrue(sans(read("floci-server.crt")).contains(NEW_HOST));
}
@Test
void anIpAddressAllowedByTheBaseUrlBecomesAnIpSan() throws Exception {
when(config.baseUrl()).thenReturn("https://192.168.1.100:4566");
manager().ensureHost("192.168.1.100");
boolean ipSan = false;
for (List<?> entry : read("floci-server.crt").getSubjectAlternativeNames()) {
if (entry.get(0).equals(GeneralName.iPAddress) && "192.168.1.100".equals(entry.get(1))) {
ipSan = true;
}
}
assertTrue(ipSan, "an IP is encoded as an iPAddress SAN, not a dNSName");
}
}
@@ -0,0 +1,360 @@
package io.github.hectorvent.floci.config;
import io.quarkus.tls.CertificateUpdatedEvent;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.nio.file.Files;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** Reissue, reload, persistence, reset, failure handling and concurrency. Name rules are next door. */
class TlsCertificateManagerTest extends TlsCertificateManagerFixture {
@Test
void newHostIsAppendedWithTheSameKeyAndReloaded() throws Exception {
byte[] keyBefore = Files.readAllBytes(tlsDir.resolve("floci-server.key"));
X509Certificate before = read("floci-server.crt");
manager().ensureHost(NEW_HOST);
X509Certificate after = read("floci-server.crt");
assertTrue(sans(after).contains(NEW_HOST), sans(after).toString());
assertTrue(sans(after).containsAll(CONFIGURED));
assertEquals(before.getPublicKey(), after.getPublicKey(), "same key pair");
assertArrayEquals(keyBefore, Files.readAllBytes(tlsDir.resolve("floci-server.key")), "key file never rewritten");
after.verify(ca.certificate().getPublicKey());
CertificateMetadata metadata = readMetadata();
assertEquals(CONFIGURED, metadata.getHostnames(), "configured list untouched");
assertEquals(List.of(NEW_HOST), metadata.getLearnedHostnames());
verify(defaultTls).reload();
ArgumentCaptor<CertificateUpdatedEvent> event = ArgumentCaptor.forClass(CertificateUpdatedEvent.class);
verify(events).fire(event.capture());
assertEquals("<default>", event.getValue().name());
assertEquals(defaultTls, event.getValue().tlsConfiguration());
assertFalse(Files.exists(tlsDir.resolve("floci-server.crt.tmp")), "temporary file renamed away");
assertFalse(Files.exists(tlsDir.resolve("floci-server.metadata.json.tmp")), "temporary file renamed away");
}
@Test
void reissuedLeafKeepsTheShapeOfTheBootLeaf() throws Exception {
X509Certificate before = read("floci-server.crt");
manager().ensureHost(NEW_HOST);
X509Certificate after = read("floci-server.crt");
assertEquals(before.getSubjectX500Principal(), after.getSubjectX500Principal(), "subject unchanged");
assertEquals(ca.certificate().getSubjectX500Principal(), after.getIssuerX500Principal(), "issued by the local CA");
assertTrue(ca.isIssuedByUs(after));
assertNotEquals(before.getSerialNumber(), after.getSerialNumber(), "a reissue is a new certificate");
assertEquals(-1, after.getBasicConstraints(), "a server leaf, not a CA");
assertEquals(List.of("1.3.6.1.5.5.7.3.1", "1.3.6.1.5.5.7.3.2"), after.getExtendedKeyUsage(),
"serverAuth and clientAuth, as an ACM-issued certificate carries");
after.checkValidity();
assertTrue(after.getNotAfter().toInstant().isBefore(Instant.now().plus(366, ChronoUnit.DAYS)),
"validity is not extended beyond a normal leaf");
assertTrue(after.getNotAfter().toInstant().isAfter(Instant.now().plus(364, ChronoUnit.DAYS)));
List<String> ordered = new ArrayList<>();
for (List<?> entry : after.getSubjectAlternativeNames()) {
ordered.add(String.valueOf(entry.get(1)));
}
assertEquals(CONFIGURED, ordered.subList(0, CONFIGURED.size()), "configured names first, in their order");
assertEquals(NEW_HOST, ordered.get(ordered.size() - 1), "learned names appended");
}
@Test
void secondCallForTheSameHostDoesNotReissue() throws Exception {
TlsCertificateManager m = manager();
m.ensureHost(NEW_HOST);
byte[] certAfterFirst = servedCertificate();
m.ensureHost(NEW_HOST);
m.ensureHost(NEW_HOST.toUpperCase());
assertArrayEquals(certAfterFirst, servedCertificate());
verify(defaultTls, times(1)).reload();
}
@Test
void learnedHostsSurviveANewManagerInstance() throws Exception {
manager().ensureHost(NEW_HOST);
TlsCertificateManager fresh = manager();
fresh.ensureHost(NEW_HOST);
verify(defaultTls, times(1)).reload();
assertTrue(fresh.knownHostnames().contains(NEW_HOST));
assertTrue(fresh.knownHostnames().containsAll(CONFIGURED));
}
@Test
void aSecondLearnedHostKeepsTheFirst() throws Exception {
manager().ensureHost(NEW_HOST);
manager().ensureHost("auth.example.localhost.floci.io");
Set<String> sans = sans(read("floci-server.crt"));
assertTrue(sans.containsAll(List.of(NEW_HOST, "auth.example.localhost.floci.io")), sans.toString());
assertEquals(List.of(NEW_HOST, "auth.example.localhost.floci.io"), readMetadata().getLearnedHostnames());
}
@Test
void clearDropsLearnedHostsAndKeepsConfiguredOnes() throws Exception {
TlsCertificateManager m = manager();
m.ensureHost(NEW_HOST);
m.clear();
Set<String> sans = sans(read("floci-server.crt"));
assertFalse(sans.contains(NEW_HOST));
assertTrue(sans.containsAll(CONFIGURED));
assertEquals(List.of(), readMetadata().getLearnedHostnames());
assertEquals(CONFIGURED, readMetadata().getHostnames());
assertFalse(m.knownHostnames().contains(NEW_HOST));
verify(defaultTls, times(2)).reload();
}
@Test
void clearWithNothingLearnedTouchesNothing() throws Exception {
byte[] certBefore = servedCertificate();
manager().clear();
assertArrayEquals(certBefore, servedCertificate());
verify(defaultTls, never()).reload();
}
@Test
void doesNothingWhenTlsIsOff() throws Exception {
when(config.tls().enabled()).thenReturn(false);
byte[] certBefore = servedCertificate();
TlsCertificateManager m = manager();
m.ensureHost(NEW_HOST);
m.clear();
assertArrayEquals(certBefore, servedCertificate());
assertEquals(Set.of(), m.knownHostnames());
verify(defaultTls, never()).reload();
}
@Test
void doesNothingWithAUserProvidedCertificate() throws Exception {
when(config.tls().certPath()).thenReturn(Optional.of("/etc/floci/user.crt"));
byte[] certBefore = servedCertificate();
manager().ensureHost(NEW_HOST);
assertArrayEquals(certBefore, servedCertificate());
verify(defaultTls, never()).reload();
}
@Test
void doesNothingWithoutAServerLeafOnDisk() throws Exception {
Files.delete(tlsDir.resolve("floci-server.crt"));
TlsCertificateManager m = manager();
m.ensureHost(NEW_HOST);
assertFalse(Files.exists(tlsDir.resolve("floci-server.crt")));
assertEquals(Set.of(), m.knownHostnames());
verify(defaultTls, never()).reload();
}
@Test
void aFailedReloadIsRetriedByTheNextCall() throws Exception {
when(defaultTls.reload()).thenReturn(false);
TlsCertificateManager m = manager();
m.ensureHost(NEW_HOST);
assertTrue(sans(read("floci-server.crt")).contains(NEW_HOST), "the file is still written for the next boot");
verify(events, never()).fire(any());
assertFalse(m.knownHostnames().contains(NEW_HOST), "a name the listener does not serve is not known");
when(defaultTls.reload()).thenReturn(true);
m.ensureHost(NEW_HOST);
verify(defaultTls, times(2)).reload();
verify(events, times(1)).fire(any());
assertTrue(m.knownHostnames().contains(NEW_HOST));
assertEquals(List.of(NEW_HOST), readMetadata().getLearnedHostnames());
}
@Test
void aNameWhoseReloadFailedIsCarriedIntoTheNextReissue() throws Exception {
when(defaultTls.reload()).thenReturn(false);
TlsCertificateManager m = manager();
m.ensureHost(NEW_HOST);
assertFalse(m.knownHostnames().contains(NEW_HOST));
when(defaultTls.reload()).thenReturn(true);
m.ensureHost("auth.example.localhost.floci.io");
Set<String> sans = sans(read("floci-server.crt"));
assertTrue(sans.contains(NEW_HOST), "the name written to disk is not dropped by the next reissue: " + sans);
assertTrue(sans.contains("auth.example.localhost.floci.io"), sans.toString());
assertTrue(m.knownHostnames().containsAll(List.of(NEW_HOST, "auth.example.localhost.floci.io")));
assertEquals(List.of(NEW_HOST, "auth.example.localhost.floci.io"), readMetadata().getLearnedHostnames());
verify(defaultTls, times(2)).reload();
verify(events, times(1)).fire(any());
}
@Test
void aResetWhoseReloadFailedIsRetriedByTheNextReset() throws Exception {
TlsCertificateManager m = manager();
m.ensureHost(NEW_HOST);
when(defaultTls.reload()).thenReturn(false);
m.clear();
assertEquals(List.of(), readMetadata().getLearnedHostnames(), "the files are reset");
assertTrue(m.knownHostnames().contains(NEW_HOST), "the listener still serves the name");
when(defaultTls.reload()).thenReturn(true);
m.clear();
verify(defaultTls, times(3)).reload();
assertFalse(m.knownHostnames().contains(NEW_HOST));
assertFalse(sans(read("floci-server.crt")).contains(NEW_HOST));
}
@Test
void aMissingDefaultTlsConfigurationIsRetriedByTheNextCall() throws Exception {
when(registry.getDefault()).thenReturn(Optional.empty());
TlsCertificateManager m = manager();
m.ensureHost(NEW_HOST);
verify(events, never()).fire(any());
assertFalse(m.knownHostnames().contains(NEW_HOST));
when(registry.getDefault()).thenReturn(Optional.of(defaultTls));
m.ensureHost(NEW_HOST);
verify(defaultTls, times(1)).reload();
verify(events, times(1)).fire(any());
assertTrue(m.knownHostnames().contains(NEW_HOST));
}
@Test
void aFailingListenerIsRetriedByTheNextCall() throws Exception {
doThrow(new IllegalStateException("observer failed")).when(events).fire(any());
TlsCertificateManager m = manager();
m.ensureHost(NEW_HOST);
assertFalse(m.knownHostnames().contains(NEW_HOST));
doNothing().when(events).fire(any());
m.ensureHost(NEW_HOST);
verify(defaultTls, times(2)).reload();
assertTrue(m.knownHostnames().contains(NEW_HOST));
}
@Test
void aFailedReissueLeavesTheServedFilesIntactAndDoesNotThrow() throws Exception {
FlociCertificateAuthority broken = spy(ca);
doThrow(new IllegalStateException("boom")).when(broken).issueServerCertificate(anyString(), anyList(), any(), any());
byte[] certBefore = servedCertificate();
String metadataBefore = Files.readString(tlsDir.resolve("floci-server.metadata.json"));
TlsCertificateManager m = new TlsCertificateManager(config, broken, registry, events);
m.ensureHost(NEW_HOST);
assertArrayEquals(certBefore, servedCertificate());
assertEquals(metadataBefore, Files.readString(tlsDir.resolve("floci-server.metadata.json")));
assertFalse(m.knownHostnames().contains(NEW_HOST), "a name that was never served is not known");
verify(defaultTls, never()).reload();
doCallRealMethod().when(broken).issueServerCertificate(anyString(), anyList(), any(), any());
m.ensureHost("auth.example.localhost.floci.io");
Set<String> sans = sans(read("floci-server.crt"));
assertTrue(sans.contains("auth.example.localhost.floci.io"), sans.toString());
assertFalse(sans.contains(NEW_HOST), "the failed name is not carried into later reissues");
assertEquals(List.of("auth.example.localhost.floci.io"), readMetadata().getLearnedHostnames());
}
@Test
void concurrentCallsForDistinctHostsAllLandInTheCertificate() throws Exception {
TlsCertificateManager m = manager();
List<String> hosts = new ArrayList<>();
for (int i = 0; i < 8; i++) {
hosts.add("svc" + i + ".example.localhost.floci.io");
}
runConcurrently(hosts, m);
Set<String> sans = sans(read("floci-server.crt"));
assertTrue(sans.containsAll(hosts), sans.toString());
assertTrue(sans.containsAll(CONFIGURED));
assertEquals(new TreeSet<>(hosts), new TreeSet<>(readMetadata().getLearnedHostnames()));
assertTrue(m.knownHostnames().containsAll(hosts));
verify(defaultTls, times(8)).reload();
}
@Test
void concurrentCallsForTheSameHostReissueOnce() throws Exception {
TlsCertificateManager m = manager();
List<String> hosts = new ArrayList<>();
for (int i = 0; i < 8; i++) {
hosts.add(NEW_HOST);
}
runConcurrently(hosts, m);
assertEquals(List.of(NEW_HOST), readMetadata().getLearnedHostnames());
verify(defaultTls, times(1)).reload();
}
private static void runConcurrently(List<String> hosts, TlsCertificateManager m) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(hosts.size());
CountDownLatch start = new CountDownLatch(1);
List<Future<?>> futures = new ArrayList<>();
try {
for (String host : hosts) {
futures.add(pool.submit(() -> {
start.await();
m.ensureHost(host);
return null;
}));
}
start.countDown();
for (Future<?> f : futures) {
f.get(60, TimeUnit.SECONDS);
}
} finally {
pool.shutdownNow();
}
}
}
@@ -0,0 +1,275 @@
package io.github.hectorvent.floci.config;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
/**
* Integration tests for TLS certificate generation with custom hostnames.
*
* Tests Task 3.5: Update certificate generation to include custom hostnames
* - Verifies that extractCustomHostnames() is called
* - Verifies that custom hostnames are combined with default SANs
* - Verifies that the combined list is deduplicated
* - Verifies that the combined SANs are passed to CertificateGenerator
* - Verifies that logging shows custom hostnames when present
*/
class TlsConfigSourceCertificateGenerationTest {
@TempDir
Path tempDir;
@BeforeEach
void setup() {
// Enable TLS and self-signed mode
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
}
@AfterEach
void cleanup() {
System.clearProperty("floci.tls.enabled");
System.clearProperty("floci.tls.self-signed");
System.clearProperty("floci.storage.persistent-path");
System.clearProperty("floci.hostname");
System.clearProperty("floci.base-url");
System.clearProperty("floci.services.iot.endpoint-address");
}
/**
* Test that certificate includes custom hostname from FLOCI_HOSTNAME
*/
@Test
void testCertificateIncludesFlociHostname() throws Exception {
// Arrange
System.setProperty("floci.hostname", "floci");
// Act
new TlsConfigSource();
// Assert
Path certFile = tempDir.resolve("tls/floci-server.crt");
assertTrue(Files.exists(certFile), "Certificate file should exist");
List<String> sans = extractSansFromCertificate(certFile);
assertTrue(sans.contains("floci"),
"Certificate SANs should include 'floci' from FLOCI_HOSTNAME");
assertTrue(sans.contains("localhost"),
"Certificate SANs should include default 'localhost'");
}
/**
* Test that certificate includes custom hostname from FLOCI_BASE_URL
*/
@Test
void testCertificateIncludesBaseUrlHostname() throws Exception {
// Arrange
System.setProperty("floci.base-url", "https://myhost:4566");
// Act
new TlsConfigSource();
// Assert
Path certFile = tempDir.resolve("tls/floci-server.crt");
assertTrue(Files.exists(certFile), "Certificate file should exist");
List<String> sans = extractSansFromCertificate(certFile);
assertTrue(sans.contains("myhost"),
"Certificate SANs should include 'myhost' from FLOCI_BASE_URL");
assertTrue(sans.contains("localhost"),
"Certificate SANs should include default 'localhost'");
}
/**
* Test that certificate includes IP address from FLOCI_BASE_URL
*/
@Test
void testCertificateIncludesIpAddress() throws Exception {
// Arrange
System.setProperty("floci.base-url", "https://192.168.1.100:4566");
// Act
new TlsConfigSource();
// Assert
Path certFile = tempDir.resolve("tls/floci-server.crt");
assertTrue(Files.exists(certFile), "Certificate file should exist");
List<String> sans = extractSansFromCertificate(certFile);
assertTrue(sans.contains("192.168.1.100"),
"Certificate SANs should include '192.168.1.100' from FLOCI_BASE_URL");
}
/**
* Test that certificate includes both FLOCI_HOSTNAME and FLOCI_BASE_URL hostnames
*/
@Test
void testCertificateIncludesBothHostnames() throws Exception {
// Arrange
System.setProperty("floci.hostname", "newhost");
System.setProperty("floci.base-url", "http://oldhost:4566");
// Act
new TlsConfigSource();
// Assert
Path certFile = tempDir.resolve("tls/floci-server.crt");
assertTrue(Files.exists(certFile), "Certificate file should exist");
List<String> sans = extractSansFromCertificate(certFile);
assertTrue(sans.contains("newhost"),
"Certificate SANs should include 'newhost' from FLOCI_HOSTNAME");
assertTrue(sans.contains("oldhost"),
"Certificate SANs should include 'oldhost' from FLOCI_BASE_URL");
assertTrue(sans.contains("localhost"),
"Certificate SANs should include default 'localhost'");
}
/**
* Test that certificate with default configuration includes only default SANs
*/
@Test
void testCertificateWithDefaultConfiguration() throws Exception {
// Arrange - no custom hostnames
// Act
new TlsConfigSource();
// Assert
Path certFile = tempDir.resolve("tls/floci-server.crt");
assertTrue(Files.exists(certFile), "Certificate file should exist");
List<String> sans = extractSansFromCertificate(certFile);
assertTrue(sans.contains("localhost"),
"Certificate SANs should include default 'localhost'");
assertTrue(sans.contains("127.0.0.1"),
"Certificate SANs should include default '127.0.0.1'");
assertTrue(sans.contains("0.0.0.0"),
"Certificate SANs should include default '0.0.0.0'");
assertTrue(sans.contains("host.docker.internal"),
"Certificate SANs should include default 'host.docker.internal'");
// Should be the defaults and nothing else - no custom hostnames
assertEquals(Set.copyOf(TlsConfigSource.DEFAULT_SAN_HOSTNAMES), Set.copyOf(sans),
"Certificate SANs should be exactly the default entries");
}
/**
* Test that duplicate hostnames are deduplicated
*/
@Test
void testDeduplicationInCertificate() throws Exception {
// Arrange - same hostname in both sources
System.setProperty("floci.hostname", "myhost");
System.setProperty("floci.base-url", "http://myhost:4566");
// Act
new TlsConfigSource();
// Assert
Path certFile = tempDir.resolve("tls/floci-server.crt");
assertTrue(Files.exists(certFile), "Certificate file should exist");
List<String> sans = extractSansFromCertificate(certFile);
long myhostCount = sans.stream().filter(s -> s.equals("myhost")).count();
assertEquals(1, myhostCount,
"Certificate SANs should contain 'myhost' exactly once (deduplicated)");
}
/**
* Test that metadata file is created with correct hostnames
*/
@Test
void testMetadataIncludesCustomHostnames() throws Exception {
// Arrange
System.setProperty("floci.hostname", "floci");
System.setProperty("floci.base-url", "https://myhost:4566");
// Act
new TlsConfigSource();
// Assert
Path metadataFile = tempDir.resolve("tls/floci-server.metadata.json");
assertTrue(Files.exists(metadataFile), "Metadata file should exist");
String json = Files.readString(metadataFile);
assertTrue(json.contains("floci"),
"Metadata should include 'floci' hostname");
assertTrue(json.contains("myhost"),
"Metadata should include 'myhost' hostname");
assertTrue(json.contains("localhost"),
"Metadata should include default 'localhost' hostname");
}
/**
* The IoT endpoint address is what devices verify on 8883 and 443, so the boot certificate
* covers it even when it has more labels than the wildcard SAN matches.
*/
@Test
void testCertificateIncludesIotEndpointAddress() throws Exception {
System.setProperty("floci.services.iot.endpoint-address", "iot.example.localhost.floci.io:8883");
new TlsConfigSource();
List<String> sans = extractSansFromCertificate(tempDir.resolve("tls/floci-server.crt"));
assertTrue(sans.contains("iot.example.localhost.floci.io"), sans.toString());
assertFalse(sans.contains("iot.example.localhost.floci.io:8883"), "the port is not part of the name");
assertTrue(Files.readString(tempDir.resolve("tls/floci-server.metadata.json")).contains("iot.example.localhost.floci.io"),
"the metadata records it as a configured name, so a later change is detected");
}
@Test
void testIotEndpointAddressAddedAfterBootRegeneratesTheCertificate() throws Exception {
new TlsConfigSource();
Path certFile = tempDir.resolve("tls/floci-server.crt");
assertFalse(extractSansFromCertificate(certFile).contains("iot.example.localhost.floci.io"));
System.setProperty("floci.services.iot.endpoint-address", "iot.example.localhost.floci.io");
new TlsConfigSource();
assertTrue(extractSansFromCertificate(certFile).contains("iot.example.localhost.floci.io"),
"a changed endpoint address is a hostname configuration change");
}
// ==================== Helper Methods ====================
/**
* Extracts Subject Alternative Names (SANs) from a certificate file.
*
* @param certFile Path to the certificate file
* @return List of SANs (DNS names and IP addresses)
*/
private List<String> extractSansFromCertificate(Path certFile) throws Exception {
String certPem = Files.readString(certFile);
// Parse certificate
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) cf.generateCertificate(
new ByteArrayInputStream(certPem.getBytes())
);
// Extract SANs
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
if (sans == null) {
return List.of();
}
return sans.stream()
.filter(san -> san.size() >= 2)
.map(san -> san.get(1).toString())
.toList();
}
}
@@ -0,0 +1,125 @@
package io.github.hectorvent.floci.config;
import io.smallrye.config.PropertiesConfigSource;
import io.smallrye.config.SmallRyeConfig;
import io.smallrye.config.SmallRyeConfigBuilder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Guards the certificate path against the backslash-mangling that broke TLS startup on Windows
* in the sibling Azure emulator (floci-az #142), where a path such as
* {@code D:\Dev\floci\data\tls\cert.crt} reached the HTTP server as
* {@code D:Devflocidatatlscert.crt} and startup died with {@code NoSuchFileException}.
*
* <p>The mangling is not general SmallRye behaviour. Expression handling leaves a value with no
* {@code $} untouched, and it deliberately compiles without escape processing. The damage comes
* from {@code StringUtil.split}, which is reached only by the converter for collection-typed
* properties, and which consumes each backslash.
*
* <p>That makes the property's declared type the whole defence. floci-aws is safe today only
* because it writes {@code quarkus.tls.key-store.pem.0.cert}, a scalar {@code Path}. The key it
* used to write, {@code quarkus.http.ssl.certificate.files}, is an {@code Optional<List<Path>>}
* and is mangled. Nothing else records that distinction, so switching back would silently
* reintroduce the bug; these tests fail if that happens.
*/
class TlsConfigSourceCertificatePathTest {
/** A Windows path with no {@code $}, the shape that broke floci-az. */
private static final String WINDOWS_PATH = "D:\\Dev\\floci\\data\\tls\\floci-server.crt";
@TempDir
Path tempDir;
@BeforeEach
void setup() {
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
}
@AfterEach
void cleanup() {
System.clearProperty("floci.tls.enabled");
System.clearProperty("floci.tls.self-signed");
System.clearProperty("floci.storage.persistent-path");
}
private static SmallRyeConfig config(String key, String value) {
return new SmallRyeConfigBuilder()
.addDefaultInterceptors()
.withSources(new PropertiesConfigSource(Map.of(key, value), "test", 300))
.build();
}
@Test
void aWindowsPathSurvivesAsAScalarPathProperty() {
SmallRyeConfig config = config("quarkus.tls.key-store.pem.0.cert", WINDOWS_PATH);
assertEquals(WINDOWS_PATH, config.getValue("quarkus.tls.key-store.pem.0.cert", String.class),
"a scalar property must round-trip a Windows path unchanged");
assertEquals(Path.of(WINDOWS_PATH),
config.getValue("quarkus.tls.key-store.pem.0.cert", Path.class),
"converting to Path must not lose the separators either");
}
@Test
void theSamePathIsMangledWhenReadAsACollection() {
// Not a wish, a demonstration: this is exactly what floci-az hit, and it is why the
// property's type is what protects us rather than anything in our own code.
SmallRyeConfig config = config("quarkus.tls.key-store.pem.0.cert", WINDOWS_PATH);
List<String> asList = config.getValues("quarkus.tls.key-store.pem.0.cert", String.class);
assertEquals(1, asList.size());
assertNotEquals(WINDOWS_PATH, asList.getFirst(),
"if this ever stops mangling, the collection converter changed and the guard below "
+ "can be relaxed");
assertEquals("D:Devflocidatatlsfloci-server.crt", asList.getFirst(),
"the collection converter consumes every backslash");
}
@Test
void theEmittedCertificateKeysAreScalarNotCollectionTyped() {
// The actual regression guard. Quarkus types `...pem.0.cert` as a scalar Path and
// `quarkus.http.ssl.certificate.files` as Optional<List<Path>>, so emitting the latter
// would put the certificate path back through the splitter that broke floci-az.
TlsConfigSource source = new TlsConfigSource();
String certKey = "quarkus.tls.key-store.pem.0.cert";
String keyKey = "quarkus.tls.key-store.pem.0.key";
assertNotNull(source.getValue(certKey), "the scalar PEM cert key must be the one emitted");
assertNotNull(source.getValue(keyKey), "the scalar PEM key key must be the one emitted");
for (String plural : List.of("quarkus.http.ssl.certificate.files",
"quarkus.http.ssl.certificate.key-files")) {
assertTrue(source.getValue(plural) == null,
"must not emit " + plural + ": it is collection-typed, so a Windows path "
+ "would be mangled by the collection converter");
}
}
@Test
void theGeneratedPathIsAbsoluteAndUsableAsWritten() {
TlsConfigSource source = new TlsConfigSource();
String certPath = source.getValue("quarkus.tls.key-store.pem.0.cert");
assertNotNull(certPath);
assertTrue(Path.of(certPath).isAbsolute(), "the emitted path must be absolute: " + certPath);
assertTrue(java.nio.file.Files.exists(Path.of(certPath)),
"the emitted path must resolve to the generated certificate: " + certPath);
}
}
@@ -0,0 +1,272 @@
package io.github.hectorvent.floci.config;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Security;
import static org.junit.jupiter.api.Assertions.*;
/**
* Integration test for Task 3.6: Certificate reuse logic with metadata checking
*
* This test verifies that TlsConfigSource correctly:
* 1. Checks metadata before reusing existing certificates
* 2. Regenerates certificates when configuration changes
* 3. Regenerates certificates when metadata is missing
* 4. Reuses certificates when configuration is unchanged
*/
class TlsConfigSourceCertificateReuseTest {
@TempDir
Path tempDir;
@BeforeAll
static void setupBouncyCastle() {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
@AfterEach
void cleanupSystemProperties() {
System.clearProperty("floci.hostname");
System.clearProperty("floci.base-url");
System.clearProperty("floci.tls.enabled");
System.clearProperty("floci.tls.self-signed");
System.clearProperty("floci.storage.persistent-path");
}
/**
* Test that certificate is regenerated when hostname configuration changes
*/
@Test
void testCertificateRegeneratedWhenHostnameChanges() throws Exception {
// Arrange: Generate initial certificate with FLOCI_HOSTNAME=host1
System.setProperty("floci.hostname", "host1");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// Act: Create TlsConfigSource - this generates the initial certificate
new TlsConfigSource();
Path tlsDir = tempDir.resolve("tls");
Path certFile = tlsDir.resolve("floci-server.crt");
Path metadataFile = tlsDir.resolve("floci-server.metadata.json");
// Verify initial certificate and metadata exist
assertTrue(Files.exists(certFile), "Initial certificate should be generated");
assertTrue(Files.exists(metadataFile), "Initial metadata should be generated");
// Read initial metadata
String initialMetadata = Files.readString(metadataFile);
long initialModifiedTime = Files.getLastModifiedTime(certFile).toMillis();
// Wait a bit to ensure timestamp difference
Thread.sleep(100);
// Change hostname configuration
System.setProperty("floci.hostname", "host2");
// Act: Create new TlsConfigSource - should regenerate certificate
new TlsConfigSource();
// Assert: Certificate and metadata should be regenerated
assertTrue(Files.exists(certFile), "Certificate should still exist");
assertTrue(Files.exists(metadataFile), "Metadata should still exist");
String newMetadata = Files.readString(metadataFile);
long newModifiedTime = Files.getLastModifiedTime(certFile).toMillis();
assertNotEquals(initialMetadata, newMetadata,
"Metadata should be updated with new hostname");
assertTrue(newModifiedTime > initialModifiedTime,
"Certificate should be regenerated (newer timestamp)");
assertTrue(newMetadata.contains("host2"),
"New metadata should contain 'host2'");
}
/**
* Test that certificate is regenerated when metadata is missing
*/
@Test
void testCertificateRegeneratedWhenMetadataMissing() throws Exception {
// Arrange: Generate initial certificate
System.setProperty("floci.hostname", "floci");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// Act: Create TlsConfigSource - this generates the initial certificate
new TlsConfigSource();
Path tlsDir = tempDir.resolve("tls");
Path certFile = tlsDir.resolve("floci-server.crt");
Path metadataFile = tlsDir.resolve("floci-server.metadata.json");
// Verify initial certificate and metadata exist
assertTrue(Files.exists(certFile), "Initial certificate should be generated");
assertTrue(Files.exists(metadataFile), "Initial metadata should be generated");
long initialModifiedTime = Files.getLastModifiedTime(certFile).toMillis();
// Delete metadata file to simulate old version
Files.delete(metadataFile);
assertFalse(Files.exists(metadataFile), "Metadata should be deleted");
// Wait a bit to ensure timestamp difference
Thread.sleep(100);
// Act: Create new TlsConfigSource - should regenerate certificate
new TlsConfigSource();
// Assert: Certificate and metadata should be regenerated
assertTrue(Files.exists(certFile), "Certificate should still exist");
assertTrue(Files.exists(metadataFile), "Metadata should be regenerated");
long newModifiedTime = Files.getLastModifiedTime(certFile).toMillis();
assertTrue(newModifiedTime > initialModifiedTime,
"Certificate should be regenerated when metadata is missing");
}
/**
* Test that certificate is reused when configuration is unchanged
*/
@Test
void testCertificateReusedWhenConfigurationUnchanged() throws Exception {
// Arrange: Generate initial certificate
System.setProperty("floci.hostname", "floci");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// Act: Create TlsConfigSource - this generates the initial certificate
new TlsConfigSource();
Path tlsDir = tempDir.resolve("tls");
Path certFile = tlsDir.resolve("floci-server.crt");
Path metadataFile = tlsDir.resolve("floci-server.metadata.json");
// Verify initial certificate and metadata exist
assertTrue(Files.exists(certFile), "Initial certificate should be generated");
assertTrue(Files.exists(metadataFile), "Initial metadata should be generated");
long initialModifiedTime = Files.getLastModifiedTime(certFile).toMillis();
String initialMetadata = Files.readString(metadataFile);
// Wait a bit to ensure timestamp difference would be visible if regenerated
Thread.sleep(100);
// Act: Create new TlsConfigSource with same configuration - should reuse certificate
new TlsConfigSource();
// Assert: Certificate and metadata should NOT be regenerated
assertTrue(Files.exists(certFile), "Certificate should still exist");
assertTrue(Files.exists(metadataFile), "Metadata should still exist");
long newModifiedTime = Files.getLastModifiedTime(certFile).toMillis();
String newMetadata = Files.readString(metadataFile);
assertEquals(initialModifiedTime, newModifiedTime,
"Certificate should be reused (same timestamp)");
assertEquals(initialMetadata, newMetadata,
"Metadata should be unchanged");
}
/**
* Regression: TlsConfigSource is a MicroProfile ConfigSource, so it runs before CDI and before
* the security provider is registered — BouncyCastle is NOT yet available. Previously
* parseCertificate then failed with "no such provider: BC", isSelfSigned returned false, and the
* persisted certificate was regenerated on every restart. The constructor now registers
* BouncyCastle up front, so an existing certificate is reused even when BC was not pre-registered.
*/
@Test
void testCertificateReusedWhenBouncyCastleNotPreRegistered() throws Exception {
System.setProperty("floci.hostname", "floci");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// First boot: generate the certificate.
new TlsConfigSource();
Path certFile = tempDir.resolve("tls").resolve("floci-server.crt");
assertTrue(Files.exists(certFile), "Initial certificate should be generated");
long initialModifiedTime = Files.getLastModifiedTime(certFile).toMillis();
String initialCert = Files.readString(certFile);
// Ensure a regeneration would be detectable via timestamp.
Thread.sleep(100);
// Second boot with BouncyCastle NOT registered — mirrors the real ConfigSource-before-CDI
// ordering. The constructor must register it and REUSE the existing cert (not regenerate).
boolean wasRegistered = Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) != null;
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME);
try {
new TlsConfigSource();
} finally {
if (wasRegistered && Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
assertEquals(initialModifiedTime, Files.getLastModifiedTime(certFile).toMillis(),
"Certificate should be reused (same timestamp) even when BouncyCastle was not pre-registered");
assertEquals(initialCert, Files.readString(certFile),
"Certificate content should be unchanged (reused, not regenerated)");
}
/**
* Test that certificate is regenerated when base URL hostname changes
*/
@Test
void testCertificateRegeneratedWhenBaseUrlChanges() throws Exception {
// Arrange: Generate initial certificate with FLOCI_BASE_URL=https://host1:4566
System.setProperty("floci.base-url", "https://host1:4566");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
// Act: Create TlsConfigSource - this generates the initial certificate
new TlsConfigSource();
Path tlsDir = tempDir.resolve("tls");
Path certFile = tlsDir.resolve("floci-server.crt");
Path metadataFile = tlsDir.resolve("floci-server.metadata.json");
// Verify initial certificate and metadata exist
assertTrue(Files.exists(certFile), "Initial certificate should be generated");
assertTrue(Files.exists(metadataFile), "Initial metadata should be generated");
String initialMetadata = Files.readString(metadataFile);
long initialModifiedTime = Files.getLastModifiedTime(certFile).toMillis();
// Wait a bit to ensure timestamp difference
Thread.sleep(100);
// Change base URL hostname
System.setProperty("floci.base-url", "https://host2:4566");
// Act: Create new TlsConfigSource - should regenerate certificate
new TlsConfigSource();
// Assert: Certificate and metadata should be regenerated
assertTrue(Files.exists(certFile), "Certificate should still exist");
assertTrue(Files.exists(metadataFile), "Metadata should still exist");
String newMetadata = Files.readString(metadataFile);
long newModifiedTime = Files.getLastModifiedTime(certFile).toMillis();
assertNotEquals(initialMetadata, newMetadata,
"Metadata should be updated with new hostname");
assertTrue(newModifiedTime > initialModifiedTime,
"Certificate should be regenerated (newer timestamp)");
assertTrue(newMetadata.contains("host2"),
"New metadata should contain 'host2'");
}
}
@@ -0,0 +1,101 @@
package io.github.hectorvent.floci.config;
import io.github.hectorvent.floci.services.acm.CertificateGenerator;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@link TlsConfigSource} writes the container CA bundle at boot, in both certificate modes, and
* a bundle that cannot be written never stops the boot.
*/
class TlsConfigSourceContainerCaBundleTest {
@TempDir
Path tempDir;
@AfterEach
void clearSystemProperties() {
System.clearProperty("floci.tls.enabled");
System.clearProperty("floci.tls.self-signed");
System.clearProperty("floci.tls.cert-path");
System.clearProperty("floci.tls.key-path");
System.clearProperty("floci.storage.persistent-path");
}
@Test
void selfSignedBootWritesTheBundleEndingWithTheLocalCa() throws Exception {
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
new TlsConfigSource();
Path bundle = tempDir.resolve("tls/floci-ca-bundle.pem");
assertTrue(Files.exists(bundle), "bundle written at boot");
String pem = Files.readString(bundle).strip();
String caPem = Files.readString(tempDir.resolve("tls/floci-root-ca.crt")).strip();
assertTrue(pem.endsWith(caPem), "the local CA is the last entry");
assertTrue(pem.indexOf("-----BEGIN CERTIFICATE-----") < pem.lastIndexOf("-----BEGIN CERTIFICATE-----"),
"public roots precede the CA");
}
@Test
void userCertificateBootWritesTheBundleEndingWithThatCertificate() throws Exception {
Path userDir = Files.createDirectories(tempDir.resolve("user"));
CertificateGenerator.GeneratedCertificate generated = new CertificateGenerator()
.generateSelfSignedCertificate("localhost", List.of("localhost"), KeyAlgorithm.RSA_2048);
Path userCert = Files.writeString(userDir.resolve("user.crt"), generated.certificatePem());
Path userKey = Files.writeString(userDir.resolve("user.key"), generated.privateKeyPem());
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.cert-path", userCert.toString());
System.setProperty("floci.tls.key-path", userKey.toString());
System.setProperty("floci.storage.persistent-path", tempDir.toString());
new TlsConfigSource();
String pem = Files.readString(tempDir.resolve("tls/floci-ca-bundle.pem")).strip();
assertTrue(pem.endsWith(generated.certificatePem().strip()));
assertFalse(Files.exists(tempDir.resolve("tls/floci-root-ca.crt")), "custom-cert mode creates no CA");
assertEquals(tempDir.resolve("tls"), TlsConfigSource.resolvedTlsDir(), "CDI consumers look where the boot wrote");
}
@Test
void tlsOffWritesNoBundle() {
System.setProperty("floci.tls.enabled", "false");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
new TlsConfigSource();
assertFalse(Files.exists(tempDir.resolve("tls")));
}
@Test
void aBundleThatCannotBeWrittenDoesNotStopTheBoot() throws Exception {
Path userDir = Files.createDirectories(tempDir.resolve("user"));
CertificateGenerator.GeneratedCertificate generated = new CertificateGenerator()
.generateSelfSignedCertificate("localhost", List.of("localhost"), KeyAlgorithm.RSA_2048);
Path userCert = Files.writeString(userDir.resolve("user.crt"), generated.certificatePem());
Path userKey = Files.writeString(userDir.resolve("user.key"), generated.privateKeyPem());
Path persistent = Files.createDirectories(tempDir.resolve("data"));
Files.writeString(persistent.resolve("tls"), "a file where the tls directory should be");
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.cert-path", userCert.toString());
System.setProperty("floci.tls.key-path", userKey.toString());
System.setProperty("floci.storage.persistent-path", persistent.toString());
TlsConfigSource source = new TlsConfigSource();
assertEquals(userCert.toString(), source.getValue("quarkus.tls.key-store.pem.0.cert"));
assertTrue(Files.isRegularFile(persistent.resolve("tls")), "the boot left the obstacle alone");
}
}
@@ -0,0 +1,333 @@
package io.github.hectorvent.floci.config;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for hostname extraction logic in TlsConfigSource.
*
* Tests the extractCustomHostnames() method to verify:
* - Extraction from FLOCI_HOSTNAME
* - Extraction from FLOCI_BASE_URL (DNS names and IP addresses)
* - Extraction from FLOCI_SERVICES_IOT_ENDPOINT_ADDRESS (the port, when given, is dropped)
* - Filtering of default values (localhost, 127.0.0.1, 0.0.0.0)
* - Deduplication of hostnames
* - Edge case handling (malformed URLs, missing host, IPv6 addresses)
*/
class TlsConfigSourceHostnameExtractionTest {
@AfterEach
void cleanupSystemProperties() {
System.clearProperty("floci.hostname");
System.clearProperty("floci.base-url");
System.clearProperty("floci.services.iot.endpoint-address");
}
/**
* Test extraction from FLOCI_HOSTNAME
*/
@Test
void testExtractFromFlociHostname() throws Exception {
// Arrange
System.setProperty("floci.hostname", "floci");
// Act
List<String> hostnames = invokeExtractCustomHostnames();
// Assert
assertTrue(hostnames.contains("floci"),
"Should extract 'floci' from FLOCI_HOSTNAME");
}
/**
* Test extraction from FLOCI_BASE_URL with DNS name
*/
@Test
void testExtractFromBaseUrlDnsName() throws Exception {
// Arrange
System.setProperty("floci.base-url", "https://myhost:4566");
// Act
List<String> hostnames = invokeExtractCustomHostnames();
// Assert
assertTrue(hostnames.contains("myhost"),
"Should extract 'myhost' from FLOCI_BASE_URL");
}
/**
* Test extraction from FLOCI_BASE_URL with IP address
*/
@Test
void testExtractFromBaseUrlIpAddress() throws Exception {
// Arrange
System.setProperty("floci.base-url", "https://192.168.1.100:4566");
// Act
List<String> hostnames = invokeExtractCustomHostnames();
// Assert
assertTrue(hostnames.contains("192.168.1.100"),
"Should extract '192.168.1.100' from FLOCI_BASE_URL");
}
/**
* Test extraction from both FLOCI_HOSTNAME and FLOCI_BASE_URL
*/
@Test
void testExtractFromBothSources() throws Exception {
// Arrange
System.setProperty("floci.hostname", "newhost");
System.setProperty("floci.base-url", "http://oldhost:4566");
// Act
List<String> hostnames = invokeExtractCustomHostnames();
// Assert
assertTrue(hostnames.contains("newhost"),
"Should extract 'newhost' from FLOCI_HOSTNAME");
assertTrue(hostnames.contains("oldhost"),
"Should extract 'oldhost' from FLOCI_BASE_URL");
assertEquals(2, hostnames.size(),
"Should contain exactly 2 hostnames");
}
/**
* Test filtering of default hostname "localhost"
*/
@Test
void testFilterDefaultLocalhostFromHostname() throws Exception {
// Arrange
System.setProperty("floci.hostname", "localhost");
// Act
List<String> hostnames = invokeExtractCustomHostnames();
// Assert
assertTrue(hostnames.isEmpty(),
"Should filter out 'localhost' as it's a default value");
}
/**
* Test filtering of default IP "127.0.0.1"
*/
@Test
void testFilterDefaultLoopbackIp() throws Exception {
// Arrange
System.setProperty("floci.base-url", "http://127.0.0.1:4566");
// Act
List<String> hostnames = invokeExtractCustomHostnames();
// Assert
assertTrue(hostnames.isEmpty(),
"Should filter out '127.0.0.1' as it's a default value");
}
/**
* Test filtering of default IP "0.0.0.0"
*/
@Test
void testFilterDefaultAllInterfacesIp() throws Exception {
// Arrange
System.setProperty("floci.base-url", "http://0.0.0.0:4566");
// Act
List<String> hostnames = invokeExtractCustomHostnames();
// Assert
assertTrue(hostnames.isEmpty(),
"Should filter out '0.0.0.0' as it's a default value");
}
/**
* Test deduplication when same hostname appears in both sources
*/
@Test
void testDeduplication() throws Exception {
// Arrange
System.setProperty("floci.hostname", "myhost");
System.setProperty("floci.base-url", "http://myhost:4566");
// Act
List<String> hostnames = invokeExtractCustomHostnames();
// Assert
assertEquals(1, hostnames.size(),
"Should deduplicate 'myhost' appearing in both sources");
assertTrue(hostnames.contains("myhost"),
"Should contain 'myhost'");
}
/**
* Test handling of malformed URL
*/
@Test
void testMalformedUrl() throws Exception {
// Arrange
System.setProperty("floci.base-url", "not-a-valid-url");
// Act & Assert - should not throw exception
List<String> hostnames = invokeExtractCustomHostnames();
// Should return empty list (malformed URL is logged but doesn't fail)
assertTrue(hostnames.isEmpty(),
"Should return empty list for malformed URL");
}
/**
* Test handling of URL without host
*/
@Test
void testUrlWithoutHost() throws Exception {
// Arrange
System.setProperty("floci.base-url", "file:///path/to/file");
// Act
List<String> hostnames = invokeExtractCustomHostnames();
// Assert
assertTrue(hostnames.isEmpty(),
"Should return empty list for URL without host");
}
/**
* Test empty configuration (no custom hostnames)
*/
@Test
void testEmptyConfiguration() throws Exception {
// Arrange - no properties set
// Act
List<String> hostnames = invokeExtractCustomHostnames();
// Assert
assertTrue(hostnames.isEmpty(),
"Should return empty list when no custom hostnames configured");
}
/**
* Test blank FLOCI_HOSTNAME is ignored
*/
@Test
void testBlankHostname() throws Exception {
// Arrange
System.setProperty("floci.hostname", " ");
// Act
List<String> hostnames = invokeExtractCustomHostnames();
// Assert
assertTrue(hostnames.isEmpty(),
"Should ignore blank FLOCI_HOSTNAME");
}
/**
* Test IPv6 address extraction
* Note: URI.getHost() returns IPv6 addresses with brackets
*/
@Test
void testIpv6Address() throws Exception {
// Arrange
System.setProperty("floci.base-url", "http://[::1]:4566");
// Act
List<String> hostnames = invokeExtractCustomHostnames();
// Assert
// IPv6 loopback should be extracted (URI.getHost() returns it with brackets)
assertTrue(hostnames.contains("[::1]"),
"Should extract IPv6 address '[::1]' from FLOCI_BASE_URL");
}
/**
* The IoT endpoint address is a name devices verify on 8883 and 443, so it joins the SANs.
*/
@Test
void testExtractFromIotEndpointAddress() throws Exception {
System.setProperty("floci.services.iot.endpoint-address", "iot.example.localhost.floci.io");
List<String> hostnames = invokeExtractCustomHostnames();
assertEquals(List.of("iot.example.localhost.floci.io"), hostnames);
}
@Test
void testIotEndpointAddressPortIsDropped() throws Exception {
System.setProperty("floci.services.iot.endpoint-address", "iot.example.localhost.floci.io:8443");
List<String> hostnames = invokeExtractCustomHostnames();
assertEquals(List.of("iot.example.localhost.floci.io"), hostnames);
}
@Test
void testIotEndpointAddressKeepsAnIpv6Literal() throws Exception {
System.setProperty("floci.services.iot.endpoint-address", "[fd00::10]:8883");
List<String> hostnames = invokeExtractCustomHostnames();
assertEquals(List.of("[fd00::10]"), hostnames);
}
@Test
void testIotEndpointAddressDefaultHostIsFiltered() throws Exception {
System.setProperty("floci.services.iot.endpoint-address", "localhost:4566");
List<String> hostnames = invokeExtractCustomHostnames();
assertTrue(hostnames.isEmpty(), "localhost is covered by the default SANs");
}
@Test
void testIotEndpointAddressIsDeduplicatedAgainstHostname() throws Exception {
System.setProperty("floci.hostname", "floci");
System.setProperty("floci.services.iot.endpoint-address", "floci:8883");
List<String> hostnames = invokeExtractCustomHostnames();
assertEquals(List.of("floci"), hostnames);
}
@Test
void testUnparsableIotEndpointAddressIsSkipped() throws Exception {
System.setProperty("floci.services.iot.endpoint-address", "not a host");
List<String> hostnames = invokeExtractCustomHostnames();
assertTrue(hostnames.isEmpty(), "an address that is not a host is logged and skipped, not fatal");
}
/**
* A URL instead of a host is an operator typo: java.net.URI would read "https" as the host,
* so the value is skipped rather than adding a wrong name to the certificate.
*/
@Test
void testIotEndpointAddressWithASchemeOrPathIsSkipped() throws Exception {
for (String typo : List.of("https://iot.example.localhost.floci.io", "iot.example.localhost.floci.io:8443/",
"user@iot.example.localhost.floci.io", "iot.example.localhost.floci.io:abc",
"iot.example.localhost.floci.io?x=1", "iot.example.localhost.floci.io#fragment")) {
System.setProperty("floci.services.iot.endpoint-address", typo);
assertTrue(invokeExtractCustomHostnames().isEmpty(), typo);
}
}
// ==================== Helper Methods ====================
/**
* Invokes the private extractCustomHostnames() method using reflection.
*/
@SuppressWarnings("unchecked")
private List<String> invokeExtractCustomHostnames() throws Exception {
TlsConfigSource configSource = new TlsConfigSource();
Method method = TlsConfigSource.class.getDeclaredMethod("extractCustomHostnames");
method.setAccessible(true);
return (List<String>) method.invoke(configSource);
}
}
@@ -0,0 +1,175 @@
package io.github.hectorvent.floci.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.services.acm.CertificateGenerator;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Security;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Hostnames {@link TlsCertificateManager} learned at runtime must survive every boot path of
* {@link TlsConfigSource}: a reused certificate, a regeneration after a configuration change,
* a reissue of an expired leaf, and a certificate whose metadata write did not complete.
*/
class TlsConfigSourceLearnedHostnamesTest {
private static final String LEARNED = "api.example.localhost.floci.io";
private static final List<String> DEFAULTS = TlsConfigSource.DEFAULT_SAN_HOSTNAMES;
private static final ObjectMapper MAPPER = new ObjectMapper();
@TempDir
Path tempDir;
private Path tlsDir;
private Path metadataFile;
@BeforeAll
static void bouncyCastle() {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
@BeforeEach
void tlsOn() {
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
tlsDir = tempDir.resolve("tls");
metadataFile = tlsDir.resolve("floci-server.metadata.json");
}
@AfterEach
void clearProperties() {
System.clearProperty("floci.hostname");
System.clearProperty("floci.tls.enabled");
System.clearProperty("floci.tls.self-signed");
System.clearProperty("floci.storage.persistent-path");
}
@Test
void learnedHostnamesAreKeptWhenBootRegeneratesTheCertificate() throws Exception {
System.setProperty("floci.hostname", "host1");
new TlsConfigSource();
recordAsLearned(LEARNED);
System.setProperty("floci.hostname", "host2");
new TlsConfigSource();
List<String> sans = servedSans();
assertTrue(sans.contains("host2"), sans.toString());
assertFalse(sans.contains("host1"), sans.toString());
assertTrue(sans.contains(LEARNED), "learned name must survive: " + sans);
CertificateMetadata after = readMetadata();
assertEquals(List.of(LEARNED), after.getLearnedHostnames());
assertFalse(after.getHostnames().contains(LEARNED), "learned names stay out of the configured list");
}
@Test
void aLearnedHostnameDoesNotCountAsAConfigurationChange() throws Exception {
new TlsConfigSource();
recordAsLearned(LEARNED);
String certBefore = Files.readString(tlsDir.resolve("floci-server.crt"));
new TlsConfigSource();
assertEquals(certBefore, Files.readString(tlsDir.resolve("floci-server.crt")),
"same configuration: the certificate is reused, not regenerated");
}
@Test
void learnedHostnamesAreKeptWhenAnExpiredLeafIsReissued() throws Exception {
Files.createDirectories(tlsDir);
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tlsDir);
CertificateGenerator gen = new CertificateGenerator();
List<String> sans = new ArrayList<>(DEFAULTS);
sans.add(LEARNED);
KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
X509Certificate expired = gen.signCertificate(new X500Name("CN=localhost"), keyPair.getPublic(),
X500Name.getInstance(ca.certificate().getSubjectX500Principal().getEncoded()), ca.key(), sans, false,
CertificateGenerator.LeafUsage.SERVER, -1);
Files.writeString(tlsDir.resolve("floci-server.crt"), gen.toPem(expired));
Files.writeString(tlsDir.resolve("floci-server.key"), gen.toPem(keyPair.getPrivate()));
CertificateMetadata metadata = CertificateMetadata.create(DEFAULTS, "dev");
metadata.setLearnedHostnames(List.of(LEARNED));
Files.writeString(metadataFile, MAPPER.writeValueAsString(metadata));
new TlsConfigSource();
X509Certificate leaf = parseCertificate(tlsDir.resolve("floci-server.crt"));
leaf.checkValidity();
assertNotEquals(expired.getSerialNumber(), leaf.getSerialNumber(), "the expired leaf was reissued");
List<String> reissued = servedSans();
assertTrue(reissued.contains(LEARNED), "learned name must survive: " + reissued);
assertTrue(reissued.containsAll(DEFAULTS), reissued.toString());
}
@Test
void aSanTheMetadataDoesNotListIsKeptAsLearnedWhenBootRegenerates() throws Exception {
System.setProperty("floci.hostname", "host1");
new TlsConfigSource();
CertificateMetadata bootMetadata = readMetadata();
// A runtime reissue whose certificate rename succeeded and whose metadata rename did not:
// the served leaf carries the learned name, the metadata knows nothing about it.
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(tlsDir);
CertificateGenerator gen = new CertificateGenerator();
X509Certificate served = parseCertificate(tlsDir.resolve("floci-server.crt"));
KeyPair keyPair = new KeyPair(served.getPublicKey(),
gen.parsePrivateKey(Files.readString(tlsDir.resolve("floci-server.key"))));
List<String> sans = new ArrayList<>(bootMetadata.getHostnames());
sans.add(LEARNED);
Files.writeString(tlsDir.resolve("floci-server.crt"),
ca.issueServerCertificate("localhost", sans, KeyAlgorithm.RSA_2048, keyPair).certificatePem());
System.setProperty("floci.hostname", "host2");
new TlsConfigSource();
List<String> regenerated = servedSans();
assertTrue(regenerated.contains(LEARNED), "served name must survive: " + regenerated);
assertTrue(regenerated.contains("host2"), regenerated.toString());
assertFalse(regenerated.contains("host1"), "a previously configured name is not mistaken for a learned one");
assertEquals(List.of(LEARNED), readMetadata().getLearnedHostnames());
}
private void recordAsLearned(String name) throws Exception {
CertificateMetadata metadata = readMetadata();
metadata.setLearnedHostnames(List.of(name));
Files.writeString(metadataFile, MAPPER.writeValueAsString(metadata));
}
private CertificateMetadata readMetadata() throws Exception {
return MAPPER.readValue(metadataFile.toFile(), CertificateMetadata.class);
}
private List<String> servedSans() throws Exception {
List<String> sans = new ArrayList<>();
for (List<?> entry : parseCertificate(tlsDir.resolve("floci-server.crt")).getSubjectAlternativeNames()) {
sans.add(String.valueOf(entry.get(1)));
}
return sans;
}
private static X509Certificate parseCertificate(Path certFile) throws Exception {
return new CertificateGenerator().parseCertificate(Files.readString(certFile));
}
}
@@ -0,0 +1,257 @@
package io.github.hectorvent.floci.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for metadata loading and comparison in TlsConfigSource.
*
* Tests the hostnameConfigChanged() method to verify: - Returns true when metadata file doesn't
* exist - Returns true when hostnames have changed - Returns false when hostnames are the same
* (order-independent) - Handles read/parse failures gracefully (returns true) - Proper logging for
* different scenarios
*/
class TlsConfigSourceMetadataComparisonTest {
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
// Set up minimal TLS configuration for testing
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
}
@AfterEach
void tearDown() {
// Clean up system properties
System.clearProperty("floci.tls.enabled");
System.clearProperty("floci.tls.self-signed");
System.clearProperty("floci.storage.persistent-path");
System.clearProperty("floci.hostname");
System.clearProperty("floci.base-url");
}
@Test
void testMetadataFileMissing_ReturnsTrue() throws Exception {
// Arrange
Path tlsDir = tempDir.resolve("tls");
Files.createDirectories(tlsDir);
List<String> currentHostnames = List.of("localhost", "127.0.0.1");
// Act
boolean changed = invokeHostnameConfigChanged(tlsDir, currentHostnames);
// Assert
assertTrue(changed, "Should return true when metadata file doesn't exist");
}
@Test
void testHostnamesChanged_ReturnsTrue() throws Exception {
// Arrange
Path tlsDir = tempDir.resolve("tls");
Files.createDirectories(tlsDir);
// Create metadata with old hostnames
List<String> oldHostnames = List.of("localhost", "127.0.0.1", "oldhost");
createMetadataFile(tlsDir, oldHostnames);
// Current hostnames are different
List<String> currentHostnames = List.of("localhost", "127.0.0.1", "newhost");
// Act
boolean changed = invokeHostnameConfigChanged(tlsDir, currentHostnames);
// Assert
assertTrue(changed, "Should return true when hostnames have changed");
}
@Test
void testHostnamesUnchanged_ReturnsFalse() throws Exception {
// Arrange
Path tlsDir = tempDir.resolve("tls");
Files.createDirectories(tlsDir);
// Create metadata with hostnames
List<String> hostnames = List.of("localhost", "127.0.0.1", "myhost");
createMetadataFile(tlsDir, hostnames);
// Current hostnames are the same
List<String> currentHostnames = List.of("localhost", "127.0.0.1", "myhost");
// Act
boolean changed = invokeHostnameConfigChanged(tlsDir, currentHostnames);
// Assert
assertFalse(changed, "Should return false when hostnames are unchanged");
}
@Test
void testHostnamesUnchanged_DifferentOrder_ReturnsFalse() throws Exception {
// Arrange
Path tlsDir = tempDir.resolve("tls");
Files.createDirectories(tlsDir);
// Create metadata with hostnames in one order
List<String> hostnames = List.of("localhost", "myhost", "127.0.0.1");
createMetadataFile(tlsDir, hostnames);
// Current hostnames in different order
List<String> currentHostnames = List.of("127.0.0.1", "localhost", "myhost");
// Act
boolean changed = invokeHostnameConfigChanged(tlsDir, currentHostnames);
// Assert
assertFalse(changed, "Should return false when hostnames are the same but in different order");
}
@Test
void testHostnameAdded_ReturnsTrue() throws Exception {
// Arrange
Path tlsDir = tempDir.resolve("tls");
Files.createDirectories(tlsDir);
// Create metadata with fewer hostnames
List<String> oldHostnames = List.of("localhost", "127.0.0.1");
createMetadataFile(tlsDir, oldHostnames);
// Current hostnames include an additional hostname
List<String> currentHostnames = List.of("localhost", "127.0.0.1", "newhost");
// Act
boolean changed = invokeHostnameConfigChanged(tlsDir, currentHostnames);
// Assert
assertTrue(changed, "Should return true when a hostname is added");
}
@Test
void testHostnameRemoved_ReturnsTrue() throws Exception {
// Arrange
Path tlsDir = tempDir.resolve("tls");
Files.createDirectories(tlsDir);
// Create metadata with more hostnames
List<String> oldHostnames = List.of("localhost", "127.0.0.1", "oldhost");
createMetadataFile(tlsDir, oldHostnames);
// Current hostnames have one removed
List<String> currentHostnames = List.of("localhost", "127.0.0.1");
// Act
boolean changed = invokeHostnameConfigChanged(tlsDir, currentHostnames);
// Assert
assertTrue(changed, "Should return true when a hostname is removed");
}
@Test
void testMalformedMetadataFile_ReturnsTrue() throws Exception {
// Arrange
Path tlsDir = tempDir.resolve("tls");
Files.createDirectories(tlsDir);
// Create malformed metadata file
Path metadataFile = tlsDir.resolve("floci-server.metadata.json");
Files.writeString(metadataFile, "{ invalid json }");
List<String> currentHostnames = List.of("localhost", "127.0.0.1");
// Act
boolean changed = invokeHostnameConfigChanged(tlsDir, currentHostnames);
// Assert
assertTrue(changed, "Should return true when metadata file is malformed");
}
@Test
void testMetadataWithNullHostnames_ReturnsTrue() throws Exception {
// Arrange
Path tlsDir = tempDir.resolve("tls");
Files.createDirectories(tlsDir);
// Create metadata with null hostnames field
Path metadataFile = tlsDir.resolve("floci-server.metadata.json");
String json = "{\"generatedAt\":\"" + Instant.now().toString() + "\",\"flociVersion\":\"dev\"}";
Files.writeString(metadataFile, json);
List<String> currentHostnames = List.of("localhost", "127.0.0.1");
// Act
boolean changed = invokeHostnameConfigChanged(tlsDir, currentHostnames);
// Assert
assertTrue(changed, "Should return true when metadata has no hostnames field");
}
@Test
void testEmptyHostnamesList_Unchanged_ReturnsFalse() throws Exception {
// Arrange
Path tlsDir = tempDir.resolve("tls");
Files.createDirectories(tlsDir);
// Create metadata with empty hostnames list
List<String> emptyHostnames = List.of();
createMetadataFile(tlsDir, emptyHostnames);
// Current hostnames are also empty
List<String> currentHostnames = List.of();
// Act
boolean changed = invokeHostnameConfigChanged(tlsDir, currentHostnames);
// Assert
assertFalse(changed, "Should return false when both hostname lists are empty");
}
/**
* Helper method to create a metadata file with specified hostnames.
*/
private void createMetadataFile(Path tlsDir, List<String> hostnames) throws IOException {
Path metadataFile = tlsDir.resolve("floci-server.metadata.json");
CertificateMetadata metadata = CertificateMetadata.create(hostnames, "dev");
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(metadata);
Files.writeString(metadataFile, json);
}
/**
* Helper method to invoke the private hostnameConfigChanged() method via reflection. We need to
* disable TLS temporarily to avoid certificate generation in the constructor.
*/
private boolean invokeHostnameConfigChanged(Path tlsDir, List<String> currentHostnames) throws Exception {
// Temporarily disable TLS to avoid certificate generation in constructor
String originalTlsEnabled = System.getProperty("floci.tls.enabled");
System.setProperty("floci.tls.enabled", "false");
try {
TlsConfigSource configSource = new TlsConfigSource();
Method method = TlsConfigSource.class.getDeclaredMethod("hostnameConfigChanged", Path.class, List.class);
method.setAccessible(true);
return (boolean) method.invoke(configSource, tlsDir, currentHostnames);
} finally {
// Restore original TLS setting
if (originalTlsEnabled != null) {
System.setProperty("floci.tls.enabled", originalTlsEnabled);
} else {
System.clearProperty("floci.tls.enabled");
}
}
}
}
@@ -0,0 +1,234 @@
package io.github.hectorvent.floci.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for metadata persistence in TlsConfigSource.
*
* Tests that metadata is correctly persisted after certificate generation:
* - Metadata file is created
* - Metadata contains correct hostnames
* - Metadata contains timestamp and version
* - Write failures are handled gracefully
*/
class TlsConfigSourceMetadataPersistenceTest {
@TempDir
Path tempDir;
@BeforeEach
void setup() {
// Set TLS enabled and self-signed mode
System.setProperty("floci.tls.enabled", "true");
System.setProperty("floci.tls.self-signed", "true");
System.setProperty("floci.storage.persistent-path", tempDir.toString());
}
@AfterEach
void cleanup() {
System.clearProperty("floci.tls.enabled");
System.clearProperty("floci.tls.self-signed");
System.clearProperty("floci.storage.persistent-path");
System.clearProperty("floci.hostname");
System.clearProperty("floci.base-url");
System.clearProperty("FLOCI_VERSION");
}
/**
* Test that metadata file is created after certificate generation
*/
@Test
void testMetadataFileCreated() {
// Act - trigger certificate generation by creating TlsConfigSource
new TlsConfigSource();
// Assert
Path metadataFile = tempDir.resolve("tls/floci-server.metadata.json");
assertTrue(Files.exists(metadataFile),
"Metadata file should be created after certificate generation");
}
/**
* Test that metadata contains default hostnames when no custom hostnames configured
*/
@Test
void testMetadataContainsDefaultHostnames() throws IOException {
// Act
new TlsConfigSource();
// Assert
Path metadataFile = tempDir.resolve("tls/floci-server.metadata.json");
CertificateMetadata metadata = readMetadata(metadataFile);
assertNotNull(metadata.getHostnames(), "Hostnames should not be null");
assertTrue(metadata.getHostnames().contains("localhost"),
"Metadata should contain 'localhost'");
assertTrue(metadata.getHostnames().contains("127.0.0.1"),
"Metadata should contain '127.0.0.1'");
assertTrue(metadata.getHostnames().contains("0.0.0.0"),
"Metadata should contain '0.0.0.0'");
assertTrue(metadata.getHostnames().contains("*.localhost"),
"Metadata should contain '*.localhost'");
assertTrue(metadata.getHostnames().contains("localhost.floci.io"),
"Metadata should contain 'localhost.floci.io'");
assertTrue(metadata.getHostnames().contains("*.localhost.floci.io"),
"Metadata should contain '*.localhost.floci.io'");
assertTrue(metadata.getHostnames().contains("host.docker.internal"),
"Metadata should contain 'host.docker.internal'");
}
/**
* Test that metadata contains custom hostname from FLOCI_HOSTNAME
*/
@Test
void testMetadataContainsCustomHostname() throws IOException {
// Arrange
System.setProperty("floci.hostname", "floci");
// Act
new TlsConfigSource();
// Assert
Path metadataFile = tempDir.resolve("tls/floci-server.metadata.json");
CertificateMetadata metadata = readMetadata(metadataFile);
assertTrue(metadata.getHostnames().contains("floci"),
"Metadata should contain custom hostname 'floci'");
}
/**
* Test that metadata contains custom hostname from FLOCI_BASE_URL
*/
@Test
void testMetadataContainsBaseUrlHostname() throws IOException {
// Arrange
System.setProperty("floci.base-url", "https://myhost:4566");
// Act
new TlsConfigSource();
// Assert
Path metadataFile = tempDir.resolve("tls/floci-server.metadata.json");
CertificateMetadata metadata = readMetadata(metadataFile);
assertTrue(metadata.getHostnames().contains("myhost"),
"Metadata should contain hostname 'myhost' from base URL");
}
/**
* Test that metadata contains both custom hostnames when both are configured
*/
@Test
void testMetadataContainsBothCustomHostnames() throws IOException {
// Arrange
System.setProperty("floci.hostname", "newhost");
System.setProperty("floci.base-url", "http://oldhost:4566");
// Act
new TlsConfigSource();
// Assert
Path metadataFile = tempDir.resolve("tls/floci-server.metadata.json");
CertificateMetadata metadata = readMetadata(metadataFile);
assertTrue(metadata.getHostnames().contains("newhost"),
"Metadata should contain 'newhost' from FLOCI_HOSTNAME");
assertTrue(metadata.getHostnames().contains("oldhost"),
"Metadata should contain 'oldhost' from FLOCI_BASE_URL");
}
/**
* Test that metadata contains timestamp
*/
@Test
void testMetadataContainsTimestamp() throws IOException {
// Act
new TlsConfigSource();
// Assert
Path metadataFile = tempDir.resolve("tls/floci-server.metadata.json");
CertificateMetadata metadata = readMetadata(metadataFile);
assertNotNull(metadata.getGeneratedAt(),
"Metadata should contain generatedAt timestamp");
assertFalse(metadata.getGeneratedAt().isBlank(),
"Timestamp should not be blank");
}
/**
* Test that metadata contains version
*/
@Test
void testMetadataContainsVersion() throws IOException {
// Act
new TlsConfigSource();
// Assert
Path metadataFile = tempDir.resolve("tls/floci-server.metadata.json");
CertificateMetadata metadata = readMetadata(metadataFile);
assertNotNull(metadata.getFlociVersion(),
"Metadata should contain flociVersion");
assertEquals("dev", metadata.getFlociVersion(),
"Version should default to 'dev' when FLOCI_VERSION not set");
}
/**
* Test that metadata contains custom version from FLOCI_VERSION environment variable
*/
@Test
void testMetadataContainsCustomVersion() throws IOException {
// Arrange
// Note: We can't set environment variables in Java, so we'll test the default behavior
// The actual environment variable handling is tested in integration tests
// Act
new TlsConfigSource();
// Assert
Path metadataFile = tempDir.resolve("tls/floci-server.metadata.json");
CertificateMetadata metadata = readMetadata(metadataFile);
assertNotNull(metadata.getFlociVersion(),
"Metadata should contain flociVersion");
}
/**
* Test that metadata file is valid JSON
*/
@Test
void testMetadataIsValidJson() throws IOException {
// Act
new TlsConfigSource();
// Assert
Path metadataFile = tempDir.resolve("tls/floci-server.metadata.json");
String json = Files.readString(metadataFile);
assertFalse(json.isBlank(), "Metadata file should not be empty");
assertTrue(json.contains("hostnames"), "JSON should contain 'hostnames' field");
assertTrue(json.contains("generatedAt"), "JSON should contain 'generatedAt' field");
assertTrue(json.contains("flociVersion"), "JSON should contain 'flociVersion' field");
}
// ==================== Helper Methods ====================
/**
* Reads and parses metadata from the specified file.
*/
private CertificateMetadata readMetadata(Path metadataFile) throws IOException {
String json = Files.readString(metadataFile);
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.readValue(json, CertificateMetadata.class);
}
}
@@ -0,0 +1,183 @@
package io.github.hectorvent.floci.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.Test;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManagerFactory;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* End to end: after {@code ensureHost}, a TLS client that trusts only the local CA and sends the
* new name as SNI receives a leaf whose SAN list contains it, and a client that also verifies the
* server's identity for that name (what every SDK does) completes the handshake, with no restart
* in between. A file assertion cannot show that the reload reached the listener; only a
* handshake can.
*/
@QuarkusTest
@TestProfile(TlsEnsureHostIntegrationTest.Profile.class)
class TlsEnsureHostIntegrationTest {
static final Path DATA_DIR = Path.of("target", "floci-tls-ensure-host-test").toAbsolutePath();
static final Path TLS_DIR = DATA_DIR.resolve("tls");
static final String NEW_HOST = "api.example.localhost.floci.io";
@ConfigProperty(name = "quarkus.http.test-ssl-port", defaultValue = "0")
int sslPort;
@Inject
TlsCertificateManager manager;
@Test
void ensureHostIsVisibleToTheNextHandshakeAndResetTakesItBack() throws Exception {
X509Certificate boot = peerLeaf();
assertFalse(sans(boot).contains(NEW_HOST), "precondition: the boot leaf does not cover " + NEW_HOST);
assertThrows(SSLHandshakeException.class, this::handshakeVerifyingNewHost,
"precondition: a client verifying the new name rejects the boot leaf");
assertEquals(200, healthOverHttpsAsLocalhost(), "the configured name is served before");
manager.ensureHost(NEW_HOST);
X509Certificate extended = peerLeaf();
Set<String> sans = sans(extended);
assertTrue(sans.contains(NEW_HOST), sans.toString());
assertTrue(sans.contains("localhost"), "configured names are still served: " + sans);
assertEquals(boot.getPublicKey(), extended.getPublicKey(), "same key pair across the reload");
handshakeVerifyingNewHost();
assertEquals(200, healthOverHttpsAsLocalhost(), "the configured name is still served after the swap");
given().when().post("/_floci/state/reset").then().statusCode(200);
Set<String> afterReset = sans(peerLeaf());
assertFalse(afterReset.contains(NEW_HOST), "reset drops the learned name: " + afterReset);
assertTrue(afterReset.contains("localhost"), afterReset.toString());
assertThrows(SSLHandshakeException.class, this::handshakeVerifyingNewHost, "reset: the new name is rejected again");
assertEquals(200, healthOverHttpsAsLocalhost(), "the configured name is served after reset");
}
/** The leaf the server presents for SNI {@code NEW_HOST}, with no identity check. */
private X509Certificate peerLeaf() throws Exception {
return handshake(false);
}
/** A handshake as an SDK would do it: SNI {@code NEW_HOST} and RFC 6125 identity verification. */
private void handshakeVerifyingNewHost() throws Exception {
handshake(true);
}
/**
* Connects by IP so the JDK has no peer host of its own and verifies the certificate against
* the SNI name; the fixture leaf carries no {@code 127.0.0.1} SAN, so nothing else can match.
*/
private X509Certificate handshake(boolean verifyIdentity) throws Exception {
try (SSLSocket socket = (SSLSocket) trustOnlyTheCa().getSocketFactory().createSocket()) {
socket.connect(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), sslPort), 5000);
SSLParameters params = socket.getSSLParameters();
params.setServerNames(List.of(new SNIHostName(NEW_HOST)));
if (verifyIdentity) {
params.setEndpointIdentificationAlgorithm("HTTPS");
}
socket.setSSLParameters(params);
socket.startHandshake();
return (X509Certificate) socket.getSession().getPeerCertificates()[0];
}
}
/** A plain HTTPS client with the JDK's default hostname verifier, for the configured name. */
private int healthOverHttpsAsLocalhost() throws Exception {
HttpsURLConnection connection = (HttpsURLConnection) URI.create(
"https://localhost:" + sslPort + "/_floci/health").toURL().openConnection();
connection.setSSLSocketFactory(trustOnlyTheCa().getSocketFactory());
try {
return connection.getResponseCode();
} finally {
connection.disconnect();
}
}
private static SSLContext trustOnlyTheCa() throws Exception {
KeyStore trust = KeyStore.getInstance(KeyStore.getDefaultType());
trust.load(null, null);
trust.setCertificateEntry("floci", FlociCertificateAuthority.loadOrCreate(TLS_DIR).certificate());
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(trust);
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, tmf.getTrustManagers(), null);
return ctx;
}
private static Set<String> sans(X509Certificate leaf) throws Exception {
Set<String> sans = new TreeSet<>();
for (List<?> entry : leaf.getSubjectAlternativeNames()) {
sans.add(String.valueOf(entry.get(1)));
}
return sans;
}
/**
* Boots with TLS on and a CA-issued leaf like the one TlsConfigSource writes. TlsConfigSource
* itself reads system properties, not profile overrides, so the profile lays the files down
* and points the TLS registry's default entry at them. The wildcard covers one label only
* (RFC 6125), so the three-label name is genuinely uncovered before ensureHost.
*/
public static final class Profile implements QuarkusTestProfile {
static {
try {
Files.createDirectories(TLS_DIR);
for (String name : List.of("floci-server.crt", "floci-server.key", "floci-server.metadata.json")) {
Files.deleteIfExists(TLS_DIR.resolve(name));
}
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(TLS_DIR);
List<String> sans = List.of("localhost", "*.localhost.floci.io");
var leaf = ca.issueServerCertificate("localhost", sans, KeyAlgorithm.RSA_2048, null);
Files.writeString(TLS_DIR.resolve("floci-server.crt"), leaf.certificatePem());
Files.writeString(TLS_DIR.resolve("floci-server.key"), leaf.privateKeyPem());
Files.writeString(TLS_DIR.resolve("floci-server.metadata.json"),
new ObjectMapper().writeValueAsString(CertificateMetadata.create(sans, "dev")));
} catch (IOException e) {
throw new IllegalStateException("could not prepare the TLS fixtures", e);
}
}
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"floci.tls.enabled", "true",
"floci.tls.self-signed", "true",
"floci.tls.aws-https-port", "0",
"floci.storage.persistent-path", DATA_DIR.toString(),
"quarkus.tls.key-store.pem.0.cert", TLS_DIR.resolve("floci-server.crt").toString(),
"quarkus.tls.key-store.pem.0.key", TLS_DIR.resolve("floci-server.key").toString(),
"quarkus.http.insecure-requests", "enabled");
}
}
}
@@ -0,0 +1,239 @@
package io.github.hectorvent.floci.config;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import io.github.hectorvent.floci.services.cloudfront.CloudFrontService;
import io.github.hectorvent.floci.services.cloudfront.model.DefaultCacheBehavior;
import io.github.hectorvent.floci.services.cloudfront.model.Distribution;
import io.github.hectorvent.floci.services.cloudfront.model.DistributionConfig;
import io.github.hectorvent.floci.services.cloudfront.model.KeyGroup;
import io.github.hectorvent.floci.services.cloudfront.model.Origin;
import io.github.hectorvent.floci.services.cloudfront.model.PublicKey;
import io.github.hectorvent.floci.services.s3.S3Service;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.restassured.http.ContentType;
import io.vertx.core.Vertx;
import io.vertx.core.http.HttpClient;
import io.vertx.core.http.HttpClientOptions;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.http.HttpVersion;
import io.vertx.core.http.RequestOptions;
import io.vertx.core.net.PemTrustOptions;
import io.vertx.core.net.SocketAddress;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Host-based routing over HTTPS with HTTP/2, where the client sends {@code :authority} and no
* {@code Host} header. The client connects to 127.0.0.1, names the service host only in
* {@code :authority}, trusts only the Floci CA and verifies that host against the served
* certificate, so no DNS lookup is needed.
*/
@QuarkusTest
@TestProfile(TlsHttp2HostRoutingIntegrationTest.Profile.class)
class TlsHttp2HostRoutingIntegrationTest {
static final Path DATA_DIR = Path.of("target", "floci-tls-http2-host-routing-test").toAbsolutePath();
static final Path TLS_DIR = DATA_DIR.resolve("tls");
static final String API_DOMAIN = "api.h2-routing.example.test";
private static final String REGION = "us-east-1";
@ConfigProperty(name = "quarkus.http.test-ssl-port", defaultValue = "0")
int sslPort;
@Inject
Vertx vertx;
@Inject
S3Service s3Service;
@Inject
CloudFrontService cloudFrontService;
@Test
void cloudFrontServesACannedSignedUrlOnEachLocalDeliveryHost() throws Exception {
String suffix = Long.toString(System.nanoTime(), 36);
String bucket = "cf-h2-" + suffix;
s3Service.createBucket(bucket, REGION);
s3Service.putObject(bucket, "private/report.txt", ("H2-" + suffix).getBytes(StandardCharsets.UTF_8),
"text/plain", Map.of());
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair keyPair = generator.generateKeyPair();
PublicKey publicKey = new PublicKey();
publicKey.setName("pk-" + suffix);
publicKey.setCallerReference("cr-" + suffix);
publicKey.setEncodedKey("-----BEGIN PUBLIC KEY-----\n"
+ Base64.getMimeEncoder().encodeToString(keyPair.getPublic().getEncoded())
+ "\n-----END PUBLIC KEY-----");
publicKey = cloudFrontService.createPublicKey(publicKey);
KeyGroup keyGroup = new KeyGroup();
keyGroup.setName("kg-" + suffix);
keyGroup.setItems(List.of(publicKey.getId()));
keyGroup = cloudFrontService.createKeyGroup(keyGroup);
Origin origin = new Origin();
origin.setId("o");
origin.setDomainName(bucket + ".s3." + REGION + ".amazonaws.com");
origin.setS3OriginConfig(new LinkedHashMap<>(Map.of("OriginAccessIdentity", "")));
DefaultCacheBehavior behavior = new DefaultCacheBehavior();
behavior.setTargetOriginId("o");
behavior.setViewerProtocolPolicy("allow-all");
behavior.setTrustedKeyGroups(List.of(keyGroup.getId()));
DistributionConfig config = new DistributionConfig();
config.setEnabled(true);
config.setOrigins(List.of(origin));
config.setDefaultCacheBehavior(behavior);
Distribution distribution = new Distribution();
distribution.setConfig(config);
distribution = cloudFrontService.createDistribution(distribution, Map.of());
// A browser sends the host in lower case, and a canned policy signs the port too.
String id = distribution.getId().toLowerCase(Locale.ROOT);
for (String host : List.of(id + ".cloudfront.localhost.floci.io", id + ".cloudfront.localhost")) {
long expires = Instant.now().getEpochSecond() + 3600;
String policy = "{\"Statement\":[{\"Resource\":\"https://" + host + ":" + sslPort
+ "/private/report.txt\",\"Condition\":{\"DateLessThan\":{\"AWS:EpochTime\":" + expires + "}}}]}";
Signature rsa = Signature.getInstance("SHA1withRSA");
rsa.initSign(keyPair.getPrivate());
rsa.update(policy.getBytes(StandardCharsets.UTF_8));
Http2Response response = getOverHttp2(host, "/private/report.txt?Expires=" + expires
+ "&Signature=" + cloudFrontBase64(rsa.sign()) + "&Key-Pair-Id=" + publicKey.getId());
assertEquals(HttpVersion.HTTP_2, response.version());
assertEquals(200, response.status(), host + " " + response.body());
assertEquals("H2-" + suffix, response.body());
}
}
@Test
void apiGatewayCustomDomainRoutesToTheRestApi() throws Exception {
String apiId = given().contentType(ContentType.JSON)
.body("{\"name\":\"h2-host-routing\"}")
.when().post("/restapis")
.then().statusCode(201).extract().path("id");
String rootId = given().when().get("/restapis/" + apiId + "/resources")
.then().statusCode(200).extract().path("item[0].id");
String resourceId = given().contentType(ContentType.JSON)
.body("{\"pathPart\":\"ping\"}")
.when().post("/restapis/" + apiId + "/resources/" + rootId)
.then().statusCode(201).extract().path("id");
String method = "/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET";
given().contentType(ContentType.JSON).body("{\"authorizationType\":\"NONE\"}")
.when().put(method).then().statusCode(201);
given().contentType(ContentType.JSON).body("{}")
.when().put(method + "/responses/200").then().statusCode(201);
given().contentType(ContentType.JSON)
.body("{\"type\":\"MOCK\",\"requestTemplates\":{\"application/json\":\"{\\\"statusCode\\\":200}\"}}")
.when().put(method + "/integration").then().statusCode(201);
given().contentType(ContentType.JSON)
.body("{\"selectionPattern\":\"\",\"responseTemplates\":{\"application/json\":\"{\\\"pong\\\":true}\"}}")
.when().put(method + "/integration/responses/200").then().statusCode(201);
String deploymentId = given().contentType(ContentType.JSON).body("{}")
.when().post("/restapis/" + apiId + "/deployments")
.then().statusCode(201).extract().path("id");
given().contentType(ContentType.JSON)
.body("{\"stageName\":\"dev\",\"deploymentId\":\"" + deploymentId + "\"}")
.when().post("/restapis/" + apiId + "/stages").then().statusCode(201);
given().contentType(ContentType.JSON)
.body("{\"domainName\":\"" + API_DOMAIN + "\"}")
.when().post("/domainnames").then().statusCode(201);
given().contentType(ContentType.JSON)
.body("{\"basePath\":\"v1\",\"restApiId\":\"" + apiId + "\",\"stage\":\"dev\"}")
.when().post("/domainnames/" + API_DOMAIN + "/basepathmappings").then().statusCode(201);
Http2Response response = getOverHttp2(API_DOMAIN, "/v1/ping");
assertEquals(HttpVersion.HTTP_2, response.version());
assertEquals(200, response.status(), response.body());
assertTrue(response.body().contains("\"pong\":true"), response.body());
}
private record Http2Response(HttpVersion version, int status, String body) {}
private Http2Response getOverHttp2(String host, String pathAndQuery) throws Exception {
HttpClient client = vertx.createHttpClient(new HttpClientOptions()
.setSsl(true)
.setUseAlpn(true)
.setForceSni(true)
.setTrustOptions(new PemTrustOptions().addCertPath(
FlociCertificateAuthority.loadOrCreate(TLS_DIR).certificatePath().toString()))
.setVerifyHost(true)
.setProtocolVersion(HttpVersion.HTTP_2));
try {
RequestOptions options = new RequestOptions()
.setServer(SocketAddress.inetSocketAddress(sslPort, "127.0.0.1"))
.setHost(host)
.setPort(sslPort)
.setMethod(HttpMethod.GET)
.setURI(pathAndQuery);
return client.request(options)
.compose(request -> request.send())
.compose(response -> response.body().map(body ->
new Http2Response(response.version(), response.statusCode(), body.toString())))
.toCompletionStage().toCompletableFuture().get(10, TimeUnit.SECONDS);
} finally {
client.close();
}
}
private static String cloudFrontBase64(byte[] bytes) {
return Base64.getEncoder().encodeToString(bytes).replace('+', '-').replace('=', '_').replace('/', '~');
}
/**
* Boots with TLS on and a CA-issued leaf carrying the SANs TlsConfigSource writes, plus the API
* Gateway custom domain.
*/
public static final class Profile implements QuarkusTestProfile {
static {
try {
Files.createDirectories(TLS_DIR);
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(TLS_DIR);
List<String> sans = new ArrayList<>(TlsConfigSource.DEFAULT_SAN_HOSTNAMES);
sans.add(API_DOMAIN);
var leaf = ca.issueServerCertificate("localhost", sans, KeyAlgorithm.RSA_2048, null);
Files.writeString(TLS_DIR.resolve("floci-server.crt"), leaf.certificatePem());
Files.writeString(TLS_DIR.resolve("floci-server.key"), leaf.privateKeyPem());
} catch (IOException e) {
throw new IllegalStateException("could not prepare the TLS fixtures", e);
}
}
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"floci.tls.enabled", "true",
"floci.tls.self-signed", "true",
"floci.tls.aws-https-port", "0",
"floci.storage.persistent-path", DATA_DIR.toString(),
"quarkus.tls.key-store.pem.0.cert", TLS_DIR.resolve("floci-server.crt").toString(),
"quarkus.tls.key-store.pem.0.key", TLS_DIR.resolve("floci-server.key").toString(),
"quarkus.http.insecure-requests", "enabled");
}
}
}
@@ -0,0 +1,157 @@
package io.github.hectorvent.floci.config;
import io.github.hectorvent.floci.services.acm.CertificateGenerator;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.restassured.RestAssured;
import io.restassured.config.RestAssuredConfig;
import io.restassured.config.SSLConfig;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Security;
import java.util.List;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.startsWith;
/**
* Integration test verifying TLS/HTTPS support when enabled via configuration.
*
* <p>Uses a {@link QuarkusTestProfile} to enable self-signed TLS so the Quarkus
* HTTP server serves HTTPS alongside HTTP (dual-mode, LocalStack parity).
*/
@QuarkusTest
@TestProfile(TlsIntegrationTest.TlsEnabledProfile.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TlsIntegrationTest {
@ConfigProperty(name = "quarkus.http.test-ssl-port", defaultValue = "0")
int testSslPort;
@ConfigProperty(name = "quarkus.http.test-port", defaultValue = "0")
int testHttpPort;
@BeforeAll
void setupRestAssured() {
// Trust self-signed certificates for all requests in this test class
RestAssured.config = RestAssuredConfig.config()
.sslConfig(SSLConfig.sslConfig().relaxedHTTPSValidation());
RestAssuredJsonUtils.configureAwsContentTypes();
}
// ==================== HTTPS Connectivity ====================
@Test
void healthEndpointOverHttps() {
given()
.baseUri("https://localhost:" + testSslPort)
.when()
.get("/_floci/health")
.then()
.statusCode(200)
.body("edition", is("community"));
}
@Test
void healthEndpointStillWorksOverHttp() {
given()
.baseUri("http://localhost:" + testHttpPort)
.when()
.get("/_floci/health")
.then()
.statusCode(200)
.body("edition", is("community"));
}
// ==================== SQS via HTTPS ====================
@Test
void sqsCreateQueueOverHttps() {
given()
.baseUri("https://localhost:" + testSslPort)
.header("X-Amz-Target", "AmazonSQS.CreateQueue")
.contentType("application/x-amz-json-1.0")
.body("{\"QueueName\": \"tls-test-queue\"}")
.when()
.post("/")
.then()
.statusCode(200)
.body("QueueUrl", startsWith("https://"));
}
// ==================== STS via HTTPS ====================
@Test
void stsGetCallerIdentityOverHttps() {
given()
.baseUri("https://localhost:" + testSslPort)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "GetCallerIdentity")
.formParam("Version", "2011-06-15")
.when()
.post("/")
.then()
.statusCode(200)
.body(containsString("GetCallerIdentityResult"));
}
// ==================== Test Profile ====================
public static final class TlsEnabledProfile implements QuarkusTestProfile {
private static final Path CERT_DIR = Path.of("/tmp/floci-tls-integration-test");
private static final Path CERT_FILE = CERT_DIR.resolve("test.crt");
private static final Path KEY_FILE = CERT_DIR.resolve("test.key");
static {
generateCertIfNeeded();
}
private static void generateCertIfNeeded() {
if (Files.exists(CERT_FILE) && Files.exists(KEY_FILE)) {
return;
}
try {
Files.createDirectories(CERT_DIR);
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
CertificateGenerator gen = new CertificateGenerator();
CertificateGenerator.GeneratedCertificate cert = gen.generateSelfSignedCertificate(
"localhost", List.of("localhost", "127.0.0.1"), KeyAlgorithm.RSA_2048);
Files.writeString(CERT_FILE, cert.certificatePem());
Files.writeString(KEY_FILE, cert.privateKeyPem());
} catch (IOException e) {
throw new RuntimeException("Failed to generate test cert", e);
}
}
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"floci.tls.enabled", "true",
"floci.tls.self-signed", "false",
"floci.tls.cert-path", CERT_FILE.toAbsolutePath().toString(),
"floci.tls.key-path", KEY_FILE.toAbsolutePath().toString(),
"floci.storage.persistent-path", "/tmp/floci-tls-integration-test-data",
"quarkus.http.ssl.certificate.files", CERT_FILE.toAbsolutePath().toString(),
"quarkus.http.ssl.certificate.key-files", KEY_FILE.toAbsolutePath().toString(),
"quarkus.http.insecure-requests", "enabled"
);
}
}
}
@@ -0,0 +1,125 @@
package io.github.hectorvent.floci.config;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.Test;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.TrustManagerFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Boots the HTTP server on the TLS registry keys {@code TlsConfigSource} publishes, with a leaf
* issued by the local CA, and proves a client that trusts only {@code GET /_floci/ca.pem} completes
* a handshake. A file assertion cannot show that Quarkus read the registry entry or that the served
* chain is the CA's; only a handshake can.
*/
@QuarkusTest
@TestProfile(TlsLocalCaIntegrationTest.LocalCaProfile.class)
class TlsLocalCaIntegrationTest {
@ConfigProperty(name = "quarkus.http.test-ssl-port", defaultValue = "0")
int testSslPort;
@Test
void handshakeSucceedsTrustingOnlyTheServedCa() throws Exception {
String caPem = given().when().get("/_floci/ca.pem").then().statusCode(200).extract().asString();
X509Certificate ca = parse(caPem);
HttpsURLConnection connection = open(trustOnly(ca));
assertEquals(200, connection.getResponseCode());
Certificate[] served = connection.getServerCertificates();
X509Certificate leaf = (X509Certificate) served[0];
assertEquals(ca.getSubjectX500Principal(), leaf.getIssuerX500Principal());
assertEquals(-1, leaf.getBasicConstraints());
leaf.verify(ca.getPublicKey());
connection.disconnect();
}
@Test
void handshakeFailsTrustingAnotherCa() throws Exception {
X509Certificate stranger = FlociCertificateAuthority.loadOrCreate(
Files.createTempDirectory("floci-stranger-ca")).certificate();
HttpsURLConnection connection = open(trustOnly(stranger));
assertThrows(SSLHandshakeException.class, connection::getResponseCode);
}
private HttpsURLConnection open(SSLContext sslContext) throws IOException {
HttpsURLConnection connection = (HttpsURLConnection) URI.create(
"https://localhost:" + testSslPort + "/_floci/health").toURL().openConnection();
connection.setSSLSocketFactory(sslContext.getSocketFactory());
return connection;
}
private static SSLContext trustOnly(X509Certificate ca) throws Exception {
KeyStore trust = KeyStore.getInstance(KeyStore.getDefaultType());
trust.load(null, null);
trust.setCertificateEntry("floci-ca", ca);
TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
factory.init(trust);
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, factory.getTrustManagers(), null);
return context;
}
private static X509Certificate parse(String pem) throws Exception {
return (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(new ByteArrayInputStream(pem.getBytes(StandardCharsets.US_ASCII)));
}
/**
* Lays down what {@code TlsConfigSource} would: a CA under {persistent-path}/tls and a leaf
* issued by it, published as the registry's default key store. The persistent path is the
* same directory, so the CDI producer behind {@code /_floci/ca.pem} loads the same CA.
*/
public static final class LocalCaProfile implements QuarkusTestProfile {
private static final Path DATA_DIR = Path.of("target", "floci-tls-local-ca-test").toAbsolutePath();
private static final Path TLS_DIR = DATA_DIR.resolve("tls");
private static final Path CERT_FILE = TLS_DIR.resolve("floci-server.crt");
private static final Path KEY_FILE = TLS_DIR.resolve("floci-server.key");
static {
try {
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(TLS_DIR);
var leaf = ca.issueServerCertificate("localhost", List.of("localhost", "127.0.0.1"),
KeyAlgorithm.RSA_2048, null);
Files.writeString(CERT_FILE, leaf.certificatePem());
Files.writeString(KEY_FILE, leaf.privateKeyPem());
} catch (IOException e) {
throw new IllegalStateException("Failed to prepare the local CA test files", e);
}
}
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"floci.storage.persistent-path", DATA_DIR.toString(),
"quarkus.tls.key-store.pem.0.cert", CERT_FILE.toString(),
"quarkus.tls.key-store.pem.0.key", KEY_FILE.toString(),
"quarkus.http.insecure-requests", "enabled"
);
}
}
}
@@ -0,0 +1,138 @@
package io.github.hectorvent.floci.config;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.net.NetClient;
import io.vertx.core.net.NetServer;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* The proxy only reserves a port it holds or still expects to hold. Binding the AWS-HTTPS port is
* privileged and its failure is non-fatal, so a port the proxy never acquired has to stay
* available: anything that yields to the proxy on configuration alone would leave it unserved.
*/
class TlsProxyServerReservedPortTest {
private static TlsProxyServer proxyWith(int flociPort, boolean tlsEnabled, int awsHttpsPort) {
EmulatorConfig.TlsConfig tls = mock(EmulatorConfig.TlsConfig.class);
when(tls.enabled()).thenReturn(tlsEnabled);
when(tls.awsHttpsPort()).thenReturn(awsHttpsPort);
EmulatorConfig config = mock(EmulatorConfig.class);
when(config.port()).thenReturn(flociPort);
when(config.tls()).thenReturn(tls);
// The listen() future is left uncompleted on purpose: that is the "bind outcome not yet
// known" state, which must still reserve the port. Tests that need a resolved outcome
// stage it directly on failedPorts.
NetServer server = mock(NetServer.class);
when(server.connectHandler(any())).thenReturn(server);
when(server.listen()).thenReturn(Future.succeededFuture(server), Promise.<NetServer>promise().future());
Vertx vertx = mock(Vertx.class);
when(vertx.createNetClient()).thenReturn(mock(NetClient.class));
when(vertx.createNetServer(any())).thenReturn(server);
return new TlsProxyServer(vertx, config, "127.0.0.1", 4510, 4511);
}
/**
* Variant that resolves listen() to {@code outcome} and lets the caller register a handler
* before construction, since the bind callback fires during the constructor.
*/
private static TlsProxyServer proxyWith(int flociPort, boolean tlsEnabled, int awsHttpsPort,
Future<NetServer> outcome,
java.util.function.Consumer<TlsProxyServer> beforeStart) {
return proxyWith(flociPort, tlsEnabled, awsHttpsPort,
Future.succeededFuture(mock(NetServer.class)), outcome, beforeStart);
}
private static TlsProxyServer proxyWith(int flociPort, boolean tlsEnabled, int awsHttpsPort,
Future<NetServer> publicOutcome,
Future<NetServer> awsHttpsOutcome,
java.util.function.Consumer<TlsProxyServer> beforeStart) {
EmulatorConfig.TlsConfig tls = mock(EmulatorConfig.TlsConfig.class);
when(tls.enabled()).thenReturn(tlsEnabled);
when(tls.awsHttpsPort()).thenReturn(awsHttpsPort);
EmulatorConfig config = mock(EmulatorConfig.class);
when(config.port()).thenReturn(flociPort);
when(config.tls()).thenReturn(tls);
NetServer server = mock(NetServer.class);
when(server.connectHandler(any())).thenReturn(server);
when(server.listen()).thenReturn(publicOutcome, awsHttpsOutcome);
Vertx vertx = mock(Vertx.class);
when(vertx.createNetClient()).thenReturn(mock(NetClient.class));
when(vertx.createNetServer(any())).thenReturn(server);
TlsProxyServer proxy = new TlsProxyServer(vertx, config, "127.0.0.1", 4510, 4511);
beforeStart.accept(proxy);
return proxy;
}
@Test
void reservesTheAwsHttpsPortBeforeTheBindOutcomeIsKnown() {
TlsProxyServer proxy = proxyWith(4566, true, 443);
assertTrue(proxy.reservesPort(443),
"an unresolved bind must still hold the port, or a listener races the proxy for it");
}
@Test
void releasesTheAwsHttpsPortOnceItsBindHasFailed() {
TlsProxyServer proxy = proxyWith(4566, true, 443);
proxy.failedPorts.add(443);
assertFalse(proxy.reservesPort(443),
"a port the proxy failed to bind must stay available to load balancer listeners");
}
@Test
void reservesNothingWhenTlsIsDisabled() {
TlsProxyServer proxy = proxyWith(4566, false, 443);
assertFalse(proxy.reservesPort(443));
}
@Test
void doesNotReserveTheAwsHttpsPortWhenItIsDisabled() {
TlsProxyServer proxy = proxyWith(4566, true, 0);
assertFalse(proxy.reservesPort(443));
}
@Test
void notifiesHandlersWhenABindFails() {
List<Integer> released = new ArrayList<>();
// listen() resolving to a failure is what happens when the privileged port is refused.
TlsProxyServer proxy = proxyWith(4566, true, 443,
Future.failedFuture(new RuntimeException("permission denied")),
p -> p.onPortReleased(released::add));
assertTrue(released.contains(443),
"a listener that yielded 443 has no other way to learn the bind failed");
assertFalse(proxy.reservesPort(443));
}
@Test
void failsStartupWhenThePublicPortCannotBind() {
assertThrows(IllegalStateException.class, () -> proxyWith(4566, true, 443,
Future.failedFuture(new RuntimeException("address already in use")),
Future.succeededFuture(mock(NetServer.class)),
ignored -> {
}));
}
@Test
void doesNotReserveAnUnrelatedPort() {
TlsProxyServer proxy = proxyWith(4566, true, 443);
assertFalse(proxy.reservesPort(8080));
}
}
@@ -0,0 +1,246 @@
package io.github.hectorvent.floci.config;
import io.vertx.core.Vertx;
import io.vertx.core.net.NetServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import java.io.IOException;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.NetworkInterface;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Tests for {@link TlsProxyServer}'s multi-port binding and first-byte protocol detection.
*
* <p>Uses ephemeral ports for the public port, the AWS-HTTPS port, and the stub HTTP/HTTPS
* backends, so nothing privileged (443) is bound and the test never collides with a running
* Floci. The stub backends simply emit a marker on connect; the proxy pipes it back, letting
* the test assert which backend a given first byte was routed to.
*/
class TlsProxyServerTest {
/** TLS ClientHello content type — the first byte the proxy keys protocol detection on. */
private static final byte TLS_HANDSHAKE = 0x16;
private Vertx vertx;
private TlsProxyServer proxy;
private NetServer httpBackend;
private NetServer httpsBackend;
@BeforeEach
void setUp() {
vertx = Vertx.vertx();
}
@AfterEach
void tearDown() {
if (proxy != null) {
proxy.stop();
}
closeBackend(httpBackend);
closeBackend(httpsBackend);
vertx.close();
}
private static EmulatorConfig configWith(boolean tlsEnabled, int publicPort, int awsHttpsPort) {
EmulatorConfig config = mock(EmulatorConfig.class);
EmulatorConfig.TlsConfig tls = mock(EmulatorConfig.TlsConfig.class);
when(config.port()).thenReturn(publicPort);
when(config.tls()).thenReturn(tls);
when(tls.enabled()).thenReturn(tlsEnabled);
when(tls.awsHttpsPort()).thenReturn(awsHttpsPort);
when(config.security()).thenReturn(mock(EmulatorConfig.SecurityConfig.class));
return config;
}
@Test
void listenPorts_includesPublicAndAwsHttpsPort() {
proxy = new TlsProxyServer(vertx, configWith(false, 4566, 443), "127.0.0.1", 4510, 4511);
assertEquals(Set.of(4566, 443), proxy.listenPorts());
}
@Test
void listenPorts_dropsAwsHttpsPortWhenZero() {
proxy = new TlsProxyServer(vertx, configWith(false, 4566, 0), "127.0.0.1", 4510, 4511);
assertEquals(Set.of(4566), proxy.listenPorts());
}
@Test
void listenPorts_dedupesWhenAwsHttpsPortEqualsPublic() {
proxy = new TlsProxyServer(vertx, configWith(false, 4566, 4566), "127.0.0.1", 4510, 4511);
assertEquals(Set.of(4566), proxy.listenPorts());
}
@Test
@Timeout(20)
void bindsTheConfiguredHost_notEveryInterface() throws Exception {
InetAddress external = nonLoopbackIpv4();
assumeTrue(external != null, "needs a non-loopback IPv4 interface");
int publicPort = freePort();
proxy = new TlsProxyServer(vertx, configWith(true, publicPort, 0), "127.0.0.1", 4510, 4511);
awaitListening(publicPort);
assertFalse(accepts(external, publicPort),
"a proxy configured for 127.0.0.1 must not be reachable on " + external.getHostAddress());
}
@Test
@Timeout(20)
void nonLoopbackHostWithoutConsent_refusesToListen() throws Exception {
int publicPort = freePort();
IllegalStateException e = assertThrows(IllegalStateException.class,
() -> new TlsProxyServer(vertx, configWith(true, publicPort, 0), "0.0.0.0", 4510, 4511));
assertTrue(e.getMessage().contains("FLOCI_SECURITY_ALLOW_UNSAFE_NETWORK_EXPOSURE"), e.getMessage());
assertFalse(portAccepts(publicPort), "a refused proxy must not listen");
}
@Test
@Timeout(20)
void nonLoopbackHostWithConsent_listens() throws Exception {
int publicPort = freePort();
EmulatorConfig config = configWith(true, publicPort, 0);
when(config.security().allowUnsafeNetworkExposure()).thenReturn(true);
proxy = new TlsProxyServer(vertx, config, "0.0.0.0", 4510, 4511);
awaitListening(publicPort);
}
@Test
@Timeout(20)
void bindsBothPorts_andRoutesByFirstByte() throws Exception {
int httpBe = freePort();
int httpsBe = freePort();
int publicPort = freePort();
int awsHttpsPort = freePort();
httpBackend = startMarkerBackend(httpBe, "PLAIN");
httpsBackend = startMarkerBackend(httpsBe, "TLS");
proxy = new TlsProxyServer(vertx, configWith(true, publicPort, awsHttpsPort), "127.0.0.1", httpBe, httpsBe);
// Public port: TLS ClientHello → HTTPS backend; anything else → HTTP backend.
assertEquals("TLS", roundTrip(publicPort, TLS_HANDSHAKE));
assertEquals("PLAIN", roundTrip(publicPort, (byte) 'G'));
// AWS-HTTPS port: same protocol detection as the public port.
assertEquals("TLS", roundTrip(awsHttpsPort, TLS_HANDSHAKE));
assertEquals("PLAIN", roundTrip(awsHttpsPort, (byte) 'G'));
}
@Test
@Timeout(20)
void tlsDisabled_bindsNothing() throws Exception {
int publicPort = freePort();
proxy = new TlsProxyServer(vertx, configWith(false, publicPort, 443), "127.0.0.1", 4510, 4511);
assertFalse(portAccepts(publicPort), "no proxy should listen when TLS is disabled");
}
// ---- helpers ----
private NetServer startMarkerBackend(int port, String marker) throws Exception {
NetServer server = vertx.createNetServer();
server.connectHandler(sock -> sock.write(marker));
CompletableFuture<Void> started = new CompletableFuture<>();
server.listen(port, "127.0.0.1").onComplete(ar -> {
if (ar.succeeded()) {
started.complete(null);
} else {
started.completeExceptionally(ar.cause());
}
});
started.get(5, TimeUnit.SECONDS);
return server;
}
private String roundTrip(int proxyPort, byte firstByte) throws Exception {
awaitListening(proxyPort);
try (Socket sock = new Socket("127.0.0.1", proxyPort)) {
sock.getOutputStream().write(new byte[]{firstByte});
sock.getOutputStream().flush();
byte[] buf = new byte[64];
int n = sock.getInputStream().read(buf);
assertTrue(n > 0, "expected a marker response from the backend");
return new String(buf, 0, n, StandardCharsets.UTF_8);
}
}
private void awaitListening(int port) throws Exception {
long deadline = System.currentTimeMillis() + 10_000;
while (System.currentTimeMillis() < deadline) {
try (Socket s = new Socket("127.0.0.1", port)) {
return;
} catch (IOException e) {
Thread.sleep(50);
}
}
fail("port " + port + " not listening within timeout");
}
private boolean portAccepts(int port) throws Exception {
// Give any (unexpected) async bind a brief chance to come up, then probe once.
Thread.sleep(500);
try (Socket s = new Socket("127.0.0.1", port)) {
return true;
} catch (IOException e) {
return false;
}
}
private static boolean accepts(InetAddress address, int port) {
try (Socket s = new Socket()) {
s.connect(new InetSocketAddress(address, port), 1000);
return true;
} catch (IOException e) {
return false;
}
}
private static InetAddress nonLoopbackIpv4() throws SocketException {
for (NetworkInterface nic : Collections.list(NetworkInterface.getNetworkInterfaces())) {
if (!nic.isUp() || nic.isLoopback()) {
continue;
}
for (InetAddress address : Collections.list(nic.getInetAddresses())) {
if (address instanceof Inet4Address) {
return address;
}
}
}
return null;
}
private static void closeBackend(NetServer server) {
if (server != null) {
server.close();
}
}
private static int freePort() throws IOException {
try (ServerSocket s = new ServerSocket(0)) {
return s.getLocalPort();
}
}
}
@@ -0,0 +1,150 @@
package io.github.hectorvent.floci.config;
import io.github.hectorvent.floci.services.acm.CertificateGenerator;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.restassured.RestAssured;
import io.restassured.config.RestAssuredConfig;
import io.restassured.config.SSLConfig;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Security;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Integration test verifying TLS works with user-provided certificate and key files.
*
* <p>Generates a cert/key pair in the static initializer (before Quarkus starts),
* then configures the profile to use those paths for both floci config and
* Quarkus SSL properties. The certificate file also carries the private key, the combined
* form OpenSSL tooling produces, so the tests prove that key never leaves the host.
*/
@QuarkusTest
@TestProfile(TlsUserCertIntegrationTest.UserCertProfile.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TlsUserCertIntegrationTest {
@ConfigProperty(name = "quarkus.http.test-ssl-port", defaultValue = "0")
int testSslPort;
@BeforeAll
void setupRestAssured() {
RestAssured.config = RestAssuredConfig.config()
.sslConfig(SSLConfig.sslConfig().relaxedHTTPSValidation());
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void healthEndpointOverHttpsWithUserCert() {
given()
.baseUri("https://localhost:" + testSslPort)
.when()
.get("/_floci/health")
.then()
.statusCode(200)
.body("edition", is("community"));
}
@Test
void caPemReturnsTheUserCertificateFollowedByTheLocalCa() throws Exception {
String pem = given()
.when()
.get("/_floci/ca.pem")
.then()
.statusCode(200)
.contentType(startsWith("text/plain"))
.extract().asString();
assertFalse(pem.contains("PRIVATE KEY"),
"the key stored next to the certificate must never be served");
List<X509Certificate> bundle = CertificateFactory.getInstance("X.509")
.generateCertificates(new ByteArrayInputStream(pem.getBytes(StandardCharsets.US_ASCII)))
.stream().map(X509Certificate.class::cast).toList();
assertEquals(2, bundle.size(), "user certificate plus the local CA");
assertEquals(
new CertificateGenerator().parseCertificate(Files.readString(UserCertProfile.CERT_FILE)), bundle.get(0),
"the user certificate, which signs the HTTPS endpoint, comes first");
X509Certificate localCa = bundle.get(1);
assertTrue(localCa.getBasicConstraints() >= 0, "the local CA is a CA");
assertEquals("CN=Floci Local CA", localCa.getSubjectX500Principal().getName());
assertTrue(pem.endsWith(Files.readString(
Path.of("/tmp/floci-tls-usercert-test-data/tls/floci-root-ca.crt"))),
"the local CA served is the one on disk");
}
@Test
void ssmPutParameterOverHttps() {
given()
.baseUri("https://localhost:" + testSslPort)
.header("X-Amz-Target", "AmazonSSM.PutParameter")
.contentType("application/x-amz-json-1.1")
.body("{\"Name\": \"/tls-user-cert-test\", \"Value\": \"test-value\", \"Type\": \"String\"}")
.when()
.post("/")
.then()
.statusCode(200);
}
public static final class UserCertProfile implements QuarkusTestProfile {
private static final Path CERT_DIR = Path.of("/tmp/floci-tls-usercert-test");
static final Path CERT_FILE = CERT_DIR.resolve("user-test.crt");
private static final Path KEY_FILE = CERT_DIR.resolve("user-test.key");
static {
generateCertIfNeeded();
}
private static void generateCertIfNeeded() {
try {
Files.createDirectories(CERT_DIR);
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
CertificateGenerator gen = new CertificateGenerator();
CertificateGenerator.GeneratedCertificate cert = gen.generateSelfSignedCertificate(
"localhost", List.of("localhost", "127.0.0.1"), KeyAlgorithm.RSA_2048);
Files.writeString(CERT_FILE, cert.certificatePem() + cert.privateKeyPem());
Files.writeString(KEY_FILE, cert.privateKeyPem());
} catch (IOException e) {
throw new RuntimeException("Failed to generate test cert", e);
}
}
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"floci.tls.enabled", "true",
"floci.tls.self-signed", "false",
"floci.tls.cert-path", CERT_FILE.toAbsolutePath().toString(),
"floci.tls.key-path", KEY_FILE.toAbsolutePath().toString(),
"floci.storage.persistent-path", "/tmp/floci-tls-usercert-test-data",
"quarkus.http.ssl.certificate.files", CERT_FILE.toAbsolutePath().toString(),
"quarkus.http.ssl.certificate.key-files", KEY_FILE.toAbsolutePath().toString(),
"quarkus.http.insecure-requests", "enabled"
);
}
}
}
@@ -0,0 +1,97 @@
package io.github.hectorvent.floci.core.common;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AbstractTagsControllerTest {
private TagDispatcher dispatcher;
private TagHandler handler;
private AbstractTagsController controller;
private static class TestTagsController extends AbstractTagsController {
TestTagsController(TagDispatcher dispatcher) {
super(dispatcher);
}
}
@BeforeEach
void setUp() {
dispatcher = mock(TagDispatcher.class);
handler = mock(TagHandler.class);
controller = new TestTagsController(dispatcher);
}
@Test
void listTagsDelegatesToDispatcher() {
HttpHeaders headers = mock(HttpHeaders.class);
String arn = "arn:aws:apigateway:us-east-1::/restapis/123";
Response expected = Response.ok().build();
when(dispatcher.listTagsForArn(headers, arn)).thenReturn(expected);
Response actual = controller.listTags(headers, arn);
assertEquals(expected, actual);
verify(dispatcher).listTagsForArn(headers, arn);
}
@Test
void tagResourcePostDispatchesWithHandlerSuccessStatus() {
HttpHeaders headers = mock(HttpHeaders.class);
String arn = "arn:aws:kafka:us-east-1:000000000000:cluster/test/id";
String body = "{\"tags\":{\"env\":\"test\"}}";
when(dispatcher.resolveHandler(arn)).thenReturn(handler);
when(handler.tagResourceSuccessStatus()).thenReturn(204);
Response expected = Response.noContent().build();
when(dispatcher.tagResourcePost(eq(headers), eq(arn), eq(body), any(Response.class))).thenReturn(expected);
Response actual = controller.tagResourcePost(headers, arn, body);
assertEquals(expected, actual);
verify(dispatcher).tagResourcePost(eq(headers), eq(arn), eq(body), any(Response.class));
}
@Test
void tagResourcePutDispatchesWithHandlerSuccessStatus() {
HttpHeaders headers = mock(HttpHeaders.class);
String arn = "arn:aws:apigateway:us-east-1::/restapis/123";
String body = "{\"tags\":{\"env\":\"test\"}}";
when(dispatcher.resolveHandler(arn)).thenReturn(handler);
when(handler.tagResourceSuccessStatus()).thenReturn(200);
Response expected = Response.ok().build();
when(dispatcher.tagResourcePut(eq(headers), eq(arn), eq(body), any(Response.class))).thenReturn(expected);
Response actual = controller.tagResourcePut(headers, arn, body);
assertEquals(expected, actual);
verify(dispatcher).tagResourcePut(eq(headers), eq(arn), eq(body), any(Response.class));
}
@Test
void untagResourceDispatchesWithHandlerSuccessStatus() {
HttpHeaders headers = mock(HttpHeaders.class);
UriInfo uriInfo = mock(UriInfo.class);
String arn = "arn:aws:apigateway:us-east-1::/restapis/123";
when(dispatcher.resolveHandler(arn)).thenReturn(handler);
when(handler.untagResourceSuccessStatus()).thenReturn(204);
Response expected = Response.noContent().build();
when(dispatcher.untagResourceForArn(eq(headers), eq(uriInfo), eq(arn), any(Response.class), eq(handler)))
.thenReturn(expected);
Response actual = controller.untagResource(headers, uriInfo, arn);
assertEquals(expected, actual);
verify(dispatcher).untagResourceForArn(eq(headers), eq(uriInfo), eq(arn), any(Response.class), eq(handler));
}
}
@@ -0,0 +1,197 @@
package io.github.hectorvent.floci.core.common;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.UriInfo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AccountContextFilterTest {
private static final String DEFAULT_ACCOUNT = "000000000000";
private static final String DEFAULT_REGION = "us-east-1";
private AccountResolver accountResolver;
private RegionResolver regionResolver;
private RequestContext requestContext;
private Map<String, String> sessionAccounts;
private AccountContextFilter filter;
@BeforeEach
void setUp() {
accountResolver = new AccountResolver(DEFAULT_ACCOUNT);
regionResolver = new RegionResolver(DEFAULT_REGION, DEFAULT_ACCOUNT);
requestContext = new RequestContext();
sessionAccounts = new java.util.HashMap<>();
SessionAccountLookup sessionLookup = akid -> Optional.ofNullable(sessionAccounts.get(akid));
filter = new AccountContextFilter(accountResolver, regionResolver, requestContext, sessionLookup);
}
@Test
void resolvesFromAuthHeaderWhenPresent() {
ContainerRequestContext ctx = mockContext(
"AWS4-HMAC-SHA256 Credential=000000000001/20260617/us-west-2/s3/aws4_request, SignedHeaders=host, Signature=abc",
null
);
filter.filter(ctx);
assertEquals("000000000001", requestContext.getAccountId());
assertEquals("us-west-2", requestContext.getRegion());
}
@Test
void resolvesFromPresignedCredentialWhenNoAuthHeader() {
ContainerRequestContext ctx = mockContext(null,
"000000000002/20260617/eu-west-1/s3/aws4_request");
filter.filter(ctx);
assertEquals("000000000002", requestContext.getAccountId());
assertEquals("eu-west-1", requestContext.getRegion());
}
@Test
void authHeaderTakesPrecedenceOverPresignedCredential() {
ContainerRequestContext ctx = mockContext(
"AWS4-HMAC-SHA256 Credential=000000000001/20260617/us-west-2/s3/aws4_request, SignedHeaders=host, Signature=abc",
"000000000002/20260617/eu-west-1/s3/aws4_request"
);
filter.filter(ctx);
assertEquals("000000000001", requestContext.getAccountId());
assertEquals("us-west-2", requestContext.getRegion());
}
@Test
void fallsBackToDefaultsWhenNoAuthInfo() {
ContainerRequestContext ctx = mockContext(null, null);
filter.filter(ctx);
assertEquals(DEFAULT_ACCOUNT, requestContext.getAccountId());
assertEquals(DEFAULT_REGION, requestContext.getRegion());
}
@Test
void emptyAuthHeaderFallsBackToPresignedCredential() {
ContainerRequestContext ctx = mockContext("",
"000000000002/20260617/eu-west-1/s3/aws4_request");
filter.filter(ctx);
assertEquals("000000000002", requestContext.getAccountId());
assertEquals("eu-west-1", requestContext.getRegion());
}
@Test
void emptyPresignedCredentialFallsBackToDefaults() {
ContainerRequestContext ctx = mockContext("", "");
filter.filter(ctx);
assertEquals(DEFAULT_ACCOUNT, requestContext.getAccountId());
assertEquals(DEFAULT_REGION, requestContext.getRegion());
}
@Test
void resolvesAssumedRoleSessionKeyToSessionAccount() {
sessionAccounts.put("ASIAEXAMPLESESSIONKEY", "222233334444");
ContainerRequestContext ctx = mockContext(
"AWS4-HMAC-SHA256 Credential=ASIAEXAMPLESESSIONKEY/20260617/us-west-2/dynamodb/aws4_request, "
+ "SignedHeaders=host, Signature=abc",
null
);
filter.filter(ctx);
assertEquals("222233334444", requestContext.getAccountId());
assertEquals("us-west-2", requestContext.getRegion());
}
@Test
void resolvesIamAccessKeyToOwningAccount() {
sessionAccounts.put("AKIAEXAMPLEACCESSKEY", "333344445555");
ContainerRequestContext ctx = mockContext(
"AWS4-HMAC-SHA256 Credential=AKIAEXAMPLEACCESSKEY/20260617/us-west-2/kms/aws4_request, "
+ "SignedHeaders=host, Signature=abc",
null
);
filter.filter(ctx);
assertEquals("333344445555", requestContext.getAccountId());
assertEquals("us-west-2", requestContext.getRegion());
}
@Test
void resolvesIamAccessKeyFromPresignedCredential() {
sessionAccounts.put("AKIAPRESIGNEDACCESSKEY", "444455556666");
ContainerRequestContext ctx = mockContext(null,
"AKIAPRESIGNEDACCESSKEY/20260617/eu-central-1/s3/aws4_request");
filter.filter(ctx);
assertEquals("444455556666", requestContext.getAccountId());
assertEquals("eu-central-1", requestContext.getRegion());
}
@Test
void twelveDigitAkidWinsOverSessionLookup() {
sessionAccounts.put("000000000001", "999999999999");
ContainerRequestContext ctx = mockContext(
"AWS4-HMAC-SHA256 Credential=000000000001/20260617/us-west-2/s3/aws4_request, "
+ "SignedHeaders=host, Signature=abc",
null
);
filter.filter(ctx);
assertEquals("000000000001", requestContext.getAccountId());
}
@Test
void unknownSessionKeyFallsBackToDefault() {
ContainerRequestContext ctx = mockContext(
"AWS4-HMAC-SHA256 Credential=ASIAUNKNOWNKEY/20260617/us-west-2/s3/aws4_request, "
+ "SignedHeaders=host, Signature=abc",
null
);
filter.filter(ctx);
assertEquals(DEFAULT_ACCOUNT, requestContext.getAccountId());
}
@Test
void resolvesSessionKeyFromPresignedCredential() {
sessionAccounts.put("ASIAPRESIGNEDKEY", "555566667777");
ContainerRequestContext ctx = mockContext(null,
"ASIAPRESIGNEDKEY/20260617/eu-west-1/s3/aws4_request");
filter.filter(ctx);
assertEquals("555566667777", requestContext.getAccountId());
assertEquals("eu-west-1", requestContext.getRegion());
}
@Test
void pinnedAccountWinsOverTheAuthorizationHeader() {
ContainerRequestContext ctx = mockContext(
"AWS4-HMAC-SHA256 Credential=000000000001/20260617/us-west-2/s3/aws4_request, SignedHeaders=host, Signature=abc",
null
);
when(ctx.getProperty(AccountContextFilter.PINNED_ACCOUNT_PROPERTY)).thenReturn("111122223333");
filter.filter(ctx);
assertEquals("111122223333", requestContext.getAccountId());
assertEquals(DEFAULT_REGION, requestContext.getRegion());
}
@Test
void pinnedAccountAppliesToAnUnsignedRequest() {
ContainerRequestContext ctx = mockContext("Basic Y2xpZW50OnNlY3JldA==", null);
when(ctx.getProperty(AccountContextFilter.PINNED_ACCOUNT_PROPERTY)).thenReturn("111122223333");
filter.filter(ctx);
assertEquals("111122223333", requestContext.getAccountId());
}
private ContainerRequestContext mockContext(String authHeader, String xAmzCredential) {
ContainerRequestContext ctx = mock(ContainerRequestContext.class);
when(ctx.getHeaderString("Authorization")).thenReturn(authHeader);
UriInfo uriInfo = mock(UriInfo.class);
MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
if (xAmzCredential != null) {
queryParams.add("X-Amz-Credential", xAmzCredential);
}
when(uriInfo.getQueryParameters()).thenReturn(queryParams);
when(ctx.getUriInfo()).thenReturn(uriInfo);
return ctx;
}
}
@@ -0,0 +1,183 @@
package io.github.hectorvent.floci.core.common;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Verifies that resources are isolated between accounts.
* Uses 12-digit numeric access key IDs so they are used directly as account IDs.
*/
@QuarkusTest
class AccountIsolationIntegrationTest {
private static final String SSM_CONTENT_TYPE = "application/x-amz-json-1.1";
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
// 12-digit numeric keys → used directly as account IDs
private static final String AUTH_ACCOUNT_1 =
"AWS4-HMAC-SHA256 Credential=000000000001/20260215/us-east-1/sqs/aws4_request, SignedHeaders=host, Signature=abc";
private static final String AUTH_ACCOUNT_2 =
"AWS4-HMAC-SHA256 Credential=000000000002/20260215/us-east-1/sqs/aws4_request, SignedHeaders=host, Signature=abc";
private static final String AUTH_ACCOUNT_1_SSM =
"AWS4-HMAC-SHA256 Credential=000000000001/20260215/us-east-1/ssm/aws4_request, SignedHeaders=host, Signature=abc";
private static final String AUTH_ACCOUNT_2_SSM =
"AWS4-HMAC-SHA256 Credential=000000000002/20260215/us-east-1/ssm/aws4_request, SignedHeaders=host, Signature=abc";
@Test
void sqsQueuesAreIsolatedBetweenAccounts() {
// Account 1 creates a queue
given()
.header("Authorization", AUTH_ACCOUNT_1)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "CreateQueue")
.formParam("QueueName", "account-isolation-queue")
.when().post("/")
.then()
.statusCode(200)
.body(containsString("000000000001"))
.body(containsString("account-isolation-queue"));
// Account 2 lists queues — should NOT see account 1's queue
given()
.header("Authorization", AUTH_ACCOUNT_2)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "ListQueues")
.formParam("QueueNamePrefix", "account-isolation-queue")
.when().post("/")
.then()
.statusCode(200)
.body(not(containsString("account-isolation-queue")));
// Account 1 lists queues — should see its queue
given()
.header("Authorization", AUTH_ACCOUNT_1)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "ListQueues")
.formParam("QueueNamePrefix", "account-isolation-queue")
.when().post("/")
.then()
.statusCode(200)
.body(containsString("account-isolation-queue"));
}
@Test
void sqsQueuesWithSameNameInDifferentAccountsAreIndependent() {
// Both accounts create a queue with the same name
given()
.header("Authorization", AUTH_ACCOUNT_1)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "CreateQueue")
.formParam("QueueName", "shared-name-queue")
.when().post("/")
.then()
.statusCode(200)
.body(containsString("000000000001/shared-name-queue"));
given()
.header("Authorization", AUTH_ACCOUNT_2)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "CreateQueue")
.formParam("QueueName", "shared-name-queue")
.when().post("/")
.then()
.statusCode(200)
.body(containsString("000000000002/shared-name-queue"));
// Account 1 sends a message to its queue
given()
.header("Authorization", AUTH_ACCOUNT_1)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "SendMessage")
.formParam("QueueUrl", "http://localhost:8081/000000000001/shared-name-queue")
.formParam("MessageBody", "message-for-account-1")
.when().post("/")
.then()
.statusCode(200);
// Account 2 receives from its own queue — should get nothing (empty queue)
given()
.header("Authorization", AUTH_ACCOUNT_2)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "ReceiveMessage")
.formParam("QueueUrl", "http://localhost:8081/000000000002/shared-name-queue")
.formParam("MaxNumberOfMessages", "1")
.formParam("WaitTimeSeconds", "0")
.when().post("/")
.then()
.statusCode(200)
.body(not(containsString("message-for-account-1")));
}
@Test
void ssmParametersAreIsolatedBetweenAccounts() {
// Account 1 puts a parameter
given()
.header("X-Amz-Target", "AmazonSSM.PutParameter")
.header("Authorization", AUTH_ACCOUNT_1_SSM)
.contentType(SSM_CONTENT_TYPE)
.body("""
{"Name": "/account-isolation/key", "Value": "value-for-account-1", "Type": "String"}
""")
.when().post("/")
.then()
.statusCode(200);
// Account 2 tries to get the same parameter — should not find it
given()
.header("X-Amz-Target", "AmazonSSM.GetParameter")
.header("Authorization", AUTH_ACCOUNT_2_SSM)
.contentType(SSM_CONTENT_TYPE)
.body("""
{"Name": "/account-isolation/key"}
""")
.when().post("/")
.then()
.statusCode(400);
// Account 1 can retrieve its parameter, ARN contains its account ID
given()
.header("X-Amz-Target", "AmazonSSM.GetParameter")
.header("Authorization", AUTH_ACCOUNT_1_SSM)
.contentType(SSM_CONTENT_TYPE)
.body("""
{"Name": "/account-isolation/key"}
""")
.when().post("/")
.then()
.statusCode(200)
.body("Parameter.Value", equalTo("value-for-account-1"))
.body("Parameter.ARN", containsString("000000000001"));
}
@Test
void sqsQueueArnContainsCorrectAccountId() {
given()
.header("Authorization", AUTH_ACCOUNT_1)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "CreateQueue")
.formParam("QueueName", "arn-account-check-queue")
.when().post("/")
.then().statusCode(200);
// GetQueueAttributes — QueueArn should embed account 1's ID
given()
.header("Authorization", AUTH_ACCOUNT_1)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "GetQueueAttributes")
.formParam("QueueUrl", "http://localhost:8081/000000000001/arn-account-check-queue")
.formParam("AttributeName.1", "QueueArn")
.when().post("/")
.then()
.statusCode(200)
.body(containsString("arn:aws:sqs:us-east-1:000000000001:arn-account-check-queue"));
}
}
@@ -0,0 +1,74 @@
package io.github.hectorvent.floci.core.common;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class AccountResolverTest {
private static final String DEFAULT_ACCOUNT = "000000000000";
private final AccountResolver resolver = new AccountResolver(DEFAULT_ACCOUNT);
// --- resolve(String authorizationHeader) tests ---
@Test
void resolvesFrom12DigitAkidInAuthHeader() {
String auth = "AWS4-HMAC-SHA256 Credential=000000000001/20260617/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=abc";
assertEquals("000000000001", resolver.resolve(auth));
}
@Test
void fallsBackToDefaultForNon12DigitAkidInAuthHeader() {
String auth = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260617/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=abc";
assertEquals(DEFAULT_ACCOUNT, resolver.resolve(auth));
}
@Test
void fallsBackToDefaultWhenNullAuthHeader() {
assertEquals(DEFAULT_ACCOUNT, resolver.resolve(null));
}
@Test
void fallsBackToDefaultWhenEmptyAuthHeader() {
assertEquals(DEFAULT_ACCOUNT, resolver.resolve(""));
}
// --- resolveFromPresignedCredential(String credentialValue) tests ---
@Test
void resolvesFromPresigned12DigitAkid() {
assertEquals("000000000001",
resolver.resolveFromPresignedCredential("000000000001/20260617/us-east-1/s3/aws4_request"));
}
@Test
void fallsBackToDefaultForPresignedNon12DigitAkid() {
assertEquals(DEFAULT_ACCOUNT,
resolver.resolveFromPresignedCredential("AKIAIOSFODNN7EXAMPLE/20260617/us-east-1/s3/aws4_request"));
}
@Test
void fallsBackToDefaultWhenPresignedCredentialNull() {
assertEquals(DEFAULT_ACCOUNT, resolver.resolveFromPresignedCredential(null));
}
@Test
void fallsBackToDefaultWhenPresignedCredentialEmpty() {
assertEquals(DEFAULT_ACCOUNT, resolver.resolveFromPresignedCredential(""));
}
// --- extractPresignedAccessKeyId(String credentialValue) tests ---
@Test
void extractsAccessKeyIdFromPresignedCredential() {
assertEquals("ASIAEXAMPLE",
resolver.extractPresignedAccessKeyId("ASIAEXAMPLE/20260617/us-east-1/s3/aws4_request"));
}
@Test
void extractPresignedAccessKeyIdReturnsNullWhenAbsent() {
assertNull(resolver.extractPresignedAccessKeyId(null));
assertNull(resolver.extractPresignedAccessKeyId(""));
}
}
@@ -0,0 +1,120 @@
package io.github.hectorvent.floci.core.common;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
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 AiMockConfigLoaderTest {
private static final String CONFIG = """
{
"comprehend": {
"I love this": { "DetectSentiment": { "Sentiment": "POSITIVE" } }
}
}
""";
@TempDir
Path tempDir;
private AiMockConfigLoader loader(String content) throws IOException {
var file = tempDir.resolve("ai-mock-config.json");
Files.writeString(file, content);
return new AiMockConfigLoader(Optional.of(file.toString()), new ObjectMapper());
}
@Test
void resolvesMatchingEntry() throws IOException {
var loader = loader(CONFIG);
var result = loader.lookup("comprehend", "I love this", "DetectSentiment");
assertTrue(result.isPresent());
assertEquals("POSITIVE", result.get().path("Sentiment").asText());
}
@Test
void fallsBackWhenServiceKeyUnmatched() throws IOException {
var loader = loader(CONFIG);
assertTrue(loader.lookup("rekognition", "I love this", "DetectSentiment").isEmpty());
}
@Test
void fallsBackWhenLookupKeyUnmatched() throws IOException {
var loader = loader(CONFIG);
assertTrue(loader.lookup("comprehend", "I hate this", "DetectSentiment").isEmpty());
}
@Test
void fallsBackWhenActionUnmatched() throws IOException {
var loader = loader(CONFIG);
assertTrue(loader.lookup("comprehend", "I love this", "DetectKeyPhrases").isEmpty());
}
@Test
void fallsBackWhenLookupKeyIsNull() throws IOException {
// Bytes-backed images/documents have no natural lookup key.
var loader = loader(CONFIG);
assertTrue(loader.lookup("comprehend", null, "DetectSentiment").isEmpty());
}
@Test
void fallsBackWhenNoConfigConfigured() {
var loader = new AiMockConfigLoader(Optional.empty(), new ObjectMapper());
assertTrue(loader.lookup("comprehend", "I love this", "DetectSentiment").isEmpty());
}
@Test
void fallsBackWhenFileMissing() {
var loader = new AiMockConfigLoader(
Optional.of(tempDir.resolve("absent.json").toString()), new ObjectMapper());
assertTrue(loader.lookup("comprehend", "I love this", "DetectSentiment").isEmpty());
}
@Test
void fallsBackWhenConfiguredPathIsUnparseable() {
// An embedded NUL byte makes Path.of() throw InvalidPathException, an unchecked
// exception that a plain "catch (IOException)" does not cover.
var loader = new AiMockConfigLoader(Optional.of("bad\0path.json"), new ObjectMapper());
assertTrue(loader.lookup("comprehend", "I love this", "DetectSentiment").isEmpty());
}
@Test
void fallsBackWhenFileIsNotAJsonObject() throws IOException {
var loader = loader("[1, 2, 3]");
assertTrue(loader.lookup("comprehend", "I love this", "DetectSentiment").isEmpty());
}
@Test
void fallsBackWhenFileIsMalformedJson() throws IOException {
var loader = loader("{not valid json");
assertTrue(loader.lookup("comprehend", "I love this", "DetectSentiment").isEmpty());
}
@Test
void treatsBlankConfiguredPathAsUnconfigured() {
var loader = new AiMockConfigLoader(Optional.of(" "), new ObjectMapper());
assertTrue(loader.lookup("comprehend", "I love this", "DetectSentiment").isEmpty());
}
@Test
void reloadsFileWhenModified() throws IOException, InterruptedException {
var file = tempDir.resolve("ai-mock-config.json");
Files.writeString(file, CONFIG);
var loader = new AiMockConfigLoader(Optional.of(file.toString()), new ObjectMapper());
assertTrue(loader.lookup("comprehend", "I love this", "DetectSentiment").isPresent());
assertFalse(loader.lookup("comprehend", "I hate this", "DetectSentiment").isPresent());
Thread.sleep(1100);
Files.writeString(file, CONFIG.replace("I love this", "I hate this"));
assertTrue(loader.lookup("comprehend", "I hate this", "DetectSentiment").isPresent());
assertFalse(loader.lookup("comprehend", "I love this", "DetectSentiment").isPresent());
}
}
@@ -0,0 +1,109 @@
package io.github.hectorvent.floci.core.common;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.ValidatableResponse;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.startsWith;
/**
* End-to-end proof that assumed-role temporary credentials are routed to the role's account.
*
* <p>Scenario: account A ({@code 111122223333}) assumes a role in account B
* ({@code 222233334444}); the returned temporary credentials are then used to create a DynamoDB
* table. The table must materialize in account B's namespace — visible to B, invisible to A —
* rather than collapsing to the default account.
*/
@QuarkusTest
class AssumedRoleAccountRoutingIntegrationTest {
private static final String ACCOUNT_A = "111122223333";
private static final String ACCOUNT_B = "222233334444";
private static final String REGION = "us-east-1";
private static final String DYNAMODB_CONTENT_TYPE = "application/x-amz-json-1.0";
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void assumedRoleCredentialsRouteResourcesToTargetAccount() {
String tableName = "routing-" + UUID.randomUUID().toString().substring(0, 8);
// 1. Account A assumes a role in account B and receives temporary credentials.
String tempAccessKeyId = given()
.formParam("Action", "AssumeRole")
.formParam("RoleArn", "arn:aws:iam::" + ACCOUNT_B + ":role/CrossAccountAccess")
.formParam("RoleSessionName", "routing-session")
.header("Authorization", auth(ACCOUNT_A, "sts"))
.when()
.post("/")
.then()
.statusCode(200)
.body("AssumeRoleResponse.AssumeRoleResult.Credentials.AccessKeyId", startsWith("ASIA"))
.extract()
.path("AssumeRoleResponse.AssumeRoleResult.Credentials.AccessKeyId");
// 2. GetCallerIdentity with the temporary credentials resolves to account B.
given()
.formParam("Action", "GetCallerIdentity")
.header("Authorization", auth(tempAccessKeyId, "sts"))
.when()
.post("/")
.then()
.statusCode(200)
.body("GetCallerIdentityResponse.GetCallerIdentityResult.Account", equalTo(ACCOUNT_B));
// 3. Create a DynamoDB table using the temporary credentials. Its ARN must carry account B.
given()
.header("X-Amz-Target", "DynamoDB_20120810.CreateTable")
.header("Authorization", auth(tempAccessKeyId, "dynamodb"))
.contentType(DYNAMODB_CONTENT_TYPE)
.body("""
{
"TableName": "%s",
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
"AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
"BillingMode": "PAY_PER_REQUEST"
}
""".formatted(tableName))
.when()
.post("/")
.then()
.statusCode(200)
.body("TableDescription.TableArn",
equalTo("arn:aws:dynamodb:" + REGION + ":" + ACCOUNT_B + ":table/" + tableName));
// 4. Account B sees the table.
listTables(ACCOUNT_B).body("TableNames", hasItem(tableName));
// 5. Account A does not — the resource is isolated to account B.
listTables(ACCOUNT_A).body("TableNames", not(hasItem(tableName)));
}
private static ValidatableResponse listTables(String account) {
return given()
.header("X-Amz-Target", "DynamoDB_20120810.ListTables")
.header("Authorization", auth(account, "dynamodb"))
.contentType(DYNAMODB_CONTENT_TYPE)
.body("{}")
.when()
.post("/")
.then()
.statusCode(200);
}
private static String auth(String accessKeyId, String service) {
return "AWS4-HMAC-SHA256 Credential=" + accessKeyId + "/20260215/" + REGION + "/" + service
+ "/aws4_request, SignedHeaders=host, Signature=abc";
}
}
@@ -0,0 +1,64 @@
package io.github.hectorvent.floci.core.common;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Ownership predicates for a parsed ARN. Both are generic on purpose: RdsService already carries
* its own copy of the same two checks inside targetGroupBelongsTo, and Lambda layer resolution
* needs them too, so the resource-shaped part stays with the caller and only these live here.
*/
class AwsArnOwnershipTest {
private static final String LOCAL = "000000000000";
private static AwsArnUtils.Arn arn(String value) {
return AwsArnUtils.parse(value);
}
@Test
void awsPartitionIsNotForeign() {
assertFalse(AwsArnUtils.isForeignPartition(
arn("arn:aws:lambda:us-east-1:000000000000:layer:l:1")));
}
@Test
void otherPartitionsAreForeign() {
assertTrue(AwsArnUtils.isForeignPartition(
arn("arn:aws-cn:lambda:cn-north-1:000000000000:layer:l:1")));
assertTrue(AwsArnUtils.isForeignPartition(
arn("arn:aws-us-gov:lambda:us-gov-west-1:000000000000:layer:l:1")));
}
@Test
void anEmptyPartitionIsNotForeign() {
assertFalse(AwsArnUtils.isForeignPartition(arn("arn::lambda:us-east-1:000000000000:layer:l:1")));
}
@Test
void theCallersOwnAccountIsNotForeign() {
assertFalse(AwsArnUtils.isForeignAccount(
arn("arn:aws:lambda:us-east-1:000000000000:layer:l:1"), LOCAL));
}
@Test
void anotherAccountIsForeign() {
assertTrue(AwsArnUtils.isForeignAccount(
arn("arn:aws:lambda:us-east-1:017000801446:layer:l:1"), LOCAL));
}
@Test
void anEmptyAccountIsNotForeign() {
// AWS-managed layers use arn:aws:lambda:::awslayer:<name>, which names no account and is
// not somebody else's resource.
assertFalse(AwsArnUtils.isForeignAccount(arn("arn:aws:lambda:::awslayer:AWSLambda-Python"), LOCAL));
}
@Test
void nullIsNotForeign() {
assertFalse(AwsArnUtils.isForeignPartition(null));
assertFalse(AwsArnUtils.isForeignAccount(null, LOCAL));
}
}
@@ -0,0 +1,233 @@
package io.github.hectorvent.floci.core.common;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@link AwsArnUtils} is the emulator's shared ARN helper and had no test of its own, despite
* routing the whole cross-service tagging API through {@code TagDispatcher}. These pin the
* parsing contract the callers depend on, most of it in the resource field: the helper splits
* six segments and deliberately hands the resource back whole, because its structure is
* service-specific.
*/
class AwsArnUtilsTest {
@Test
void parsesTheSixSegments() {
AwsArnUtils.Arn arn = AwsArnUtils.parse("arn:aws:sqs:us-east-1:000000000000:my-queue");
assertEquals("aws", arn.partition());
assertEquals("sqs", arn.service());
assertEquals("us-east-1", arn.region());
assertEquals("000000000000", arn.accountId());
assertEquals("my-queue", arn.resource());
}
/**
* The resource keeps its own colons. {@code split(":", 6)} is what makes a Lambda qualified
* ARN survive; a plain {@code split(":")} would strip the alias off the end.
*/
@Test
void resourceKeepsItsOwnColons() {
AwsArnUtils.Arn arn = AwsArnUtils.parse(
"arn:aws:lambda:us-east-1:000000000000:function:order-processor:PROD");
assertEquals("function:order-processor:PROD", arn.resource());
}
/** Global services omit region and account, and those arrive as empty strings, never null. */
@Test
void omittedRegionAndAccountAreEmptyNotNull() {
AwsArnUtils.Arn arn = AwsArnUtils.parse("arn:aws:s3:::my-bucket");
assertEquals("", arn.region());
assertEquals("", arn.accountId());
assertEquals("my-bucket", arn.resource());
}
/** An empty resource is legal input and must not collapse the segment count. */
@Test
void emptyResourceStillParses() {
AwsArnUtils.Arn arn = AwsArnUtils.parse("arn:aws:glue:us-east-1:000000000000:");
assertEquals("glue", arn.service());
assertEquals("", arn.resource());
}
@Test
void parseIsTheInverseOfToString() {
String text = "arn:aws:iam::000000000000:role/service-role/my-role";
assertEquals(text, AwsArnUtils.parse(text).toString());
}
@ParameterizedTest
@ValueSource(strings = {
"arn:aws:sqs:us-east-1:000000000000",
"arn:aws:sqs",
"my-queue",
"urn:aws:sqs:us-east-1:000000000000:my-queue",
" "})
void malformedArnsAreRejected(String value) {
assertThrows(IllegalArgumentException.class, () -> AwsArnUtils.parse(value));
}
@Test
void nullIsRejected() {
assertThrows(IllegalArgumentException.class, () -> AwsArnUtils.parse(null));
}
/**
* Non-commercial partitions parse like any other. Only construction pinned the partition,
* never parsing.
*/
@ParameterizedTest
@CsvSource({
"arn:aws-us-gov:kms:us-gov-west-1:000000000000:key/abc, aws-us-gov, us-gov-west-1",
"arn:aws-cn:s3:cn-north-1:000000000000:bucket, aws-cn, cn-north-1",
"arn:aws-iso:sns:us-iso-east-1:000000000000:topic, aws-iso, us-iso-east-1"})
void foreignPartitionsParse(String text, String partition, String region) {
AwsArnUtils.Arn arn = AwsArnUtils.parse(text);
assertEquals(partition, arn.partition());
assertEquals(region, arn.region());
}
@Test
void regionOrDefaultFallsBackOnEveryFailureMode() {
assertEquals("us-east-1", AwsArnUtils.regionOrDefault(null, "us-east-1"));
assertEquals("us-east-1", AwsArnUtils.regionOrDefault("not-an-arn", "us-east-1"));
assertEquals("us-east-1", AwsArnUtils.regionOrDefault("arn:aws:s3:::bucket", "us-east-1"));
assertEquals("eu-west-1",
AwsArnUtils.regionOrDefault("arn:aws:sqs:eu-west-1:000000000000:q", "us-east-1"));
}
@Test
void accountOrDefaultFallsBackOnEveryFailureMode() {
assertEquals("000000000000", AwsArnUtils.accountOrDefault(null, "000000000000"));
assertEquals("000000000000", AwsArnUtils.accountOrDefault("not-an-arn", "000000000000"));
assertEquals("000000000000",
AwsArnUtils.accountOrDefault("arn:aws:s3:::bucket", "000000000000"));
assertEquals("123456789012",
AwsArnUtils.accountOrDefault("arn:aws:sqs:eu-west-1:123456789012:q", "000000000000"));
}
/**
* The partition is derived from the region, so a resource in GovCloud or China is named with
* the partition it actually lives in rather than always {@code aws}.
*/
@ParameterizedTest
@CsvSource({
"us-east-1, arn:aws:sqs:us-east-1:000000000000:q",
"eu-west-1, arn:aws:sqs:eu-west-1:000000000000:q",
"us-gov-west-1, arn:aws-us-gov:sqs:us-gov-west-1:000000000000:q",
"cn-north-1, arn:aws-cn:sqs:cn-north-1:000000000000:q",
"us-iso-east-1, arn:aws-iso:sqs:us-iso-east-1:000000000000:q",
"us-isob-east-1, arn:aws-iso-b:sqs:us-isob-east-1:000000000000:q",
"eu-isoe-west-1, arn:aws-iso-e:sqs:eu-isoe-west-1:000000000000:q",
"us-isof-south-1,arn:aws-iso-f:sqs:us-isof-south-1:000000000000:q",
"eusc-de-east-1, arn:aws-eusc:sqs:eusc-de-east-1:000000000000:q"})
void partitionIsDerivedFromTheRegion(String region, String expected) {
assertEquals(expected, AwsArnUtils.Arn.of("sqs", region, "000000000000", "q").toString());
}
/**
* Global services pass no region, and they keep the commercial partition. Thirty call sites
* do this; anything else would rewrite every IAM, S3 and CloudFront ARN the emulator mints.
*/
@Test
void aRegionlessArnStaysInTheCommercialPartition() {
assertEquals("arn:aws:iam::000000000000:role/r",
AwsArnUtils.Arn.of("iam", "", "000000000000", "role/r").toString());
}
/** A region AWS has not launched yet must not fail closed. */
@Test
void anUnrecognisedRegionFallsBackToTheCommercialPartition() {
assertEquals("arn:aws:sqs:xx-nowhere-9:000000000000:q",
AwsArnUtils.Arn.of("sqs", "xx-nowhere-9", "000000000000", "q").toString());
}
/** The record constructor still takes an explicit partition, for echoing a caller's own. */
@Test
void theRecordConstructorKeepsAnExplicitPartition() {
assertEquals("arn:aws-cn:secretsmanager:us-east-1:000000000000:secret:s",
new AwsArnUtils.Arn("aws-cn", "secretsmanager", "us-east-1", "000000000000",
"secret:s").toString());
}
@ParameterizedTest
@ValueSource(strings = {
"arn:aws:sqs:us-east-1:000000000000:my-queue",
"arn:aws-us-gov:sqs:us-gov-west-1:000000000000:my-queue",
"arn:aws-cn:s3:::bucket",
"arn:aws:lambda:us-east-1:000000000000:function:f:PROD"})
void isArnAcceptsAnyPartition(String value) {
assertTrue(AwsArnUtils.isArn(value));
}
@ParameterizedTest
@ValueSource(strings = {"my-queue", "arn:aws:sqs:us-east-1:000000000000", "arn:", "", " "})
void isArnRejectsNonArns(String value) {
assertFalse(AwsArnUtils.isArn(value));
}
@Test
void isArnRejectsNullRatherThanThrowing() {
assertFalse(AwsArnUtils.isArn(null));
}
/**
* The shape the {@code startsWith("arn:aws:dynamodb:")} probes were reaching for: is this
* identifier an ARN for my service, whatever partition it came from.
*/
@Test
void isArnForMatchesTheServiceInAnyPartition() {
assertTrue(AwsArnUtils.isArnFor("arn:aws:dynamodb:us-east-1:000000000000:table/t",
"dynamodb"));
assertTrue(AwsArnUtils.isArnFor("arn:aws-cn:dynamodb:cn-north-1:000000000000:table/t",
"dynamodb"));
assertFalse(AwsArnUtils.isArnFor("arn:aws:kinesis:us-east-1:000000000000:stream/s",
"dynamodb"));
assertFalse(AwsArnUtils.isArnFor("my-table", "dynamodb"));
assertFalse(AwsArnUtils.isArnFor(null, "dynamodb"));
}
@Test
void s3ObjectArnSplitsBucketAndKey() {
assertEquals(new AwsArnUtils.S3ObjectRef("logs", "firelens/extra.conf"),
AwsArnUtils.parseS3ObjectArn("arn:aws:s3:::logs/firelens/extra.conf"));
assertEquals(new AwsArnUtils.S3ObjectRef("logs", "extra.conf"),
AwsArnUtils.parseS3ObjectArn("arn:aws:s3:us-east-1:000000000000:logs/extra.conf"));
}
/**
* Everything ECS rejects as "Invalid arn syntax" for a FireLens {@code config-file-value}:
* not an ARN at all (the {@code s3://bucket/key} form users try), another service's ARN, and
* an ARN with no key or no bucket. Null rather than an exception, because the same helper
* backs the defensive path at task launch.
*/
@Test
void s3ObjectArnIsNullForAnythingThatIsNotAnS3Object() {
assertNull(AwsArnUtils.parseS3ObjectArn("s3://logs/extra.conf"));
assertNull(AwsArnUtils.parseS3ObjectArn("arn:aws:s3:::logs"));
assertNull(AwsArnUtils.parseS3ObjectArn("arn:aws:s3:::logs/"));
assertNull(AwsArnUtils.parseS3ObjectArn("arn:aws:sqs:us-east-1:000000000000:my-queue"));
assertNull(AwsArnUtils.parseS3ObjectArn(null));
}
@Test
void queueUrlIsAccountThenQueueName() {
assertEquals("http://localhost:4566/000000000000/my-queue",
AwsArnUtils.arnToQueueUrl("arn:aws:sqs:us-east-1:000000000000:my-queue",
"http://localhost:4566"));
}
}
@@ -0,0 +1,96 @@
package io.github.hectorvent.floci.core.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.dataformat.cbor.CBORFactory;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Regression guard for the exact bug this repo's own {@code AwsJsonCborSerializerTest}
* originally introduced while fixing floci-io/floci#2368: scaling smithy-rpc-v2-cbor
* timestamps to milliseconds (correct for the legacy {@code application/x-amz-cbor-1.1}
* dialect, per the same issue) broke CloudWatch Metrics, whose {@code PutMetricData}/
* {@code GetMetricStatistics} round-trip a {@code Timestamp} field over the
* smithy-rpc-v2-cbor protocol - which uses RFC 8949's plain, unscaled epoch-seconds
* tag(1) convention. A wrongly-scaled timestamp lands outside any query window, so
* {@code GetMetricStatistics} silently returns zero datapoints (matches the real Go SDK
* compat test failure this was caught by, {@code TestCloudWatch/GetMetricStatistics}
* asserting {@code Datapoints} non-empty).
* <p>
* Drives the real HTTP endpoint end-to-end via the same {@code
* POST /service/GraniteServiceVersion20100801/operation/{action}} routing
* {@link SmithyRpcV2RoutingIntegrationTest#cloudWatchRoutesViaGraniteServiceName} uses,
* with request bodies built via {@link AwsJsonCborController#nodeToSmithyCbor} - the
* correct (unscaled) encoder for this protocol - so this test would fail the same way
* the real regression did if that scaling were ever mistakenly reapplied here.
*/
@QuarkusTest
class AwsJsonCborCloudWatchTimestampIntegrationTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final ObjectMapper CBOR = new ObjectMapper(new CBORFactory());
@Test
void getMetricStatisticsFindsADatapointPublishedJustNow() throws Exception {
String namespace = "CborRpcV2TimestampTest-" + System.nanoTime();
Instant now = Instant.now();
ObjectNode putRequest = JSON.createObjectNode();
putRequest.put("Namespace", namespace);
ArrayNode metricData = putRequest.putArray("MetricData");
ObjectNode datum = metricData.addObject();
datum.put("MetricName", "RequestCount");
datum.put("Value", 42.0);
datum.put("Unit", "Count");
datum.put("Timestamp", now.getEpochSecond());
rpcV2Call("PutMetricData", putRequest);
ObjectNode statsRequest = JSON.createObjectNode();
statsRequest.put("Namespace", namespace);
statsRequest.put("MetricName", "RequestCount");
statsRequest.put("StartTime", now.minusSeconds(300).getEpochSecond());
statsRequest.put("EndTime", now.plusSeconds(60).getEpochSecond());
statsRequest.put("Period", 60);
statsRequest.putArray("Statistics").add("Sum");
JsonNode statsResponse = rpcV2Call("GetMetricStatistics", statsRequest);
// Before this fix, a stray millisecond-scaling of "Timestamp" here would have
// stored the datapoint far outside [StartTime, EndTime], and this would be 0 -
// exactly the real Go SDK compat test failure this class guards against.
assertFalse(statsResponse.path("Datapoints").isEmpty(),
"GetMetricStatistics must find the datapoint just published: " + statsResponse);
// Not an exact match: CloudWatch buckets a datapoint's own returned Timestamp
// to the start of its Period (60s here), so it legitimately differs from the
// submitted value by up to one period - a real, unrelated behaviour, not this
// fix's concern. What this DOES still catch: a 1000x scale error would push the
// value thousands of years away, far outside this window.
JsonNode datapoint = statsResponse.path("Datapoints").get(0);
long bucketedEpochSecond = datapoint.path("Timestamp").asLong();
long driftSeconds = Math.abs(now.getEpochSecond() - bucketedEpochSecond);
assertTrue(driftSeconds <= 60,
"the round-tripped Timestamp must stay in plain epoch seconds, unscaled (drifted "
+ driftSeconds + "s): " + datapoint);
}
private static JsonNode rpcV2Call(String action, ObjectNode body) throws Exception {
byte[] responseCbor = given()
.contentType("application/cbor")
.accept("application/cbor")
.header("smithy-protocol", "rpc-v2-cbor")
.body(AwsJsonCborController.nodeToSmithyCbor(body))
.when().post("/service/GraniteServiceVersion20100801/operation/" + action)
.then().statusCode(200)
.extract().asByteArray();
return CBOR.readTree(responseCbor);
}
}
@@ -0,0 +1,98 @@
package io.github.hectorvent.floci.core.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import java.io.ByteArrayOutputStream;
import java.util.Map;
import java.util.zip.GZIPOutputStream;
import org.jboss.resteasy.reactive.server.jaxrs.HttpHeadersImpl;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
/**
* Integration test for {@link AwsJsonCborController#bodyToJson(HttpHeaders, byte[])}.
* <p>
* Verifies handling of incoming smithy-rpc-v2-cbor request bodies: a gzip-encoded body
* (as signalled by a {@code Content-Encoding: gzip} header) must be transparently
* decompressed and decoded back into the original {@link JsonNode}, and a decompressed
* body that exceeds the 10 MB safety limit enforced in {@code decodeBody} must fail fast
* with an {@link AwsException} (413 Payload Too Large) rather than risk an OOM from an
* unbounded gzip bomb.
* <p>
* Boots Quarkus ({@code @QuarkusTest}) since the controller is injected and exercises the
* real request-decoding path used by CloudWatch Metrics and other smithy-rpc-v2-cbor
* services.
*/
@QuarkusTest
class AwsJsonCborIncomingRequestIntegrationTest {
@Inject AwsJsonCborController awsJsonCborController;
@Test
void gzippedRequestIsDecompressed() throws Exception {
ObjectMapper jsonMapper = new ObjectMapper();
ObjectNode metrics = jsonMapper.createObjectNode();
metrics.put("Namespace", "Test");
ArrayNode metricData = metrics.putArray("MetricData");
ObjectNode o = metricData.addObject();
o.put("MetricName", "SYSTEM_NORMALIZED_CPU_STEAL");
ArrayNode dimensions = o.putArray("Dimensions");
ObjectNode dimension = dimensions.addObject();
dimension.put("Name","clusterName");
dimension.put("Value", "test");
o.put("Value", 9344221);
o.put("Unit", "Seconds");
// Encode the metrics payload to CBOR and gzip it, simulating what a real AWS SDK
// client sends when it compresses a smithy-rpc-v2-cbor request body.
byte[] compressedNode = AwsJsonCborController.nodeToSmithyCbor(metrics);
ByteArrayOutputStream gzipped = new ByteArrayOutputStream();
try (GZIPOutputStream gon = new GZIPOutputStream(gzipped)) {
gon.write(compressedNode);
}
// bodyToJson must detect the Content-Encoding header, gunzip the body, and decode
// the resulting CBOR bytes back into the original JsonNode.
JsonNode node =
awsJsonCborController.bodyToJson(
new HttpHeadersImpl(Map.of("Content-Encoding", "gzip").entrySet()),
gzipped.toByteArray());
Assertions.assertEquals(metrics, node);
}
@Test
void payloadTooLargeExceptionThrown() throws Exception {
// No need for a valid CBOR structure here — decodeBody's 10 MB limit is enforced
// purely on decompressed byte count, so raw pseudo-random bytes are enough to
// exceed it. The varying pattern (instead of all-zero bytes) also keeps gzip from
// trivially collapsing the payload via run-length compression.
int elevenMB = 11 * 1024 * 1024;
byte[] rawBytes = new byte[elevenMB];
for (int i = 0,j = 37; i < elevenMB; i++, j+=19) {
rawBytes[i] = (byte) (i+j & 0xFF);
}
ByteArrayOutputStream gzipped = new ByteArrayOutputStream();
try (GZIPOutputStream gon = new GZIPOutputStream(gzipped)) {
gon.write(rawBytes);
}
// Exceeding the decompressed size limit must surface as a 413 Payload Too Large
// AwsException, not an OutOfMemoryError or a silently truncated body.
Assertions.assertThrows(AwsException.class, () -> {
awsJsonCborController.bodyToJson(
new HttpHeadersImpl(Map.of("Content-Encoding", "gzip").entrySet()),
gzipped.toByteArray());
});
}
}
@@ -0,0 +1,164 @@
package io.github.hectorvent.floci.core.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.cbor.CBORFactory;
import com.fasterxml.jackson.dataformat.cbor.CBORGenerator;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.time.Instant;
import java.util.Base64;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* End-to-end reproduction of floci-io/floci#2368's actual reported symptom, driven
* through the real HTTP CBOR endpoint (not just the unit-level encode/decode round-trip
* already covered by {@link AwsJsonCborSerializerTest}): an {@code AT_TIMESTAMP} Kinesis
* shard iterator built over the CBOR wire protocol - the AWS SDK for Java v2's default
* transport - for a timestamp strictly before an already-written record.
* <p>
* Before the fix, this never found that record: {@code handleGetShardIterator} treated
* the decoded {@code Timestamp} as epoch seconds when the CBOR wire value a real client
* actually sends is epoch milliseconds, landing the iterator around the year 58598 -
* past every real record - so {@code GetRecords} returned an empty page forever, with no
* error at all.
* <p>
* The {@code GetShardIterator} request's {@code Timestamp} is hand-built with a raw
* tag(1) epoch-<em>millisecond</em> value via {@link #cborTag1MillisTimestampRequest},
* deliberately bypassing {@link AwsJsonCborController#nodeToLegacyCbor} (the method that
* would normally build this exact request, correctly, for the legacy
* {@code application/x-amz-cbor-1.1} dialect Kinesis uses) - building the request
* through Floci's own encoder would only prove Floci's own encoder and decoder agree
* with each other, not that Floci actually speaks the real AWS wire format a genuine
* CBOR-default SDK client sends. Hand-building the millis value directly is what makes
* this test fail against the pre-fix code and pass against the fix, matching the
* issue's own wire capture (tag(1) followed by an 8-byte integer, e.g.
* {@code c1 1b 000001a013accc38}).
* <p>
* The other requests here (none carry a timestamp field, so the choice of encoder makes
* no difference to their bytes) go through {@link AwsJsonCborController#nodeToSmithyCbor}
* as a matter of convenience, and CBOR response bodies are decoded with a plain CBOR
* {@link ObjectMapper} since the server mirrors the request's own content type back on
* the response.
*/
@QuarkusTest
class AwsJsonCborKinesisAtTimestampIntegrationTest {
private static final String CBOR_CONTENT_TYPE = "application/x-amz-cbor-1.1";
private static final ObjectMapper JSON = new ObjectMapper();
private static final ObjectMapper CBOR = new ObjectMapper(new CBORFactory());
@Test
void atTimestampShardIteratorOverCborFindsAnAlreadyWrittenRecord() throws Exception {
String streamName = "cbor-at-timestamp-" + System.nanoTime();
cborCall("Kinesis_20131202.CreateStream",
AwsJsonCborController.nodeToSmithyCbor(
JSON.createObjectNode().put("StreamName", streamName).put("ShardCount", 1)));
// Cutoff strictly before the record about to be written.
Instant cutoff = Instant.now().minusSeconds(30);
cborCall("Kinesis_20131202.PutRecord", AwsJsonCborController.nodeToSmithyCbor(JSON.createObjectNode()
.put("StreamName", streamName)
.put("Data", Base64.getEncoder().encodeToString("hello".getBytes()))
.put("PartitionKey", "pk")));
byte[] getShardIteratorRequest = cborTag1MillisTimestampRequest(
streamName, "shardId-000000000000", cutoff.toEpochMilli());
JsonNode shardIteratorResponse = cborCall("Kinesis_20131202.GetShardIterator", getShardIteratorRequest);
String shardIterator = shardIteratorResponse.path("ShardIterator").asText();
assertFalse(shardIterator.isEmpty(), "GetShardIterator must actually return an iterator");
JsonNode records = cborCall("Kinesis_20131202.GetRecords", AwsJsonCborController.nodeToSmithyCbor(
JSON.createObjectNode().put("ShardIterator", shardIterator)));
// Before the #2368 fix, this was always 0 regardless of how long the caller waited.
assertEquals(1, records.path("Records").size(),
"AT_TIMESTAMP over CBOR must find the record written after the cutoff");
}
/**
* A real CBOR-default SDK client encodes {@code Data} as a CBOR byte string (major
* type 2), which Jackson decodes to a binary node — not the base64 text string
* Floci's own encoder writes. The request is hand-built for the same reason as the
* timestamp one: posting Floci-encoded bytes would only prove the emulator agrees
* with itself. This pins that the floci-io/floci#2516 gate accepts the byte-string
* shape a real client sends rather than rejecting it as non-blob {@code Data}.
*/
@Test
void putRecordOverCborCarriesDataAsAByteString() throws Exception {
String streamName = "cbor-binary-data-" + System.nanoTime();
cborCall("Kinesis_20131202.CreateStream",
AwsJsonCborController.nodeToSmithyCbor(
JSON.createObjectNode().put("StreamName", streamName).put("ShardCount", 1)));
byte[] payload = "hello-cbor".getBytes();
ByteArrayOutputStream out = new ByteArrayOutputStream();
CBORFactory factory = (CBORFactory) CBOR.getFactory();
try (CBORGenerator gen = factory.createGenerator(out)) {
gen.writeStartObject();
gen.writeStringField("StreamName", streamName);
gen.writeBinaryField("Data", payload);
gen.writeStringField("PartitionKey", "pk");
gen.writeEndObject();
}
JsonNode putResponse = cborCall("Kinesis_20131202.PutRecord", out.toByteArray());
assertFalse(putResponse.path("SequenceNumber").asText().isEmpty(),
"PutRecord with a CBOR byte-string Data must succeed");
JsonNode iteratorResponse = cborCall("Kinesis_20131202.GetShardIterator",
AwsJsonCborController.nodeToSmithyCbor(JSON.createObjectNode()
.put("StreamName", streamName)
.put("ShardId", "shardId-000000000000")
.put("ShardIteratorType", "TRIM_HORIZON")));
JsonNode records = cborCall("Kinesis_20131202.GetRecords", AwsJsonCborController.nodeToSmithyCbor(
JSON.createObjectNode().put("ShardIterator", iteratorResponse.path("ShardIterator").asText())));
assertEquals(1, records.path("Records").size());
assertEquals(Base64.getEncoder().encodeToString(payload),
records.path("Records").get(0).path("Data").asText(),
"the byte-string payload must land byte-exact");
}
/**
* Hand-builds a {@code GetShardIterator} CBOR request body with {@code Timestamp}
* encoded exactly as a real AWS SDK for Java v2 client sends it: CBOR tag(1)
* immediately followed by an integer epoch-<em>millisecond</em> value - see this
* class's own javadoc for why this must not go through
* {@link AwsJsonCborController#nodeToSmithyCbor}.
*/
private static byte[] cborTag1MillisTimestampRequest(String streamName, String shardId, long epochMillis)
throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
CBORFactory factory = (CBORFactory) CBOR.getFactory();
try (CBORGenerator gen = factory.createGenerator(out)) {
gen.writeStartObject();
gen.writeStringField("StreamName", streamName);
gen.writeStringField("ShardId", shardId);
gen.writeStringField("ShardIteratorType", "AT_TIMESTAMP");
gen.writeFieldName("Timestamp");
gen.writeTag(1);
gen.writeNumber(epochMillis);
gen.writeEndObject();
}
return out.toByteArray();
}
private static JsonNode cborCall(String target, byte[] requestCbor) throws Exception {
byte[] responseCbor = given()
.header("X-Amz-Target", target)
.contentType(CBOR_CONTENT_TYPE)
.body(requestCbor)
.when().post("/")
.then().statusCode(200)
.extract().asByteArray();
return CBOR.readTree(responseCbor);
}
}
@@ -0,0 +1,269 @@
package io.github.hectorvent.floci.core.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.cbor.CBORFactory;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Fast, schema-less unit test for {@link AwsJsonCborController}'s two CBOR timestamp
* dialects.
* <p>
* There are genuinely two different tag(1) conventions sharing the same CBOR tag
* mechanism, not one protocol with an inconsistency (see floci-io/floci#2368's
* resolution):
* <ul>
* <li>{@link AwsJsonCborController#nodeToSmithyCbor} / plain {@link
* AwsJsonCborController#bodyToJson} - the smithy-rpc-v2-cbor protocol (CloudWatch
* Metrics, confirmed via a Go SDK compat test), which uses RFC 8949 section 3.4.2's
* own tag(1) convention: plain, unscaled epoch seconds.</li>
* <li>{@link AwsJsonCborController#nodeToLegacyCbor} / {@link
* AwsJsonCborController#normalizeLegacyCborTimestampsFromMillis} - the older,
* separate {@code application/x-amz-cbor-1.1} dialect (Kinesis, confirmed via a real
* wire capture), whose tag(1) is epoch <em>milliseconds</em> instead.</li>
* </ul>
* This file previously (before #2368's resolution was itself corrected) asserted that
* {@code nodeToSmithyCbor} scales to milliseconds - that was wrong for the
* smithy-rpc-v2-cbor protocol it actually covers (it broke CloudWatch's
* {@code GetMetricStatistics}/{@code GetMetricData}, whose timestamps came back
* outside any query window once wrongly divided by 1000) and is why the two dialects
* now have distinctly-named methods rather than one shared, ambiguous pair. CBOR tag 1
* is the single byte {@code 0xC1} (major type 6, value 1) immediately preceding the
* encoded number. These assertions are deterministic and do not boot Quarkus.
* <p>
* The end-to-end acceptance gate that tag(1)+integer is actually accepted by
* aws-sdk-go-v2 / smithy-go is the OTel awscloudwatch receiver run; this test guards the
* wire format so a regression fails fast in CI without needing the live receiver.
*/
class AwsJsonCborSerializerTest {
private static final byte CBOR_TAG_1 = (byte) 0xC1;
private static final int CBOR_MAJOR_UNSIGNED_INT = 0; // major type 0
private static final int CBOR_MAJOR_FLOAT = 7; // major type 7 (0xF9/0xFA/0xFB)
private final ObjectMapper jsonMapper = new ObjectMapper();
private final ObjectMapper cborMapper = new ObjectMapper(new CBORFactory());
/** Counts occurrences of the tag(1) marker byte in the encoded output. */
private static int countTag1(byte[] bytes) {
int count = 0;
for (byte b : bytes) {
if (b == CBOR_TAG_1) {
count++;
}
}
return count;
}
/** CBOR major type of a head byte (top 3 bits). */
private static int majorType(byte headByte) {
return (headByte & 0xFF) >> 5;
}
// --- smithy-rpc-v2-cbor (nodeToSmithyCbor) - plain, unscaled epoch seconds ---
@Test
void smithyRpcV2ScalarTimestampIsTaggedAsIntegerUnscaled() throws Exception {
JsonNode node = jsonMapper.readTree("{\"Timestamp\": 1700000000}");
byte[] cbor = AwsJsonCborController.nodeToSmithyCbor(node);
assertEquals(1, countTag1(cbor), "scalar Timestamp must be tagged exactly once");
int tagIndex = indexOf(cbor, CBOR_TAG_1);
assertTrue(tagIndex >= 0, "tag(1) byte must be present");
assertTrue(tagIndex + 1 < cbor.length, "tag(1) byte must not be the last byte in the output");
assertEquals(CBOR_MAJOR_UNSIGNED_INT, majorType(cbor[tagIndex + 1]),
"integral epoch seconds must be encoded as a CBOR integer, not a float");
// smithy-rpc-v2-cbor's tag(1) is plain epoch seconds - the value round-trips
// unchanged, unlike the legacy dialect's millisecond scaling below.
JsonNode decoded = cborMapper.readTree(cbor);
assertTrue(decoded.path("Timestamp").isIntegralNumber(), "decoded timestamp must be integral");
assertEquals(1700000000L, decoded.path("Timestamp").asLong());
}
@Test
void smithyRpcV2FractionalTimestampIsTaggedAsFloat() throws Exception {
// A non-integral timestamp is a valid tag(1) floating-point value (RFC 8949 3.4.2).
JsonNode node = jsonMapper.readTree("{\"Timestamp\": 1700000000.5}");
byte[] cbor = AwsJsonCborController.nodeToSmithyCbor(node);
assertEquals(1, countTag1(cbor), "fractional Timestamp must still be tagged");
int tagIndex = indexOf(cbor, CBOR_TAG_1);
assertTrue(tagIndex + 1 < cbor.length, "tag(1) byte must not be the last byte in the output");
assertEquals(CBOR_MAJOR_FLOAT, majorType(cbor[tagIndex + 1]),
"fractional epoch seconds must be encoded as a CBOR float");
assertEquals(1700000000.5, cborMapper.readTree(cbor).path("Timestamp").asDouble());
}
@Test
void smithyRpcV2NonTimestampNumberIsNotTagged() throws Exception {
// A plain numeric field (e.g. CloudWatch Period) must remain an untagged integer.
JsonNode node = jsonMapper.readTree("{\"Period\": 60}");
byte[] cbor = AwsJsonCborController.nodeToSmithyCbor(node);
assertEquals(0, countTag1(cbor), "non-timestamp numbers must not carry a tag(1) byte");
assertFalse(contains(cbor, CBOR_TAG_1));
assertEquals(60L, cborMapper.readTree(cbor).path("Period").asLong());
}
@Test
void smithyRpcV2DecodedRequestTimestampIsNotScaled() throws Exception {
// The read side (plain bodyToJson, no legacyDialect) must leave a decoded
// timestamp-shaped field completely untouched - this is the exact regression
// that broke CloudWatch's GetMetricStatistics/GetMetricData: a real value
// wrongly divided by 1000 landed outside any query window.
byte[] cbor = AwsJsonCborController.nodeToSmithyCbor(jsonMapper.readTree("{\"Timestamp\": 1700000000}"));
JsonNode decoded = cborMapper.readTree(cbor);
assertEquals(1700000000L, decoded.path("Timestamp").asLong());
}
// --- legacy application/x-amz-cbor-1.1 (nodeToLegacyCbor) - epoch milliseconds ---
@Test
void legacyScalarTimestampIsTaggedAsIntegerMillis() throws Exception {
JsonNode node = jsonMapper.readTree("{\"Timestamp\": 1700000000}");
byte[] cbor = AwsJsonCborController.nodeToLegacyCbor(node);
// Exactly one tag(1) byte, and it is immediately followed by an unsigned integer
// (major type 0) — i.e. integer-ness is preserved, not coerced to a float.
assertEquals(1, countTag1(cbor), "scalar Timestamp must be tagged exactly once");
int tagIndex = indexOf(cbor, CBOR_TAG_1);
assertTrue(tagIndex >= 0, "tag(1) byte must be present");
assertTrue(tagIndex + 1 < cbor.length, "tag(1) byte must not be the last byte in the output");
assertEquals(CBOR_MAJOR_UNSIGNED_INT, majorType(cbor[tagIndex + 1]),
"epoch millis must be encoded as a CBOR integer, not a float");
// 1700000000 epoch SECONDS (this codebase's internal convention) must be written
// under tag(1) as 1700000000000 epoch MILLISECONDS (the legacy dialect's real
// convention) — not the same number unchanged, which is exactly the
// pre-#2368-fix bug for this dialect.
JsonNode decoded = cborMapper.readTree(cbor);
assertTrue(decoded.path("Timestamp").isIntegralNumber(), "decoded timestamp must be integral");
assertEquals(1700000000000L, decoded.path("Timestamp").asLong());
}
@Test
void legacyTimestampListTagsEveryElementAsIntegerMillis() throws Exception {
JsonNode node = jsonMapper.readTree("{\"Timestamps\": [1700000000, 1700000060]}");
byte[] cbor = AwsJsonCborController.nodeToLegacyCbor(node);
assertEquals(2, countTag1(cbor), "each Timestamps element must be tagged with tag(1)");
for (int i = 0; i < cbor.length; i++) {
if (cbor[i] == CBOR_TAG_1) {
assertTrue(i + 1 < cbor.length, "tag(1) byte at index " + i + " must not be the last byte in the output");
assertEquals(CBOR_MAJOR_UNSIGNED_INT, majorType(cbor[i + 1]),
"timestamp list elements must be encoded as CBOR integers");
}
}
JsonNode decoded = cborMapper.readTree(cbor);
JsonNode ts = decoded.path("Timestamps");
assertTrue(ts.isArray());
assertEquals(2, ts.size());
assertEquals(1700000000000L, ts.get(0).asLong());
assertEquals(1700000060000L, ts.get(1).asLong());
}
@Test
void legacySuffixedTimestampFieldIsTagged() throws Exception {
// Future-proofing: the heuristic matches by "Timestamp" suffix.
JsonNode node = jsonMapper.readTree("{\"StateUpdatedTimestamp\": 1700000000}");
byte[] cbor = AwsJsonCborController.nodeToLegacyCbor(node);
assertEquals(1, countTag1(cbor), "a *Timestamp-suffixed field must be tagged");
assertEquals(1700000000000L, cborMapper.readTree(cbor).path("StateUpdatedTimestamp").asLong());
}
@Test
void legacyMillisecondPrecisionFractionalTimestampBecomesAnExactIntegerMillisValue() throws Exception {
// A fractional epoch-SECONDS value with real AWS's own millisecond-precision limit
// (3 decimal places) converts to an EXACT integer number of milliseconds - not a
// float - once written under tag(1). This is the realistic case (AWS timestamps
// never carry sub-millisecond precision) and the actual reproducer from
// floci-io/floci#2368: 1700000000.712s -> 1700000000712ms, exactly.
JsonNode node = jsonMapper.readTree("{\"Timestamp\": 1700000000.712}");
byte[] cbor = AwsJsonCborController.nodeToLegacyCbor(node);
int tagIndex = indexOf(cbor, CBOR_TAG_1);
assertTrue(tagIndex >= 0, "tag(1) byte must be present");
assertTrue(tagIndex + 1 < cbor.length, "tag(1) byte must not be the last byte in the output");
assertEquals(CBOR_MAJOR_UNSIGNED_INT, majorType(cbor[tagIndex + 1]),
"millisecond-precision epoch seconds must convert to an exact integer number "
+ "of milliseconds, not a float");
JsonNode decoded = cborMapper.readTree(cbor);
assertTrue(decoded.path("Timestamp").isIntegralNumber(), "decoded millis value must be integral");
assertEquals(1700000000712L, decoded.path("Timestamp").asLong());
}
@Test
void legacyRoundTripsThroughEncodeAndDecodeBackToTheOriginalEpochSecondsValue() throws Exception {
// The actual end-to-end guarantee that matters: whatever
// normalizeLegacyCborTimestampsFromMillis decodes back out must equal what
// nodeToLegacyCbor was given, for both an integral and a millisecond-precision
// fractional epoch-seconds value - proving the two directions are genuine
// inverses of each other, not just independently plausible.
JsonNode integral = jsonMapper.readTree("{\"Timestamp\": 1700000000}");
JsonNode fractional = jsonMapper.readTree("{\"Timestamp\": 1700000000.712}");
JsonNode decodedIntegral = AwsJsonCborController.normalizeLegacyCborTimestampsFromMillis(
cborMapper.readTree(AwsJsonCborController.nodeToLegacyCbor(integral)));
JsonNode decodedFractional = AwsJsonCborController.normalizeLegacyCborTimestampsFromMillis(
cborMapper.readTree(AwsJsonCborController.nodeToLegacyCbor(fractional)));
assertEquals(1700000000, decodedIntegral.path("Timestamp").decimalValue().doubleValue());
assertEquals(1700000000.712, decodedFractional.path("Timestamp").decimalValue().doubleValue());
}
@Test
void legacyNonTimestampFieldIsNotConvertedByNormalization() {
// normalizeLegacyCborTimestampsFromMillis must use the identical field-name
// heuristic as the writer - a plain numeric field (e.g. CloudWatch Period) must
// pass through completely unchanged.
JsonNode node = jsonMapper.createObjectNode().put("Period", 60);
JsonNode normalized = AwsJsonCborController.normalizeLegacyCborTimestampsFromMillis(node);
assertEquals(60L, normalized.path("Period").asLong());
}
@Test
void legacyNonTimestampNumberIsNotTagged() throws Exception {
// A plain numeric field (e.g. CloudWatch Period) must remain an untagged integer.
JsonNode node = jsonMapper.readTree("{\"Period\": 60}");
byte[] cbor = AwsJsonCborController.nodeToLegacyCbor(node);
assertEquals(0, countTag1(cbor), "non-timestamp numbers must not carry a tag(1) byte");
assertFalse(contains(cbor, CBOR_TAG_1));
JsonNode decoded = cborMapper.readTree(cbor);
assertEquals(60L, decoded.path("Period").asLong());
}
private static int indexOf(byte[] bytes, byte target) {
for (int i = 0; i < bytes.length; i++) {
if (bytes[i] == target) {
return i;
}
}
return -1;
}
private static boolean contains(byte[] bytes, byte target) {
return indexOf(bytes, target) >= 0;
}
}
@@ -0,0 +1,79 @@
package io.github.hectorvent.floci.core.common;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.not;
@QuarkusTest
class AwsQueryControllerIntegrationTest {
@Test
void missingActionParameterReturns400MissingAction() {
given()
.contentType("application/x-www-form-urlencoded")
.when()
.post("/")
.then()
.statusCode(400)
.contentType("application/xml")
.body("ErrorResponse.Error.Code", equalTo("MissingAction"))
.body("ErrorResponse.Error.Message", equalTo("The request must contain the parameter Action"));
}
@Test
void globalClusterActionFallbackWithoutAuthorizationHeader() {
// Without a credential scope the service is inferred from the action. An action the RDS
// handler serves but the inference list does not name is dispatched to SQS, which answers
// UnsupportedOperation for it.
given()
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "DescribeGlobalClusters")
.when()
.post("/")
.then()
.statusCode(200)
.contentType("application/xml")
.body("DescribeGlobalClustersResponse.DescribeGlobalClustersResult.GlobalClusters",
equalTo(""));
}
@ParameterizedTest
@ValueSource(strings = {"DescribeDBClusterSnapshots", "DescribeDBSnapshots",
"DescribeDBClusterParameterGroups", "DescribeDBClusterParameters", "DescribeOptionGroups",
"CreateDBClusterParameterGroup", "ModifyDBClusterParameterGroup",
"DeleteDBClusterParameterGroup", "CreateOptionGroup", "ModifyOptionGroup",
"DeleteOptionGroup"})
void everyRdsActionIsInferredWithoutAnAuthorizationHeader(String action) {
// Whatever each answers — a result or a validation error for the parameters left out here —
// RDS has to be the one answering. An action the handler serves but the inference list does
// not name is dispatched to SQS instead.
given()
.contentType("application/x-www-form-urlencoded")
.formParam("Action", action)
.when()
.post("/")
.then()
.body(not(containsString("sqs.amazonaws.com")))
.body(not(containsString("is not supported by SQS")));
}
@Test
void ec2ActionFallbackWithoutAuthorizationHeader() {
given()
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "DescribeRegions")
.when()
.post("/")
.then()
.statusCode(200)
.contentType("application/xml")
.body("DescribeRegionsResponse.regionInfo.item.size()", greaterThan(0));
}
}
@@ -0,0 +1,83 @@
package io.github.hectorvent.floci.core.common;
import io.github.hectorvent.floci.services.iam.IamService;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* A handler bug on the Query protocol must render the XML InternalFailure contract, never
* Quarkus's default JSON error page — SDK XML parsers fail on JSON before surfacing anything
* (issue #1753 hit exactly this via an NPE in the IAM handler).
*/
@QuarkusTest
class AwsQueryInternalErrorIntegrationTest {
@InjectMock
IamService iamService;
@Test
void unhandledHandlerExceptionRendersXmlInternalFailure() {
when(iamService.listUsers(any())).thenThrow(new RuntimeException("simulated handler bug"));
given()
.formParam("Action", "ListUsers")
.header("Authorization",
"AWS4-HMAC-SHA256 Credential=test/20260227/us-east-1/iam/aws4_request")
.when()
.post("/")
.then()
.statusCode(500)
.contentType(containsString("xml"))
.body("ErrorResponse.Error.Type", equalTo("Receiver"))
.body("ErrorResponse.Error.Code", equalTo("InternalFailure"))
.body("ErrorResponse.Error.Message", containsString("simulated handler bug"));
}
@Test
void messagelessHandlerExceptionNamesTheExceptionClass() {
// On the native image an NPE carries no message; "Unexpected error: null" is what the
// reporter of #3356 saw. The class name is the least a caller needs to file a report.
when(iamService.listUsers(any())).thenThrow(new NullPointerException());
given()
.formParam("Action", "ListUsers")
.header("Authorization",
"AWS4-HMAC-SHA256 Credential=test/20260227/us-east-1/iam/aws4_request")
.when()
.post("/")
.then()
.statusCode(500)
.body("ErrorResponse.Error.Code", equalTo("InternalFailure"))
.body("ErrorResponse.Error.Message", equalTo("Unexpected error: NullPointerException"));
}
/**
* The catch-all must not swallow AwsException. CloudWatch Metrics is the Query handler that
* renders no error XML of its own, so its AwsException reaches the controller: it has to keep
* its declared code and 4xx status and be encoded as XML, not collapse to a 500 InternalFailure
* (nor reach AwsExceptionMapper, which would emit JSON onto the XML wire).
*/
@Test
void awsExceptionFromAHandlerKeepsItsCodeAndStatusAsXml() {
given()
.formParam("Action", "SetAlarmState")
.formParam("AlarmName", "no-such-alarm")
.formParam("StateValue", "ALARM")
.formParam("StateReason", "reason")
.header("Authorization",
"AWS4-HMAC-SHA256 Credential=test/20260227/us-east-1/monitoring/aws4_request")
.when()
.post("/")
.then()
.statusCode(404)
.contentType(containsString("xml"))
.body("ErrorResponse.Error.Type", equalTo("Sender"))
.body("ErrorResponse.Error.Code", equalTo("ResourceNotFound"));
}
}
@@ -0,0 +1,113 @@
package io.github.hectorvent.floci.core.common;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
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 AwsRegionsTest {
/**
* {@code ALL} is what the emulator advertises; {@code KNOWN_IDS} is what it recognises. The
* second must contain the first, or DescribeRegions could name a region that hostname parsing
* refuses to read back.
*/
@Test
void everyAdvertisedRegionIsAKnownRegionId() {
for (String region : AwsRegions.ALL) {
assertTrue(AwsRegions.KNOWN_IDS.contains(region),
region + " is advertised by DescribeRegions but is not a known region id");
}
}
@ParameterizedTest
@ValueSource(strings = {"us-east-1", "eu-west-1", "ap-northeast-3", "us-gov-west-1",
"cn-northwest-1", "il-central-1", "US-EAST-1"})
void realRegionIdsAreRecognised(String label) {
assertTrue(AwsRegions.isRegionId(label));
}
/**
* The point of an id list over a pattern: these all match the region <em>shape</em>
* {@code [a-z]{2}-[a-z-]+-\d+} and none of them is a region. Treating them as regions is what
* made {@code data.my-cd-1} unreachable as an S3 bucket.
*/
@ParameterizedTest
@ValueSource(strings = {"my-cd-1", "eu-team-2", "us-west-9", "ap-corp-1", "no-such-region-12"})
void regionShapedStringsThatAreNotRegionsAreRejected(String label) {
assertFalse(AwsRegions.isRegionId(label));
}
@Test
void nullIsNotARegionId() {
assertFalse(AwsRegions.isRegionId(null));
}
/**
* One row per partition botocore defines, so a partition going missing is a failing test
* rather than a silently commercial ARN.
*/
@ParameterizedTest
@CsvSource({
"us-east-1, aws, amazonaws.com",
"eu-west-1, aws, amazonaws.com",
"us-gov-west-1, aws-us-gov, amazonaws.com",
"us-gov-east-1, aws-us-gov, amazonaws.com",
"cn-north-1, aws-cn, amazonaws.com.cn",
"cn-northwest-1, aws-cn, amazonaws.com.cn",
"us-iso-east-1, aws-iso, c2s.ic.gov",
"us-isob-east-1, aws-iso-b, sc2s.sgov.gov",
"eu-isoe-west-1, aws-iso-e, cloud.adc-e.uk",
"us-isof-south-1, aws-iso-f, csp.hci.ic.gov",
"eusc-de-east-1, aws-eusc, amazonaws.eu"})
void everyPartitionResolvesFromItsRegion(String region, String partition, String dnsSuffix) {
assertEquals(partition, AwsRegions.partitionFor(region));
assertEquals(dnsSuffix, AwsRegions.dnsSuffixFor(region));
}
/**
* The pair that would collide under a naive prefix check: {@code us-isob-east-1} is
* {@code aws-iso-b}, not {@code aws-iso}.
*/
@Test
void isobIsNotIso() {
assertEquals("aws-iso", AwsRegions.partitionFor("us-iso-west-1"));
assertEquals("aws-iso-b", AwsRegions.partitionFor("us-isob-west-1"));
}
/**
* Blank is the common case, not an error: a global-service ARN carries no region and keeps
* the commercial partition.
*/
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "xx-nowhere-9", "not-a-region", "us-gov", "cn-north"})
void blankAndUnrecognisedRegionsFallBackToCommercial(String region) {
assertEquals("aws", AwsRegions.partitionFor(region));
assertEquals("amazonaws.com", AwsRegions.dnsSuffixFor(region));
}
@Test
void regionMatchingIsCaseInsensitive() {
assertEquals("aws-cn", AwsRegions.partitionFor("CN-NORTH-1"));
}
/** Every GovCloud and China id the emulator recognises must land outside {@code aws}. */
@Test
void knownNonCommercialIdsAreNotCommercial() {
for (String region : AwsRegions.KNOWN_IDS) {
if (region.startsWith("us-gov-")) {
assertEquals("aws-us-gov", AwsRegions.partitionFor(region), region);
} else if (region.startsWith("cn-")) {
assertEquals("aws-cn", AwsRegions.partitionFor(region), region);
} else {
assertEquals("aws", AwsRegions.partitionFor(region), region);
}
}
}
}
@@ -0,0 +1,151 @@
package io.github.hectorvent.floci.core.common;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.notNullValue;
import org.junit.jupiter.api.BeforeAll;
/**
* Verifies that {@link AwsRequestIdFilter} injects {@code x-amz-request-id} and
* {@code x-amzn-RequestId} headers on every response, across all three AWS wire
* protocols supported by Floci: REST XML (S3), JSON 1.0 (DynamoDB), and Query (SQS).
*
* <p>These headers are the source from which the AWS SDK v3 populates
* {@code $metadata.requestId} and {@code $metadata.httpStatusCode} on every
* command output.
*/
@QuarkusTest
class AwsRequestIdFilterIntegrationTest {
private static final String SSM_CONTENT_TYPE = "application/x-amz-json-1.1";
private static final String DYNAMODB_CONTENT_TYPE = "application/x-amz-json-1.0";
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
// --- REST XML protocol (S3) ---
@Test
void s3SuccessResponseContainsRequestIdHeaders() {
// Create a temporary bucket, verify headers, then clean it up
String bucket = "request-id-test-bucket";
given()
.when()
.put("/" + bucket)
.then()
.statusCode(200)
.header("x-amz-request-id", notNullValue())
.header("x-amzn-RequestId", notNullValue());
given().delete("/" + bucket);
}
@Test
void s3ErrorResponseContainsRequestIdHeaders() {
// Requesting a non-existent bucket produces a 404 error response —
// the headers must still be present so the SDK can surface the request ID.
given()
.when()
.get("/no-such-bucket-floci-test")
.then()
.statusCode(404)
.header("x-amz-request-id", notNullValue())
.header("x-amzn-RequestId", notNullValue());
}
@Test
void s3CopyObjectResponseContainsRequestIdHeaders() {
String bucket = "request-id-copy-bucket";
given().put("/" + bucket).then().statusCode(200);
given()
.contentType("text/plain")
.body("hello")
.when()
.put("/" + bucket + "/src.txt")
.then()
.statusCode(200);
// CopyObject is the operation the user reported as missing $metadata.requestId
given()
.header("x-amz-copy-source", "/" + bucket + "/src.txt")
.when()
.put("/" + bucket + "/dst.txt")
.then()
.statusCode(200)
.header("x-amz-request-id", notNullValue())
.header("x-amzn-RequestId", notNullValue());
given().delete("/" + bucket + "/src.txt");
given().delete("/" + bucket + "/dst.txt");
given().delete("/" + bucket);
}
// --- JSON 1.0 protocol (DynamoDB) ---
@Test
void dynamoDbSuccessResponseContainsRequestIdHeaders() {
given()
.header("X-Amz-Target", "DynamoDB_20120810.ListTables")
.contentType(DYNAMODB_CONTENT_TYPE)
.body("{}")
.when()
.post("/")
.then()
.statusCode(200)
.header("x-amz-request-id", notNullValue())
.header("x-amzn-RequestId", notNullValue());
}
@Test
void dynamoDbErrorResponseContainsRequestIdHeaders() {
given()
.header("X-Amz-Target", "DynamoDB_20120810.GetItem")
.contentType(DYNAMODB_CONTENT_TYPE)
.body("{\"TableName\": \"NonExistentTable\", \"Key\": {\"id\": {\"S\": \"1\"}}}")
.when()
.post("/")
.then()
.statusCode(400)
.header("x-amz-request-id", notNullValue())
.header("x-amzn-RequestId", notNullValue());
}
// --- Query protocol (SQS) ---
@Test
void sqsSuccessResponseContainsRequestIdHeaders() {
given()
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "ListQueues")
.when()
.post("/")
.then()
.statusCode(200)
.header("x-amz-request-id", notNullValue())
.header("x-amzn-RequestId", notNullValue());
}
// --- JSON 1.1 protocol (SSM) ---
@Test
void ssmSuccessResponseContainsRequestIdHeaders() {
given()
.header("X-Amz-Target", "AmazonSSM.DescribeParameters")
.contentType(SSM_CONTENT_TYPE)
.body("{}")
.when()
.post("/")
.then()
.statusCode(200)
.header("x-amz-request-id", notNullValue())
.header("x-amzn-RequestId", notNullValue());
}
}
@@ -0,0 +1,33 @@
package io.github.hectorvent.floci.core.common;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerResponseContext;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AwsRestJsonErrorHeaderFilterTest {
@Test
void doesNotAddRestJsonHeaderToJsonProtocolErrorForDualProtocolService() {
ResolvedServiceCatalog catalog = mock(ResolvedServiceCatalog.class);
ServiceDescriptor dualProtocolService = mock(ServiceDescriptor.class);
when(dualProtocolService.supportsProtocol(ServiceProtocol.REST_JSON)).thenReturn(true);
ContainerRequestContext request = mock(ContainerRequestContext.class);
when(request.getProperty(AwsProtocolClaimFilter.CLAIM_PROPERTY)).thenReturn(
new ProtocolClaim(WireProtocol.AWS_JSON_1_1, dualProtocolService, "Operation", null));
ContainerResponseContext response = mock(ContainerResponseContext.class);
when(response.getStatus()).thenReturn(400);
when(response.getHeaderString("X-Amzn-Errortype")).thenReturn(null);
new AwsRestJsonErrorHeaderFilter(catalog).filter(request, response);
verify(response, never()).getEntity();
verify(response, never()).getHeaders();
}
}
@@ -0,0 +1,164 @@
package io.github.hectorvent.floci.core.common;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
class CidrCanonicalizerTest {
// -----------------------------------------------------------------
// Pinned cases from the bead — exact strings, not just equality.
// -----------------------------------------------------------------
@Test
void awsDocumentedExample_rewritesHostBitsToZero() {
assertEquals(Optional.of("100.68.0.0/18"), CidrCanonicalizer.canonicalize("100.68.0.18/18"));
}
@Test
void alreadyCanonicalIpv4_isIdentityNotJustEqual() {
String input = "10.0.0.0/16";
Optional<String> result = CidrCanonicalizer.canonicalize(input);
assertEquals(Optional.of("10.0.0.0/16"), result);
// Must be the identical string value, not merely equal.
assertSame(input, result.orElseThrow());
}
@Test
void defaultRouteIpv4_zeroPrefixDoesNotDivideByZeroOrShiftBy32() {
String input = "0.0.0.0/0";
Optional<String> result = CidrCanonicalizer.canonicalize(input);
assertEquals(Optional.of("0.0.0.0/0"), result);
assertSame(input, result.orElseThrow());
}
@Test
void hostRouteIpv4_slash32IsUnchanged() {
String input = "10.1.2.3/32";
Optional<String> result = CidrCanonicalizer.canonicalize(input);
assertEquals(Optional.of("10.1.2.3/32"), result);
assertSame(input, result.orElseThrow());
}
@Test
void ipv6_rewritesHostBitsToZero() {
assertEquals(Optional.of("2001:db8::/32"), CidrCanonicalizer.canonicalize("2001:db8::1/32"));
}
@Test
void defaultRouteIpv6_zeroPrefixIsUnchanged() {
String input = "::/0";
Optional<String> result = CidrCanonicalizer.canonicalize(input);
assertEquals(Optional.of("::/0"), result);
assertSame(input, result.orElseThrow());
}
@Test
void ipv6_expandedInputCanonicalizesToCompressedForm() {
// AWS itself returns compressed IPv6; an expanded round-trip would be a diff.
assertEquals(Optional.of("2001:db8::/48"), CidrCanonicalizer.canonicalize("2001:0db8:0000::/48"));
}
@ParameterizedTest
@ValueSource(
strings = {
"",
"not-a-cidr",
"10.0.0.0",
"10.0.0.0/",
"/24",
"10.0.0.0/-1",
"10.0.0.0/33",
"2001:db8::1/129",
"2001:db8::1/-1",
"999.0.0.0/24",
"10.0.0.256/24",
"10.0.0.0/abc",
"example.com/24",
"10.0.0.0/24/extra",
})
void malformedOrOutOfRangeCidr_isDistinguishablyInvalid(String input) {
assertEquals(Optional.empty(), CidrCanonicalizer.canonicalize(input));
assertFalse(CidrCanonicalizer.isCanonical(input));
}
@Test
void nullCidr_isDistinguishablyInvalid() {
assertEquals(Optional.empty(), CidrCanonicalizer.canonicalize(null));
assertFalse(CidrCanonicalizer.isCanonical(null));
}
// -----------------------------------------------------------------
// Additional coverage.
// -----------------------------------------------------------------
@Test
void ipv6HostRoute_slash128IsUnchanged() {
String input = "2001:db8::1/128";
Optional<String> result = CidrCanonicalizer.canonicalize(input);
assertEquals(Optional.of("2001:db8::1/128"), result);
assertSame(input, result.orElseThrow());
}
@Test
void ipv4MappedIpv6Literal_ofLiteralCollapsesItToIpv4_soPrefixIsBoundedByThirtyTwo() {
// InetAddress.ofLiteral("::ffff:a.b.c.d") returns an Inet4Address, not an
// Inet6Address — Java itself collapses the IPv4-mapped form. A prefix that
// would be valid for the 128-bit IPv6 reading (e.g. /120) is therefore out of
// range once resolved, and must be rejected rather than silently accepted
// against the wrong bit width.
assertEquals(Optional.empty(), CidrCanonicalizer.canonicalize("::ffff:192.168.1.1/120"));
// A prefix within IPv4's 0-32 range works, canonicalizing as plain IPv4.
Optional<String> result = CidrCanonicalizer.canonicalize("::ffff:192.168.1.1/24");
assertEquals(Optional.of("192.168.1.0/24"), result);
// Re-canonicalizing the output must be a no-op (idempotence).
assertEquals(result, CidrCanonicalizer.canonicalize(result.orElseThrow()));
}
@ParameterizedTest
@CsvSource({
"100.68.0.18/18, 100.68.0.0/18",
"10.0.0.0/16, 10.0.0.0/16",
"0.0.0.0/0, 0.0.0.0/0",
"10.1.2.3/32, 10.1.2.3/32",
"2001:db8::1/32, 2001:db8::/32",
"::/0, ::/0",
"2001:0db8:0000::/48, 2001:db8::/48",
"192.168.1.255/24, 192.168.1.0/24",
"172.16.5.5/22, 172.16.4.0/22",
"255.255.255.255/32, 255.255.255.255/32",
"2001:db8:abcd:0012:0000:0000:0000:0001/64, 2001:db8:abcd:12::/64",
})
void canonicalize_matchesExpected(String input, String expected) {
assertEquals(Optional.of(expected), CidrCanonicalizer.canonicalize(input));
}
@Test
void isCanonical_trueOnlyForAlreadyCanonicalCidr() {
assertTrue(CidrCanonicalizer.isCanonical("10.0.0.0/16"));
assertTrue(CidrCanonicalizer.isCanonical("0.0.0.0/0"));
assertTrue(CidrCanonicalizer.isCanonical("2001:db8::/32"));
assertFalse(CidrCanonicalizer.isCanonical("100.68.0.18/18"));
assertFalse(CidrCanonicalizer.isCanonical("2001:0db8:0000::/48"));
}
@Test
void prefixLengthZero_forBothFamilies_isAccepted() {
assertEquals(Optional.of("0.0.0.0/0"), CidrCanonicalizer.canonicalize("192.168.1.1/0"));
assertEquals(Optional.of("::/0"), CidrCanonicalizer.canonicalize("2001:db8::1/0"));
}
@Test
void whitespaceOnlyCidr_isDistinguishablyInvalid() {
assertEquals(Optional.empty(), CidrCanonicalizer.canonicalize(" "));
assertFalse(CidrCanonicalizer.isCanonical(" "));
}
}
@@ -0,0 +1,114 @@
package io.github.hectorvent.floci.core.common;
import io.github.hectorvent.floci.services.iam.IamActionRegistry;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.UriInfo;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* A service may accept more than one signing scope (S3 also answers requests signed for
* {@code s3express}), but IAM action rules, ARN building and condition keys are keyed by the
* canonical name. An unnormalised alias resolves to no action, which the enforcement filter
* treats as ALLOW — so the alias must map back to the canonical scope before enforcement runs.
*/
@QuarkusTest
class CredentialScopeAliasTest {
@Inject
ResolvedServiceCatalog catalog;
@Inject
IamActionRegistry actionRegistry;
@Test
void s3ExpressAliasNormalisesToS3() {
assertEquals("s3", catalog.canonicalCredentialScope("s3express"));
}
@Test
void iotJobsDataAliasNormalisesToIot() {
// The IoT Jobs Data Plane signs as iot-jobs-data, but its policy actions live under the
// iot: namespace (iot:DescribeJobExecution and peers in the Service Authorization
// Reference), so enforcement has to resolve it there rather than invent iot-jobs-data:.
assertEquals("iot", catalog.canonicalCredentialScope("iot-jobs-data"));
}
@Test
void ssoPortalAliasNormalisesToSso() {
assertEquals("sso", catalog.canonicalCredentialScope("awsssoportal"));
}
@Test
void canonicalScopeIsUnchanged() {
assertEquals("s3", catalog.canonicalCredentialScope("s3"));
assertEquals("dynamodb", catalog.canonicalCredentialScope("dynamodb"));
}
@Test
void unknownScopeIsLeftAlone() {
assertEquals("securityhub", catalog.canonicalCredentialScope("securityhub"));
}
@Test
void scopeWhoseExternalKeyIsNotItsIamNamespaceIsLeftAlone() {
// These are the traps: the catalog routes SES under "email" and Bedrock Runtime under
// "bedrock-runtime", but their IAM namespaces are "ses:" and "bedrock:". Deriving the
// canonical scope from the external key would rewrite valid scopes onto prefixes AWS
// never issues, so every action would resolve to null and enforcement would be skipped.
assertEquals("ses", catalog.canonicalCredentialScope("ses"));
assertEquals("sesv2", catalog.canonicalCredentialScope("sesv2"));
assertEquals("bedrock", catalog.canonicalCredentialScope("bedrock"));
assertEquals("logs", catalog.canonicalCredentialScope("logs"));
}
@Test
void onlyExplicitlyAliasedScopesAreRewritten() {
// Sweep every scope the catalog declares. Anything that changes is a service whose
// IAM namespace we just moved, so it must be a deliberate alias, not a derivation.
Map<String, String> rewritten = catalog.all().stream()
.flatMap(descriptor -> descriptor.credentialScopes().stream())
.distinct()
.filter(scope -> !scope.equals(catalog.canonicalCredentialScope(scope)))
.collect(Collectors.toMap(scope -> scope, catalog::canonicalCredentialScope));
assertEquals(Map.of("s3express", "s3", "iot-jobs-data", "iot", "awsssoportal", "sso"), rewritten);
}
@Test
void normalisedAliasResolvesTheSameS3ActionAsTheCanonicalScope() {
ContainerRequestContext ctx = getObjectRequest();
String viaAlias = actionRegistry.resolve(catalog.canonicalCredentialScope("s3express"), ctx);
String viaCanonical = actionRegistry.resolve("s3", ctx);
assertEquals(viaCanonical, viaAlias);
assertEquals("s3:GetObject", viaAlias);
}
@Test
void rawAliasResolvesNoActionWithoutNormalisation() {
assertEquals(null, actionRegistry.resolve("s3express", getObjectRequest()));
}
private static ContainerRequestContext getObjectRequest() {
UriInfo uriInfo = mock(UriInfo.class);
when(uriInfo.getPath()).thenReturn("/my-bucket/my-key");
when(uriInfo.getQueryParameters()).thenReturn(new MultivaluedHashMap<>());
ContainerRequestContext ctx = mock(ContainerRequestContext.class);
when(ctx.getMethod()).thenReturn("GET");
when(ctx.getUriInfo()).thenReturn(uriInfo);
when(ctx.getHeaderString("X-Amz-Target")).thenReturn(null);
return ctx;
}
}
@@ -0,0 +1,167 @@
package io.github.hectorvent.floci.core.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Tripwire for the failure mode behind issue #1754's guard: Floci serves a service's routes but
* never enumerates the name that service signs with, so the guard rejects requests to routes that
* do work. The IoT Jobs Data Plane hit exactly this — {@code IotController} serves
* {@code /things/{thing}/jobs} while the scope is {@code iot-jobs-data} — and only the SDK compat
* suites caught it.
*
* <p>The check walks botocore's models: for every AWS signing name the catalog does not declare,
* it measures how much of that service's REST surface Floci already routes. A service whose routes
* Floci overwhelmingly serves is one Floci effectively implements, so its signing name belongs in
* the catalog.
*
* <p>Thresholds are deliberately conservative. Small services share generic templates
* ({@code /tags/{id}}, {@code /jobs/{id}}) with dozens of unrelated APIs, so this is a heuristic
* tripwire rather than a proof of completeness — the SDK compat suites remain the real check.
*
* <p>Skips when the botocore checkout is absent, which is the case on CI runners: the reference
* models live in the gitignored {@code local/aws/} tree, so this runs for maintainers who have it.
*/
@QuarkusTest
class CredentialScopeCoverageTest {
private static final Path BOTOCORE = Path.of("local/aws/botocore/botocore/data");
private static final Pattern PATH_PARAM = Pattern.compile("\\{[^}]+}");
private static final Pattern PATH_ANNOTATION = Pattern.compile("@Path\\(\"([^\"]*)\"\\)");
private static final Pattern CLASS_PATH_ANNOTATION =
Pattern.compile("@Path\\(\"([^\"]*)\"\\)\\s*(?:@\\w+(?:\\([^)]*\\))?\\s*)*public\\s+class");
/** Below this many modelled routes a service is too small to tell coincidence from coverage. */
private static final int MIN_ROUTES = 3;
/** Fraction of a service's routes Floci must already serve before its scope is expected. */
private static final double MIN_COVERAGE = 0.6;
@Inject
ResolvedServiceCatalog catalog;
@Test
void everyServiceWhoseRoutesFlociServesHasItsSigningNameDeclared() throws IOException {
assumeTrue(Files.isDirectory(BOTOCORE),
"botocore checkout not present (see CLAUDE.md, local/aws/) — skipping");
Set<String> declared = catalog.all().stream()
.flatMap(descriptor -> descriptor.credentialScopes().stream())
.collect(Collectors.toSet());
Set<String> flociRoutes = flociRouteTemplates();
Map<String, Set<String>> modelled = routeTemplatesBySigningName();
Map<String, String> undeclared = new TreeMap<>();
modelled.forEach((signingName, routes) -> {
if (declared.contains(signingName) || routes.size() < MIN_ROUTES) {
return;
}
long served = routes.stream().filter(flociRoutes::contains).count();
double coverage = (double) served / routes.size();
if (coverage >= MIN_COVERAGE) {
undeclared.put(signingName, served + "/" + routes.size() + " routes served");
}
});
assertTrue(undeclared.isEmpty(),
"Floci routes these services' operations but never declares the name they sign with, "
+ "so the unknown-service guard rejects requests that would otherwise work. "
+ "Add each to the owning descriptor's credential scopes in ResolvedServiceCatalog: "
+ undeclared);
}
/** Path templates Floci's JAX-RS controllers declare, normalised to botocore's shape. */
private static Set<String> flociRouteTemplates() throws IOException {
try (Stream<Path> sources = Files.walk(Path.of("src/main/java"))) {
List<Path> controllers = sources
.filter(p -> p.getFileName().toString().endsWith("Controller.java"))
.toList();
Set<String> templates = new HashSet<>();
for (Path controller : controllers) {
String source = Files.readString(controller);
Matcher classMatcher = CLASS_PATH_ANNOTATION.matcher(source);
String classPath = classMatcher.find() ? trimSlashes(classMatcher.group(1)) : "";
Matcher methodMatcher = PATH_ANNOTATION.matcher(source);
while (methodMatcher.find()) {
String methodPath = trimSlashes(methodMatcher.group(1));
if (methodPath.equals(classPath)) {
continue;
}
String full = classPath.isEmpty() ? "/" + methodPath : "/" + classPath + "/" + methodPath;
templates.add(normalise(full));
}
}
return templates;
}
}
/** Every AWS signing name mapped to the request-URI templates its operations declare. */
private static Map<String, Set<String>> routeTemplatesBySigningName() throws IOException {
ObjectMapper mapper = new ObjectMapper();
Map<String, Set<String>> bySigningName = new HashMap<>();
try (Stream<Path> services = Files.list(BOTOCORE)) {
for (Path service : services.filter(Files::isDirectory).toList()) {
Optional<Path> model = latestModel(service);
if (model.isEmpty()) {
continue;
}
JsonNode root = mapper.readTree(model.get().toFile());
JsonNode metadata = root.path("metadata");
String signingName = metadata.path("signingName").asText(
metadata.path("endpointPrefix").asText(null));
if (signingName == null || signingName.isBlank()) {
continue;
}
root.path("operations").forEach(operation -> {
String uri = operation.path("http").path("requestUri").asText(null);
if (uri == null) {
return;
}
String template = normalise(uri.split("\\?")[0]);
if (template.chars().filter(c -> c == '/').count() < 2) {
return;
}
bySigningName.computeIfAbsent(signingName, k -> new HashSet<>()).add(template);
});
}
}
return bySigningName;
}
private static Optional<Path> latestModel(Path serviceDir) throws IOException {
try (Stream<Path> versions = Files.list(serviceDir)) {
return versions.filter(Files::isDirectory)
.map(version -> version.resolve("service-2.json"))
.filter(Files::isRegularFile)
.max(Path::compareTo);
}
}
private static String normalise(String path) {
return PATH_PARAM.matcher(path).replaceAll("{}").replace("//", "/");
}
private static String trimSlashes(String path) {
return path.replaceAll("^/+", "").replaceAll("/+$", "");
}
}
@@ -0,0 +1,51 @@
package io.github.hectorvent.floci.core.common;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
/**
* Regression coverage for the descriptor-backed target matcher. catalog.matchTarget
* is protocol-agnostic: it will return a descriptor for a JSON 1.1 target even when
* the request arrived at the JSON 1.0 controller (and vice versa). Each controller
* must map such mismatches to UnknownOperationException rather than dropping the
* request on a null switch branch.
*/
@QuarkusTest
class CrossProtocolTargetRoutingIntegrationTest {
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void json11ControllerRejectsJson10TargetAsUnknownOperation() {
given()
.contentType("application/x-amz-json-1.1")
.header("X-Amz-Target", "DynamoDB_20120810.ListTables")
.body("{}")
.when()
.post("/")
.then()
.statusCode(404)
.body("__type", equalTo("UnknownOperationException"));
}
@Test
void json10ControllerRejectsJson11TargetAsUnknownOperation() {
given()
.contentType("application/x-amz-json-1.0")
.header("X-Amz-Target", "AmazonSSM.ListDocuments")
.body("{}")
.when()
.post("/")
.then()
.statusCode(404)
.body("__type", equalTo("UnknownOperationException"));
}
}
@@ -0,0 +1,99 @@
package io.github.hectorvent.floci.core.common;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CsvParserTest {
/**
* The file from the Step Functions "how Step Functions parses input CSV files" reference,
* whose third field carries a doubled quote and a line break and whose fourth field is an
* unquoted object relying on doubled quotes to survive States.StringToJson.
*/
@Test
void parsesTheAwsReferenceRecord() {
String csv = "abc,123,\"This string contains commas, a double quotation marks (\"\"), "
+ "and a newline (\n)\",{\"\"MyKey\"\":\"\"MyValue\"\"},\"[1,2,3]\"";
List<List<String>> rows = CsvParser.parseAll(csv, ',');
assertEquals(1, rows.size(), "the quoted newline must not split the record");
assertEquals(List.of(
"abc",
"123",
"This string contains commas, a double quotation marks (\"), and a newline (\n)",
"{\"MyKey\":\"MyValue\"}",
"[1,2,3]"), rows.get(0));
}
@Test
void doubledQuotesSurviveInAnUnquotedField() {
assertEquals(List.of("{\"MyKey\":\"MyValue\"}"),
CsvParser.parseAll("{\"\"MyKey\"\":\"\"MyValue\"\"}", ',').get(0));
}
@Test
void doubledQuotesSurviveInAQuotedField() {
assertEquals(List.of("a\"b"), CsvParser.parseAll("\"a\"\"b\"", ',').get(0));
}
/** A field that opens with a quote still follows CSV rules, so "" stays an empty field. */
@Test
void aQuotedEmptyFieldStaysEmpty() {
assertEquals(List.of("", "x"), CsvParser.parseAll("\"\",x", ',').get(0));
}
@Test
void backslashEscapesABackslash() {
String csv = "path,size\nC:\\\\Program Files\\\\MyApp.exe,6534512\n";
List<List<String>> rows = CsvParser.parseAll(csv, ',');
assertEquals(List.of("C:\\Program Files\\MyApp.exe", "6534512"), rows.get(1));
}
@Test
void backslashEscapesAQuoteAndTheDelimiter() {
assertEquals(List.of("a\"b"), CsvParser.parseAll("a\\\"b", ',').get(0));
assertEquals(List.of("a,b"), CsvParser.parseAll("a\\,b", ',').get(0));
assertEquals(List.of("a|b"), CsvParser.parseAll("a\\|b", '|').get(0));
}
/** AWS drops a backslash that precedes anything else and keeps the character. */
@Test
void backslashBeforeAnyOtherCharacterIsDropped() {
assertEquals(List.of("azb"), CsvParser.parseAll("a\\zb", ',').get(0));
}
@Test
void honoursANonCommaDelimiter() {
assertEquals(List.of("a", "b,c"), CsvParser.parseAll("a|\"b,c\"", '|').get(0));
}
@Test
void skipsABlankTrailingLine() {
assertEquals(1, CsvParser.parseAll("a,b\n", ',').size());
}
/**
* S3 Select and Athena read line by line and expect a backslash to stay put, so the backslash
* rules above must not reach parseLine.
*/
@Test
void parseLineKeepsBackslashesLiteral() {
assertEquals(List.of("C:\\Users\\x"), CsvParser.parseLine("C:\\Users\\x"));
}
@Test
void parseLineKeepsQuotedCommasTogether() {
assertEquals(List.of("a", "b,c"), CsvParser.parseLine("a,\"b,c\""));
}
@Test
void parseLineReturnsOneEmptyFieldForAnEmptyLine() {
assertEquals(List.of(""), CsvParser.parseLine(""));
}
}
@@ -0,0 +1,64 @@
package io.github.hectorvent.floci.core.common;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Token extraction is the one part of the heartbeat that can fail silently: if it never matches, the
* idle budget simply never resets and the original fixed-timeout bug is back, invisibly. So the
* payloads here are shaped like the ones actually observed on the wire rather than invented.
*/
class CustomResourceLivenessTest {
private static final String TOKEN = "6ef77fe2-38c8-41b0-bec6-68879687fbc8";
private Optional<String> tokenIn(String payload) {
return CustomResourceLiveness.tokenIn(payload.getBytes(StandardCharsets.UTF_8));
}
@Test
void findsTheTokenInAProviderFrameworkPoll() {
// Verbatim shape of the isComplete poll captured from a Prepare-stage run.
String payload = "{\"RequestType\":\"Create\","
+ "\"ServiceToken\":\"arn:aws:lambda:us-east-1:000000000000:function:framework-onEvent\","
+ "\"ResponseURL\":\"http://localhost.floci.io:4566/cfn-response/" + TOKEN + "\","
+ "\"StackId\":\"arn:aws:cloudformation:us-east-1:000000000000:stack/AWSAccelerator-PrepareStack/abc\","
+ "\"RequestId\":\"req-1\",\"LogicalResourceId\":\"CreateAccounts\","
+ "\"ResourceType\":\"Custom::CreateOrganizationAccounts\","
+ "\"ResourceProperties\":{\"ServiceToken\":\"arn:aws:lambda:...\",\"uuid\":\"72f7b8a8\"}}";
assertEquals(Optional.of(TOKEN), tokenIn(payload));
}
@Test
void findsTheTokenWhenTheUrlIsTheLastFieldWithNoTrailingPunctuation() {
assertEquals(Optional.of(TOKEN),
tokenIn("http://localhost.floci.io:4566/cfn-response/" + TOKEN));
}
@Test
void findsTheTokenAcrossJsonWhitespaceAndSingleQuotedRenderings() {
assertEquals(Optional.of(TOKEN),
tokenIn("{ \"ResponseURL\" : 'http://host:4566/cfn-response/" + TOKEN + "' }"));
}
@Test
void ignoresOrdinaryInvokesThatCarryNoCallbackUrl() {
assertTrue(tokenIn("{\"Records\":[{\"body\":\"hello\"}]}").isEmpty());
assertTrue(CustomResourceLiveness.tokenIn(new byte[0]).isEmpty());
assertTrue(CustomResourceLiveness.tokenIn(null).isEmpty());
}
@Test
void ignoresATruncatedUrlWithNoTokenAfterThePath() {
// The framework's own logging redacts ResponseURL to "..."; that rendering must not produce
// a bogus token that would touch nothing and mask the real signal.
assertTrue(tokenIn("{\"ResponseURL\":\"...\"}").isEmpty());
assertTrue(tokenIn("http://host:4566/cfn-response/").isEmpty());
}
}
@@ -0,0 +1,55 @@
package io.github.hectorvent.floci.core.common;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue;
@QuarkusTest
@TestProfile(GlobalCorsFilterAliasIntegrationTest.AliasProfile.class)
public class GlobalCorsFilterAliasIntegrationTest {
@BeforeAll
static void configure() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void preflightWithAliasEnvVarReturnsOk() {
given()
.header("Origin", "http://localhost:4000")
.header("Access-Control-Request-Method", "PUT")
.when()
.options("/my-bucket/some-key")
.then()
.statusCode(204)
.header("Access-Control-Allow-Origin", equalTo("http://localhost:4000"));
}
@Test
void preflightFromUnlistedOriginGetsNoGlobalCorsHeaders() {
given()
.header("Origin", "http://localhost:5000")
.header("Access-Control-Request-Method", "PUT")
.when()
.options("/my-bucket/some-key")
.then()
.statusCode(403)
.header("Access-Control-Allow-Origin", nullValue());
}
public static final class AliasProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of("EXTRA_CORS_ALLOWED_ORIGINS", "http://localhost:4000");
}
}
}
@@ -0,0 +1,221 @@
package io.github.hectorvent.floci.core.common;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue;
@QuarkusTest
@TestProfile(GlobalCorsFilterIntegrationTest.CorsProfile.class)
class GlobalCorsFilterIntegrationTest {
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void preflightFromExtraAllowedOriginReturnsCorsHeaders() {
given()
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "x-custom-header, x-amz-target")
.when()
.options("/")
.then()
.statusCode(204)
.header("Access-Control-Allow-Origin", equalTo("http://localhost:3000"))
.header("Access-Control-Allow-Methods", containsString("POST"))
.header("Access-Control-Allow-Headers", containsString("x-custom-header"))
.header("Access-Control-Allow-Headers", containsString("x-added-header"))
.header("Access-Control-Expose-Headers", containsString("x-visible-header"))
.header("Vary", containsString("Origin"));
}
@Test
void preflightS3ObjectReturnsCorsHeaders() {
given()
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", "PUT")
.when()
.options("/my-bucket/some-key")
.then()
.statusCode(204)
.header("Access-Control-Allow-Origin", equalTo("http://localhost:3000"));
}
@Test
void preflightS3ObjectVirtualHostReturnsCorsHeaders() {
given()
.header("Host", "my-bucket.s3.localhost.floci.io:4566")
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", "PUT")
.when()
.options("/some-key")
.then()
.statusCode(204)
.header("Access-Control-Allow-Origin", equalTo("http://localhost:3000"));
}
@Test
void preflightRequestingPrivateNetworkAccessIsGranted() {
given()
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Private-Network", "true")
.when()
.options("/")
.then()
.statusCode(204)
.header("Access-Control-Allow-Origin", equalTo("http://localhost:3000"))
.header("Access-Control-Allow-Private-Network", equalTo("true"));
}
@Test
void preflightWithoutPrivateNetworkRequestOmitsAllowHeader() {
given()
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", "POST")
.when()
.options("/")
.then()
.statusCode(204)
.header("Access-Control-Allow-Private-Network", nullValue());
}
@Test
void actualRequestFromExtraAllowedOriginGetsCorsHeaders() {
given()
.header("Origin", "https://ui.example.test")
.header("X-Amz-Target", "DynamoDB_20120810.ListTables")
.contentType("application/x-amz-json-1.0")
.body("{}")
.when()
.post("/")
.then()
.statusCode(200)
.header("Access-Control-Allow-Origin", equalTo("https://ui.example.test"))
.header("Access-Control-Expose-Headers", containsString("x-visible-header"))
.header("Vary", containsString("Origin"));
}
@Test
void requestFromUnlistedOriginGetsNoGlobalCorsHeaders() {
given()
.header("Origin", "https://not-allowed.example.test")
.header("X-Amz-Target", "DynamoDB_20120810.ListTables")
.contentType("application/x-amz-json-1.0")
.body("{}")
.when()
.post("/")
.then()
.statusCode(200)
.header("Access-Control-Allow-Origin", nullValue());
}
@Test
void preflightFromUnlistedOriginGetsNoGlobalCorsHeaders() {
given()
.header("Origin", "https://not-allowed.example.test")
.header("Access-Control-Request-Method", "PUT")
.when()
.options("/my-bucket/some-key")
.then()
.statusCode(403)
.header("Access-Control-Allow-Origin", nullValue());
}
public static final class CorsProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"floci.security.extra-cors-allowed-origins", "http://localhost:3000,https://ui.example.test",
"floci.security.extra-cors-allowed-headers", "x-added-header",
"floci.security.extra-cors-expose-headers", "x-visible-header",
"floci.security.cors-allow-private-network", "true");
}
}
@QuarkusTest
@TestProfile(PrivateNetworkDefaultOffTest.CorsPnaDefaultProfile.class)
static class PrivateNetworkDefaultOffTest {
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void privateNetworkAccessNotGrantedByDefault() {
given()
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Private-Network", "true")
.when()
.options("/")
.then()
.statusCode(204)
.header("Access-Control-Allow-Origin", equalTo("http://localhost:3000"))
.header("Access-Control-Allow-Private-Network", nullValue());
}
// Only origins set — cors-allow-private-network left at its (off) default.
public static final class CorsPnaDefaultProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of("floci.security.extra-cors-allowed-origins", "http://localhost:3000");
}
}
}
@QuarkusTest
@TestProfile(DisabledGlobalCorsFilterTest.DisabledCorsProfile.class)
static class DisabledGlobalCorsFilterTest {
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void preflightReturnsForbiddenWithoutCorsHeaders() {
given()
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", "PUT")
.when()
.options("/my-bucket/some-key")
.then()
.statusCode(403)
.header("Access-Control-Allow-Origin", nullValue());
}
@Test
void actualRequestGetsNoCorsHeaders() {
given()
.header("Origin", "http://localhost:3000")
.header("X-Amz-Target", "DynamoDB_20120810.ListTables")
.contentType("application/x-amz-json-1.0")
.body("{}")
.when()
.post("/")
.then()
.statusCode(200)
.header("Access-Control-Allow-Origin", nullValue());
}
public static final class DisabledCorsProfile implements QuarkusTestProfile {
// No config overrides, testing default application.yml values
}
}
}
@@ -0,0 +1,45 @@
package io.github.hectorvent.floci.core.common;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GlobalCorsFilterTest {
// Deployed API Gateway stages own their CORS — the global filter must skip them (#1928).
@ParameterizedTest
@CsvSource({
"/execute-api/abc123/prod/graphql",
"/execute-api/abc123/prod/",
"/_aws/execute-api/abc123/prod/graphql",
"/restapis/abc123/prod/_user_request_/graphql",
"/restapis/abc123/prod/_user_request_",
// tolerant of the leading-slash-less form UriInfo.getPath() can return
"execute-api/abc123/prod/graphql",
})
void deployedApiPathsAreExcludedFromGlobalCors(String path) {
assertTrue(GlobalCorsFilter.isDeployedApiPath(path));
}
// Management API and everything else keep Floci's global CORS handling.
@ParameterizedTest
@CsvSource({
"/restapis/abc123/resources/xyz/methods/GET",
"/restapis/abc123/deployments",
"/restapis",
"/my-bucket/key.txt",
"/",
"/_health",
})
void managementAndOtherPathsKeepGlobalCors(String path) {
assertFalse(GlobalCorsFilter.isDeployedApiPath(path));
}
@Test
void nullPathIsNotDeployedApi() {
assertFalse(GlobalCorsFilter.isDeployedApiPath(null));
}
}
@@ -0,0 +1,555 @@
package io.github.hectorvent.floci.core.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.services.dynamodb.DynamoDbService;
import io.github.hectorvent.floci.services.dynamodb.model.KeySchemaElement;
import io.github.hectorvent.floci.services.dynamodb.model.TableDefinition;
import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.ec2.model.Tag;
import io.github.hectorvent.floci.services.iam.IamPolicyEvaluator;
import io.github.hectorvent.floci.services.iam.IamPolicyEvaluator.Decision;
import io.github.hectorvent.floci.services.s3.S3Service;
import jakarta.enterprise.inject.Instance;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.UriInfo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class IamConditionContextResolverTest {
private final ObjectMapper mapper = new ObjectMapper();
private DynamoDbService dynamoDbService;
private Instance<DynamoDbService> dynamoDbServiceInstance;
private Ec2Service ec2Service;
private S3Service s3Service;
private RequestContext requestContext;
private EmulatorConfig config;
private IamConditionContextResolver resolver;
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
dynamoDbService = mock(DynamoDbService.class);
dynamoDbServiceInstance = mock(Instance.class);
when(dynamoDbServiceInstance.isResolvable()).thenReturn(true);
when(dynamoDbServiceInstance.get()).thenReturn(dynamoDbService);
ec2Service = mock(Ec2Service.class);
Instance<Ec2Service> ec2ServiceInstance = mock(Instance.class);
when(ec2ServiceInstance.isResolvable()).thenReturn(true);
when(ec2ServiceInstance.get()).thenReturn(ec2Service);
s3Service = mock(S3Service.class);
Instance<S3Service> s3ServiceInstance = mock(Instance.class);
when(s3ServiceInstance.isResolvable()).thenReturn(true);
when(s3ServiceInstance.get()).thenReturn(s3Service);
requestContext = new RequestContext();
requestContext.setRegion("us-east-1");
config = mock(EmulatorConfig.class);
when(config.defaultRegion()).thenReturn("us-east-1");
resolver = new IamConditionContextResolver(
dynamoDbServiceInstance, ec2ServiceInstance, s3ServiceInstance, requestContext, config);
}
/** A form request whose body can be read again, as a real request's restored stream can. */
private static ContainerRequestContext formRequest(String body) {
ContainerRequestContext containerRequest = mock(ContainerRequestContext.class);
when(containerRequest.getMediaType())
.thenReturn(MediaType.valueOf("application/x-www-form-urlencoded"));
when(containerRequest.getEntityStream())
.thenAnswer(invocation -> new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
return containerRequest;
}
private static ContainerRequestContext s3Request(String path, String body) {
ContainerRequestContext containerRequest = mock(ContainerRequestContext.class);
UriInfo uriInfo = mock(UriInfo.class);
when(containerRequest.getUriInfo()).thenReturn(uriInfo);
when(uriInfo.getPath()).thenReturn(path);
when(containerRequest.getEntityStream())
.thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
return containerRequest;
}
private TableDefinition fgacTable() {
TableDefinition table = new TableDefinition();
table.setTableName("FgacTable");
table.setKeySchema(List.of(new KeySchemaElement("PK", "HASH")));
return table;
}
private JsonNode json(String raw) {
try {
return mapper.readTree(raw);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
@Test
void resolvesS3ListBucketQueryConditionContext() {
ContainerRequestContext containerRequest = mock(ContainerRequestContext.class);
UriInfo uriInfo = mock(UriInfo.class);
MultivaluedMap<String, String> query = new MultivaluedHashMap<>();
query.add("prefix", "my_namespace/table/");
query.add("delimiter", "/");
query.add("max-keys", "100");
when(containerRequest.getUriInfo()).thenReturn(uriInfo);
when(uriInfo.getQueryParameters()).thenReturn(query);
Map<String, List<String>> conditions =
resolver.resolve("s3", "s3:ListBucket", containerRequest);
assertEquals(List.of("my_namespace/table/"), conditions.get("s3:prefix"));
assertEquals(List.of("/"), conditions.get("s3:delimiter"));
assertEquals(List.of("100"), conditions.get("s3:max-keys"));
}
@Test
void s3BucketListConditionContextReturnsNullWhenNoSupportedQueryParametersArePresent() {
assertNull(resolver.s3BucketListConditionContext(new MultivaluedHashMap<>()));
}
@Test
void resolveReturnsNullForUnsupportedServiceOrAction() {
ContainerRequestContext containerRequest = mock(ContainerRequestContext.class);
assertNull(resolver.resolve("lambda", "lambda:InvokeFunction", containerRequest));
// s3:DeleteObject is unsupported on purpose: real AWS gives it no object-tag keys, so a
// delete conditioned on s3:ExistingObjectTag is denied.
assertNull(resolver.resolve("s3", "s3:DeleteObject", containerRequest));
// A supported object action with nothing to read tags from offers no keys either.
assertNull(resolver.resolve("s3", "s3:GetObject", containerRequest));
}
@Test
void resolvesDynamoDbLeadingKeysForGetItem() {
ContainerRequestContext containerRequest = mock(ContainerRequestContext.class);
when(containerRequest.getProperty("floci.bufferedJsonBody")).thenReturn(json("""
{"TableName":"FgacTable","Key":{"PK":{"S":"USER_alice"}}}"""));
when(dynamoDbService.findTable("FgacTable", "us-east-1")).thenReturn(Optional.of(fgacTable()));
Map<String, List<String>> conditions =
resolver.resolve("dynamodb", "dynamodb:GetItem", containerRequest);
assertEquals(List.of("USER_alice"), conditions.get("dynamodb:LeadingKeys"));
assertEquals(List.of("PK"), conditions.get("dynamodb:Attributes"));
assertFalse(conditions.containsKey("dynamodb:Select"));
}
@Test
void omitsLeadingKeysWhenTheTableIsUnknown() {
ContainerRequestContext containerRequest = mock(ContainerRequestContext.class);
when(containerRequest.getProperty("floci.bufferedJsonBody")).thenReturn(json("""
{"TableName":"MissingTable","Key":{"PK":{"S":"USER_alice"}}}"""));
when(dynamoDbService.findTable(eq("MissingTable"), anyString()))
.thenReturn(Optional.empty());
Map<String, List<String>> conditions =
resolver.resolve("dynamodb", "dynamodb:GetItem", containerRequest);
assertFalse(conditions.containsKey("dynamodb:LeadingKeys"));
// Attribute names do not depend on the key schema, so they are still populated.
assertEquals(List.of("PK"), conditions.get("dynamodb:Attributes"));
}
@Test
void carriesSelectForQuery() {
ContainerRequestContext containerRequest = mock(ContainerRequestContext.class);
when(containerRequest.getProperty("floci.bufferedJsonBody")).thenReturn(json("""
{"TableName":"FgacTable",
"KeyConditionExpression":"PK = :v",
"ExpressionAttributeValues":{":v":{"S":"USER_alice"}},
"Select":"COUNT"}"""));
when(dynamoDbService.findTable("FgacTable", "us-east-1")).thenReturn(Optional.of(fgacTable()));
Map<String, List<String>> conditions =
resolver.resolve("dynamodb", "dynamodb:Query", containerRequest);
assertEquals(List.of("USER_alice"), conditions.get("dynamodb:LeadingKeys"));
assertEquals(List.of("COUNT"), conditions.get("dynamodb:Select"));
}
@Test
void returnsNullWhenNoBodyWasBuffered() {
ContainerRequestContext containerRequest = mock(ContainerRequestContext.class);
when(containerRequest.getProperty("floci.bufferedJsonBody")).thenReturn(null);
assertNull(resolver.resolve("dynamodb", "dynamodb:GetItem", containerRequest));
}
@Test
void resolvesBatchGetItemAcrossEveryRequestedKey() {
ContainerRequestContext containerRequest = mock(ContainerRequestContext.class);
when(containerRequest.getProperty("floci.bufferedJsonBody")).thenReturn(json("""
{"RequestItems":{"FgacTable":{"Keys":[
{"PK":{"S":"USER_alice"}},{"PK":{"S":"USER_bob"}}]}}}"""));
when(dynamoDbService.findTable("FgacTable", "us-east-1")).thenReturn(Optional.of(fgacTable()));
Map<String, List<String>> conditions =
resolver.resolve("dynamodb", "dynamodb:BatchGetItem", containerRequest);
assertEquals(List.of("USER_alice", "USER_bob"), conditions.get("dynamodb:LeadingKeys"));
}
@Test
void omitsLeadingKeysForAMultiTableBatch() {
// A batch spanning two tables has no single key schema: applying table A's partition
// key name to table B's items would be wrong, so no leading keys are produced.
ContainerRequestContext containerRequest = mock(ContainerRequestContext.class);
when(containerRequest.getProperty("floci.bufferedJsonBody")).thenReturn(json("""
{"RequestItems":{
"FgacTable":{"Keys":[{"PK":{"S":"USER_alice"}}]},
"OtherTable":{"Keys":[{"PK":{"S":"USER_bob"}}]}}}"""));
Map<String, List<String>> conditions =
resolver.resolve("dynamodb", "dynamodb:BatchGetItem", containerRequest);
assertFalse(conditions.containsKey("dynamodb:LeadingKeys"));
}
// ── aws:RequestTag and aws:ResourceTag ─────────────────────────────────────
@Test
void putBucketTaggingResolvesRequestTagsFromTheXmlBody() {
ContainerRequestContext containerRequest = s3Request("/my-bucket", "<Tagging><TagSet>"
+ "<Tag><Key>Team</Key><Value>payments</Value></Tag>"
+ "<Tag><Key>Env</Key><Value>prod</Value></Tag>"
+ "</TagSet></Tagging>");
Map<String, List<String>> conditions =
resolver.resolve("s3", "s3:PutBucketTagging", containerRequest);
assertEquals(List.of("payments"), conditions.get("aws:RequestTag/Team"));
assertEquals(List.of("prod"), conditions.get("aws:RequestTag/Env"));
}
@Test
void putBucketTaggingRestoresTheBodyForTheController() throws Exception {
String body = "<Tagging><TagSet><Tag><Key>Team</Key><Value>payments</Value></Tag></TagSet></Tagging>";
ContainerRequestContext containerRequest = s3Request("/my-bucket", body);
resolver.resolve("s3", "s3:PutBucketTagging", containerRequest);
ArgumentCaptor<InputStream> restored = ArgumentCaptor.forClass(InputStream.class);
verify(containerRequest).setEntityStream(restored.capture());
assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), restored.getValue().readAllBytes());
}
@Test
void bucketActionsResolveResourceTagsFromTheBucketsCurrentTags() {
when(s3Service.getBucketTagging("my-bucket")).thenReturn(Map.of("Owner", "alice"));
for (String action : List.of("s3:GetBucketTagging", "s3:DeleteBucketTagging", "s3:DeleteBucket")) {
Map<String, List<String>> conditions =
resolver.resolve("s3", action, s3Request("/my-bucket", ""));
assertEquals(List.of("alice"), conditions.get("aws:ResourceTag/Owner"), action);
}
}
@Test
void bucketActionsOfferNoResourceTagsWhenTheBucketLookupFails() {
when(s3Service.getBucketTagging(anyString()))
.thenThrow(new AwsException("NoSuchBucket", "The specified bucket does not exist.", 404));
assertNull(resolver.resolve("s3", "s3:GetBucketTagging", s3Request("/missing-bucket", "")));
}
@Test
void runInstancesResolvesRequestTagsFromEveryTagSpecification() {
ContainerRequestContext containerRequest = formRequest("Action=RunInstances"
+ "&TagSpecification.1.ResourceType=instance"
+ "&TagSpecification.1.Tag.1.Key=Team&TagSpecification.1.Tag.1.Value=payments"
+ "&TagSpecification.2.ResourceType=volume"
+ "&TagSpecification.2.Tag.1.Key=Env&TagSpecification.2.Tag.1.Value=prod");
Map<String, List<String>> conditions =
resolver.resolve("ec2", "ec2:RunInstances", containerRequest);
assertEquals(List.of("payments"), conditions.get("aws:RequestTag/Team"));
assertEquals(List.of("prod"), conditions.get("aws:RequestTag/Env"));
assertFalse(conditions.containsKey("aws:ResourceTag/Team"));
}
@Test
void createTagsResolvesBothTheRequestedTagsAndTheResourcesCurrentTags() {
when(ec2Service.resourceTags("i-0123456789")).thenReturn(List.of(new Tag("Owner", "alice")));
ContainerRequestContext containerRequest = formRequest(
"Action=CreateTags&ResourceId.1=i-0123456789&Tag.1.Key=Env&Tag.1.Value=prod");
Map<String, List<String>> conditions =
resolver.resolve("ec2", "ec2:CreateTags", containerRequest);
assertEquals(List.of("prod"), conditions.get("aws:RequestTag/Env"));
assertEquals(List.of("alice"), conditions.get("aws:ResourceTag/Owner"));
}
@Test
void instanceActionsResolveResourceTagsFromTheFirstNamedInstance() {
when(ec2Service.resourceTags("i-0123456789")).thenReturn(List.of(new Tag("Team", "payments")));
for (String action : List.of("ec2:TerminateInstances", "ec2:DescribeInstances")) {
Map<String, List<String>> conditions = resolver.resolve("ec2", action,
formRequest("Action=X&InstanceId.1=i-0123456789&InstanceId.2=i-9999999999"));
assertEquals(List.of("payments"), conditions.get("aws:ResourceTag/Team"), action);
}
assertEquals(List.of("payments"),
resolver.resolve("ec2", "ec2:DeleteTags",
formRequest("Action=DeleteTags&ResourceId.1=i-0123456789&Tag.1.Key=Team"))
.get("aws:ResourceTag/Team"));
}
@Test
void ec2ContextIsAbsentWhenNoResourceIsNamedOrTheActionIsNotCovered() {
assertNull(resolver.resolve("ec2", "ec2:DescribeInstances", formRequest("Action=DescribeInstances")));
assertNull(resolver.resolve("ec2", "ec2:DescribeVpcs", formRequest("Action=DescribeVpcs")));
assertNull(resolver.resolve("ec2", "ec2:CreateTags",
formRequest("Action=CreateTags&ResourceId.1=i-0123456789")));
}
@Test
void formParametersAreBufferedOncePerRequest() {
ContainerRequestContext containerRequest = formRequest("Action=TerminateInstances&InstanceId.1=i-1");
when(ec2Service.resourceTags("i-1")).thenReturn(List.of(new Tag("Team", "payments")));
resolver.resolve("ec2", "ec2:TerminateInstances", containerRequest);
verify(containerRequest).setEntityStream(any());
verify(containerRequest).setProperty(eq("floci.bufferedFormBody"), any());
}
// ── The resolved context drives the real evaluator ─────────────────────────
private final IamPolicyEvaluator evaluator = new IamPolicyEvaluator(new ObjectMapper());
@Test
void resourceTagConditionAllowsATaggedInstanceAndDeniesAnUntaggedOne() {
String policy = """
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"ec2:TerminateInstances","Resource":"*",
"Condition":{"StringEquals":{"aws:ResourceTag/Team":"payments"}}}
]}""";
String body = "Action=TerminateInstances&InstanceId.1=i-0123456789";
when(ec2Service.resourceTags("i-0123456789")).thenReturn(List.of(new Tag("Team", "payments")));
assertEquals(Decision.ALLOW, evaluator.simulateCustomPolicy(List.of(policy),
"ec2:TerminateInstances", "*",
resolver.resolve("ec2", "ec2:TerminateInstances", formRequest(body))));
when(ec2Service.resourceTags("i-0123456789")).thenReturn(List.of(new Tag("Team", "engineering")));
assertEquals(Decision.DENY, evaluator.simulateCustomPolicy(List.of(policy),
"ec2:TerminateInstances", "*",
resolver.resolve("ec2", "ec2:TerminateInstances", formRequest(body))));
}
@Test
void requestTagConditionAllowsTheDemandedTagValueAndDeniesAnotherOne() {
String policy = """
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"ec2:CreateTags","Resource":"*",
"Condition":{"StringEquals":{"aws:RequestTag/CostCenter":"1234"}}}
]}""";
assertEquals(Decision.ALLOW, evaluator.simulateCustomPolicy(List.of(policy), "ec2:CreateTags", "*",
resolver.resolve("ec2", "ec2:CreateTags", formRequest(
"Action=CreateTags&ResourceId.1=i-1&Tag.1.Key=CostCenter&Tag.1.Value=1234"))));
assertEquals(Decision.DENY, evaluator.simulateCustomPolicy(List.of(policy), "ec2:CreateTags", "*",
resolver.resolve("ec2", "ec2:CreateTags", formRequest(
"Action=CreateTags&ResourceId.1=i-1&Tag.1.Key=CostCenter&Tag.1.Value=9999"))));
}
@Test
void resourceTagConditionOnABucketFollowsItsOwnerTag() {
String policy = """
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"s3:GetBucketTagging","Resource":"*",
"Condition":{"StringEquals":{"aws:ResourceTag/Owner":"alice"}}}
]}""";
when(s3Service.getBucketTagging("my-bucket")).thenReturn(Map.of("Owner", "alice"));
assertEquals(Decision.ALLOW, evaluator.simulateCustomPolicy(List.of(policy), "s3:GetBucketTagging", "*",
resolver.resolve("s3", "s3:GetBucketTagging", s3Request("/my-bucket", ""))));
when(s3Service.getBucketTagging("my-bucket")).thenReturn(Map.of("Owner", "bob"));
assertEquals(Decision.DENY, evaluator.simulateCustomPolicy(List.of(policy), "s3:GetBucketTagging", "*",
resolver.resolve("s3", "s3:GetBucketTagging", s3Request("/my-bucket", ""))));
}
// ── Every target of a multi-resource request is evaluated ──────────────────
@Test
void remainingTargetsCarryEachLaterResourcesOwnTags() {
when(ec2Service.resourceTags("i-1")).thenReturn(List.of(new Tag("Team", "payments")));
when(ec2Service.resourceTags("i-2")).thenReturn(List.of(new Tag("Team", "engineering")));
when(ec2Service.resourceTags("i-3")).thenReturn(List.of());
ContainerRequestContext containerRequest = formRequest(
"Action=TerminateInstances&InstanceId.1=i-1&InstanceId.2=i-2&InstanceId.3=i-3");
List<Map<String, List<String>>> remaining =
resolver.resolveRemainingTargets("ec2", "ec2:TerminateInstances", containerRequest);
assertEquals(2, remaining.size());
assertEquals(List.of("engineering"), remaining.get(0).get("aws:ResourceTag/Team"));
// An untagged target still gets its own, empty, context so a tag condition fails on it.
assertEquals(Map.of(), remaining.get(1));
}
@Test
void remainingTargetsOfCreateTagsRepeatTheRequestedTagsPerResource() {
when(ec2Service.resourceTags("vpc-2")).thenReturn(List.of(new Tag("Owner", "bob")));
ContainerRequestContext containerRequest = formRequest(
"Action=CreateTags&ResourceId.1=vpc-1&ResourceId.2=vpc-2&Tag.1.Key=Env&Tag.1.Value=prod");
List<Map<String, List<String>>> remaining =
resolver.resolveRemainingTargets("ec2", "ec2:CreateTags", containerRequest);
assertEquals(1, remaining.size());
assertEquals(List.of("prod"), remaining.get(0).get("aws:RequestTag/Env"));
assertEquals(List.of("bob"), remaining.get(0).get("aws:ResourceTag/Owner"));
}
@Test
void singleTargetRequestsAndOtherServicesHaveNoRemainingTargets() {
assertEquals(List.of(), resolver.resolveRemainingTargets("ec2", "ec2:TerminateInstances",
formRequest("Action=TerminateInstances&InstanceId.1=i-1")));
assertEquals(List.of(), resolver.resolveRemainingTargets("ec2", "ec2:RunInstances",
formRequest("Action=RunInstances&TagSpecification.1.ResourceType=instance")));
assertEquals(List.of(), resolver.resolveRemainingTargets("s3", "s3:DeleteBucket",
s3Request("/my-bucket", "")));
}
@Test
void policyMustHoldForEveryTargetNotJustTheFirst() {
String policy = """
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"ec2:TerminateInstances","Resource":"*",
"Condition":{"StringEquals":{"aws:ResourceTag/Team":"payments"}}}
]}""";
String body = "Action=TerminateInstances&InstanceId.1=i-1&InstanceId.2=i-2";
when(ec2Service.resourceTags("i-1")).thenReturn(List.of(new Tag("Team", "payments")));
when(ec2Service.resourceTags("i-2")).thenReturn(List.of(new Tag("Team", "engineering")));
assertEquals(Decision.DENY, decisionForEveryTarget(policy, "ec2:TerminateInstances", formRequest(body)));
when(ec2Service.resourceTags("i-2")).thenReturn(List.of(new Tag("Team", "payments")));
assertEquals(Decision.ALLOW, decisionForEveryTarget(policy, "ec2:TerminateInstances", formRequest(body)));
}
@Test
void aRepeatedNumberedParameterKeepsItsFirstValueLikeTheHandlers() {
when(ec2Service.resourceTags("i-first")).thenReturn(List.of(new Tag("Team", "engineering")));
when(ec2Service.resourceTags("i-second")).thenReturn(List.of(new Tag("Team", "payments")));
Map<String, List<String>> conditions = resolver.resolve("ec2", "ec2:TerminateInstances",
formRequest("Action=TerminateInstances&InstanceId.1=i-first&InstanceId.1=i-second"));
assertEquals(List.of("engineering"), conditions.get("aws:ResourceTag/Team"));
}
@Test
void remainingTargetsStopAtTheFirstNumberingGapLikeTheHandlers() {
when(ec2Service.resourceTags("i-2")).thenReturn(List.of(new Tag("Team", "payments")));
assertEquals(List.of(), resolver.resolveRemainingTargets("ec2", "ec2:TerminateInstances",
formRequest("Action=TerminateInstances&InstanceId.2=i-2")));
assertEquals(List.of(), resolver.resolveRemainingTargets("ec2", "ec2:TerminateInstances",
formRequest("Action=TerminateInstances&InstanceId.1=i-1&InstanceId.3=i-3")));
}
@Test
void aRepeatedKeyCannotSwapAnAllowedResourceInFrontOfTheDeniedOne() {
String policy = """
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"ec2:TerminateInstances","Resource":"*",
"Condition":{"StringEquals":{"aws:ResourceTag/Team":"payments"}}}
]}""";
when(ec2Service.resourceTags("i-denied")).thenReturn(List.of(new Tag("Team", "engineering")));
when(ec2Service.resourceTags("i-allowed")).thenReturn(List.of(new Tag("Team", "payments")));
// The handler terminates i-denied, the first value, so that is what must be authorized.
assertEquals(Decision.DENY, decisionForEveryTarget(policy, "ec2:TerminateInstances",
formRequest("Action=TerminateInstances&InstanceId.1=i-denied&InstanceId.1=i-allowed")));
}
@Test
void globalKeysArePopulatedForEveryServiceAndAction() {
Map<String, List<String>> conditions = IamConditionContextResolver.withGlobalContext(
null, "arn:aws:lambda:eu-west-2:000000000000:function:task", "eu-west-2",
"000000000000", "000000000000");
assertEquals(List.of("000000000000"), conditions.get("aws:ResourceAccount"));
assertEquals(List.of("000000000000"), conditions.get("aws:PrincipalAccount"));
assertEquals(List.of("eu-west-2"), conditions.get("aws:RequestedRegion"));
}
@Test
void resourceAccountComesFromTheResourceArnWhenItCarriesOne() {
Map<String, List<String>> conditions = IamConditionContextResolver.withGlobalContext(
Map.of("service:key", List.of("value")),
"arn:aws:sqs:eu-west-2:111111111111:queue", "eu-west-2", "000000000000", "000000000000");
assertEquals(List.of("111111111111"), conditions.get("aws:ResourceAccount"));
assertEquals(List.of("value"), conditions.get("service:key"));
assertFalse(conditions.containsKey("aws:SecureTransport"));
}
@Test
void resourceAccountIsTheBucketOwnerWhenAnS3ArnCarriesNoAccount() {
// S3 bucket and object ARNs have an empty account segment by design, so the owner has to
// come from service state. Falling back to the caller would make a cross-account bucket
// look like the caller's own.
Map<String, List<String>> conditions = IamConditionContextResolver.withGlobalContext(
null, "arn:aws:s3:::partner-bucket/report.csv", "eu-west-2",
"000000000000", "111111111111");
assertEquals(List.of("111111111111"), conditions.get("aws:ResourceAccount"));
assertEquals(List.of("000000000000"), conditions.get("aws:PrincipalAccount"));
}
@Test
void resourceAccountIsOmittedWhenNeitherTheArnNorServiceStateKnowsTheOwner() {
Map<String, List<String>> conditions = IamConditionContextResolver.withGlobalContext(
null, "arn:aws:s3:::orphan-bucket", "eu-west-2", "000000000000", null);
assertFalse(conditions.containsKey("aws:ResourceAccount"),
"guessing the owner would authorize cross-account access a Condition meant to refuse");
assertEquals(List.of("000000000000"), conditions.get("aws:PrincipalAccount"));
}
private Decision decisionForEveryTarget(String policy, String action, ContainerRequestContext request) {
Decision first = evaluator.simulateCustomPolicy(List.of(policy), action, "*",
resolver.resolve("ec2", action, request));
if (first == Decision.DENY) {
return first;
}
for (Map<String, List<String>> target : resolver.resolveRemainingTargets("ec2", action, request)) {
if (evaluator.simulateCustomPolicy(List.of(policy), action, "*", target) == Decision.DENY) {
return Decision.DENY;
}
}
return Decision.ALLOW;
}
}
@@ -0,0 +1,128 @@
package io.github.hectorvent.floci.core.common;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Unit tests for the static helpers that drive CloudTrail emission on the
* IAM-deny path. Full HTTP-level coverage (filter → emit → log file) lives in
* the SDK compatibility suite where SigV4 signing is available.
*/
class IamEnforcementFilterUnitTest {
// --- mapS3ActionToEventName ---
@Test
void getObject_overGet_emitsAsGetObject() {
assertEquals("GetObject",
IamEnforcementFilter.mapS3ActionToEventName("s3:GetObject", "GET"));
}
@Test
void getObject_overHead_emitsAsHeadObject() {
assertEquals("HeadObject",
IamEnforcementFilter.mapS3ActionToEventName("s3:GetObject", "HEAD"));
}
@Test
void putObject_emitsAsPutObject() {
assertEquals("PutObject",
IamEnforcementFilter.mapS3ActionToEventName("s3:PutObject", "PUT"));
}
@Test
void deleteObject_emitsAsDeleteObject() {
assertEquals("DeleteObject",
IamEnforcementFilter.mapS3ActionToEventName("s3:DeleteObject", "DELETE"));
}
@Test
void listBucket_emitsAsListObjects() {
assertEquals("ListObjects",
IamEnforcementFilter.mapS3ActionToEventName("s3:ListBucket", "GET"));
}
@Test
void listAllMyBuckets_emitsAsListBuckets() {
assertEquals("ListBuckets",
IamEnforcementFilter.mapS3ActionToEventName("s3:ListAllMyBuckets", "GET"));
}
@Test
void getObjectAcl_emitsAsGetObjectAcl() {
assertEquals("GetObjectAcl",
IamEnforcementFilter.mapS3ActionToEventName("s3:GetObjectAcl", "GET"));
}
@Test
void putObjectAcl_emitsAsPutObjectAcl() {
assertEquals("PutObjectAcl",
IamEnforcementFilter.mapS3ActionToEventName("s3:PutObjectAcl", "PUT"));
}
@Test
void objectTagging_emitsCorrectEventNames() {
assertEquals("GetObjectTagging",
IamEnforcementFilter.mapS3ActionToEventName("s3:GetObjectTagging", "GET"));
assertEquals("PutObjectTagging",
IamEnforcementFilter.mapS3ActionToEventName("s3:PutObjectTagging", "PUT"));
assertEquals("DeleteObjectTagging",
IamEnforcementFilter.mapS3ActionToEventName("s3:DeleteObjectTagging", "DELETE"));
}
@Test
void unknownAction_returnsNull() {
assertNull(IamEnforcementFilter.mapS3ActionToEventName("s3:GetBucketAcl", "GET"));
assertNull(IamEnforcementFilter.mapS3ActionToEventName("dynamodb:Query", "POST"));
assertNull(IamEnforcementFilter.mapS3ActionToEventName(null, "GET"));
}
// --- parseS3Resource ---
@Test
void parseObjectArn_splitsBucketAndKey() {
assertArrayEquals(new String[] { "audit-source", "documents/hello.txt" },
IamEnforcementFilter.parseS3Resource("arn:aws:s3:::audit-source/documents/hello.txt"));
}
@Test
void parseBucketArn_returnsBucketWithNullKey() {
assertArrayEquals(new String[] { "audit-source", null },
IamEnforcementFilter.parseS3Resource("arn:aws:s3:::audit-source"));
}
@Test
void parseBucketArnWithTrailingSlash_returnsBucketWithNullKey() {
assertArrayEquals(new String[] { "audit-source", null },
IamEnforcementFilter.parseS3Resource("arn:aws:s3:::audit-source/"));
}
@Test
void parseKeyWithSlashes_keepsFullPath() {
assertArrayEquals(new String[] { "b", "a/b/c.txt" },
IamEnforcementFilter.parseS3Resource("arn:aws:s3:::b/a/b/c.txt"));
}
@Test
void parseStarArn_returnsNulls() {
assertArrayEquals(new String[] { null, null },
IamEnforcementFilter.parseS3Resource("arn:aws:s3:::*"));
}
@Test
void parseEmptyArn_returnsNulls() {
assertArrayEquals(new String[] { null, null },
IamEnforcementFilter.parseS3Resource("arn:aws:s3:::"));
}
@Test
void parseNonS3Arn_returnsNulls() {
assertArrayEquals(new String[] { null, null },
IamEnforcementFilter.parseS3Resource("arn:aws:lambda:us-east-1:000000000000:function:foo"));
assertArrayEquals(new String[] { null, null },
IamEnforcementFilter.parseS3Resource(null));
}
}
@@ -0,0 +1,32 @@
package io.github.hectorvent.floci.core.common;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
@QuarkusTest
@TestProfile(IamStsSharedEnablementIntegrationTest.IamDisabledProfile.class)
class IamStsSharedEnablementIntegrationTest {
@Inject
ServiceRegistry serviceRegistry;
@Test
void disablingIamAlsoDisablesSts() {
assertFalse(serviceRegistry.isServiceEnabled("iam"));
assertFalse(serviceRegistry.isServiceEnabled("sts"));
}
public static final class IamDisabledProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of("floci.services.iam.enabled", "false");
}
}
}
@@ -0,0 +1,254 @@
package io.github.hectorvent.floci.core.common;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import io.restassured.config.EncoderConfig;
import io.restassured.http.ContentType;
import io.restassured.parsing.Parser;
import io.restassured.specification.RequestSpecification;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.startsWith;
/**
* Malformed client input on a JSON-protocol service must yield a 4xx typed error, never a 500
* InternalFailure. Both JSON dispatchers already render {@code AwsException} correctly; the leaks
* are (a) an unparseable request body reaching {@code objectMapper.readTree} and (b) service
* handlers that decode base64 / parse ints from client input without catching the failure. Each
* of those escapes the dispatcher's generic {@code catch (Exception)} and surfaces as 500.
*
* <p>Every case here asserts the status is a 4xx (never 500) and, where AWS defines it, the exact
* error code. Inputs are crafted to fail on the malformed value before any resource lookup, so no
* seeding is required.
*/
@QuarkusTest
class JsonMalformedInputIntegrationTest {
private static final String JSON_1_1 = "application/x-amz-json-1.1";
private static final String JSON_1_0 = "application/x-amz-json-1.0";
@BeforeAll
static void registerJsonParsers() {
RestAssured.registerParser(JSON_1_1, Parser.JSON);
RestAssured.registerParser(JSON_1_0, Parser.JSON);
}
private static RequestSpecification req(String contentType, String target, String body) {
// RestAssured has no built-in serializer for the x-amz-json content types; send the raw body as text.
return given()
.config(RestAssured.config().encoderConfig(EncoderConfig.encoderConfig()
.encodeContentTypeAs(JSON_1_1, ContentType.TEXT)
.encodeContentTypeAs(JSON_1_0, ContentType.TEXT)))
.header("X-Amz-Target", target)
.contentType(contentType)
.header("Authorization",
"AWS4-HMAC-SHA256 Credential=test/20260227/us-east-1/service/aws4_request")
.body(body);
}
// ── Dispatcher: an unparseable body is a client deserialization error (both controllers) ──
@Test
void malformedJsonBodyOnJson11IsSerializationException() {
req(JSON_1_1, "TrentService.CreateKey", "{ this is not json")
.when().post("/")
.then()
.statusCode(400)
.body("__type", equalTo("SerializationException"));
}
@Test
void malformedJsonBodyOnJson10IsSerializationException() {
req(JSON_1_0, "DynamoDB_20120810.ListTables", "{ not json ]")
.when().post("/")
.then()
.statusCode(400)
.body("__type", equalTo("SerializationException"));
}
@Test
void trailingContentAfterValidJsonOnJson11IsSerializationException() {
// Jackson's default readTree stops at the first complete value; without
// FAIL_ON_TRAILING_TOKENS the garbage after {} is silently ignored.
req(JSON_1_1, "TrentService.CreateKey", "{} not-json")
.when().post("/")
.then()
.statusCode(400)
.body("__type", equalTo("SerializationException"));
}
@Test
void trailingContentAfterValidJsonOnJson10IsSerializationException() {
req(JSON_1_0, "DynamoDB_20120810.ListTables", "{}{\"second\":\"document\"}")
.when().post("/")
.then()
.statusCode(400)
.body("__type", equalTo("SerializationException"));
}
@Test
void trailingWhitespaceAfterValidJsonIsAccepted() {
req(JSON_1_0, "DynamoDB_20120810.ListTables", "{} \n")
.when().post("/")
.then()
.statusCode(200);
}
// ── Dispatcher: the body must be a JSON object; no body at all is the wire form of "no input" ──
@Test
void emptyBodyOnJson11IsReadAsEmptyInput() {
req(JSON_1_1, "TrentService.ListKeys", "")
.when().post("/")
.then()
.statusCode(200);
}
@Test
void whitespaceOnlyBodyOnJson11IsReadAsEmptyInput() {
req(JSON_1_1, "TrentService.ListKeys", " \n ")
.when().post("/")
.then()
.statusCode(200);
}
@Test
void emptyBodyOnJson11NoInputOperationBehavesLikeEmptyObject() {
// The awsJson1_1 protocol requires services to accept no payload for operations that
// define no input, so an empty body must be answered exactly like {}.
int statusWithEmptyObject = req(JSON_1_1, "AWSOrganizationsV20161128.DescribeOrganization", "{}")
.when().post("/")
.then().extract().statusCode();
req(JSON_1_1, "AWSOrganizationsV20161128.DescribeOrganization", "")
.when().post("/")
.then()
.statusCode(statusWithEmptyObject)
.body("__type", not(equalTo("SerializationException")));
}
@Test
void emptyBodyOnJson10IsReadAsEmptyInput() {
req(JSON_1_0, "DynamoDB_20120810.ListTables", "")
.when().post("/")
.then()
.statusCode(200);
}
@Test
void jsonNullBodyOnJson11IsSerializationException() {
req(JSON_1_1, "TrentService.ListKeys", "null")
.when().post("/")
.then()
.statusCode(400)
.body("__type", equalTo("SerializationException"));
}
@Test
void arrayBodyOnJson11IsSerializationException() {
req(JSON_1_1, "TrentService.ListKeys", "[]")
.when().post("/")
.then()
.statusCode(400)
.body("__type", equalTo("SerializationException"));
}
@Test
void scalarBodyOnJson11IsSerializationException() {
req(JSON_1_1, "TrentService.ListKeys", "\"ListKeys\"")
.when().post("/")
.then()
.statusCode(400)
.body("__type", equalTo("SerializationException"));
}
@Test
void jsonNullBodyOnJson10IsSerializationException() {
req(JSON_1_0, "DynamoDB_20120810.ListTables", "null")
.when().post("/")
.then()
.statusCode(400)
.body("__type", equalTo("SerializationException"));
}
@Test
void arrayBodyOnJson10IsSerializationException() {
req(JSON_1_0, "DynamoDB_20120810.ListTables", "[]")
.when().post("/")
.then()
.statusCode(400)
.body("__type", equalTo("SerializationException"));
}
// ── KMS: base64 blob fields ──
@Test
void kmsDecryptMalformedCiphertextIsNot500() {
req(JSON_1_1, "TrentService.Decrypt", "{\"CiphertextBlob\":\"!!!not-base64!!!\"}")
.when().post("/")
.then()
.statusCode(400)
.body("__type", equalTo("SerializationException"));
}
@Test
void kmsEncryptMalformedPlaintextIsNot500() {
req(JSON_1_1, "TrentService.Encrypt", "{\"KeyId\":\"k\",\"Plaintext\":\"@@notbase64@@\"}")
.when().post("/")
.then()
.statusCode(400)
.body("__type", equalTo("SerializationException"));
}
// ── Kinesis: shard iterator (opaque token we mint) and the PutRecord data blob ──
@Test
void kinesisGetRecordsMalformedShardIteratorIsInvalidArgument() {
req(JSON_1_1, "Kinesis_20131202.GetRecords", "{\"ShardIterator\":\"!!!not-base64!!!\"}")
.when().post("/")
.then()
.statusCode(400)
.body("__type", startsWith("InvalidArgumentException"));
}
@Test
void kinesisGetRecordsIteratorWithNonNumericIndexIsInvalidArgument() {
// valid base64 of "stream|shard|type|seq|NOTANUMBER" — decodes fine, parseInt(index) would throw
String iterator = java.util.Base64.getEncoder()
.encodeToString("s|shard-0|AT|0|xyz".getBytes(java.nio.charset.StandardCharsets.UTF_8));
req(JSON_1_1, "Kinesis_20131202.GetRecords", "{\"ShardIterator\":\"" + iterator + "\"}")
.when().post("/")
.then()
.statusCode(400)
.body("__type", startsWith("InvalidArgumentException"));
}
@Test
void kinesisPutRecordMalformedDataIsSerializationException() {
req(JSON_1_1, "Kinesis_20131202.PutRecord",
"{\"StreamName\":\"s\",\"PartitionKey\":\"p\",\"Data\":\"@@notbase64@@\"}")
.when().post("/")
.then()
.statusCode(400)
.body("__type", equalTo("SerializationException"));
}
// ── DynamoDB Streams: iterator base64 is guarded, the position parseInt was not ──
@Test
void dynamoDbStreamsGetRecordsIteratorWithNonNumericPositionIsNot500() {
// valid base64 of "arn|NOTANUMBER" — decodeIterator succeeds, Integer.parseInt(position) would throw
String iterator = java.util.Base64.getEncoder()
.encodeToString("arn:aws:dynamodb:us-east-1:0:table/T/stream/x|notanumber"
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
req(JSON_1_0, "DynamoDBStreams_20120810.GetRecords", "{\"ShardIterator\":\"" + iterator + "\"}")
.when().post("/")
.then()
.statusCode(400)
.body("__type", not(equalTo("InternalFailure")));
}
}
@@ -0,0 +1,167 @@
package io.github.hectorvent.floci.core.common;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@QuarkusTest
class ProtocolClaimerIntegrationTest {
private static final String SQS_AUTH =
"AWS4-HMAC-SHA256 Credential=test/20260702/us-east-1/sqs/aws4_request, "
+ "SignedHeaders=host;x-amz-date, Signature=abc";
@Inject
ProtocolClaimer claimer;
private Optional<ProtocolClaim> claim(String method, String path, String contentType,
String smithyProtocol, String xAmzTarget, String authorization) {
return claimer.claim(new ClaimSignals(method, path, contentType, smithyProtocol, xAmzTarget, authorization));
}
@Test
void smithyProtocolHeaderClaimsRpcV2CborAheadOfEverything() {
// All signals at once: the Smithy-Protocol header wins by precedence.
ProtocolClaim claim = claim("POST", "/service/DynamoDB_20120810/operation/ListTables",
"application/x-amz-json-1.1", "rpc-v2-cbor", "DynamoDB_20120810.ListTables", null).orElseThrow();
assertEquals(WireProtocol.RPCV2_CBOR, claim.protocol());
assertEquals("dynamodb", claim.service().externalKey());
assertEquals("ListTables", claim.operation());
}
@Test
void rpcV2PathToleratesPrefixSegments() {
ProtocolClaim claim = claim("POST", "/v1/service/Kinesis_20131202/operation/ListStreams",
"application/cbor", "rpc-v2-cbor", null, null).orElseThrow();
assertEquals(WireProtocol.RPCV2_CBOR, claim.protocol());
assertEquals("kinesis", claim.service().externalKey());
}
@Test
void headerlessCborOnRpcV2PathStillClaims() {
ProtocolClaim claim = claim("POST", "/service/DynamoDB/operation/GetItem",
"application/cbor", null, null, null).orElseThrow();
assertEquals(WireProtocol.RPCV2_CBOR, claim.protocol());
assertEquals("dynamodb", claim.service().externalKey());
}
@Test
void rpcV2JsonIsRecognized() {
ProtocolClaim claim = claim("POST", "/service/AmazonSQS/operation/ListQueues",
"application/json", "rpc-v2-json", null, null).orElseThrow();
assertEquals(WireProtocol.RPCV2_JSON, claim.protocol());
assertEquals("sqs", claim.service().externalKey());
}
@Test
void malformedRpcV2SignalsAreUnclaimable() {
// Valid header value but wrong method.
assertTrue(claim("GET", "/service/DynamoDB_20120810/operation/ListTables",
null, "rpc-v2-cbor", null, null).isEmpty());
// Valid header value but wrong path shape.
assertTrue(claim("POST", "/", "application/cbor", "rpc-v2-cbor", null, null).isEmpty());
// Unknown header value.
assertTrue(claim("POST", "/service/DynamoDB_20120810/operation/ListTables",
"application/cbor", "rpc-v2-msgpack", null, null).isEmpty());
// Header value comparison is exact per spec (header NAME is case-insensitive, value is not).
assertTrue(claim("POST", "/service/DynamoDB_20120810/operation/ListTables",
"application/cbor", "RPC-V2-CBOR", null, null).isEmpty());
}
@Test
void jsonVersionsSplitOnContentType() {
ProtocolClaim json10 = claim("POST", "/", "application/x-amz-json-1.0",
null, "DynamoDB_20120810.ListTables", null).orElseThrow();
assertEquals(WireProtocol.AWS_JSON_1_0, json10.protocol());
assertEquals("dynamodb", json10.service().externalKey());
assertEquals("ListTables", json10.operation());
ProtocolClaim json11 = claim("POST", "/", "application/x-amz-json-1.1",
null, "AmazonSSM.DescribeParameters", null).orElseThrow();
assertEquals(WireProtocol.AWS_JSON_1_1, json11.protocol());
assertEquals("ssm", json11.service().externalKey());
}
@Test
void mediaTypeParametersAndCasingAreNormalized() {
ProtocolClaim claim = claim("POST", "/", "Application/X-Amz-Json-1.1; charset=utf-8",
null, "AmazonSSM.DescribeParameters", null).orElseThrow();
assertEquals(WireProtocol.AWS_JSON_1_1, claim.protocol());
}
@Test
void legacyCborWithTargetAtRootClaimsCborTarget() {
// Kinesis via aws-sdk-java: x-amz-cbor-1.1 + X-Amz-Target at root, no smithy header.
ProtocolClaim claim = claim("POST", "/", "application/x-amz-cbor-1.1",
null, "Kinesis_20131202.ListStreams", null).orElseThrow();
assertEquals(WireProtocol.AWS_CBOR_TARGET, claim.protocol());
assertEquals("kinesis", claim.service().externalKey());
assertEquals("ListStreams", claim.operation());
}
@Test
void targetWithForeignContentTypeIsUnclaimable() {
assertTrue(claim("POST", "/", "text/plain",
null, "AmazonSSM.DescribeParameters", null).isEmpty());
}
@Test
void unknownTargetStillClaimsProtocolWithoutService() {
ProtocolClaim claim = claim("POST", "/", "application/x-amz-json-1.1",
null, "NoSuchService.DoThing", null).orElseThrow();
assertEquals(WireProtocol.AWS_JSON_1_1, claim.protocol());
assertNull(claim.service());
assertEquals("NoSuchService.DoThing", claim.target());
}
@Test
void formPostAtRootClaimsQueryAndResolvesServiceFromCredentialScope() {
ProtocolClaim withAuth = claim("POST", "/",
"application/x-www-form-urlencoded; charset=utf-8", null, null, SQS_AUTH).orElseThrow();
assertEquals(WireProtocol.AWS_QUERY, withAuth.protocol());
assertEquals("sqs", withAuth.service().externalKey());
ProtocolClaim withoutAuth = claim("POST", "/",
"application/x-www-form-urlencoded", null, null, null).orElseThrow();
assertEquals(WireProtocol.AWS_QUERY, withoutAuth.protocol());
assertNull(withoutAuth.service());
}
@Test
void nonRpcTrafficFallsThroughAsRest() {
Optional<ProtocolClaim> get = claim("GET", "/", null, null, null, null);
assertEquals(WireProtocol.REST, get.orElseThrow().protocol());
Optional<ProtocolClaim> s3Post = claim("POST", "/my-bucket",
"multipart/form-data; boundary=x", null, null, null);
assertEquals(WireProtocol.REST, s3Post.orElseThrow().protocol());
Optional<ProtocolClaim> lambdaPath = claim("POST", "/2015-03-31/functions",
"application/json", null, null, null);
assertEquals(WireProtocol.REST, lambdaPath.orElseThrow().protocol());
}
@Test
void queryFormOnNonRootPathIsRest() {
// Form posts on non-root paths (e.g. before SQS queue-URL rewriting) are
// not query claims; the router filter rewrites them to "/" before this
// claimer runs in the filter chain.
ProtocolClaim claim = claim("POST", "/123456789012/my-queue",
"application/x-www-form-urlencoded", null, null, null).orElseThrow();
assertEquals(WireProtocol.REST, claim.protocol());
}
}
@@ -0,0 +1,207 @@
package io.github.hectorvent.floci.core.common;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Integration test verifying that data is isolated between regions.
* Uses different Authorization headers to simulate requests from different regions.
*/
@QuarkusTest
class RegionIsolationIntegrationTest {
private static final String SSM_CONTENT_TYPE = "application/x-amz-json-1.1";
private static final String DYNAMODB_CONTENT_TYPE = "application/x-amz-json-1.0";
private static final String AUTH_US_EAST_1 =
"AWS4-HMAC-SHA256 Credential=AKID/20260215/us-east-1/ssm/aws4_request, SignedHeaders=host, Signature=abc";
private static final String AUTH_US_WEST_2 =
"AWS4-HMAC-SHA256 Credential=AKID/20260215/us-west-2/ssm/aws4_request, SignedHeaders=host, Signature=abc";
private static final String AUTH_EU_WEST_1 =
"AWS4-HMAC-SHA256 Credential=AKID/20260215/eu-west-1/dynamodb/aws4_request, SignedHeaders=host, Signature=abc";
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void ssmParametersAreIsolatedByRegion() {
// Put parameter in us-east-1
given()
.header("X-Amz-Target", "AmazonSSM.PutParameter")
.header("Authorization", AUTH_US_EAST_1)
.contentType(SSM_CONTENT_TYPE)
.body("""
{"Name": "/region-test/key", "Value": "east-value", "Type": "String"}
""")
.when().post("/")
.then().statusCode(200);
// Put same parameter name in us-west-2 with different value
given()
.header("X-Amz-Target", "AmazonSSM.PutParameter")
.header("Authorization", AUTH_US_WEST_2)
.contentType(SSM_CONTENT_TYPE)
.body("""
{"Name": "/region-test/key", "Value": "west-value", "Type": "String"}
""")
.when().post("/")
.then().statusCode(200);
// Get from us-east-1 — should return east-value
given()
.header("X-Amz-Target", "AmazonSSM.GetParameter")
.header("Authorization", AUTH_US_EAST_1)
.contentType(SSM_CONTENT_TYPE)
.body("""
{"Name": "/region-test/key"}
""")
.when().post("/")
.then()
.statusCode(200)
.body("Parameter.Value", equalTo("east-value"))
.body("Parameter.ARN", containsString("us-east-1"));
// Get from us-west-2 — should return west-value
given()
.header("X-Amz-Target", "AmazonSSM.GetParameter")
.header("Authorization", AUTH_US_WEST_2)
.contentType(SSM_CONTENT_TYPE)
.body("""
{"Name": "/region-test/key"}
""")
.when().post("/")
.then()
.statusCode(200)
.body("Parameter.Value", equalTo("west-value"))
.body("Parameter.ARN", containsString("us-west-2"));
}
@Test
void dynamoDbTablesAreIsolatedByRegion() {
// Create table in us-east-1
given()
.header("X-Amz-Target", "DynamoDB_20120810.CreateTable")
.header("Authorization", AUTH_US_EAST_1)
.contentType(DYNAMODB_CONTENT_TYPE)
.body("""
{
"TableName": "RegionTestTable",
"KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
"AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"}],
"ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}
}
""")
.when().post("/")
.then()
.statusCode(200)
.body("TableDescription.TableArn", containsString("us-east-1"));
// Create same table name in eu-west-1 — should succeed (different region)
given()
.header("X-Amz-Target", "DynamoDB_20120810.CreateTable")
.header("Authorization", AUTH_EU_WEST_1)
.contentType(DYNAMODB_CONTENT_TYPE)
.body("""
{
"TableName": "RegionTestTable",
"KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
"AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"}],
"ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}
}
""")
.when().post("/")
.then()
.statusCode(200)
.body("TableDescription.TableArn", containsString("eu-west-1"));
// List tables in us-east-1 — should see RegionTestTable
given()
.header("X-Amz-Target", "DynamoDB_20120810.ListTables")
.header("Authorization", AUTH_US_EAST_1)
.contentType(DYNAMODB_CONTENT_TYPE)
.body("{}")
.when().post("/")
.then()
.statusCode(200)
.body("TableNames", hasItem("RegionTestTable"));
// List tables in us-west-2 (no tables created there) — should NOT see RegionTestTable
given()
.header("X-Amz-Target", "DynamoDB_20120810.ListTables")
.header("Authorization", AUTH_US_WEST_2)
.contentType(DYNAMODB_CONTENT_TYPE)
.body("{}")
.when().post("/")
.then()
.statusCode(200)
.body("TableNames", not(hasItem("RegionTestTable")));
}
@Test
void sqsQueuesAreIsolatedByRegion() {
// Create queue in us-east-1
given()
.header("Authorization", AUTH_US_EAST_1)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "CreateQueue")
.formParam("QueueName", "region-test-queue")
.when().post("/")
.then()
.statusCode(200)
.body(containsString("region-test-queue"));
// Create same queue name in us-west-2 — should succeed (different region)
given()
.header("Authorization", AUTH_US_WEST_2)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "CreateQueue")
.formParam("QueueName", "region-test-queue")
.when().post("/")
.then()
.statusCode(200)
.body(containsString("region-test-queue"));
// List queues in us-east-1 — should see it
given()
.header("Authorization", AUTH_US_EAST_1)
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "ListQueues")
.when().post("/")
.then()
.statusCode(200)
.body(containsString("region-test-queue"));
}
@Test
void defaultRegionUsedWhenNoAuthHeader() {
// Request without Authorization header falls back to default (us-east-1)
given()
.header("X-Amz-Target", "AmazonSSM.PutParameter")
.contentType(SSM_CONTENT_TYPE)
.body("""
{"Name": "/default-region-test", "Value": "default", "Type": "String"}
""")
.when().post("/")
.then().statusCode(200);
// Can retrieve with explicit us-east-1 auth
given()
.header("X-Amz-Target", "AmazonSSM.GetParameter")
.header("Authorization", AUTH_US_EAST_1)
.contentType(SSM_CONTENT_TYPE)
.body("""
{"Name": "/default-region-test"}
""")
.when().post("/")
.then()
.statusCode(200)
.body("Parameter.Value", equalTo("default"));
}
}
@@ -0,0 +1,197 @@
package io.github.hectorvent.floci.core.common;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Cookie;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import org.junit.jupiter.api.Test;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class RegionResolverTest {
private final RegionResolver resolver = new RegionResolver("us-east-1", "000000000000");
@Test
void resolveRegionFromAuthorizationHeader() {
HttpHeaders headers = stubHeaders(
"AWS4-HMAC-SHA256 Credential=AKID/20260215/us-west-2/s3/aws4_request, " +
"SignedHeaders=host, Signature=abc123");
assertEquals("us-west-2", resolver.resolveRegion(headers));
}
@Test
void resolveRegionFromDifferentRegion() {
HttpHeaders headers = stubHeaders(
"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260215/eu-west-1/sqs/aws4_request, " +
"SignedHeaders=host, Signature=xyz");
assertEquals("eu-west-1", resolver.resolveRegion(headers));
}
@Test
void fallsBackToDefaultWhenNoAuthHeader() {
HttpHeaders headers = stubHeaders(null);
assertEquals("us-east-1", resolver.resolveRegion(headers));
}
@Test
void resolveRegionFromAuthOrNullReturnsCredentialRegion() {
assertEquals("eu-west-1", resolver.resolveRegionFromAuthOrNull(
"AWS4-HMAC-SHA256 Credential=AKID/20260215/eu-west-1/iotdata/aws4_request, " +
"SignedHeaders=host, Signature=abc123"));
}
@Test
void resolveRegionFromAuthOrNullIsNullWhereResolveReturnsDefault() {
assertNull(resolver.resolveRegionFromAuthOrNull("Bearer some-token"));
assertEquals("us-east-1", resolver.resolveRegionFromAuth("Bearer some-token"));
}
@Test
void resolveRegionFromAuthOrNullIsNullForAbsentOrBlank() {
assertNull(resolver.resolveRegionFromAuthOrNull(null));
assertNull(resolver.resolveRegionFromAuthOrNull(""));
assertNull(resolver.resolveRegionFromAuthOrNull(" "));
}
@Test
void fallsBackToDefaultWhenEmptyAuthHeader() {
HttpHeaders headers = stubHeaders("");
assertEquals("us-east-1", resolver.resolveRegion(headers));
}
@Test
void fallsBackToDefaultWhenNullHeaders() {
assertEquals("us-east-1", resolver.resolveRegion(null));
}
@Test
void fallsBackToDefaultWhenMalformedAuthHeader() {
HttpHeaders headers = stubHeaders("Bearer some-token");
assertEquals("us-east-1", resolver.resolveRegion(headers));
}
@Test
void getAccountId() {
assertEquals("000000000000", resolver.getAccountId());
}
@Test
void buildArn() {
assertEquals("arn:aws:ssm:us-west-2:000000000000:parameter/myParam",
resolver.buildArn("ssm", "us-west-2", "parameter/myParam"));
}
@Test
void customDefaultRegionAndAccountId() {
RegionResolver custom = new RegionResolver("ap-southeast-1", "123456789012");
assertEquals("ap-southeast-1", custom.getDefaultRegion());
assertEquals("123456789012", custom.getAccountId());
assertEquals("ap-southeast-1", custom.resolveRegion(null));
}
// --- resolveRegionFromPresignedCredential(String credentialValue) tests ---
@Test
void resolveRegionFromPresignedCredential_validCredential_returnsRegion() {
assertEquals("us-west-2",
resolver.resolveRegionFromPresignedCredential("000000000001/20260617/us-west-2/s3/aws4_request"));
}
@Test
void resolveRegionFromPresignedCredential_differentRegion() {
assertEquals("eu-central-1",
resolver.resolveRegionFromPresignedCredential("AKID/20260617/eu-central-1/s3/aws4_request"));
}
@Test
void resolveRegionFromPresignedCredential_null_returnsDefault() {
assertEquals("us-east-1", resolver.resolveRegionFromPresignedCredential(null));
}
@Test
void resolveRegionFromPresignedCredential_empty_returnsDefault() {
assertEquals("us-east-1", resolver.resolveRegionFromPresignedCredential(""));
}
@Test
void resolveRegionFromPresignedCredential_malformedTooFewParts_returnsDefault() {
assertEquals("us-east-1", resolver.resolveRegionFromPresignedCredential("only-two/parts"));
}
// --- resolveRegionFromHost(String host) tests (issue #1871) ---
@Test
void resolveRegionFromHost_regionBearingHost_returnsRegion() {
assertEquals("ap-northeast-2",
resolver.resolveRegionFromHost("abc123.execute-api.ap-northeast-2.localhost:4566"));
assertEquals("us-west-2",
resolver.resolveRegionFromHost("abc123.execute-api.us-west-2.localhost"));
assertEquals("eu-west-1",
resolver.resolveRegionFromHost("abc123.execute-api.eu-west-1.amazonaws.com"));
assertEquals("us-gov-east-1",
resolver.resolveRegionFromHost("abc123.execute-api.us-gov-east-1.amazonaws.com"));
}
@Test
void resolveRegionFromHost_normalizesUppercaseRegionToLowercase() {
// Hostnames are case-insensitive; the region must be lowercased so the region-scoped
// API lookup does not 403 on a casing mismatch (PR review P1).
assertEquals("us-east-1",
resolver.resolveRegionFromHost("abc123.execute-api.US-EAST-1.localhost:4566"));
}
@Test
void resolveRegionFromHost_builtinDnsSuffixes_returnNull() {
// Floci's built-in execute-api suffixes carry NO region label. The old pattern parsed
// "localhost" as the region and broke the lookup — these must return null so the caller
// falls back (default region / cross-region apiId scan) instead. (PR #2188 review.)
assertNull(resolver.resolveRegionFromHost("abc123.execute-api.localhost:4566"));
assertNull(resolver.resolveRegionFromHost("abc123.execute-api.localhost.floci.io:4566"));
assertNull(resolver.resolveRegionFromHost("abc123.execute-api.localhost.localstack.cloud:4566"));
}
@Test
void resolveRegionFromHost_nonExecuteApiHost_returnsNull() {
assertNull(resolver.resolveRegionFromHost("localhost:4566"));
assertNull(resolver.resolveRegionFromHost("my-bucket.localhost:4566"));
assertNull(resolver.resolveRegionFromHost("abc123.lambda-url.us-east-1.localhost"));
}
@Test
void resolveRegionFromHost_nullOrEmpty_returnsNull() {
assertNull(resolver.resolveRegionFromHost(null));
assertNull(resolver.resolveRegionFromHost(""));
}
private static HttpHeaders stubHeaders(String authorizationValue) {
return new HttpHeaders() {
@Override public List<String> getRequestHeader(String name) {
if ("Authorization".equalsIgnoreCase(name) && authorizationValue != null) {
return List.of(authorizationValue);
}
return List.of();
}
@Override public String getHeaderString(String name) {
if ("Authorization".equalsIgnoreCase(name)) return authorizationValue;
return null;
}
@Override public MultivaluedMap<String, String> getRequestHeaders() { return new MultivaluedHashMap<>(); }
@Override public List<MediaType> getAcceptableMediaTypes() { return List.of(); }
@Override public List<Locale> getAcceptableLanguages() { return List.of(); }
@Override public MediaType getMediaType() { return null; }
@Override public Locale getLanguage() { return null; }
@Override public Map<String, Cookie> getCookies() { return Map.of(); }
@Override public Date getDate() { return null; }
@Override public int getLength() { return 0; }
};
}
}
@@ -0,0 +1,67 @@
package io.github.hectorvent.floci.core.common;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.net.HostAndPort;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.UriInfo;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.net.URI;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.when;
class RequestHostTest {
private static final URI HTTP2_URI =
URI.create("https://e1z2x3c4v5b6n7.cloudfront.localhost.floci.io:4566/hello.txt");
@Test
void prefersTheHostHeader() {
assertEquals("viewer.example.test", RequestHost.of("viewer.example.test", HTTP2_URI));
}
@Test
void fallsBackToTheUriAuthorityWithoutAHostHeader() {
assertEquals("e1z2x3c4v5b6n7.cloudfront.localhost.floci.io:4566", RequestHost.of(null, HTTP2_URI));
assertEquals("e1z2x3c4v5b6n7.cloudfront.localhost.floci.io:4566", RequestHost.of(" ", HTTP2_URI));
}
@Test
void answersNullWithNeitherAHostHeaderNorAnAuthority() {
assertNull(RequestHost.of(null, (URI) null));
assertNull(RequestHost.of(null, URI.create("/relative/path")));
}
@Test
void readsARequestContext() {
UriInfo uriInfo = Mockito.mock(UriInfo.class);
when(uriInfo.getRequestUri()).thenReturn(HTTP2_URI);
ContainerRequestContext context = Mockito.mock(ContainerRequestContext.class);
when(context.getUriInfo()).thenReturn(uriInfo);
assertEquals("e1z2x3c4v5b6n7.cloudfront.localhost.floci.io:4566", RequestHost.of(context));
when(context.getHeaderString("Host")).thenReturn("viewer.example.test");
assertEquals("viewer.example.test", RequestHost.of(context));
}
@Test
void readsTheHttp2AuthorityOfAVertxRequest() {
HttpServerRequest request = Mockito.mock(HttpServerRequest.class);
when(request.authority()).thenReturn(
HostAndPort.create("e1z2x3c4v5b6n7.cloudfront.localhost.floci.io", 4566));
assertEquals("e1z2x3c4v5b6n7.cloudfront.localhost.floci.io:4566", RequestHost.of(request));
when(request.getHeader("Host")).thenReturn("viewer.example.test");
assertEquals("viewer.example.test", RequestHost.of(request));
}
@Test
void answersNullForAVertxRequestWithoutAnAuthority() {
assertNull(RequestHost.of(Mockito.mock(HttpServerRequest.class)));
}
}
@@ -0,0 +1,216 @@
package io.github.hectorvent.floci.core.common;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ReservedTagsTest {
@Test
void stripReservedTagsReturnsEmptyMapForNullInput() {
assertTrue(ReservedTags.stripReservedTags(null).isEmpty());
}
@Test
void stripReservedTagsReturnsEmptyMapForEmptyInput() {
assertTrue(ReservedTags.stripReservedTags(Map.of()).isEmpty());
}
@Test
void stripReservedTagsKeepsNonReservedTags() {
Map<String, String> tags = Map.of("env", "test", "team", "platform");
assertEquals(tags, ReservedTags.stripReservedTags(tags));
}
@Test
void stripReservedTagsRemovesOnlyReservedTags() {
Map<String, String> tags = new LinkedHashMap<>();
tags.put("env", "test");
tags.put(ReservedTags.OVERRIDE_ID_KEY, "my-id");
tags.put("floci:internal", "hidden");
tags.put("team", "platform");
Map<String, String> stripped = ReservedTags.stripReservedTags(tags);
assertEquals(Map.of("env", "test", "team", "platform"), stripped);
}
@Test
void stripReservedTagsRemovesAllReservedTags() {
Map<String, String> tags = Map.of(
ReservedTags.OVERRIDE_ID_KEY, "my-id",
"floci:internal", "hidden"
);
assertTrue(ReservedTags.stripReservedTags(tags).isEmpty());
}
@Test
void extractOverrideUserPoolIdReturnsNullForNullInput() {
assertNull(ReservedTags.extractOverrideUserPoolId(null));
}
@Test
void extractOverrideIdReturnsReservedOverrideUserPoolOnly() {
Map<String, String> tags = Map.of(
ReservedTags.OVERRIDE_ID_KEY, "my-id",
"floci:internal", "hidden",
"env", "test"
);
assertEquals("my-id", ReservedTags.extractOverrideUserPoolId(tags));
}
@Test
void rejectReservedTagsOnUpdateAllowsNormalTags() {
assertDoesNotThrow(() -> ReservedTags.rejectReservedTagsOnUpdate(Map.of("env", "test")));
}
@Test
void rejectReservedTagsOnUpdateRejectsReservedTags() {
AwsException exception = assertThrows(
AwsException.class,
() -> ReservedTags.rejectReservedTagsOnUpdate(Map.of(ReservedTags.OVERRIDE_ID_KEY, "my-id"))
);
assertEquals("ValidationException", exception.getErrorCode());
}
// ──────────────────────────── API Gateway override id ────────────────────────────
@Test
void extractOverrideApiIdReturnsNullForNullInput() {
assertNull(ReservedTags.extractOverrideApiId(null));
}
@Test
void extractOverrideApiIdReturnsNullWhenNeitherKeyPresent() {
assertNull(ReservedTags.extractOverrideApiId(Map.of("env", "test")));
}
@Test
void extractOverrideApiIdReadsReservedOverrideKey() {
assertEquals("my-api", ReservedTags.extractOverrideApiId(
Map.of(ReservedTags.OVERRIDE_ID_KEY, "my-api", "env", "test")));
}
@Test
void extractOverrideApiIdFallsBackToDeprecatedCustomIdKey() {
assertEquals("legacy-api", ReservedTags.extractOverrideApiId(
Map.of(ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, "legacy-api")));
}
@Test
void extractOverrideApiIdPrefersReservedKeyOverDeprecatedKey() {
assertEquals("winner", ReservedTags.extractOverrideApiId(Map.of(
ReservedTags.OVERRIDE_ID_KEY, "winner",
ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, "loser")));
}
@Test
void extractOverrideApiIdRejectsBlankValueWithApiGatewayErrorCode() {
AwsException exception = assertThrows(
AwsException.class,
() -> ReservedTags.extractOverrideApiId(Map.of(ReservedTags.OVERRIDE_ID_KEY, " "))
);
assertEquals("BadRequestException", exception.getErrorCode());
assertEquals(400, exception.getHttpStatus());
}
@Test
void extractOverrideApiIdRejectsUnsupportedCharacters() {
for (String bad : new String[] {"has/slash", "has?query", "has#fragment"}) {
AwsException exception = assertThrows(
AwsException.class,
() -> ReservedTags.extractOverrideApiId(Map.of(ReservedTags.OVERRIDE_ID_KEY, bad)),
"expected rejection for " + bad
);
assertEquals("BadRequestException", exception.getErrorCode());
}
}
@Test
void extractOverrideApiIdValidatesTheDeprecatedKeyToo() {
AwsException exception = assertThrows(
AwsException.class,
() -> ReservedTags.extractOverrideApiId(
Map.of(ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, "has/slash"))
);
assertEquals("BadRequestException", exception.getErrorCode());
}
@Test
void stripApiGatewayReservedTagsRemovesBothOverrideKeys() {
Map<String, String> tags = new LinkedHashMap<>();
tags.put("env", "test");
tags.put(ReservedTags.OVERRIDE_ID_KEY, "my-api");
tags.put(ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, "legacy-api");
tags.put("team", "platform");
assertEquals(Map.of("env", "test", "team", "platform"),
ReservedTags.stripApiGatewayReservedTags(tags));
}
@Test
void stripReservedTagsLeavesDeprecatedCustomIdForOtherServices() {
// _custom_id_ is API Gateway specific. KMS and Cognito use the shared strip and must keep it.
Map<String, String> tags = Map.of(ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, "legacy-api");
assertEquals(tags, ReservedTags.stripReservedTags(tags));
}
@Test
void rejectApiGatewayReservedTagsOnUpdateAllowsNormalTags() {
assertDoesNotThrow(() -> ReservedTags.rejectApiGatewayReservedTagsOnUpdate(Map.of("env", "test")));
assertDoesNotThrow(() -> ReservedTags.rejectApiGatewayReservedTagsOnUpdate(null));
}
@Test
void rejectApiGatewayReservedTagsOnUpdateRejectsBothOverrideKeys() {
for (String key : new String[] {
ReservedTags.OVERRIDE_ID_KEY, ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY}) {
AwsException exception = assertThrows(
AwsException.class,
() -> ReservedTags.rejectApiGatewayReservedTagsOnUpdate(Map.of(key, "too-late")),
"expected rejection for " + key
);
assertEquals("BadRequestException", exception.getErrorCode());
}
}
@Test
void rejectReservedTagsOnUpdateIgnoresDeprecatedCustomIdForOtherServices() {
assertDoesNotThrow(() -> ReservedTags.rejectReservedTagsOnUpdate(
Map.of(ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, "legacy-api")));
}
@Test
void rejectUnknownReservedTagsRejectsReservedTagsWithUserPoolTaggingException() {
AwsException exception = assertThrows(
AwsException.class,
() -> ReservedTags.rejectUnknownReservedTags(Map.of(ReservedTags.RESERVED_PREFIX + "unknown-override", "something"), "UserPoolTaggingException")
);
assertEquals("UserPoolTaggingException", exception.getErrorCode());
}
@Test
void rejectUnknownReservedTagsRejectsReservedTagsWithTagException() {
AwsException exception = assertThrows(
AwsException.class,
() -> ReservedTags.rejectUnknownReservedTags(Map.of(ReservedTags.RESERVED_PREFIX + "unknown-override", "something"), "TagException")
);
assertEquals("TagException", exception.getErrorCode());
}
}
@@ -0,0 +1,89 @@
package io.github.hectorvent.floci.core.common;
import io.github.hectorvent.floci.services.securityadmin.SecurityAdminController;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@QuarkusTest
class ServiceCatalogRoutingIntegrationTest {
@Inject
ResolvedServiceCatalog catalog;
@Test
void targetResolutionExtractsMatchingPrefixAndAction() {
ServiceCatalog.TargetMatch match = catalog.matchTarget("AWSEvents.PutEvents").orElseThrow();
assertEquals("events", match.descriptor().externalKey());
assertEquals("AWSEvents.", match.prefix());
assertEquals("PutEvents", match.action());
}
@Test
void dynamodbStreamsTargetUsesStreamsPrefix() {
ServiceCatalog.TargetMatch match = catalog.matchTarget("DynamoDBStreams_20120810.DescribeStream").orElseThrow();
assertEquals("dynamodb", match.descriptor().externalKey());
assertEquals("DynamoDBStreams_20120810.", match.prefix());
assertEquals("DescribeStream", match.action());
}
@Test
void cborSdkServiceIdsResolveThroughCatalog() {
assertEquals("states", catalog.byCborSdkServiceId("SFN").orElseThrow().externalKey());
assertEquals("monitoring", catalog.byCborSdkServiceId("GraniteServiceVersion20100801").orElseThrow().externalKey());
}
@Test
void rpcV2ServiceNamesDeriveFromTargetPrefixes() {
// The smithy-rpc-v2 path segment is the service shape name == target prefix
// without the trailing dot (what the AWS SDKs actually send on the wire).
assertEquals("dynamodb", catalog.byCborSdkServiceId("DynamoDB_20120810").orElseThrow().externalKey());
assertEquals("dynamodb", catalog.byCborSdkServiceId("DynamoDBStreams_20120810").orElseThrow().externalKey());
assertEquals("kinesis", catalog.byCborSdkServiceId("Kinesis_20131202").orElseThrow().externalKey());
assertEquals("sqs", catalog.byCborSdkServiceId("AmazonSQS").orElseThrow().externalKey());
assertEquals("sns", catalog.byCborSdkServiceId("SNS_20100331").orElseThrow().externalKey());
assertEquals("states", catalog.byCborSdkServiceId("AWSStepFunctions").orElseThrow().externalKey());
}
@Test
void queryProtocolAliasesAreDeclaredOnDescriptors() {
assertTrue(catalog.byCredentialScope("sesv2").orElseThrow().supportsProtocol(ServiceProtocol.QUERY));
assertTrue(catalog.byCredentialScope("cognito-idp").orElseThrow().supportsProtocol(ServiceProtocol.QUERY));
}
@Test
void rdsDataResolvesAsRestJsonByCredentialScope() {
ServiceDescriptor descriptor = catalog.byCredentialScope("rds-data").orElseThrow();
assertEquals("rds-data", descriptor.externalKey());
assertEquals(ServiceProtocol.REST_JSON, descriptor.defaultProtocol());
assertTrue(descriptor.supportsProtocol(ServiceProtocol.REST_JSON));
}
@Test
void fisResolvesAsRestJsonByCredentialScope() {
ServiceDescriptor descriptor = catalog.byCredentialScope("fis").orElseThrow();
assertEquals("fis", descriptor.externalKey());
assertEquals("fis", descriptor.storageKey());
assertEquals(ServiceProtocol.REST_JSON, descriptor.defaultProtocol());
assertTrue(descriptor.supportsProtocol(ServiceProtocol.REST_JSON));
}
@Test
void sharedSecurityAdminRouteResolvesOnlyBySigningScope() {
assertTrue(catalog.byResourceClass(SecurityAdminController.class).isEmpty());
assertEquals("macie2", catalog.byCredentialScope("macie2").orElseThrow().externalKey());
assertEquals("guardduty", catalog.byCredentialScope("guardduty").orElseThrow().externalKey());
}
@Test
void unknownTargetsRemainUnresolved() {
assertTrue(catalog.matchTarget("UnknownService.DoThing").isEmpty());
}
}
@@ -0,0 +1,216 @@
package io.github.hectorvent.floci.core.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.cbor.CBORFactory;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
import io.quarkus.test.junit.QuarkusTestProfile;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.assertEquals;
@QuarkusTest
@TestProfile(ServiceEnablementIntegrationTest.DisabledServicesProfile.class)
class ServiceEnablementIntegrationTest {
private static final ObjectMapper CBOR_MAPPER = new ObjectMapper(new CBORFactory());
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void acmTargetedRequestsAreRejected() {
given()
.contentType("application/x-amz-json-1.1")
.header("X-Amz-Target", "CertificateManager.ListCertificates")
.body("{}")
.when()
.post("/")
.then()
.statusCode(400)
.contentType("application/json")
.body("__type", equalTo("ServiceNotAvailableException"))
.body("message", equalTo("Service acm is not enabled."));
}
@Test
void ecsTargetedRequestsAreRejected() {
given()
.contentType("application/x-amz-json-1.1")
.header("X-Amz-Target", "AmazonEC2ContainerServiceV20141113.ListClusters")
.body("{}")
.when()
.post("/")
.then()
.statusCode(400)
.contentType("application/json")
.body("__type", equalTo("ServiceNotAvailableException"))
.body("message", equalTo("Service ecs is not enabled."));
}
@Test
void sqsQueueUrlJsonRequestsAreRejectedWhenServiceDisabled() {
given()
.contentType("application/x-amz-json-1.0")
.header("X-Amz-Target", "AmazonSQS.GetQueueAttributes")
.body("""
{"QueueUrl":"http://localhost:4566/000000000000/disabled-queue","AttributeNames":["All"]}
""")
.when()
.post("/000000000000/disabled-queue")
.then()
.statusCode(400)
.contentType("application/json")
.body("__type", equalTo("ServiceNotAvailableException"))
.body("message", equalTo("Service sqs is not enabled."));
}
@Test
void sqsQueryRequestsReturnXmlWhenServiceDisabled() {
given()
.contentType("application/x-www-form-urlencoded")
.header("Authorization", authorization("sqs"))
.formParam("Action", "ListQueues")
.when()
.post("/")
.then()
.statusCode(400)
.contentType("application/xml")
.body(containsString("<Code>ServiceNotAvailableException</Code>"))
.body(containsString("<Message>Service sqs is not enabled.</Message>"));
}
@Test
void dynamodbTargetedCborRequestsReturnCborErrors() throws Exception {
JsonNode body = cborBody(
given()
.contentType("application/cbor")
.accept("application/cbor")
.header("X-Amz-Target", "DynamoDB_20120810.ListTables")
.body(CBOR_MAPPER.writeValueAsBytes(Map.of()))
.when()
.post("/")
.then()
.statusCode(400)
.contentType("application/cbor")
.extract().asByteArray()
);
assertEquals("ServiceNotAvailableException", body.get("__type").asText());
assertEquals("Service dynamodb is not enabled.", body.get("message").asText());
}
@Test
void dynamodbSmithyCborRequestsReturnCborErrors() throws Exception {
JsonNode body = cborBody(
given()
.contentType("application/cbor")
.accept("application/cbor")
.header("Authorization", authorization("dynamodb"))
.body(CBOR_MAPPER.writeValueAsBytes(Map.of()))
.when()
.post("/service/DynamoDB/operation/ListTables")
.then()
.statusCode(400)
.contentType("application/cbor")
.extract().asByteArray()
);
assertEquals("ServiceNotAvailableException", body.get("__type").asText());
assertEquals("Service dynamodb is not enabled.", body.get("message").asText());
}
@Test
void signedLambdaGetRequestsReturnJsonWhenServiceDisabled() {
given()
.header("Authorization", authorization("lambda"))
.when()
.get("/2015-03-31/functions")
.then()
.statusCode(400)
.contentType("application/json")
.body("__type", equalTo("ServiceNotAvailableException"))
.body("message", equalTo("Service lambda is not enabled."));
}
@Test
void signedOpenSearchGetRequestsReturnJsonWhenServiceDisabled() {
given()
.header("Authorization", authorization("es"))
.when()
.get("/2021-01-01/domain")
.then()
.statusCode(400)
.contentType("application/json")
.body("__type", equalTo("ServiceNotAvailableException"))
.body("message", equalTo("Service es is not enabled."));
}
@Test
void signedFisGetRequestsReturnJsonWhenServiceDisabled() {
given()
.header("Authorization", authorization("fis"))
.when()
.get("/actions")
.then()
.statusCode(400)
.contentType("application/json")
.header("X-Amzn-Errortype", "ServiceNotAvailableException")
.body("__type", equalTo("ServiceNotAvailableException"))
.body("message", equalTo("Service fis is not enabled."));
}
@Test
void signedRdsDataExecuteRequestsReturnJsonWhenServiceDisabled() {
given()
.contentType("application/json")
.header("Authorization", authorization("rds-data"))
.body("""
{"resourceArn":"arn:aws:rds:us-east-1:000000000000:cluster:db","secretArn":"arn","database":"app","sql":"select 1"}
""")
.when()
.post("/Execute")
.then()
.statusCode(400)
.contentType("application/json")
.header("X-Amzn-Errortype", "ServiceNotAvailableException")
.header("x-amzn-query-error", "ServiceNotAvailableException;Sender")
.body("__type", equalTo("ServiceNotAvailableException"))
.body("message", equalTo("Service rds-data is not enabled."));
}
private static JsonNode cborBody(byte[] body) throws Exception {
return CBOR_MAPPER.readTree(body);
}
private static String authorization(String service) {
return "AWS4-HMAC-SHA256 Credential=test/20260411/us-east-1/" + service
+ "/aws4_request, SignedHeaders=host;x-amz-date, Signature=deadbeef";
}
public static final class DisabledServicesProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"floci.services.acm.enabled", "false",
"floci.services.dynamodb.enabled", "false",
"floci.services.ecs.enabled", "false",
"floci.services.fis.enabled", "false",
"floci.services.lambda.enabled", "false",
"floci.services.opensearch.enabled", "false",
"floci.services.rds-data.enabled", "false",
"floci.services.sqs.enabled", "false"
);
}
}
}
@@ -0,0 +1,25 @@
package io.github.hectorvent.floci.core.common;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
@QuarkusTest
class ServiceRegistryIntegrationTest {
@Inject
ServiceRegistry serviceRegistry;
@Test
void enabledServicesIncludeEc2AndEcs() {
assertTrue(serviceRegistry.getEnabledServices().contains("ec2"));
assertTrue(serviceRegistry.getEnabledServices().contains("ecs"));
}
@Test
void unknownServicesDefaultToEnabled() {
assertTrue(serviceRegistry.isServiceEnabled("totally-unknown-service"));
}
}
@@ -0,0 +1,51 @@
package io.github.hectorvent.floci.core.common;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Pins the boundary the unknown-service guard relies on (issue #1754): the guard only fires
* on a scope this parser returns, so anything it declines to parse is left to normal routing.
*/
class SigV4CredentialScopeTest {
@Test
void parsesSigV4Scope() {
assertEquals(Optional.of("s3"), SigV4CredentialScope.serviceName(
"AWS4-HMAC-SHA256 Credential=AKID/20260707/us-east-1/s3/aws4_request,"
+ " SignedHeaders=host;x-amz-date, Signature=deadbeef"));
}
@Test
void lowercasesScope() {
assertEquals(Optional.of("dynamodb"), SigV4CredentialScope.serviceName(
"AWS4-HMAC-SHA256 Credential=AKID/20260707/us-east-1/DynamoDB/aws4_request,"
+ " SignedHeaders=host, Signature=deadbeef"));
}
@Test
void parsesPresignedCredentialScope() {
assertEquals(Optional.of("macie2"), SigV4CredentialScope.serviceNameFromCredential(
"222222222222/20260707/us-east-1/macie2/aws4_request"));
}
@Test
void ignoresSigV4aBecauseItsCredentialOmitsTheRegion() {
// SigV4a carries the region in X-Amz-Region-Set, so its credential is
// <key>/<date>/<service>/aws4_request — one segment shorter than SigV4.
assertTrue(SigV4CredentialScope.serviceName(
"AWS4-ECDSA-P256-SHA256 Credential=AKID/20260707/s3/aws4_request,"
+ " SignedHeaders=host;x-amz-region-set, Signature=deadbeef").isEmpty());
}
@Test
void ignoresMissingAndUnsignedHeaders() {
assertTrue(SigV4CredentialScope.serviceName(null).isEmpty());
assertTrue(SigV4CredentialScope.serviceName("Bearer token").isEmpty());
assertTrue(SigV4CredentialScope.serviceName("Basic dXNlcjpwYXNz").isEmpty());
}
}
@@ -0,0 +1,189 @@
package io.github.hectorvent.floci.core.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.cbor.CBORFactory;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.notNullValue;
/**
* Exercises the smithy-rpc-v2-cbor wire format exactly as the AWS SDKs send it:
* POST /service/{serviceShapeName}/operation/{operation} where the service name
* segment is the X-Amz-Target prefix without its trailing dot (e.g.
* DynamoDB_20120810, Kinesis_20131202, GraniteServiceVersion20100801), with the
* smithy-protocol request header and a generic application/cbor content type.
* Also covers the sibling legacy format (x-amz-cbor-1.1 + X-Amz-Target at the
* root path) and content-type normalization during protocol transitions.
* Captured from aws-sdk-java 2.46.7 — see local/plan/smithy/captured/SUMMARY.md.
*/
@QuarkusTest
class SmithyRpcV2RoutingIntegrationTest {
private static final ObjectMapper CBOR_MAPPER = new ObjectMapper(new CBORFactory());
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
@BeforeAll
static void configureContentTypes() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void dynamoDbRoutesViaTargetPrefixServiceName() throws Exception {
JsonNode createRequest = JSON_MAPPER.readTree("""
{
"TableName": "RpcV2WireTable",
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
"AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
"ProvisionedThroughput": {"ReadCapacityUnits": 1, "WriteCapacityUnits": 1}
}
""");
byte[] created = given()
.contentType("application/cbor")
.accept("application/cbor")
.header("smithy-protocol", "rpc-v2-cbor")
.body(CBOR_MAPPER.writeValueAsBytes(createRequest))
.when()
.post("/service/DynamoDB_20120810/operation/CreateTable")
.then()
.statusCode(200)
.header("smithy-protocol", equalTo("rpc-v2-cbor"))
.contentType("application/cbor")
.extract().asByteArray();
assertThat(CBOR_MAPPER.readTree(created)
.path("TableDescription").path("TableName").asText(), equalTo("RpcV2WireTable"));
given()
.contentType("application/cbor")
.accept("application/cbor")
.header("smithy-protocol", "rpc-v2-cbor")
.body(CBOR_MAPPER.writeValueAsBytes(JSON_MAPPER.readTree("""
{"TableName": "RpcV2WireTable"}
""")))
.when()
.post("/service/DynamoDB_20120810/operation/DeleteTable")
.then()
.statusCode(200);
}
@Test
void kinesisRoutesViaTargetPrefixServiceName() throws Exception {
byte[] response = given()
.contentType("application/cbor")
.accept("application/cbor")
.header("smithy-protocol", "rpc-v2-cbor")
.body(CBOR_MAPPER.writeValueAsBytes(JSON_MAPPER.createObjectNode()))
.when()
.post("/service/Kinesis_20131202/operation/ListStreams")
.then()
.statusCode(200)
.header("smithy-protocol", equalTo("rpc-v2-cbor"))
.contentType("application/cbor")
.extract().asByteArray();
assertThat(CBOR_MAPPER.readTree(response).path("StreamNames"), notNullValue());
}
@Test
void cloudWatchRoutesViaGraniteServiceName() throws Exception {
byte[] response = given()
.contentType("application/cbor")
.accept("application/cbor")
.header("smithy-protocol", "rpc-v2-cbor")
.body(CBOR_MAPPER.writeValueAsBytes(JSON_MAPPER.createObjectNode()))
.when()
.post("/service/GraniteServiceVersion20100801/operation/ListMetrics")
.then()
.statusCode(200)
.header("smithy-protocol", equalTo("rpc-v2-cbor"))
.contentType("application/cbor")
.extract().asByteArray();
assertThat(CBOR_MAPPER.readTree(response).path("Metrics"), notNullValue());
}
@Test
void unknownServiceNameReturns404() throws Exception {
given()
.contentType("application/cbor")
.accept("application/cbor")
.header("smithy-protocol", "rpc-v2-cbor")
.body(CBOR_MAPPER.writeValueAsBytes(JSON_MAPPER.createObjectNode()))
.when()
.post("/service/NoSuchService/operation/DoThing")
.then()
.statusCode(404);
}
@Test
void legacyCborTargetAtRootEchoesAmzCborContentType() throws Exception {
// The live Kinesis format from aws-sdk-java: x-amz-cbor-1.1 body with
// X-Amz-Target at the root path, no smithy-protocol request header.
byte[] response = given()
.contentType("application/x-amz-cbor-1.1")
.header("X-Amz-Target", "Kinesis_20131202.ListStreams")
.body(CBOR_MAPPER.writeValueAsBytes(JSON_MAPPER.createObjectNode()))
.when()
.post("/")
.then()
.statusCode(200)
.contentType("application/x-amz-cbor-1.1")
.extract().asByteArray();
assertThat(CBOR_MAPPER.readTree(response).path("StreamNames"), notNullValue());
}
@Test
void smithyProtocolHeaderNormalizesDriftedContentType() throws Exception {
// A rpcv2Cbor request whose Content-Type drifted away from application/cbor
// must still route: the smithy-protocol header is the definitive claim
// signal and AwsCborContentTypeFilter normalizes the media type for it.
given()
.contentType("application/octet-stream")
.header("smithy-protocol", "rpc-v2-cbor")
.body(CBOR_MAPPER.writeValueAsBytes(JSON_MAPPER.createObjectNode()))
.when()
.post("/service/Kinesis_20131202/operation/ListStreams")
.then()
.statusCode(200)
.header("smithy-protocol", equalTo("rpc-v2-cbor"));
}
@Test
void straySmithyHeaderOutsideRpcV2PathDoesNotRewriteContentType() {
// A Smithy-Protocol header on a non-rpcv2 path must not trigger content
// type normalization: later filters (e.g. S3VirtualHostFilter) rely on
// seeing the original management content type, and the request must
// still reach its normal JSON controller.
given()
.contentType("application/x-amz-json-1.1")
.header("Smithy-Protocol", "rpc-v2-cbor")
.header("X-Amz-Target", "AmazonSSM.DescribeParameters")
.body("{}")
.when()
.post("/")
.then()
.statusCode(200);
}
@Test
void amzCbor11OnRpcV2PathEchoesOriginalContentType() throws Exception {
given()
.contentType("application/x-amz-cbor-1.1")
.header("smithy-protocol", "rpc-v2-cbor")
.body(CBOR_MAPPER.writeValueAsBytes(JSON_MAPPER.createObjectNode()))
.when()
.post("/service/Kinesis_20131202/operation/ListStreams")
.then()
.statusCode(200)
.contentType("application/x-amz-cbor-1.1");
}
}
@@ -0,0 +1,149 @@
package io.github.hectorvent.floci.core.common;
import io.github.hectorvent.floci.core.common.SqlParameterParser.Options;
import io.github.hectorvent.floci.core.common.SqlParameterParser.ParsedSql;
import org.junit.jupiter.api.Test;
import java.util.List;
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 SqlParameterParserTest {
@Test
void rewritesNamedPlaceholdersToPositional() {
ParsedSql parsed = SqlParameterParser.parse(
"select * from t where id = :id and name = :name", Options.REDSHIFT);
assertEquals("select * from t where id = ? and name = ?", parsed.sql());
assertEquals(List.of("id", "name"), parsed.parameterOrder());
}
@Test
void repeatsPlaceholderOncePerOccurrence() {
ParsedSql parsed = SqlParameterParser.parse(
"select * from t where a = :id or b = :id", Options.REDSHIFT);
assertEquals("select * from t where a = ? or b = ?", parsed.sql());
assertEquals(List.of("id", "id"), parsed.parameterOrder());
}
@Test
void ignoresColonsInsideStringLiteralsAndIdentifiersWhenBackticksEnabled() {
ParsedSql parsed = SqlParameterParser.parse(
"select ':notparam', \":col:\", `x:y` from t where id = :id", Options.RDS_POSTGRESQL);
assertEquals("select ':notparam', \":col:\", `x:y` from t where id = ?", parsed.sql());
assertEquals(List.of("id"), parsed.parameterOrder());
}
@Test
void redshiftDoesNotTreatBackticksAsQuotedIdentifiers() {
ParsedSql parsed = SqlParameterParser.parse(
"select `x:y` from t where id = :id", Options.REDSHIFT);
assertEquals("select `x?` from t where id = ?", parsed.sql());
assertEquals(List.of("y", "id"), parsed.parameterOrder());
}
@Test
void preservesPostgresCastOperatorAndCastsParameters() {
ParsedSql parsed = SqlParameterParser.parse(
"select id::text from t where created = :ts::timestamp", Options.REDSHIFT);
assertEquals("select id::text from t where created = ?::timestamp", parsed.sql());
assertEquals(List.of("ts"), parsed.parameterOrder());
}
@Test
void ignoresColonsInsideComments() {
ParsedSql parsed = SqlParameterParser.parse(
"select 1 -- :nope\n/* :also */ where id = :id", Options.REDSHIFT);
assertEquals("select 1 -- :nope\n/* :also */ where id = ?", parsed.sql());
assertEquals(List.of("id"), parsed.parameterOrder());
}
@Test
void ignoresColonsInsideDollarQuotedStrings() {
ParsedSql parsed = SqlParameterParser.parse(
"select $tag$ :nope $tag$ where id = :id", Options.REDSHIFT);
assertEquals("select $tag$ :nope $tag$ where id = ?", parsed.sql());
assertEquals(List.of("id"), parsed.parameterOrder());
}
@Test
void treatsBackslashAsEscapeInStringLiteralWhenEnabled() {
ParsedSql parsed = SqlParameterParser.parse(
"select * from t where note = 'it\\'s a :id' and id = :id", Options.RDS_MYSQL);
assertEquals("select * from t where note = 'it\\'s a :id' and id = ?", parsed.sql());
assertEquals(List.of("id"), parsed.parameterOrder());
}
@Test
void treatsBackslashQuoteAsClosingQuoteWhenEscapesDisabled() {
ParsedSql parsed = SqlParameterParser.parse(
"select 'a\\' as c, :id", Options.RDS_POSTGRESQL);
assertEquals("select 'a\\' as c, ?", parsed.sql());
assertEquals(List.of("id"), parsed.parameterOrder());
}
@Test
void ignoresBackslashInsideBacktickIdentifierEvenWhenEscapesEnabled() {
ParsedSql parsed = SqlParameterParser.parse(
"select `a\\` , id from t where id = :id", Options.RDS_MYSQL);
assertEquals("select `a\\` , id from t where id = ?", parsed.sql());
assertEquals(List.of("id"), parsed.parameterOrder());
}
@Test
void backslashEscapedQuoteInsideAnEscapeStringDoesNotEndTheLiteral() {
ParsedSql parsed = SqlParameterParser.parse(
"select E'it\\'s :value' as v where id = :id", Options.REDSHIFT);
assertEquals("select E'it\\'s :value' as v where id = ?", parsed.sql());
assertEquals(List.of("id"), parsed.parameterOrder());
}
@Test
void rdsPostgresDoesNotTreatEscapeStringsSpecially() {
ParsedSql parsed = SqlParameterParser.parse(
"select E'it\\'s :value' as v where id = :id", Options.RDS_POSTGRESQL);
assertEquals("select E'it\\'s ?' as v where id = :id", parsed.sql());
assertEquals(List.of("value"), parsed.parameterOrder());
}
@Test
void isMultiStatementIgnoresSemicolonsInsideCommentsLiteralsAndDollarQuotes() {
assertFalse(SqlParameterParser.isMultiStatement("select 1 -- a; b\n", Options.REDSHIFT));
assertFalse(SqlParameterParser.isMultiStatement("select * from t /* x; y */ where a = 1", Options.REDSHIFT));
assertFalse(SqlParameterParser.isMultiStatement("select ';' as sep", Options.REDSHIFT));
assertFalse(SqlParameterParser.isMultiStatement("select $tag$a;b$tag$", Options.REDSHIFT));
assertFalse(SqlParameterParser.isMultiStatement("select 1;", Options.REDSHIFT));
assertFalse(SqlParameterParser.isMultiStatement("select 1 ; \n ", Options.REDSHIFT));
}
@Test
void isMultiStatementDetectsARealSecondStatement() {
assertTrue(SqlParameterParser.isMultiStatement("select 1; select 2", Options.REDSHIFT));
assertTrue(SqlParameterParser.isMultiStatement("insert into t values (1); delete from t", Options.REDSHIFT));
}
@Test
void escapeStringWithAnEscapedQuoteIsNotSeenAsMultiStatement() {
assertFalse(SqlParameterParser.isMultiStatement("select E'a\\';b' as v", Options.REDSHIFT));
}
@Test
void isMultiStatementRespectsBacktickOption() {
assertTrue(SqlParameterParser.isMultiStatement("select `a;b`", Options.REDSHIFT));
assertFalse(SqlParameterParser.isMultiStatement("select `a;b`", Options.RDS_MYSQL));
}
}
@@ -0,0 +1,39 @@
package io.github.hectorvent.floci.core.common;
import org.junit.jupiter.api.Test;
import java.net.InetAddress;
import java.net.UnknownHostException;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SsrfProtectionTest {
@Test
void metadataAddressCoversIpv4LinkLocal() throws UnknownHostException {
assertTrue(SsrfProtection.isMetadataAddress(InetAddress.getByName("169.254.169.254")));
assertTrue(SsrfProtection.isMetadataAddress(InetAddress.getByName("169.254.0.1")));
}
@Test
void metadataAddressCoversIpv6LinkLocalAndAwsMetadata() throws UnknownHostException {
assertTrue(SsrfProtection.isMetadataAddress(InetAddress.getByName("fe80::1")));
assertTrue(SsrfProtection.isMetadataAddress(InetAddress.getByName("fd00:ec2::254")));
}
@Test
void metadataAddressCoversIpv4MappedLinkLocal() throws UnknownHostException {
assertTrue(SsrfProtection.isMetadataAddress(InetAddress.getByName("::ffff:169.254.169.254")));
}
@Test
void metadataAddressLeavesLoopbackAndPrivateRangesReachable() throws UnknownHostException {
assertFalse(SsrfProtection.isMetadataAddress(InetAddress.getByName("127.0.0.1")));
assertFalse(SsrfProtection.isMetadataAddress(InetAddress.getByName("::1")));
assertFalse(SsrfProtection.isMetadataAddress(InetAddress.getByName("10.0.0.5")));
assertFalse(SsrfProtection.isMetadataAddress(InetAddress.getByName("172.17.0.2")));
assertFalse(SsrfProtection.isMetadataAddress(InetAddress.getByName("192.168.1.10")));
assertFalse(SsrfProtection.isMetadataAddress(InetAddress.getByName("fd00:ec2::255")));
}
}
@@ -0,0 +1,110 @@
package io.github.hectorvent.floci.core.common;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue;
/**
* Verifies the floci.protocols.strict-claiming enforcement: RPC-signaled
* requests that no supported wire protocol claims are rejected per the Smithy
* wire-protocol-selection guide, while REST and well-formed RPC traffic is
* untouched.
*/
@QuarkusTest
@TestProfile(StrictProtocolClaimingIntegrationTest.StrictClaimingProfile.class)
class StrictProtocolClaimingIntegrationTest {
public static final class StrictClaimingProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of("floci.protocols.strict-claiming", "true");
}
}
@BeforeAll
static void configureContentTypes() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void rpcV2JsonIsRecognizedAndRejected() {
given()
.contentType("application/json")
.header("Smithy-Protocol", "rpc-v2-json")
.body("{}")
.when()
.post("/service/AmazonSQS/operation/ListQueues")
.then()
.statusCode(400)
.header("x-amzn-query-error", equalTo("UnknownOperationException;Sender"))
.body("__type", equalTo("UnknownOperationException"));
}
@Test
void unknownSmithyProtocolValueIsRejected() {
given()
.contentType("application/cbor")
.header("Smithy-Protocol", "rpc-v2-msgpack")
.body(new byte[]{(byte) 0xbf, (byte) 0xff})
.when()
.post("/service/DynamoDB_20120810/operation/ListTables")
.then()
.statusCode(404)
.body("__type", equalTo("UnknownOperationException"));
}
@Test
void targetPostWithForeignContentTypeIsRejected() {
given()
.contentType("text/plain")
.header("X-Amz-Target", "AmazonSSM.DescribeParameters")
.body("{}")
.when()
.post("/")
.then()
.statusCode(404)
.body("__type", equalTo("UnknownOperationException"));
}
@Test
void wellFormedRpcTrafficIsUnaffected() {
given()
.contentType("application/x-amz-json-1.1")
.header("X-Amz-Target", "AmazonSSM.DescribeParameters")
.body("{}")
.when()
.post("/")
.then()
.statusCode(200);
}
@Test
void restTrafficIsUnaffected() {
// S3 list-buckets at root bypasses claiming enforcement.
given()
.when()
.get("/")
.then()
.statusCode(200);
// A POST on a REST path reaches its normal handler (here S3's
// /{bucket}/{key} catch-all) instead of the claimer's rejection,
// which would carry the x-amzn-query-error header.
given()
.contentType("application/json")
.body("{}")
.when()
.post("/no/such/rest/path")
.then()
.header("x-amzn-query-error", nullValue());
}
}
@@ -0,0 +1,144 @@
package io.github.hectorvent.floci.core.common;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
/**
* A JSON POST without an X-Amz-Target header must fail with
* UnknownOperationException (404) rather than be served as an empty success.
* The status and error name follow the AWS Common Errors entry for an
* unrecognized operation; Smithy requires the header but does not specify a
* server response when it is absent, so this pins Floci's chosen behavior.
* Both the JSON 1.0 and JSON 1.1 controllers share the null-target branch.
*/
@QuarkusTest
class UnknownOperationMissingTargetIntegrationTest {
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void json10RequestWithoutTargetReturnsUnknownOperation() {
given()
.contentType("application/x-amz-json-1.0")
.body("{}")
.when()
.post("/")
.then()
.statusCode(404)
.header("x-amzn-query-error", equalTo("UnknownOperationException;Sender"))
.body("__type", equalTo("UnknownOperationException"))
.body("message", equalTo("Missing X-Amz-Target header."));
}
@Test
void json10RequestWithEmptyBodyAndNoTargetReturnsUnknownOperation() {
given()
.contentType("application/x-amz-json-1.0")
.when()
.post("/")
.then()
.statusCode(404)
.body("__type", equalTo("UnknownOperationException"))
.body("message", equalTo("Missing X-Amz-Target header."));
}
@Test
void json11RequestWithoutTargetReturnsUnknownOperation() {
given()
.contentType("application/x-amz-json-1.1")
.body("{}")
.when()
.post("/")
.then()
.statusCode(404)
.header("x-amzn-query-error", equalTo("UnknownOperationException;Sender"))
.body("__type", equalTo("UnknownOperationException"))
.body("message", equalTo("Missing X-Amz-Target header."));
}
@Test
void json11RequestWithEmptyBodyAndNoTargetReturnsUnknownOperation() {
given()
.contentType("application/x-amz-json-1.1")
.when()
.post("/")
.then()
.statusCode(404)
.body("__type", equalTo("UnknownOperationException"))
.body("message", equalTo("Missing X-Amz-Target header."));
}
@Test
void unknownTargetReturnsUnknownOperation() {
given()
.contentType("application/x-amz-json-1.0")
.header("X-Amz-Target", "Nope.Nope")
.body("{}")
.when()
.post("/")
.then()
.statusCode(404)
.body("__type", equalTo("UnknownOperationException"))
.body("message", equalTo("Unknown operation: Nope.Nope"));
}
@Test
void json10ValidTargetStillReachesTheHandler() {
given()
.contentType("application/x-amz-json-1.0")
.header("X-Amz-Target", "DynamoDB_20120810.ListTables")
.body("{}")
.when()
.post("/")
.then()
.statusCode(200)
.body("__type", not(equalTo("UnknownOperationException")));
}
@Test
void json11ValidTargetStillReachesTheHandler() {
given()
.contentType("application/x-amz-json-1.1")
.header("X-Amz-Target", "AmazonSSM.DescribeParameters")
.body("{}")
.when()
.post("/")
.then()
.statusCode(200)
.body("__type", not(equalTo("UnknownOperationException")));
}
@Test
void cborRequestWithoutTargetReturnsUnknownOperation() {
given()
.contentType("application/x-amz-cbor-1.1")
.when()
.post("/")
.then()
.statusCode(404)
.header("x-amzn-query-error", equalTo("UnknownOperationException;Sender"))
.header("smithy-protocol", equalTo("rpc-v2-cbor"));
}
@Test
void cborRequestWithUnknownTargetReturnsUnknownOperation() {
given()
.contentType("application/x-amz-cbor-1.1")
.header("X-Amz-Target", "Nope.Nope")
.when()
.post("/")
.then()
.statusCode(404)
.header("x-amzn-query-error", equalTo("UnknownOperationException;Sender"))
.header("smithy-protocol", equalTo("rpc-v2-cbor"));
}
}
@@ -0,0 +1,63 @@
package io.github.hectorvent.floci.core.common;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
/**
* The guard is only as good as the catalog's scope list, so it has an off switch: if Floci
* serves a route whose signing scope is not enumerated, rejecting it is a 404 with no
* workaround. Disabling restores the pre-#1754 fall-through rather than failing the request.
*/
@QuarkusTest
@TestProfile(UnknownServiceScopeGuardDisabledIntegrationTest.GuardDisabledProfile.class)
class UnknownServiceScopeGuardDisabledIntegrationTest {
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void unsupportedScopeFallsThroughWhenRejectionDisabled() {
given()
.header("Authorization", "AWS4-HMAC-SHA256 Credential=test/20260707/us-east-1/account"
+ "/aws4_request, SignedHeaders=host;x-amz-date, Signature=deadbeef")
.when()
.get("/guard-disabled-no-such-bucket")
.then()
// Back to the old behaviour: S3's path-style catch-all answers for the bucket
// named "accounts", instead of the guard's UnknownOperationException.
.statusCode(404)
.body(containsString("<Code>NoSuchBucket</Code>"));
}
@Test
void knownRestJsonScopeFallsThroughWhenRejectionDisabled() {
given()
.header("Authorization", "AWS4-HMAC-SHA256 Credential=test/20260707/us-east-1/bedrock"
+ "/aws4_request, SignedHeaders=host;x-amz-date, Signature=deadbeef")
.contentType("application/x-amz-json-1.1")
.body("{\"name\":\"probe\"}")
.when()
.post("/prompts")
.then()
.statusCode(400)
.body(containsString("<Code>InvalidArgument</Code>"));
}
public static final class GuardDisabledProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of("floci.protocols.reject-unknown-service-scope", "false");
}
}
}
@@ -0,0 +1,165 @@
package io.github.hectorvent.floci.core.common;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
/**
* REST requests signed for a service Floci does not implement must be rejected with the
* clean UnknownOperationException shape instead of falling through JAX-RS matching into
* S3's path-style wildcard routes (issue #1754). The guard requires positive identification
* via the SigV4 credential scope: unsigned, presigned, and Bearer traffic stays untouched.
*/
@QuarkusTest
class UnknownServiceScopeGuardIntegrationTest {
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
private static String authorization(String service) {
return "AWS4-HMAC-SHA256 Credential=test/20260707/us-east-1/" + service
+ "/aws4_request, SignedHeaders=host;x-amz-date, Signature=deadbeef";
}
@Test
void unsupportedRestServiceScopeGetsUnknownOperation() {
given()
.header("Authorization", authorization("support"))
.contentType("application/json")
.body("{}")
.when()
.post("/listRegions")
.then()
.statusCode(404)
.contentType(containsString("application/json"))
.header("X-Amzn-Errortype", "UnknownOperationException")
.header("x-amzn-query-error", "UnknownOperationException;Sender")
.body("__type", equalTo("UnknownOperationException"));
}
@Test
void knownRestJsonScopeCannotFallThroughToS3Wildcard() {
given()
.header("Authorization", authorization("bedrock"))
.contentType("application/x-amz-json-1.1")
.body("{\"name\":\"probe\"}")
.when()
.post("/prompts")
.then()
.statusCode(404)
.contentType(containsString("application/json"))
.header("X-Amzn-Errortype", "UnknownOperationException")
.body("__type", equalTo("UnknownOperationException"))
.body("message", equalTo("Unknown operation: POST /prompts"));
}
@Test
void bedrockScopeStillReachesBedrockRuntimeRoutes() {
given()
.header("Authorization", authorization("bedrock"))
.contentType("application/json")
.body("{\"prompt\":\"hello\"}")
.when()
.post("/model/test-model/invoke")
.then()
.statusCode(200)
.contentType(containsString("application/json"));
}
@Test
void s3ScopedRequestKeepsS3Behavior() {
given()
.header("Authorization", authorization("s3"))
.when()
.get("/no-such-bucket-1754?list-type=2")
.then()
.statusCode(404)
.contentType(containsString("xml"))
.body(containsString("<Code>NoSuchBucket</Code>"));
}
@Test
void s3expressScopedRequestKeepsS3Behavior() {
given()
.header("Authorization", authorization("s3express"))
.when()
.get("/no-such-bucket-1754?list-type=2")
.then()
.statusCode(404)
.body(containsString("<Code>NoSuchBucket</Code>"));
}
@Test
void iotJobsDataPlaneScopeReachesItsIotRoutes() {
// The IoT Jobs Data Plane signs as iot-jobs-data while IotController serves its
// /things/{thing}/jobs routes. The scope has to be enumerated or the guard rejects a
// route Floci does implement — the SDK compat suites caught exactly this on
// GetPendingJobExecutions.
given()
.header("Authorization", authorization("iot-jobs-data"))
.when()
.get("/things/guard-test-thing/jobs")
.then()
// IoT's own "thing not found", which only IotController can produce: the request
// reached the route. The guard's rejection would be UnknownOperationException instead.
.statusCode(404)
.body("message", containsString("guard-test-thing"))
.body("__type", not(equalTo("UnknownOperationException")));
}
@Test
void unsignedRequestIsUntouched() {
given()
.when()
.get("/no-such-bucket-1754?list-type=2")
.then()
.statusCode(404)
.body(containsString("<Code>NoSuchBucket</Code>"));
}
@Test
void presignedStyleQueryCredentialIsUntouched() {
// No Authorization header: query-string credentials are presigned-URL territory,
// out of the guard's positive-identification scope even with a bogus service.
given()
.queryParam("X-Amz-Credential", "test/20260707/us-east-1/account/aws4_request")
.when()
.get("/no-such-bucket-1754")
.then()
.statusCode(404)
.body(containsString("<Code>NoSuchBucket</Code>"));
}
@Test
void bearerAuthorizationIsUntouched() {
given()
.header("Authorization", "Bearer some-token")
.when()
.get("/no-such-bucket-1754?list-type=2")
.then()
.statusCode(404)
.body(containsString("<Code>NoSuchBucket</Code>"));
}
@Test
void rootFormPostSignedForAbsentServiceKeepsQueryProtocolPath() {
// Form-encoded POST / claims AWS_QUERY, not REST — the guard must not intercept it.
given()
.header("Authorization", authorization("account"))
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "ListRegions")
.when()
.post("/")
.then()
.statusCode(400)
.header("X-Amzn-Errortype", (String) null);
}
}
@@ -0,0 +1,229 @@
package io.github.hectorvent.floci.core.common;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.core.storage.InMemoryStorage;
import io.github.hectorvent.floci.core.storage.StorageBackend;
import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.eks.EksOidcService;
import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.interfaces.RSAPublicKey;
import java.util.Base64;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WebIdentityTokenVerifierTest {
private static final String ISSUER =
"https://oidc.eks.us-east-1.amazonaws.com/id/ABCDEF0123456789ABCDEF0123456789";
private static final String CLUSTER = "irsa-test-cluster";
private WebIdentityTokenVerifier verifier;
private EksOidcService oidcService;
@BeforeEach
void setUp() {
ObjectMapper objectMapper = new ObjectMapper();
verifier = new WebIdentityTokenVerifier(objectMapper);
oidcService = new EksOidcService(new StorageFactory(null, null) {
@Override
public <V> AccountAwareStorageBackend<V> create(String serviceName, String fileName,
TypeReference<Map<String, V>> typeReference) {
return AccountAwareStorageBackend.inMemory("000000000000");
}
}, objectMapper);
oidcService.ensureKey(CLUSTER, ISSUER);
}
private RSAPublicKey publicKey() {
return oidcService.findVerificationKey(ISSUER).orElseThrow();
}
private String mint(String namespace, String serviceAccount, String audience, Integer lifetime) {
return oidcService.mintServiceAccountToken(CLUSTER, ISSUER, namespace, serviceAccount,
audience, lifetime);
}
private static KeyPair newKeyPair() throws GeneralSecurityException {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
}
private static String base64Url(String value) {
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.getBytes(StandardCharsets.UTF_8));
}
/** Signs an arbitrary claim set, so tests can build tokens the production mint path never would. */
private static String signRs256(PrivateKey privateKey, String claimsJson)
throws GeneralSecurityException {
String header = base64Url("{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
String payload = base64Url(claimsJson);
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
signature.update((header + "." + payload).getBytes(StandardCharsets.UTF_8));
return header + "." + payload + "."
+ Base64.getUrlEncoder().withoutPadding().encodeToString(signature.sign());
}
@Test
void verifiesFlociMintedToken() throws Exception {
String token = mint("my-namespace", "my-service-account", null, null);
WebIdentityToken verified = verifier.verify(token, publicKey(), ISSUER,
EksOidcService.STS_AUDIENCE);
assertEquals(ISSUER, verified.issuer());
assertEquals("system:serviceaccount:my-namespace:my-service-account", verified.subject());
assertTrue(verified.audiences().contains(EksOidcService.STS_AUDIENCE));
}
@Test
void peeksIssuerWithoutVerifying() {
String token = mint("my-namespace", "my-service-account", null, null);
assertEquals(ISSUER, verifier.peekIssuer(token).orElseThrow());
}
@Test
void peekIssuerReturnsEmptyForOpaqueToken() {
assertTrue(verifier.peekIssuer("dummy-token").isEmpty());
assertTrue(verifier.peekIssuer(null).isEmpty());
assertTrue(verifier.peekIssuer("").isEmpty());
}
@Test
void peekIssuerSeesTheIssuerOfAnUnsignedToken() {
// Regression guard: an unsigned "header.payload." token must not read as opaque. If its
// issuer went unseen, the caller would skip validation for a token naming a Floci issuer
// and hand out credentials for an unsigned JWT.
String header = base64Url("{\"alg\":\"none\",\"typ\":\"JWT\"}");
String payload = base64Url("{\"iss\":\"" + ISSUER + "\","
+ "\"aud\":[\"" + EksOidcService.STS_AUDIENCE + "\"],"
+ "\"sub\":\"system:serviceaccount:my-namespace:my-service-account\","
+ "\"exp\":" + (System.currentTimeMillis() / 1000 + 3600) + "}");
assertEquals(ISSUER, verifier.peekIssuer(header + "." + payload + ".").orElseThrow());
}
@Test
void rejectsTamperedPayload() {
String token = mint("my-namespace", "my-service-account", null, null);
String[] parts = token.split("\\.", -1);
// The forged payload is otherwise valid — same issuer, audience, and a future expiry — so
// only the signature check can reject it.
String forgedPayload = base64Url("{\"iss\":\"" + ISSUER + "\","
+ "\"aud\":[\"" + EksOidcService.STS_AUDIENCE + "\"],"
+ "\"sub\":\"system:serviceaccount:my-namespace:attacker\","
+ "\"nbf\":" + (System.currentTimeMillis() / 1000 - 60) + ","
+ "\"exp\":" + (System.currentTimeMillis() / 1000 + 3600) + "}");
String tampered = parts[0] + "." + forgedPayload + "." + parts[2];
WebIdentityTokenVerifier.InvalidTokenException thrown = assertThrows(
WebIdentityTokenVerifier.InvalidTokenException.class,
() -> verifier.verify(tampered, publicKey(), ISSUER, EksOidcService.STS_AUDIENCE));
assertTrue(thrown.getMessage().contains("signature"),
"expected a signature failure, got: " + thrown.getMessage());
}
@Test
void rejectsWrongIssuer() {
String token = mint("my-namespace", "my-service-account", null, null);
assertThrows(WebIdentityTokenVerifier.InvalidTokenException.class,
() -> verifier.verify(token, publicKey(),
"https://oidc.eks.us-east-1.amazonaws.com/id/OTHER",
EksOidcService.STS_AUDIENCE));
}
@Test
void rejectsWrongAudience() {
String token = mint("my-namespace", "my-service-account", "some.other.audience", null);
assertThrows(WebIdentityTokenVerifier.InvalidTokenException.class,
() -> verifier.verify(token, publicKey(), ISSUER, EksOidcService.STS_AUDIENCE));
}
@Test
void rejectsExpiredToken() throws Exception {
// Signed with a locally generated key so the payload can carry an arbitrary past expiry;
// the production mint path always issues a future-dated token.
KeyPair keyPair = newKeyPair();
long expired = System.currentTimeMillis() / 1000 - 7200;
String token = signRs256(keyPair.getPrivate(),
"{\"iss\":\"" + ISSUER + "\",\"aud\":[\"" + EksOidcService.STS_AUDIENCE + "\"],"
+ "\"sub\":\"system:serviceaccount:my-namespace:my-service-account\","
+ "\"exp\":" + expired + "}");
// The distinct subtype is what lets STS answer ExpiredTokenException instead of the
// generic InvalidIdentityToken it returns for every other verification failure.
WebIdentityTokenVerifier.ExpiredTokenException thrown = assertThrows(
WebIdentityTokenVerifier.ExpiredTokenException.class,
() -> verifier.verify(token, (RSAPublicKey) keyPair.getPublic(), ISSUER,
EksOidcService.STS_AUDIENCE));
assertTrue(thrown.getMessage().contains("expired"));
}
@Test
void rejectsTokenNotYetValid() throws Exception {
KeyPair keyPair = newKeyPair();
long now = System.currentTimeMillis() / 1000;
String token = signRs256(keyPair.getPrivate(),
"{\"iss\":\"" + ISSUER + "\",\"aud\":[\"" + EksOidcService.STS_AUDIENCE + "\"],"
+ "\"sub\":\"system:serviceaccount:my-namespace:my-service-account\","
+ "\"nbf\":" + (now + 7200) + ",\"exp\":" + (now + 10800) + "}");
WebIdentityTokenVerifier.InvalidTokenException thrown = assertThrows(
WebIdentityTokenVerifier.InvalidTokenException.class,
() -> verifier.verify(token, (RSAPublicKey) keyPair.getPublic(), ISSUER,
EksOidcService.STS_AUDIENCE));
assertTrue(thrown.getMessage().contains("not yet valid"));
}
@Test
void rejectsKeyFromAnotherCluster() {
String otherIssuer = "https://oidc.eks.us-east-1.amazonaws.com/id/OTHERCLUSTER0000000000000000";
oidcService.ensureKey("other-cluster", otherIssuer);
String token = mint("my-namespace", "my-service-account", null, null);
RSAPublicKey otherKey = oidcService.findVerificationKey(otherIssuer).orElseThrow();
assertThrows(WebIdentityTokenVerifier.InvalidTokenException.class,
() -> verifier.verify(token, otherKey, ISSUER, EksOidcService.STS_AUDIENCE));
}
@Test
void rejectsMalformedToken() {
assertThrows(WebIdentityTokenVerifier.InvalidTokenException.class,
() -> verifier.verify("not-a-jwt", publicKey(), ISSUER, EksOidcService.STS_AUDIENCE));
}
@Test
void rejectsUnsignedAlgNoneToken() {
String header = base64Url("{\"alg\":\"none\",\"typ\":\"JWT\"}");
String payload = base64Url("{\"iss\":\"" + ISSUER + "\","
+ "\"aud\":[\"" + EksOidcService.STS_AUDIENCE + "\"],"
+ "\"sub\":\"system:serviceaccount:my-namespace:my-service-account\","
+ "\"exp\":" + (System.currentTimeMillis() / 1000 + 3600) + "}");
// The empty third segment must be preserved when splitting, so this is rejected by the
// algorithm check rather than being mistaken for a two-segment malformed token.
WebIdentityTokenVerifier.InvalidTokenException thrown = assertThrows(
WebIdentityTokenVerifier.InvalidTokenException.class,
() -> verifier.verify(header + "." + payload + ".", publicKey(), ISSUER,
EksOidcService.STS_AUDIENCE));
assertTrue(thrown.getMessage().contains("algorithm"),
"expected an algorithm rejection, got: " + thrown.getMessage());
}
}
@@ -0,0 +1,413 @@
package io.github.hectorvent.floci.core.common;
import org.junit.jupiter.api.Test;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class XmlParserTest {
// --- newStreamReader: the hardening this centralises ---
/**
* The reason three controllers stopped standing up their own XMLInputFactory. Each restated
* these settings, and a copy that forgot one would be an XXE hole nothing would catch.
*/
@Test
void newStreamReaderIgnoresEntitiesDeclaredInADoctype() throws Exception {
// The DTD is skipped rather than rejected, so the document itself still parses. What
// protects us is that nothing in it takes effect: an entity it declares stays undefined,
// so any reference to one fails instead of expanding.
String declaredButUnused = """
<!DOCTYPE root [ <!ENTITY x "expanded"> ]>
<Root><Value>plain</Value></Root>
""";
XMLStreamReader r = XmlParser.newStreamReader(declaredButUnused);
StringBuilder text = new StringBuilder();
while (r.hasNext()) {
if (r.next() == XMLStreamConstants.CHARACTERS) {
text.append(r.getText());
}
}
assertEquals("plain", text.toString().trim(), "the document parses, the DTD is ignored");
String referenced = """
<!DOCTYPE root [ <!ENTITY x "expanded"> ]>
<Root><Value>&x;</Value></Root>
""";
XMLStreamException e = assertThrows(XMLStreamException.class, () -> {
XMLStreamReader r2 = XmlParser.newStreamReader(referenced);
while (r2.hasNext()) {
r2.next();
}
}, "an entity from the skipped DTD must not expand");
assertFailedOnUndeclaredEntity(e, "x");
}
@Test
void newStreamReaderDoesNotExpandAnExternalEntity() {
// Pointing at a path that does not exist: if the entity were resolved this would fail
// by trying to read it. Refusing the DTD outright is the behaviour we want.
String xml = """
<!DOCTYPE root [ <!ENTITY secret SYSTEM "file:///nonexistent/secret"> ]>
<Root><Value>&secret;</Value></Root>
""";
XMLStreamException e = assertThrows(XMLStreamException.class, () -> {
XMLStreamReader r = XmlParser.newStreamReader(xml);
while (r.hasNext()) {
r.next();
}
}, "an external entity must never be resolved");
assertFailedOnUndeclaredEntity(e, "secret");
String message = String.valueOf(e.getMessage());
assertFalse(message.contains("nonexistent"),
"must fail without reading the file at all, was: " + message);
}
@Test
void newStreamReaderIsNamespaceAwareAndReadsAttributes() throws Exception {
// Attributes are why a caller reaches for a raw reader instead of the helpers here.
String xml = "<Root xmlns=\"http://example.com/ns\"><Item id=\"7\" name=\"x\"/></Root>";
XMLStreamReader r = XmlParser.newStreamReader(xml);
String id = null;
while (r.hasNext()) {
if (r.next() == XMLStreamConstants.START_ELEMENT && "Item".equals(r.getLocalName())) {
id = r.getAttributeValue(null, "id");
assertEquals("http://example.com/ns", r.getNamespaceURI(),
"the reader must be namespace-aware");
}
}
assertEquals("7", id);
}
// --- extractGroupsMulti: nested element resilience ---
@Test
void extractGroupsMultiSkipsNestedElementsAndParsesLeaves() {
String xml = """
<Root>
<Group>
<Id>g1</Id>
<Arn>arn:example</Arn>
<Event>event:one</Event>
<Nested>
<Deep>value</Deep>
</Nested>
</Group>
</Root>
""";
List<Map<String, List<String>>> groups = XmlParser.extractGroupsMulti(xml, "Group");
assertEquals(1, groups.size());
assertEquals(List.of("g1"), groups.get(0).get("Id"));
assertEquals(List.of("arn:example"), groups.get(0).get("Arn"));
assertEquals(List.of("event:one"), groups.get(0).get("Event"));
assertNull(groups.get(0).get("Nested"));
}
@Test
void extractGroupsMultiNestedElementBeforeLeaves() {
String xml = """
<Root>
<Group>
<Nested><Child>x</Child></Nested>
<Name>after-nested</Name>
</Group>
</Root>
""";
List<Map<String, List<String>>> groups = XmlParser.extractGroupsMulti(xml, "Group");
assertEquals(1, groups.size());
assertEquals(List.of("after-nested"), groups.get(0).get("Name"));
}
@Test
void extractGroupsMultiMultipleGroupsWithAndWithoutNested() {
String xml = """
<Root>
<Group>
<Key>a</Key>
</Group>
<Group>
<Key>b</Key>
<Container><Inner>skip</Inner></Container>
</Group>
</Root>
""";
List<Map<String, List<String>>> groups = XmlParser.extractGroupsMulti(xml, "Group");
assertEquals(2, groups.size());
assertEquals(List.of("a"), groups.get(0).get("Key"));
assertEquals(List.of("b"), groups.get(1).get("Key"));
}
// --- extractGroups: same resilience for single-value variant ---
@Test
void extractGroupsSkipsNestedElements() {
String xml = """
<Root>
<Group>
<Name>test</Name>
<Nested><Deep>skip</Deep></Nested>
<Value>kept</Value>
</Group>
</Root>
""";
List<Map<String, String>> groups = XmlParser.extractGroups(xml, "Group");
assertEquals(1, groups.size());
assertEquals("test", groups.get(0).get("Name"));
assertEquals("kept", groups.get(0).get("Value"));
assertNull(groups.get(0).get("Nested"));
}
// --- extractElementTree: scoped structured parsing ---
@Test
void extractElementTreePreservesNestedNamespaceQualifiedBlocks() {
var root = XmlParser.extractElementTree("""
<Envelope xmlns:cf="https://cloudfront.amazonaws.com/doc/2020-05-31/">
<cf:Config>
<cf:First><cf:Value>one</cf:Value></cf:First>
<cf:Second><cf:Value><![CDATA[two]]></cf:Value></cf:Second>
</cf:Config>
</Envelope>
""", "Config");
assertNotNull(root);
assertEquals("Config", root.name());
assertEquals("one", root.child("First").child("Value").text());
assertEquals("two", root.child("Second").child("Value").text());
}
@Test
void extractElementTreeReturnsNullForMalformedOrMissingElements() {
assertNull(XmlParser.extractElementTree("<Root><Target></Root>", "Target"));
assertNull(XmlParser.extractElementTree("<Root/>", "Target"));
assertNull(XmlParser.extractElementTree(null, "Target"));
}
// --- extractDeleteObjectEntries: batch delete Key/VersionId pairing ---
@Test
void extractDeleteObjectEntriesPairsKeyWithVersionId() {
String xml = """
<Delete>
<Object><Key>a.txt</Key><VersionId>v1</VersionId></Object>
<Object><Key>b.txt</Key><VersionId>v2</VersionId></Object>
</Delete>
""";
List<XmlParser.KeyVersion> entries = XmlParser.extractDeleteObjectEntries(xml);
assertEquals(2, entries.size());
assertEquals(new XmlParser.KeyVersion("a.txt", "v1"), entries.get(0));
assertEquals(new XmlParser.KeyVersion("b.txt", "v2"), entries.get(1));
}
@Test
void extractDeleteObjectEntriesVersionIdIsOptional() {
String xml = """
<Delete>
<Object><Key>no-version.txt</Key></Object>
</Delete>
""";
List<XmlParser.KeyVersion> entries = XmlParser.extractDeleteObjectEntries(xml);
assertEquals(1, entries.size());
assertEquals("no-version.txt", entries.get(0).key());
assertNull(entries.get(0).versionId());
}
@Test
void extractDeleteObjectEntriesMixedVersionedAndUnversioned() {
String xml = """
<Delete>
<Quiet>true</Quiet>
<Object><Key>versioned.txt</Key><VersionId>v1</VersionId></Object>
<Object><Key>plain.txt</Key></Object>
</Delete>
""";
List<XmlParser.KeyVersion> entries = XmlParser.extractDeleteObjectEntries(xml);
assertEquals(2, entries.size());
assertEquals("v1", entries.get(0).versionId());
assertNull(entries.get(1).versionId());
}
@Test
void extractDeleteObjectEntriesEmptyWhenNoObjects() {
assertTrue(XmlParser.extractDeleteObjectEntries("<Delete><Quiet>true</Quiet></Delete>").isEmpty());
assertTrue(XmlParser.extractDeleteObjectEntries(null).isEmpty());
assertTrue(XmlParser.extractDeleteObjectEntries("").isEmpty());
}
// --- extractPairsPerGroup ---
@Test
void extractPairsPerGroupBasic() {
String xml = """
<Root>
<Group>
<Leaf>text</Leaf>
<Wrapper>
<Pair>
<Key>color</Key>
<Val>red</Val>
</Pair>
</Wrapper>
</Group>
</Root>
""";
List<Map<String, String>> pairs =
XmlParser.extractPairsPerGroup(xml, "Group", "Pair", "Key", "Val");
assertEquals(1, pairs.size());
assertEquals("red", pairs.get(0).get("color"));
}
@Test
void extractPairsPerGroupMultiplePairsPerGroup() {
String xml = """
<Root>
<Group>
<Wrapper>
<Rule><Name>prefix</Name><Value>images/</Value></Rule>
<Rule><Name>suffix</Name><Value>.jpg</Value></Rule>
</Wrapper>
</Group>
</Root>
""";
List<Map<String, String>> pairs =
XmlParser.extractPairsPerGroup(xml, "Group", "Rule", "Name", "Value");
assertEquals(1, pairs.size());
assertEquals("images/", pairs.get(0).get("prefix"));
assertEquals(".jpg", pairs.get(0).get("suffix"));
}
@Test
void extractPairsPerGroupMultipleGroups() {
String xml = """
<Root>
<Group>
<Tag><Key>env</Key><Val>prod</Val></Tag>
</Group>
<Group>
<Tag><Key>team</Key><Val>infra</Val></Tag>
<Tag><Key>cost</Key><Val>shared</Val></Tag>
</Group>
</Root>
""";
List<Map<String, String>> pairs =
XmlParser.extractPairsPerGroup(xml, "Group", "Tag", "Key", "Val");
assertEquals(2, pairs.size());
assertEquals(Map.of("env", "prod"), pairs.get(0));
assertEquals(Map.of("team", "infra", "cost", "shared"), pairs.get(1));
}
@Test
void extractPairsPerGroupEmptyWhenNoPairsFound() {
String xml = """
<Root>
<Group>
<Name>no-pairs-here</Name>
</Group>
</Root>
""";
List<Map<String, String>> pairs =
XmlParser.extractPairsPerGroup(xml, "Group", "Pair", "Key", "Value");
assertEquals(1, pairs.size());
assertTrue(pairs.get(0).isEmpty());
}
@Test
void extractPairsPerGroupNullAndEmptyXml() {
assertTrue(XmlParser.extractPairsPerGroup(null, "G", "P", "K", "V").isEmpty());
assertTrue(XmlParser.extractPairsPerGroup("", "G", "P", "K", "V").isEmpty());
}
@Test
void extractPairsPerGroupIndexAlignedWithExtractGroupsMulti() {
String xml = """
<Conf>
<QueueConfiguration>
<Queue>arn:q1</Queue>
<Event>s3:ObjectCreated:*</Event>
</QueueConfiguration>
<QueueConfiguration>
<Queue>arn:q2</Queue>
<Event>s3:ObjectRemoved:*</Event>
<Filter><S3Key>
<FilterRule><Name>prefix</Name><Value>logs/</Value></FilterRule>
</S3Key></Filter>
</QueueConfiguration>
</Conf>
""";
var groups = XmlParser.extractGroupsMulti(xml, "QueueConfiguration");
var filters = XmlParser.extractPairsPerGroup(xml, "QueueConfiguration",
"FilterRule", "Name", "Value");
assertEquals(2, groups.size());
assertEquals(2, filters.size());
assertTrue(filters.get(0).isEmpty());
assertEquals("logs/", filters.get(1).get("prefix"));
}
// --- rootElementName: root identification and well-formedness ---
@Test
void rootElementNameReturnsTheRootLocalName() {
assertEquals("AccelerateConfiguration", XmlParser.rootElementName(
"<AccelerateConfiguration><Status>Enabled</Status></AccelerateConfiguration>"));
assertEquals("AccelerateConfiguration", XmlParser.rootElementName(
"<ns:AccelerateConfiguration xmlns:ns=\"urn:x\"/>"));
assertEquals("Wrapper", XmlParser.rootElementName(
"<Wrapper><AccelerateConfiguration/></Wrapper>"));
}
@Test
void rootElementNameReturnsNullForBodiesThatDoNotParse() {
assertNull(XmlParser.rootElementName(null));
assertNull(XmlParser.rootElementName(""));
assertNull(XmlParser.rootElementName("garbage {} not xml"));
assertNull(XmlParser.rootElementName("<AccelerateConfiguration><Status>Enabled"));
assertNull(XmlParser.rootElementName("<AccelerateConfiguration/>trailing"));
}
/**
* Asserts the parse stopped at an entity reference the document never declared. The parser
* translates the sentence it reports, so a JVM with a non-English default locale spells this
* failure differently, and on macOS that locale comes from system preferences rather than from
* LANG. The entity name it quotes is the part no translation touches.
*/
private static void assertFailedOnUndeclaredEntity(XMLStreamException e, String entityName) {
String message = String.valueOf(e.getMessage());
assertTrue(message.contains("\"" + entityName + "\""),
"should fail on the undeclared entity " + entityName + ", was: " + message);
}
}
@@ -0,0 +1,71 @@
package io.github.hectorvent.floci.core.common.auth;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Pins the shared SigV4 crypto primitives against AWS's own published test vector, independent
* of any one caller's canonical-request shape. SigV4Validator, RdsSigV4Validator, and S3's
* PreSignedUrlFilter/S3HeaderSignatureFilter each exercise these indirectly through their own
* request flows; this targets the primitives themselves at their one shared source.
*/
class SigV4RequestValidatorTest {
/**
* Signing-key derivation for AWS's well-known example secret key
* ("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLE", date 20150830, us-east-1, iam), the same
* inputs AWS uses throughout its SigV4 documentation. Expected value cross-checked
* independently with Python's hmac/hashlib, not derived from this code.
*/
@Test
void deriveSigningKeyMatchesAnIndependentlyComputedValue() throws Exception {
byte[] signingKey = SigV4RequestValidator.deriveSigningKey(
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLE", "20150830", "us-east-1", "iam");
assertEquals("93c91b7c5da17c72120bd321a9833353b5dd75355fe396cc91abc149ad9755b5",
SigV4RequestValidator.hexEncode(signingKey));
}
@Test
void sha256HexOfEmptyStringIsTheWellKnownConstant() throws Exception {
// This exact value is also hardcoded in validate()'s canonical request for the empty
// body ElastiCache/RDS presigned tokens always carry; this pins it independently.
assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
SigV4RequestValidator.sha256Hex(""));
}
@Test
void hmacSha256IsDeterministicForTheSameKeyAndData() throws Exception {
byte[] key = "a-secret-key".getBytes(StandardCharsets.UTF_8);
byte[] first = SigV4RequestValidator.hmacSha256(key, "some data");
byte[] second = SigV4RequestValidator.hmacSha256(key, "some data");
assertEquals(SigV4RequestValidator.hexEncode(first), SigV4RequestValidator.hexEncode(second));
}
@Test
void hexEncodeLowercasesAndZeroPadsEachByte() {
assertEquals("00ff0a", SigV4RequestValidator.hexEncode(new byte[]{0x00, (byte) 0xFF, 0x0A}));
}
@Test
void containsHeaderMatchesAnyOfTheSemicolonSeparatedNames() {
assertTrue(SigV4RequestValidator.containsHeader("host;x-amz-date;x-amz-content-sha256", "host"));
assertTrue(SigV4RequestValidator.containsHeader("host;x-amz-date", "x-amz-date"));
assertFalse(SigV4RequestValidator.containsHeader("host;x-amz-date", "authorization"));
}
@Test
void isSha256HexRequiresExactlySixtyFourHexCharacters() {
assertTrue(SigV4RequestValidator.isSha256Hex(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"));
assertFalse(SigV4RequestValidator.isSha256Hex("UNSIGNED-PAYLOAD"));
assertFalse(SigV4RequestValidator.isSha256Hex("e3b0c442"));
}
}
@@ -0,0 +1,336 @@
package io.github.hectorvent.floci.core.common.dns;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.ByteBuffer;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class EmbeddedDnsServerTest {
private EmbeddedDnsServer dns;
@BeforeEach
void setUp() {
dns = new EmbeddedDnsServer(List.of("localhost.floci.io"));
}
// ── matchesSuffix — configured suffix ────────────────────────────────────
@Test
void matchesSuffix_exactMatch() {
assertTrue(dns.matchesSuffix("localhost.floci.io"));
}
@Test
void matchesSuffix_singleSubdomain() {
assertTrue(dns.matchesSuffix("my-bucket.localhost.floci.io"));
}
@Test
void matchesSuffix_deeplyNested() {
assertTrue(dns.matchesSuffix("deeply.nested.bucket.localhost.floci.io"));
}
@Test
void matchesSuffix_caseInsensitive() {
assertTrue(dns.matchesSuffix("My-Bucket.Localhost.Floci.IO"));
}
@Test
void matchesSuffix_noMatch() {
assertFalse(dns.matchesSuffix("my-bucket.s3.amazonaws.com"));
}
@Test
void matchesSuffix_partialSuffixNoMatch() {
assertFalse(dns.matchesSuffix("floci.io"));
}
// bare *.floci.io (without localhost.) must NOT match — only *.localhost.floci.io is registered
@Test
void matchesSuffix_bareFlociIoSubdomainNoMatch() {
assertFalse(dns.matchesSuffix("my-bucket.floci.io"));
}
@Test
void matchesSuffix_bareS3FlociIoNoMatch() {
assertFalse(dns.matchesSuffix("s3.floci.io"));
}
// bare *.localstack.cloud (without localhost.) must NOT match either
@Test
void matchesSuffix_bareLocalstackCloudNoMatch() {
assertFalse(dns.matchesSuffix("my-bucket.localstack.cloud"));
}
@Test
void matchesSuffix_nullAndEmpty() {
assertFalse(dns.matchesSuffix(null));
assertFalse(dns.matchesSuffix(""));
}
// ── EC2 private DNS names ────────────────────────────────────────────────
@Test
void resolveEc2PrivateDnsName_decodesAwsIpName() {
assertEquals(
"172.16.128.9",
dns.resolveEc2PrivateDnsName("ip-172-16-128-9.ec2.internal").orElseThrow());
}
@Test
void resolveEc2PrivateDnsName_isCaseInsensitive() {
assertEquals(
"10.42.32.17",
dns.resolveEc2PrivateDnsName("IP-10-42-32-17.EC2.INTERNAL").orElseThrow());
}
@Test
void resolveEc2PrivateDnsName_rejectsInvalidOctets() {
assertTrue(dns.resolveEc2PrivateDnsName("ip-172-16-128-300.ec2.internal").isEmpty());
}
@Test
void resolveARecord_prefersEc2PrivateDnsAddressOverFlociWildcard() {
assertEquals(
"172.16.128.9",
dns.resolveARecord("ip-172-16-128-9.ec2.internal", "172.16.128.5").orElseThrow());
}
// ── matchesSuffix — built-in emulator domains ─────────────────────────────
@Test
void matchesSuffix_localhostFlociIo_exact() {
assertTrue(dns.matchesSuffix("localhost.floci.io"));
}
@Test
void matchesSuffix_localhostFlociIo_subdomain() {
assertTrue(dns.matchesSuffix("my-bucket.localhost.floci.io"));
}
@Test
void matchesSuffix_s3LocalhostFlociIo() {
assertTrue(dns.matchesSuffix("s3.localhost.floci.io"));
}
@Test
void matchesSuffix_bucketS3LocalhostFlociIo() {
assertTrue(dns.matchesSuffix("my-bucket.s3.localhost.floci.io"));
}
@Test
void matchesSuffix_localhostLocalstackCloud_exact() {
assertTrue(dns.matchesSuffix("localhost.localstack.cloud"));
}
@Test
void matchesSuffix_localhostLocalstackCloud_subdomain() {
assertTrue(dns.matchesSuffix("my-bucket.localhost.localstack.cloud"));
}
@Test
void matchesSuffix_s3LocalhostLocalstackCloud() {
assertTrue(dns.matchesSuffix("s3.localhost.localstack.cloud"));
}
@Test
void matchesSuffix_bucketS3LocalhostLocalstackCloud() {
assertTrue(dns.matchesSuffix("my-bucket.s3.localhost.localstack.cloud"));
}
@Test
void matchesSuffix_bucketS3RegionLocalstackCloud() {
assertTrue(dns.matchesSuffix("my-bucket.s3.us-east-1.localhost.localstack.cloud"));
}
// ── readName ──────────────────────────────────────────────────────────────
@Test
void readName_simple() {
// my-bucket.localhost.floci.io encoded as DNS labels
byte[] encoded = encodeName("my-bucket.localhost.floci.io");
ByteBuffer buf = ByteBuffer.wrap(encoded);
assertEquals("my-bucket.localhost.floci.io", dns.readName(buf, encoded));
}
@Test
void readName_singleLabel() {
byte[] encoded = encodeName("floci");
ByteBuffer buf = ByteBuffer.wrap(encoded);
assertEquals("floci", dns.readName(buf, encoded));
}
@Test
void readName_withCompressionPointer() {
// Build a buffer where the name at offset 12 is "floci.io" and
// a pointer at offset 0 points to it.
byte[] data = new byte[20];
// pointer at offset 0 → offset 4
data[0] = (byte) 0xC0;
data[1] = 0x04;
// "floci.io" at offset 4
byte[] name = encodeName("floci.io");
System.arraycopy(name, 0, data, 4, name.length);
ByteBuffer buf = ByteBuffer.wrap(data);
assertEquals("floci.io", dns.readName(buf, data));
}
// ── buildAResponse ────────────────────────────────────────────────────────
@Test
void buildAResponse_hasCorrectTransactionId() {
byte[] query = buildQuery("my-bucket.localhost.floci.io", (short) 0x1234);
byte[] response = dns.buildAResponse(query, (short) 0x1234, 12, query.length, "172.19.0.2");
short txId = ByteBuffer.wrap(response).getShort(0);
assertEquals((short) 0x1234, txId);
}
@Test
void buildAResponse_flagsIndicateResponse() {
byte[] query = buildQuery("bucket.localhost.floci.io", (short) 1);
byte[] response = dns.buildAResponse(query, (short) 1, 12, query.length, "10.0.0.1");
short flags = ByteBuffer.wrap(response).getShort(2);
assertTrue((flags & 0x8000) != 0, "QR bit must be set");
}
@Test
void buildAResponse_answerCountIsOne() {
byte[] query = buildQuery("bucket.localhost.floci.io", (short) 2);
byte[] response = dns.buildAResponse(query, (short) 2, 12, query.length, "10.0.0.1");
short ancount = ByteBuffer.wrap(response).getShort(6);
assertEquals(1, ancount);
}
@Test
void buildAResponse_ipAddressIsCorrect() {
byte[] query = buildQuery("bucket.localhost.floci.io", (short) 3);
byte[] response = dns.buildAResponse(query, (short) 3, 12, query.length, "172.19.0.42");
// IP starts at offset: 12 (header) + questionLength + 2+2+2+4+2 = questionLength + 24
int questionLength = query.length - 12;
ByteBuffer resp = ByteBuffer.wrap(response);
resp.position(12 + questionLength + 10); // skip header + question + name-ptr(2) + type(2) + class(2) + ttl(4)
short rdlen = resp.getShort();
assertEquals(4, rdlen);
assertEquals((byte) 172, resp.get());
assertEquals((byte) 19, resp.get());
assertEquals((byte) 0, resp.get());
assertEquals((byte) 42, resp.get());
}
// ── composeUpstreams — forwarder upstream ordering ────────────────────────
@Test
void composeUpstreams_resolvConfFirstThenFallbacks() {
List<String> upstreams = EmbeddedDnsServer.composeUpstreams(
List.of("192.168.65.7"), List.of("8.8.8.8", "8.8.4.4"));
assertEquals(List.of("192.168.65.7", "8.8.8.8", "8.8.4.4"), upstreams);
}
@Test
void composeUpstreams_usesDockerResolverBaselineWhenResolvConfEmpty() {
List<String> upstreams = EmbeddedDnsServer.composeUpstreams(
List.of(), List.of("8.8.8.8"));
// Docker's embedded resolver is the baseline, then the configured fallback.
assertEquals(List.of("127.0.0.11", "8.8.8.8"), upstreams);
}
@Test
void composeUpstreams_skipsLoopbackAndBlankEntries() {
List<String> upstreams = EmbeddedDnsServer.composeUpstreams(
List.of("127.0.0.1", " ", "10.0.0.2"), List.of("", "8.8.8.8"));
assertEquals(List.of("10.0.0.2", "8.8.8.8"), upstreams);
}
@Test
void composeUpstreams_dedupesAcrossResolvConfAndFallbacks() {
List<String> upstreams = EmbeddedDnsServer.composeUpstreams(
List.of("8.8.8.8"), List.of("8.8.8.8", "8.8.4.4"));
assertEquals(List.of("8.8.8.8", "8.8.4.4"), upstreams);
}
@Test
void composeUpstreams_toleratesNullFallbacks() {
List<String> upstreams = EmbeddedDnsServer.composeUpstreams(List.of("10.0.0.2"), null);
assertEquals(List.of("10.0.0.2"), upstreams);
}
// ── forwarding — response buffer size (issue #1110 regression) ────────────
@Test
void forwardToUpstreams_returnsResponseLargerThan512BytesIntact() throws Exception {
// Regression guard: a 512-byte receive buffer silently truncated EDNS0 responses from
// CDN-backed public hosts, corrupting the answer forwarded back to the Lambda container.
byte[] bigResponse = new byte[1500];
for (int i = 0; i < bigResponse.length; i++) {
bigResponse[i] = (byte) (i & 0xFF);
}
try (DatagramSocket responder = new DatagramSocket(0, InetAddress.getByName("127.0.0.1"))) {
startResponder(responder, bigResponse);
byte[] query = buildQuery("business-api.tiktok.com", (short) 0x1234);
byte[] response = dns.forwardToUpstreams(
query, List.of("127.0.0.1"), responder.getLocalPort());
assertEquals(bigResponse.length, response.length,
"response larger than 512 bytes must be forwarded without truncation");
assertArrayEquals(bigResponse, response);
}
}
/** Replies to the first datagram received with a fixed payload, on a daemon thread. */
private void startResponder(DatagramSocket responder, byte[] responsePayload) {
Thread t = new Thread(() -> {
try {
DatagramPacket req = new DatagramPacket(new byte[4096], 4096);
responder.receive(req);
responder.send(new DatagramPacket(
responsePayload, responsePayload.length, req.getAddress(), req.getPort()));
} catch (Exception ignored) {
// socket closed when the test completes
}
});
t.setDaemon(true);
t.start();
}
// ── helpers ───────────────────────────────────────────────────────────────
private byte[] encodeName(String name) {
String[] labels = name.split("\\.");
int len = 1; // trailing zero
for (String l : labels) len += 1 + l.length();
byte[] buf = new byte[len];
int pos = 0;
for (String label : labels) {
buf[pos++] = (byte) label.length();
for (char c : label.toCharArray()) buf[pos++] = (byte) c;
}
buf[pos] = 0;
return buf;
}
private byte[] buildQuery(String name, short txId) {
byte[] encodedName = encodeName(name);
// header(12) + name + type(2) + class(2)
ByteBuffer buf = ByteBuffer.allocate(12 + encodedName.length + 4);
buf.putShort(txId);
buf.putShort((short) 0x0100); // standard query, RD=1
buf.putShort((short) 1); // qdcount
buf.putShort((short) 0);
buf.putShort((short) 0);
buf.putShort((short) 0);
buf.put(encodedName);
buf.putShort((short) 1); // type A
buf.putShort((short) 1); // class IN
return buf.array();
}
}
@@ -0,0 +1,343 @@
package io.github.hectorvent.floci.core.common.docker;
import com.github.dockerjava.api.model.DeviceRequest;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.core.common.dns.EmbeddedDnsServer;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ContainerBuilderTest {
@Test
void withDockerNetwork_usesExplicitServiceNetworkFirst() {
TestFixture fixture = new TestFixture();
when(fixture.currentContainerNetworkResolver.resolveNetworkName()).thenReturn(Optional.of("compose_default"));
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withDockerNetwork(Optional.of("lambda_network"))
.build();
assertEquals("lambda_network", spec.networkMode());
}
@Test
void withDockerNetwork_usesGlobalNetworkBeforeDetectedCurrentNetwork() {
TestFixture fixture = new TestFixture();
when(fixture.services.dockerNetwork()).thenReturn(Optional.of("global_network"));
when(fixture.currentContainerNetworkResolver.resolveNetworkName()).thenReturn(Optional.of("compose_default"));
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withDockerNetwork(Optional.empty())
.build();
assertEquals("global_network", spec.networkMode());
}
@Test
void withDockerNetwork_inheritsCurrentContainerNetworkWhenNoConfigIsSet() {
TestFixture fixture = new TestFixture();
when(fixture.currentContainerNetworkResolver.resolveNetworkName()).thenReturn(Optional.of("avoxx-network"));
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withDockerNetwork(Optional.empty())
.build();
assertEquals("avoxx-network", spec.networkMode());
}
@Test
void withEmbeddedDns_appendsFallbackResolversAfterFlociIp() {
TestFixture fixture = new TestFixture();
when(fixture.embeddedDnsServer.getServerIp()).thenReturn(Optional.of("172.18.0.4"));
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withEmbeddedDns()
.build();
assertEquals(List.of("172.18.0.4", "8.8.8.8", "8.8.4.4"), spec.dnsServers());
}
@Test
void withEmbeddedDns_omitsFallbackResolversWhenDisabled() {
TestFixture fixture = new TestFixture();
when(fixture.embeddedDnsServer.getServerIp()).thenReturn(Optional.of("172.18.0.4"));
when(fixture.dns.containerFallbackEnabled()).thenReturn(false);
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withEmbeddedDns()
.build();
assertEquals(List.of("172.18.0.4"), spec.dnsServers());
}
@Test
void withEmbeddedDns_noOpWhenEmbeddedDnsNotRunning() {
TestFixture fixture = new TestFixture();
// getServerIp() empty (default) — no Floci IP, so no fallbacks are injected either.
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withEmbeddedDns()
.build();
assertEquals(List.of(), spec.dnsServers());
}
@Test
void withCgroupnsModeRecordsDockerNamespaceMode() {
TestFixture fixture = new TestFixture();
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withCgroupnsMode("host")
.build();
assertEquals("host", spec.cgroupnsMode());
}
@Test
void withUserAndGroupAddRoundTripIntoSpec() {
TestFixture fixture = new TestFixture();
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withUser("1001:1001")
.withGroupAdd("1001")
.withGroupAdd("2000")
.withGroupAdd(" ") // blank ignored
.build();
assertEquals("1001:1001", spec.user());
assertEquals(List.of("1001", "2000"), spec.groupAdd());
}
@Test
void userAndGroupAddDefaultToUnset() {
TestFixture fixture = new TestFixture();
ContainerSpec spec = fixture.builder.newContainer("alpine").build();
assertNull(spec.user());
assertEquals(List.of(), spec.groupAdd());
}
@Test
void withLinkLocalIpRoundTripsIntoSpecIgnoringBlanksAndDuplicates() {
TestFixture fixture = new TestFixture();
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withDockerNetwork(Optional.of("test-network"))
.withLinkLocalIp("169.254.170.3")
.withLinkLocalIp(" 169.254.170.3 ")
.withLinkLocalIp(" ")
.build();
assertEquals(List.of("169.254.170.3"), spec.linkLocalIps());
}
@Test
void linkLocalIpWithoutUserDefinedNetworkIsRejected() {
TestFixture fixture = new TestFixture();
assertThrows(IllegalArgumentException.class,
() -> fixture.builder.newContainer("alpine").withLinkLocalIp("169.254.170.3").build());
for (String network : List.of("bridge", "host", "none", "container:router")) {
assertThrows(IllegalArgumentException.class,
() -> fixture.builder.newContainer("alpine")
.withDockerNetwork(Optional.of(network))
.withLinkLocalIp("169.254.170.3")
.build(),
network);
}
}
@Test
void nonLinkLocalAddressIsRejected() {
TestFixture fixture = new TestFixture();
for (String ip : List.of("10.0.0.5", "169.254.300.1", "169.254.1", "not-an-ip")) {
assertThrows(IllegalArgumentException.class,
() -> fixture.builder.newContainer("alpine")
.withDockerNetwork(Optional.of("test-network"))
.withLinkLocalIp(ip)
.build(),
ip);
}
}
@Test
void linkLocalIpsDefaultToNone() {
TestFixture fixture = new TestFixture();
assertEquals(List.of(), fixture.builder.newContainer("alpine").build().linkLocalIps());
}
@Test
void withGpuCountRequestsThatManyGpus() {
TestFixture fixture = new TestFixture();
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withGpuCount(2)
.build();
assertEquals(1, spec.deviceRequests().size());
DeviceRequest request = spec.deviceRequests().get(0);
assertEquals(2, request.getCount());
assertEquals(List.of(List.of("gpu")), request.getCapabilities());
// Docker rejects a request carrying both a count and explicit ids.
assertNull(request.getDeviceIds());
assertNull(request.getDriver());
}
@Test
void withAllGpusRequestsEveryDevice() {
TestFixture fixture = new TestFixture();
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withAllGpus()
.build();
assertEquals(-1, spec.deviceRequests().get(0).getCount());
assertEquals(List.of(List.of("gpu")), spec.deviceRequests().get(0).getCapabilities());
}
@Test
void withGpuDeviceIdsPinsSpecificDevicesAndLeavesCountUnset() {
TestFixture fixture = new TestFixture();
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withGpuDeviceIds(List.of("0", "GPU-1fc572c4-6cd7-0e21-dceb-fa71af82eed5"))
.build();
DeviceRequest request = spec.deviceRequests().get(0);
assertEquals(List.of("0", "GPU-1fc572c4-6cd7-0e21-dceb-fa71af82eed5"), request.getDeviceIds());
assertEquals(List.of(List.of("gpu")), request.getCapabilities());
assertNull(request.getCount());
}
@Test
void withCdiDevicesSelectsDevicesByCdiName() {
TestFixture fixture = new TestFixture();
ContainerSpec spec = fixture.builder.newContainer("alpine")
.withCdiDevices(List.of("nvidia.com/gpu=GPU-1fc572c4-6cd7-0e21-dceb-fa71af82eed5"))
.build();
DeviceRequest request = spec.deviceRequests().get(0);
assertEquals("cdi", request.getDriver());
assertEquals(List.of("nvidia.com/gpu=GPU-1fc572c4-6cd7-0e21-dceb-fa71af82eed5"), request.getDeviceIds());
// The CDI name already names the device, so no capability is negotiated.
assertNull(request.getCapabilities());
assertNull(request.getCount());
}
@Test
void containersRequestNoDevicesByDefault() {
TestFixture fixture = new TestFixture();
ContainerSpec spec = fixture.builder.newContainer("alpine").build();
assertEquals(List.of(), spec.deviceRequests());
assertFalse(spec.hasDeviceRequests());
}
@Test
void gpuCountMustBePositive() {
TestFixture fixture = new TestFixture();
ContainerBuilder.Builder builder = fixture.builder.newContainer("alpine");
assertThrows(IllegalArgumentException.class, () -> builder.withGpuCount(0));
assertThrows(IllegalArgumentException.class, () -> builder.withGpuCount(-1));
}
@Test
void deviceIdsMustBePresentAndNonBlank() {
TestFixture fixture = new TestFixture();
ContainerBuilder.Builder builder = fixture.builder.newContainer("alpine");
assertThrows(IllegalArgumentException.class, () -> builder.withGpuDeviceIds(List.of()));
assertThrows(IllegalArgumentException.class, () -> builder.withGpuDeviceIds(List.of(" ")));
}
@Test
void cdiDeviceNameMustBeFullyQualified() {
TestFixture fixture = new TestFixture();
ContainerBuilder.Builder builder = fixture.builder.newContainer("alpine");
// A device path is the mistake this guards: the daemon would otherwise fail the
// start with an unresolvable-device error that does not name the real problem.
assertThrows(IllegalArgumentException.class, () -> builder.withCdiDevices(List.of("/dev/nvidia0")));
assertThrows(IllegalArgumentException.class, () -> builder.withCdiDevices(List.of("nvidia.com/gpu")));
}
@Test
void countAndExplicitDevicesAreMutuallyExclusive() {
TestFixture fixture = new TestFixture();
assertThrows(IllegalStateException.class,
() -> fixture.builder.newContainer("alpine").withGpuCount(1).withAllGpus());
assertThrows(IllegalStateException.class,
() -> fixture.builder.newContainer("alpine").withGpuCount(1).withGpuDeviceIds(List.of("0")));
assertThrows(IllegalStateException.class,
() -> fixture.builder.newContainer("alpine")
.withCdiDevices(List.of("nvidia.com/gpu=all"))
.withGpuCount(1));
}
@Test
void imageRegistryBasePrefixesEveryContainerImage() {
TestFixture fixture = new TestFixture();
when(fixture.docker.imageRegistryBase()).thenReturn(Optional.of("ghcr.io/floci-io/mirror/"));
assertEquals(
"ghcr.io/floci-io/mirror/postgres:16-alpine",
fixture.builder.newContainer("postgres:16-alpine").build().image());
assertEquals(
"ghcr.io/floci-io/mirror/public.ecr.aws/docker/library/ubuntu:24.04",
fixture.builder.newContainer("public.ecr.aws/docker/library/ubuntu:24.04").build().image());
}
@Test
void imageRegistryBaseDoesNotDoublePrefixImagesAlreadyUnderBase() {
TestFixture fixture = new TestFixture();
when(fixture.docker.imageRegistryBase()).thenReturn(Optional.of("ghcr.io/floci-io/mirror"));
ContainerSpec spec = fixture.builder
.newContainer("ghcr.io/floci-io/mirror/floci/ami-ubuntu:24.04-arm64")
.build();
assertEquals("ghcr.io/floci-io/mirror/floci/ami-ubuntu:24.04-arm64", spec.image());
}
private static class TestFixture {
final EmulatorConfig config = mock(EmulatorConfig.class);
final EmulatorConfig.ServicesConfig services = mock(EmulatorConfig.ServicesConfig.class);
final EmulatorConfig.DockerConfig docker = mock(EmulatorConfig.DockerConfig.class);
final EmulatorConfig.DnsConfig dns = mock(EmulatorConfig.DnsConfig.class);
final DockerHostResolver dockerHostResolver = mock(DockerHostResolver.class);
final EmbeddedDnsServer embeddedDnsServer = mock(EmbeddedDnsServer.class);
final CurrentContainerNetworkResolver currentContainerNetworkResolver =
mock(CurrentContainerNetworkResolver.class);
final ContainerBuilder builder =
new ContainerBuilder(config, dockerHostResolver, embeddedDnsServer, currentContainerNetworkResolver);
TestFixture() {
when(config.services()).thenReturn(services);
when(services.dockerNetwork()).thenReturn(Optional.empty());
when(config.docker()).thenReturn(docker);
when(docker.logMaxSize()).thenReturn("10m");
when(docker.logMaxFile()).thenReturn("3");
when(docker.imageRegistryBase()).thenReturn(Optional.empty());
when(config.dns()).thenReturn(dns);
when(dns.containerFallbackEnabled()).thenReturn(true);
when(dns.containerFallbackServers()).thenReturn(List.of("8.8.8.8", "8.8.4.4"));
when(embeddedDnsServer.getServerIp()).thenReturn(Optional.empty());
}
}
}
@@ -0,0 +1,166 @@
package io.github.hectorvent.floci.core.common.docker;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.model.Frame;
import com.github.dockerjava.core.command.WaitContainerResultCallback;
import io.github.hectorvent.floci.config.ContainerCaBundle;
import io.github.hectorvent.floci.config.FlociCertificateAuthority;
import io.github.hectorvent.floci.services.acm.model.KeyAlgorithm;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* A container Floci launches, with no setup of its own, calls Floci over HTTPS and a public
* HTTPS site. Python is the runtime because it honours {@code SSL_CERT_FILE}, the variable that
* replaces the trust store: the image's own root directory is pointed away, so if the bundle
* lacked the public roots the second call would fail, and if it lacked the Floci CA the first
* would. Skipped without a Docker daemon, like the other {@code *DockerIntegrationTest} classes.
*/
@QuarkusTest
@TestProfile(ContainerCaBundleDockerIntegrationTest.Profile.class)
class ContainerCaBundleDockerIntegrationTest {
private static final String IMAGE = "public.ecr.aws/docker/library/python:3.12-alpine";
@Inject
DockerClient dockerClient;
@Inject
ContainerBuilder containerBuilder;
@Inject
ContainerLifecycleManager lifecycle;
@Inject
ContainerDetector containerDetector;
@ConfigProperty(name = "quarkus.http.test-ssl-port", defaultValue = "0")
int testSslPort;
@BeforeEach
void requireDockerOnTheHost() {
Assumptions.assumeTrue(isDockerAvailable(), "Docker daemon must be available");
Assumptions.assumeFalse(containerDetector.isRunningInContainer(),
"the container dials host.docker.internal, which only names a host-side Floci");
}
@Test
void launchedContainerTrustsFlociAndPublicRootsWithoutAnySetup() throws Exception {
String script = """
import os, urllib.request, urllib.error
bundle = "/etc/floci-ca-bundle.pem"
for var in ("SSL_CERT_FILE", "CURL_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS", "AWS_CA_BUNDLE"):
assert os.environ.get(var) == bundle, (var, os.environ.get(var))
assert open(bundle).read().count("BEGIN CERTIFICATE") > 1, "bundle must hold public roots and the Floci CA"
ca = urllib.request.urlopen("https://host.docker.internal:%d/_floci/ca.pem", timeout=20).read()
assert b"BEGIN CERTIFICATE" in ca, ca[:80]
try:
urllib.request.urlopen("https://public.ecr.aws/", timeout=20).read()
except urllib.error.HTTPError:
pass
print("ok")
""".formatted(testSslPort);
ContainerSpec spec = containerBuilder.newContainer(IMAGE)
.withName("floci-ca-bundle-test-" + Long.toString(System.nanoTime(), 36))
.withHostDockerInternalOnLinux()
// Only the bundle may supply roots: the image's /etc/ssl/certs must not rescue the public call.
.withEnv("SSL_CERT_DIR", "/nonexistent")
.withEntrypoint(List.of("python3", "-c", script))
.build();
ContainerLifecycleManager.ContainerInfo info = lifecycle.createAndStart(spec);
try {
Integer status = dockerClient.waitContainerCmd(info.containerId())
.exec(new WaitContainerResultCallback())
.awaitStatusCode(120, TimeUnit.SECONDS);
assertEquals(0, status, () -> logs(info.containerId()));
} finally {
dockerClient.removeContainerCmd(info.containerId()).withForce(true).exec();
}
}
private String logs(String containerId) {
StringBuilder out = new StringBuilder();
try {
dockerClient.logContainerCmd(containerId).withStdOut(true).withStdErr(true)
.exec(new ResultCallback.Adapter<Frame>() {
@Override
public void onNext(Frame frame) {
out.append(new String(frame.getPayload(), StandardCharsets.UTF_8));
}
}).awaitCompletion(10, TimeUnit.SECONDS);
} catch (Exception e) {
out.append("(could not read logs: ").append(e.getMessage()).append(')');
}
return out.toString();
}
private boolean isDockerAvailable() {
try {
dockerClient.pingCmd().exec();
return true;
} catch (Exception e) {
return false;
}
}
/**
* Lays down what a TLS-on boot writes: the local CA, a leaf whose SANs include the name the
* container dials, and the bundle. The bootstrap config source reads system properties and the
* environment, which a test profile does not set, so the files are prepared here and the
* registry keys point at them; {@code floci.tls.enabled} is what makes the lifecycle manager
* inject the bundle. Quarkus binds every interface so the container reaches the test port.
*/
public static final class Profile implements QuarkusTestProfile {
private static final Path DATA_DIR = Path.of("target", "floci-ca-bundle-docker-test").toAbsolutePath();
private static final Path TLS_DIR = DATA_DIR.resolve("tls");
private static final Path CERT_FILE = TLS_DIR.resolve("floci-server.crt");
private static final Path KEY_FILE = TLS_DIR.resolve("floci-server.key");
static {
try {
FlociCertificateAuthority ca = FlociCertificateAuthority.loadOrCreate(TLS_DIR);
var leaf = ca.issueServerCertificate("localhost",
List.of("localhost", "127.0.0.1", "host.docker.internal"), KeyAlgorithm.RSA_2048, null);
Files.writeString(CERT_FILE, leaf.certificatePem());
Files.writeString(KEY_FILE, leaf.privateKeyPem());
ContainerCaBundle.write(TLS_DIR, ca.certificatePath());
} catch (IOException | GeneralSecurityException e) {
throw new IllegalStateException("Failed to prepare the TLS test files", e);
}
}
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"floci.tls.enabled", "true",
"floci.tls.aws-https-port", "0",
"floci.storage.persistent-path", DATA_DIR.toString(),
"quarkus.tls.key-store.pem.0.cert", CERT_FILE.toString(),
"quarkus.tls.key-store.pem.0.key", KEY_FILE.toString(),
"quarkus.http.insecure-requests", "enabled",
"quarkus.http.host", "0.0.0.0",
"floci.security.allow-unsafe-network-exposure", "true");
}
}
}
@@ -0,0 +1,196 @@
package io.github.hectorvent.floci.core.common.docker;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class ContainerDetectorTest {
private static ContainerDetector detector(boolean dockerEnvExists,
boolean podmanEnvExists,
String cgroupContent,
String mountInfoContent,
String containerEnv,
String dotnetEnv,
String genericContainerEnv) {
return new ContainerDetector() {
@Override
boolean fileExists(String path) {
if ("/.dockerenv".equals(path)) return dockerEnvExists;
if ("/run/.containerenv".equals(path)) return podmanEnvExists;
// For cgroup / mountinfo we control via readFileContent
if ("/proc/1/cgroup".equals(path)) return cgroupContent != null;
if ("/proc/self/mountinfo".equals(path)) return mountInfoContent != null;
return false;
}
@Override
String readFileContent(Path path) throws IOException {
if (path.equals(Path.of("/proc/1/cgroup")) && cgroupContent != null) {
return cgroupContent;
}
if (path.equals(Path.of("/proc/self/mountinfo")) && mountInfoContent != null) {
return mountInfoContent;
}
throw new IOException("File not available in test stub: " + path);
}
@Override
String getEnv(String name) {
return switch (name) {
case "container" -> containerEnv;
case "DOTNET_RUNNING_IN_CONTAINER" -> dotnetEnv;
case "CONTAINER" -> genericContainerEnv;
default -> null;
};
}
};
}
@Test
void notInContainer() {
ContainerDetector d = detector(false, false, null, null, null, null, null);
assertFalse(d.isRunningInContainer());
}
@Test
void detectedViaDockerenvMarker() {
ContainerDetector d = detector(true, false, null, null, null, null, null);
assertTrue(d.isRunningInContainer());
}
@Test
void detectedViaPodmanMarker() {
ContainerDetector d = detector(false, true, null, null, null, null, null);
assertTrue(d.isRunningInContainer());
}
@Test
void detectedViaPodmanContainerEnv() {
ContainerDetector d = detector(false, false, null, null, "podman", null, null);
assertTrue(d.isRunningInContainer());
}
@Test
void detectedViaDotnetEnvVariable() {
ContainerDetector d = detector(false, false, null, null, null, "true", null);
assertTrue(d.isRunningInContainer());
}
@Test
void dotnetEnvFalseDoesNotDetect() {
ContainerDetector d = detector(false, false, null, null, null, "false", null);
assertFalse(d.isRunningInContainer());
}
@Test
void detectedViaGenericContainerEnv() {
ContainerDetector d = detector(false, false, null, null, null, null, "docker");
assertTrue(d.isRunningInContainer());
}
@Test
void detectedViaCgroupDocker() {
ContainerDetector d = detector(false, false,
"12:memory:/docker/abc123\n", null, null, null, null);
assertTrue(d.isRunningInContainer());
}
@Test
void detectedViaCgroupMoby() {
ContainerDetector d = detector(false, false,
"12:memory:/moby/abc123\n", null, null, null, null);
assertTrue(d.isRunningInContainer());
}
@Test
void detectedViaCgroupKubepods() {
ContainerDetector d = detector(false, false,
"10:cpuset:/kubepods/pod-xyz\n", null, null, null, null);
assertTrue(d.isRunningInContainer());
}
@Test
void detectedViaCgroupLibpod() {
ContainerDetector d = detector(false, false,
"3:pids:/libpod-abc123\n", null, null, null, null);
assertTrue(d.isRunningInContainer());
}
@Test
void detectedViaCgroupContainerd() {
ContainerDetector d = detector(false, false,
"5:memory:/containerd/xyz\n", null, null, null, null);
assertTrue(d.isRunningInContainer());
}
@Test
void detectedViaCgroupCriContainerd() {
ContainerDetector d = detector(false, false,
"5:memory:/cri-containerd-abc\n", null, null, null, null);
assertTrue(d.isRunningInContainer());
}
@Test
void cgroupWithoutMarkers() {
ContainerDetector d = detector(false, false,
"12:memory:/user.slice\n", null, null, null, null);
assertFalse(d.isRunningInContainer());
}
@Test
void detectedViaMountInfoDocker() {
ContainerDetector d = detector(false, false, null,
"100 95 0:54 / / rw,relatime - overlay overlay rw,lowerdir=/docker/overlay2/l/ABC\n",
null, null, null);
assertTrue(d.isRunningInContainer());
}
@Test
void hostDockerMountsDoNotDetectAsContainer() {
ContainerDetector d = detector(false, false, null,
"""
1505 1423 252:1 / / rw,relatime - ext4 /dev/mapper/root rw
1514 1511 0:4 net:[4026533347] /run/docker/netns/abc rw - nsfs nsfs rw
1538 1505 0:67 / /var/lib/docker/rootfs/abc rw - overlay overlay rw,lowerdir=/var/lib/containerd/snapshots/1/fs
""",
null, null, null);
assertFalse(d.isRunningInContainer());
}
@Test
void detectedViaMountInfoMoby() {
ContainerDetector d = detector(false, false, null,
"100 95 0:54 / / rw - overlay overlay lowerdir=/moby/something\n",
null, null, null);
assertTrue(d.isRunningInContainer());
}
@Test
void detectedViaMountInfoLibpod() {
ContainerDetector d = detector(false, false, null,
"100 95 0:54 / / rw - overlay overlay lowerdir=/libpod-abc123/overlay\n",
null, null, null);
assertTrue(d.isRunningInContainer());
}
@Test
void mountInfoWithoutMarkers() {
ContainerDetector d = detector(false, false, null,
"100 95 0:54 / / rw - ext4 /dev/sda1 rw\n",
null, null, null);
assertFalse(d.isRunningInContainer());
}
@Test
void resultIsCached() {
ContainerDetector d = detector(true, false, null, null, null, null, null);
assertTrue(d.isRunningInContainer());
// Second call should return the cached result (still true)
assertTrue(d.isRunningInContainer());
}
}
@@ -0,0 +1,169 @@
package io.github.hectorvent.floci.core.common.docker;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.model.Frame;
import com.github.dockerjava.core.command.WaitContainerResultCallback;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.jboss.logging.Logger;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Proves a GPU device request survives the round trip to a real daemon and that the
* container can see the device. Opt-in, because it needs hardware no CI runner has:
* run it with {@code -Dgpu.integration.enabled=true}. Without that flag it skips, so the
* request-construction coverage in {@link ContainerBuilderTest} and
* {@link ContainerLifecycleManagerDeviceRequestTest} is what runs by default.
*
* <p>Select a device explicitly with
* {@code -Dgpu.integration.cdi-device=nvidia.com/gpu=GPU-<uuid>} against a daemon that
* resolves Container Device Interface names, such as Podman. With no device named, the
* test asks for every GPU using Docker's count form instead, which is the shape to run
* against Docker. Note that Podman accepts the count form and then attaches no device
* (containers/podman#22645), so that combination fails here by design.
*/
@QuarkusTest
class ContainerGpuDockerIntegrationTest {
private static final Logger LOG = Logger.getLogger(ContainerGpuDockerIntegrationTest.class);
private static final String ENABLED_PROPERTY = "gpu.integration.enabled";
private static final String CDI_DEVICE_PROPERTY = "gpu.integration.cdi-device";
private static final String IMAGE_PROPERTY = "gpu.integration.image";
private static final String DEFAULT_IMAGE = "docker.io/nvidia/cuda:12.8.1-base-ubuntu24.04";
@Inject
DockerClient dockerClient;
@Inject
ContainerBuilder containerBuilder;
@Inject
ContainerLifecycleManager lifecycleManager;
@BeforeEach
void requireGpuEnabledDaemon() {
Assumptions.assumeTrue(Boolean.getBoolean(ENABLED_PROPERTY),
"GPU hardware tests are opt-in: run with -D" + ENABLED_PROPERTY + "=true on a host with a GPU");
Assumptions.assumeTrue(isDockerAvailable(),
"A container daemon must be available for GPU integration tests");
}
@Test
void containerWithGpuRequestSeesTheDevice() {
String image = System.getProperty(IMAGE_PROPERTY, DEFAULT_IMAGE);
String cdiDevice = System.getProperty(CDI_DEVICE_PROPERTY);
ContainerBuilder.Builder builder = containerBuilder.newContainer(image)
.withName("floci-gpu-test-" + UUID.randomUUID())
.withCmd(List.of("nvidia-smi", "-L"));
if (cdiDevice != null && !cdiDevice.isBlank()) {
builder.withCdiDevices(List.of(cdiDevice));
} else {
builder.withAllGpus();
}
String containerId = null;
try {
containerId = lifecycleManager.create(builder.build());
dockerClient.startContainerCmd(containerId).exec();
Integer status = dockerClient.waitContainerCmd(containerId)
.exec(new WaitContainerResultCallback())
.awaitStatusCode(120, TimeUnit.SECONDS);
String output = logs(containerId);
assertEquals(0, status, "nvidia-smi exited nonzero: " + output);
// `nvidia-smi -L` prints one "GPU <n>: <name> (UUID: GPU-...)" line per visible
// device, so this is the daemon confirming the request was honoured.
assertTrue(output.contains("GPU"), "no GPU listed in the container: " + output);
if (cdiDevice != null && !cdiDevice.isBlank()) {
String uuid = cdiDevice.substring(cdiDevice.indexOf('=') + 1);
assertTrue(output.contains(uuid),
"the container saw a device other than the one requested: " + output);
}
LOG.infov("GPU visible to container: {0}", output.trim());
} finally {
if (containerId != null) {
dockerClient.removeContainerCmd(containerId).withForce(true).exec();
}
}
}
@Test
void containerWithoutGpuRequestSeesNoDevice() {
String image = System.getProperty(IMAGE_PROPERTY, DEFAULT_IMAGE);
ContainerSpec spec = containerBuilder.newContainer(image)
.withName("floci-gpu-test-cpu-" + UUID.randomUUID())
.withCmd(List.of("nvidia-smi", "-L"))
.build();
String containerId = null;
try {
containerId = lifecycleManager.create(spec);
// Two shapes of "no device", both acceptable: the start itself fails, because
// nvidia-smi is injected by the runtime alongside the device and is absent from
// the image without one; or the container starts and exits nonzero. What must
// not happen is a clean exit, which would mean a container that asked for
// nothing still reached a GPU because the daemon defaults to an accelerator
// runtime.
try {
dockerClient.startContainerCmd(containerId).exec();
} catch (RuntimeException expected) {
LOG.infov("Container without a GPU request could not start, as expected: {0}",
expected.getMessage());
return;
}
Integer status = dockerClient.waitContainerCmd(containerId)
.exec(new WaitContainerResultCallback())
.awaitStatusCode(120, TimeUnit.SECONDS);
assertTrue(status != null && status != 0,
"a container that requested no GPU still reached one: " + logs(containerId));
} finally {
if (containerId != null) {
dockerClient.removeContainerCmd(containerId).withForce(true).exec();
}
}
}
/** Best effort, as in the sibling Docker tests: a read that fails reports itself in the text. */
private String logs(String containerId) {
StringBuilder out = new StringBuilder();
try {
dockerClient.logContainerCmd(containerId).withStdOut(true).withStdErr(true)
.exec(new ResultCallback.Adapter<Frame>() {
@Override
public void onNext(Frame frame) {
out.append(new String(frame.getPayload(), StandardCharsets.UTF_8));
}
}).awaitCompletion(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
out.append("(interrupted while reading logs)");
} catch (Exception e) {
out.append("(could not read logs: ").append(e.getMessage()).append(')');
}
return out.toString();
}
private boolean isDockerAvailable() {
try {
dockerClient.pingCmd().exec();
return true;
} catch (Exception e) {
LOG.warn("No container daemon available for the GPU integration test", e);
return false;
}
}
}
@@ -0,0 +1,240 @@
package io.github.hectorvent.floci.core.common.docker;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.CopyArchiveToContainerCmd;
import com.github.dockerjava.api.command.CreateContainerCmd;
import com.github.dockerjava.api.command.CreateContainerResponse;
import com.github.dockerjava.api.command.RemoveContainerCmd;
import com.github.dockerjava.api.exception.NotFoundException;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.config.TlsConfigSource;
import io.github.hectorvent.floci.services.lambda.launcher.ImageCacheService;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.RETURNS_SELF;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Every emulator-created container passes through {@link ContainerLifecycleManager#create}, so
* asserting the CA trust here covers Lambda, ECS, MWAA, Flink, RDS and the rest by construction.
*/
@ExtendWith(MockitoExtension.class)
class ContainerLifecycleManagerCaBundleTest {
private static final String BUNDLE_PEM = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n";
@Mock DockerClient dockerClient;
@Mock ImageCacheService imageCacheService;
@Mock ContainerDetector containerDetector;
@Mock PortAllocator portAllocator;
@Mock EmulatorConfig config;
@Mock EmulatorConfig.DockerConfig dockerConfig;
@Mock EmulatorConfig.TlsConfig tlsConfig;
@Mock EmulatorConfig.StorageConfig storageConfig;
@TempDir Path tempDir;
@BeforeEach
void setUp() {
forgetBootstrapTlsDir();
lenient().when(config.docker()).thenReturn(dockerConfig);
lenient().when(dockerConfig.resourceNamespace()).thenReturn(Optional.empty());
lenient().when(config.tls()).thenReturn(tlsConfig);
lenient().when(config.storage()).thenReturn(storageConfig);
lenient().when(storageConfig.persistentPath()).thenReturn(tempDir.toString());
lenient().when(imageCacheService.ensureImageExists(any())).thenAnswer(invocation -> invocation.getArgument(0));
}
@AfterEach
void forgetBootstrapTlsDir() {
// TlsConfigSource.resolvedTlsDir is static: a TLS-on bootstrap run by another test class
// in this JVM would otherwise steer the bundle lookup at that test's directory.
System.setProperty("floci.tls.enabled", "false");
new TlsConfigSource();
System.clearProperty("floci.tls.enabled");
}
@Test
void tlsOnCopiesTheBundleAndAppendsTheTrustEnvAfterTheSpecEnv() throws Exception {
when(tlsConfig.enabled()).thenReturn(true);
Path bundle = writeBundle();
CreateContainerCmd createCmd = stubCreateContainer();
CopyArchiveToContainerCmd copyCmd = mock(CopyArchiveToContainerCmd.class, RETURNS_SELF);
when(dockerClient.copyArchiveToContainerCmd("container-id")).thenReturn(copyCmd);
assertEquals("container-id", manager().create(specWithEnv(List.of("FOO=bar"))));
List<String> env = capturedEnv(createCmd);
assertEquals(List.of("FOO=bar",
"SSL_CERT_FILE=/etc/floci-ca-bundle.pem",
"CURL_CA_BUNDLE=/etc/floci-ca-bundle.pem",
"REQUESTS_CA_BUNDLE=/etc/floci-ca-bundle.pem",
"NODE_EXTRA_CA_CERTS=/etc/floci-ca-bundle.pem",
"AWS_CA_BUNDLE=/etc/floci-ca-bundle.pem"), env);
verify(copyCmd).withRemotePath("/etc");
ArgumentCaptor<InputStream> tar = ArgumentCaptor.forClass(InputStream.class);
verify(copyCmd).withTarInputStream(tar.capture());
verify(copyCmd).exec();
try (TarArchiveInputStream in = new TarArchiveInputStream(tar.getValue())) {
TarArchiveEntry entry = in.getNextEntry();
assertEquals("floci-ca-bundle.pem", entry.getName());
assertEquals(0644, entry.getMode() & 0777, "world-readable, so a non-root USER can read it");
assertEquals(Files.readString(bundle), new String(in.readAllBytes(), StandardCharsets.UTF_8));
assertNull(in.getNextEntry(), "the archive holds only the bundle");
}
}
@Test
void specValuesWinOverTheInjectedEnv() throws Exception {
when(tlsConfig.enabled()).thenReturn(true);
writeBundle();
CreateContainerCmd createCmd = stubCreateContainer();
when(dockerClient.copyArchiveToContainerCmd("container-id"))
.thenReturn(mock(CopyArchiveToContainerCmd.class, RETURNS_SELF));
manager().create(specWithEnv(List.of("SSL_CERT_FILE=/my/own.pem", "NODE_EXTRA_CA_CERTS=")));
List<String> env = capturedEnv(createCmd);
assertEquals(1, env.stream().filter(e -> e.startsWith("SSL_CERT_FILE=")).count());
assertEquals(1, env.stream().filter(e -> e.startsWith("NODE_EXTRA_CA_CERTS=")).count());
assertTrue(env.contains("SSL_CERT_FILE=/my/own.pem"));
assertTrue(env.contains("NODE_EXTRA_CA_CERTS="));
assertTrue(env.contains("AWS_CA_BUNDLE=/etc/floci-ca-bundle.pem"));
}
@Test
void anEmptySpecEnvStillGetsTheTrustEnv() throws Exception {
when(tlsConfig.enabled()).thenReturn(true);
writeBundle();
CreateContainerCmd createCmd = stubCreateContainer();
when(dockerClient.copyArchiveToContainerCmd("container-id"))
.thenReturn(mock(CopyArchiveToContainerCmd.class, RETURNS_SELF));
manager().create(new ContainerSpec("busybox:stable"));
assertEquals(5, capturedEnv(createCmd).size());
}
@Test
void tlsOffLeavesTheContainerAlone() {
when(tlsConfig.enabled()).thenReturn(false);
CreateContainerCmd createCmd = stubCreateContainer();
manager().create(specWithEnv(List.of("FOO=bar")));
assertEquals(List.of("FOO=bar"), capturedEnv(createCmd));
verify(dockerClient, never()).copyArchiveToContainerCmd(anyString());
}
@Test
void tlsOffWithNoEnvSetsNoEnvAtAll() {
when(tlsConfig.enabled()).thenReturn(false);
CreateContainerCmd createCmd = stubCreateContainer();
manager().create(new ContainerSpec("busybox:stable"));
verify(createCmd, never()).withEnv(any(List.class));
verify(dockerClient, never()).copyArchiveToContainerCmd(anyString());
}
@Test
void aMissingBundleMeansNoEnvAndNoCopy() {
when(tlsConfig.enabled()).thenReturn(true);
CreateContainerCmd createCmd = stubCreateContainer();
manager().create(specWithEnv(List.of("FOO=bar")));
assertEquals(List.of("FOO=bar"), capturedEnv(createCmd));
verify(dockerClient, never()).copyArchiveToContainerCmd(anyString());
}
@Test
void aFailedCopyRecreatesTheContainerWithoutTheTrustVariables() throws Exception {
// SSL_CERT_FILE and friends replace the image's trust store, so they must never name a
// file that is not there: an image without /etc keeps its own trust instead.
when(tlsConfig.enabled()).thenReturn(true);
writeBundle();
CreateContainerCmd createCmd = mock(CreateContainerCmd.class, RETURNS_SELF);
when(dockerClient.createContainerCmd("busybox:stable")).thenReturn(createCmd);
CreateContainerResponse first = mock(CreateContainerResponse.class);
when(first.getId()).thenReturn("container-id");
CreateContainerResponse second = mock(CreateContainerResponse.class);
when(second.getId()).thenReturn("container-id-2");
when(createCmd.exec()).thenReturn(first, second);
CopyArchiveToContainerCmd copyCmd = mock(CopyArchiveToContainerCmd.class, RETURNS_SELF);
when(dockerClient.copyArchiveToContainerCmd("container-id")).thenReturn(copyCmd);
when(copyCmd.exec()).thenThrow(new NotFoundException("Could not find the file /etc in container"));
RemoveContainerCmd removeCmd = mock(RemoveContainerCmd.class, RETURNS_SELF);
when(dockerClient.removeContainerCmd("container-id")).thenReturn(removeCmd);
assertEquals("container-id-2", manager().create(specWithEnv(List.of("FOO=bar"))));
verify(removeCmd).withForce(true);
verify(removeCmd).exec();
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> env = ArgumentCaptor.forClass((Class<List<String>>) (Class<?>) List.class);
verify(createCmd, times(2)).withEnv(env.capture());
assertEquals(6, env.getAllValues().get(0).size(), "the first attempt carried the trust variables");
assertEquals(List.of("FOO=bar"), env.getAllValues().get(1), "the retry carries only the spec's own");
verify(dockerClient, times(1)).copyArchiveToContainerCmd(anyString());
verify(portAllocator, never()).allocateAny();
}
private Path writeBundle() throws Exception {
Path tlsDir = Files.createDirectories(tempDir.resolve("tls"));
return Files.writeString(tlsDir.resolve("floci-ca-bundle.pem"), BUNDLE_PEM);
}
private ContainerLifecycleManager manager() {
return new ContainerLifecycleManager(dockerClient, imageCacheService, containerDetector, portAllocator, config);
}
private static ContainerSpec specWithEnv(List<String> env) {
return new ContainerSpec("busybox:stable", null, env, null, null, null, Map.of(), List.of(), null,
List.of(), List.of(), List.of(), Map.of(), null, false, null, List.of(), null, null, List.of());
}
private CreateContainerCmd stubCreateContainer() {
CreateContainerCmd createCmd = mock(CreateContainerCmd.class, RETURNS_SELF);
when(dockerClient.createContainerCmd("busybox:stable")).thenReturn(createCmd);
CreateContainerResponse response = mock(CreateContainerResponse.class);
when(response.getId()).thenReturn("container-id");
when(createCmd.exec()).thenReturn(response);
return createCmd;
}
@SuppressWarnings("unchecked")
private static List<String> capturedEnv(CreateContainerCmd createCmd) {
ArgumentCaptor<List<String>> env = ArgumentCaptor.forClass((Class<List<String>>) (Class<?>) List.class);
verify(createCmd).withEnv(env.capture());
return env.getValue();
}
}
@@ -0,0 +1,150 @@
package io.github.hectorvent.floci.core.common.docker;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.CreateContainerCmd;
import com.github.dockerjava.api.command.CreateContainerResponse;
import com.github.dockerjava.api.command.InfoCmd;
import com.github.dockerjava.api.model.HostConfig;
import com.github.dockerjava.api.model.Info;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.core.common.dns.EmbeddedDnsServer;
import io.github.hectorvent.floci.services.lambda.launcher.ImageCacheService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Optional;
import java.util.function.UnaryOperator;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.RETURNS_SELF;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Pins the CPU mapping a container spec carries into Docker's HostConfig: ECS CPU units
* become a nanoCPU quota, a container-level cpu stays a relative weight, and a quota larger
* than the host is held to what the daemon will accept rather than failing the launch.
*/
class ContainerLifecycleManagerCpuLimitTest {
private static final int EIGHT_CPU_HOST = 8;
@Test
void cpuUnitsBecomeANanoCpuQuota() {
HostConfig hostConfig = createHostConfig(EIGHT_CPU_HOST,
builder -> builder.withCpuUnits(512));
assertEquals(500_000_000L, hostConfig.getNanoCPUs());
}
@Test
void zeroCpuUnitsLeaveTheQuotaOff() {
HostConfig hostConfig = createHostConfig(EIGHT_CPU_HOST,
builder -> builder.withCpuUnits(0));
assertNull(hostConfig.getNanoCPUs());
}
@Test
void negativeCpuUnitsLeaveTheQuotaOff() {
HostConfig hostConfig = createHostConfig(EIGHT_CPU_HOST,
builder -> builder.withCpuUnits(-512));
assertNull(hostConfig.getNanoCPUs());
}
@Test
void cpuSharesPassThroughAsARelativeWeight() {
HostConfig hostConfig = createHostConfig(EIGHT_CPU_HOST,
builder -> builder.withCpuShares(256));
assertEquals(256, hostConfig.getCpuShares());
assertNull(hostConfig.getNanoCPUs());
}
@Test
void readonlyRootfsReachesHostConfig() {
HostConfig hostConfig = createHostConfig(EIGHT_CPU_HOST,
ContainerBuilder.Builder::withReadonlyRootfs);
assertTrue(hostConfig.getReadonlyRootfs());
}
@Test
void containerThatAskedForNoCpuLimitGetsNone() {
HostConfig hostConfig = createHostConfig(EIGHT_CPU_HOST, UnaryOperator.identity());
assertNull(hostConfig.getNanoCPUs());
assertNull(hostConfig.getCpuShares());
assertNull(hostConfig.getReadonlyRootfs());
}
/**
* A 16 vCPU Fargate task is a legal ECS size but Docker refuses a quota above the host's
* CPU count, so asking for it verbatim would fail the launch on any smaller machine.
*/
@Test
void quotaAboveTheHostCpuCountIsClampedToTheDaemonsCpus() {
HostConfig hostConfig = createHostConfig(EIGHT_CPU_HOST,
builder -> builder.withCpuUnits(16 * 1024));
assertEquals(8_000_000_000L, hostConfig.getNanoCPUs());
}
@Test
void quotaAtTheHostCpuCountIsNotClamped() {
HostConfig hostConfig = createHostConfig(EIGHT_CPU_HOST,
builder -> builder.withCpuUnits(8 * 1024));
assertEquals(8_000_000_000L, hostConfig.getNanoCPUs());
}
/** A daemon that does not report a CPU count is no reason to rewrite the caller's quota. */
@Test
void quotaIsLeftAloneWhenTheDaemonReportsNoCpuCount() {
HostConfig hostConfig = createHostConfig(null,
builder -> builder.withCpuUnits(16 * 1024));
assertEquals(16_000_000_000L, hostConfig.getNanoCPUs());
}
private static HostConfig createHostConfig(Integer hostCpus,
UnaryOperator<ContainerBuilder.Builder> customizer) {
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
when(config.docker().resourceNamespace()).thenReturn(Optional.empty());
ContainerBuilder builder = new ContainerBuilder(
config, mock(DockerHostResolver.class), mock(EmbeddedDnsServer.class));
ContainerSpec spec = customizer.apply(builder.newContainer("app:latest")).build();
DockerClient dockerClient = mock(DockerClient.class);
stubDaemonCpuCount(dockerClient, hostCpus);
CreateContainerCmd createCmd = mock(CreateContainerCmd.class, RETURNS_SELF);
when(dockerClient.createContainerCmd("app:latest")).thenReturn(createCmd);
CreateContainerResponse response = mock(CreateContainerResponse.class);
when(response.getId()).thenReturn("app-id");
when(createCmd.exec()).thenReturn(response);
ImageCacheService imageCacheService = mock(ImageCacheService.class);
when(imageCacheService.ensureImageExists(any())).thenAnswer(invocation -> invocation.getArgument(0));
ContainerLifecycleManager manager = new ContainerLifecycleManager(
dockerClient, imageCacheService, mock(ContainerDetector.class), mock(PortAllocator.class), config);
manager.create(spec);
ArgumentCaptor<HostConfig> hostConfig = ArgumentCaptor.forClass(HostConfig.class);
verify(createCmd).withHostConfig(hostConfig.capture());
return hostConfig.getValue();
}
private static void stubDaemonCpuCount(DockerClient dockerClient, Integer hostCpus) {
InfoCmd infoCmd = mock(InfoCmd.class);
Info info = mock(Info.class);
when(dockerClient.infoCmd()).thenReturn(infoCmd);
when(infoCmd.exec()).thenReturn(info);
when(info.getNCPU()).thenReturn(hostCpus);
}
}
@@ -0,0 +1,136 @@
package io.github.hectorvent.floci.core.common.docker;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.CreateContainerCmd;
import com.github.dockerjava.api.command.CreateContainerResponse;
import com.github.dockerjava.api.model.DeviceRequest;
import com.github.dockerjava.api.model.HostConfig;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.services.lambda.launcher.ImageCacheService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.RETURNS_SELF;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Asserts that a spec's device requests reach Docker's HostConfig unchanged, and that a
* container which asked for nothing still asks for nothing. The second half is the one
* that matters operationally: on a host whose daemon defaults to an accelerator runtime,
* silently granting devices to every container Floci launches would be invisible until
* something contended for the hardware.
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("ContainerLifecycleManager: device requests")
class ContainerLifecycleManagerDeviceRequestTest {
@Mock
DockerClient dockerClient;
@Mock
ImageCacheService imageCacheService;
@Mock
ContainerDetector containerDetector;
@Mock
PortAllocator portAllocator;
@Mock
EmulatorConfig config;
@Mock
EmulatorConfig.DockerConfig dockerConfig;
@Mock
EmulatorConfig.TlsConfig tlsConfig;
@BeforeEach
void setUp() {
lenient().when(config.docker()).thenReturn(dockerConfig);
lenient().when(config.tls()).thenReturn(tlsConfig);
lenient().when(dockerConfig.resourceNamespace()).thenReturn(Optional.empty());
lenient().when(imageCacheService.ensureImageExists(any()))
.thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
void gpuCountRequestReachesHostConfig() {
CreateContainerCmd createCmd = stubCreateContainer();
DeviceRequest request = new DeviceRequest()
.withCount(1)
.withCapabilities(List.of(List.of("gpu")));
manager().create(specWithDeviceRequests(List.of(request)));
assertEquals(List.of(request), capturedHostConfig(createCmd).getDeviceRequests());
}
@Test
void cdiDeviceRequestReachesHostConfig() {
CreateContainerCmd createCmd = stubCreateContainer();
DeviceRequest request = new DeviceRequest()
.withDriver("cdi")
.withDeviceIds(List.of("nvidia.com/gpu=GPU-1fc572c4-6cd7-0e21-dceb-fa71af82eed5"));
manager().create(specWithDeviceRequests(List.of(request)));
List<DeviceRequest> applied = capturedHostConfig(createCmd).getDeviceRequests();
assertEquals(1, applied.size());
assertEquals("cdi", applied.get(0).getDriver());
assertEquals(
List.of("nvidia.com/gpu=GPU-1fc572c4-6cd7-0e21-dceb-fa71af82eed5"),
applied.get(0).getDeviceIds());
}
@Test
void containerThatAskedForNoDeviceGetsNone() {
CreateContainerCmd createCmd = stubCreateContainer();
manager().create(new ContainerSpec("busybox:stable"));
assertNull(capturedHostConfig(createCmd).getDeviceRequests());
}
private ContainerLifecycleManager manager() {
return new ContainerLifecycleManager(
dockerClient, imageCacheService, containerDetector, portAllocator, config);
}
private static ContainerSpec specWithDeviceRequests(List<DeviceRequest> deviceRequests) {
return new ContainerSpec(
"busybox:stable", null, List.of(), null, null, null, Map.of(), List.of(), List.of(), null,
List.of(), List.of(), List.of(), Map.of(), null, false, null, List.of(), null,
null, List.of(), deviceRequests);
}
private CreateContainerCmd stubCreateContainer() {
CreateContainerCmd createCmd = mock(CreateContainerCmd.class, RETURNS_SELF);
when(dockerClient.createContainerCmd("busybox:stable")).thenReturn(createCmd);
CreateContainerResponse response = mock(CreateContainerResponse.class);
when(response.getId()).thenReturn("container-id");
when(createCmd.exec()).thenReturn(response);
return createCmd;
}
private static HostConfig capturedHostConfig(CreateContainerCmd createCmd) {
ArgumentCaptor<HostConfig> hostConfig = ArgumentCaptor.forClass(HostConfig.class);
verify(createCmd).withHostConfig(hostConfig.capture());
return hostConfig.getValue();
}
}
@@ -0,0 +1,98 @@
package io.github.hectorvent.floci.core.common.docker;
import java.util.Map;
import java.util.Optional;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.InspectImageCmd;
import com.github.dockerjava.api.command.InspectImageResponse;
import com.github.dockerjava.api.exception.NotFoundException;
import com.github.dockerjava.api.model.ContainerConfig;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.services.lambda.launcher.ImageCacheService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* An image's labels are advisory metadata: the web console reads them to learn the console's shape.
* A failure to read them must therefore never break the start they were being read for.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ContainerLifecycleManagerImageLabelsTest {
@Mock
DockerClient dockerClient;
@Mock
ImageCacheService imageCacheService;
@Mock
ContainerDetector containerDetector;
@Mock
PortAllocator portAllocator;
@Mock
EmulatorConfig config;
private ContainerLifecycleManager manager() {
return new ContainerLifecycleManager(dockerClient, imageCacheService, containerDetector,
portAllocator, config);
}
private void imageInspectReturns(Map<String, String> labels) {
ContainerConfig containerConfig = new ContainerConfig().withLabels(labels);
InspectImageResponse response = mock(InspectImageResponse.class);
when(response.getConfig()).thenReturn(containerConfig);
InspectImageCmd cmd = mock(InspectImageCmd.class);
when(cmd.exec()).thenReturn(response);
when(dockerClient.inspectImageCmd("acme/console:1")).thenReturn(cmd);
}
@Test
void labelsAreReadFromTheImageThePullResolvedTo() {
when(imageCacheService.ensureImageExists("acme/console")).thenReturn("acme/console:1");
imageInspectReturns(Map.of("io.floci.console.contract", "1"));
assertEquals(Optional.of(Map.of("io.floci.console.contract", "1")),
manager().imageLabels("acme/console"));
}
@Test
void anImageWithNoLabelsReadsAsEmptyRatherThanAsUnreadable() {
// "This image declares nothing" and "I could not ask" are different answers: only the
// second should send the caller looking for another source.
when(imageCacheService.ensureImageExists("acme/console")).thenReturn("acme/console:1");
imageInspectReturns(null);
assertEquals(Optional.of(Map.of()), manager().imageLabels("acme/console"));
}
@Test
void anUnavailableImageReportsUnreadableLabelsRatherThanThrowing() {
when(imageCacheService.ensureImageExists("acme/console"))
.thenThrow(new NotFoundException("no such image"));
assertTrue(manager().imageLabels("acme/console").isEmpty(),
"a failed pull must leave the caller to its own defaults, not fail the start");
}
@Test
void anUnreachableRuntimeReportsUnreadableLabelsRatherThanThrowing() {
when(imageCacheService.ensureImageExists("acme/console")).thenReturn("acme/console:1");
when(dockerClient.inspectImageCmd("acme/console:1"))
.thenThrow(new RuntimeException("Cannot connect to the Docker daemon"));
assertTrue(manager().imageLabels("acme/console").isEmpty());
}
}
@@ -0,0 +1,272 @@
package io.github.hectorvent.floci.core.common.docker;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.CreateContainerCmd;
import com.github.dockerjava.api.command.CreateContainerResponse;
import com.github.dockerjava.api.command.CreateVolumeCmd;
import com.github.dockerjava.api.command.InspectVolumeCmd;
import com.github.dockerjava.api.command.RemoveContainerCmd;
import com.github.dockerjava.api.command.StartContainerCmd;
import com.github.dockerjava.api.command.WaitContainerCmd;
import com.github.dockerjava.api.exception.NotFoundException;
import com.github.dockerjava.api.model.Capability;
import com.github.dockerjava.api.model.HostConfig;
import com.github.dockerjava.core.command.WaitContainerResultCallback;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.services.lambda.launcher.ImageCacheService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.RETURNS_SELF;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Asserts the default Docker labels at the single choke point every emulator-created
* container and volume passes through. All container-backed services funnel into
* {@link ContainerLifecycleManager#create}, so asserting here covers every service
* by construction.
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("ContainerLifecycleManager — default emulator labels")
class ContainerLifecycleManagerLabelsTest {
@Mock
DockerClient dockerClient;
@Mock
ImageCacheService imageCacheService;
@Mock
ContainerDetector containerDetector;
@Mock
PortAllocator portAllocator;
@Mock
EmulatorConfig config;
@Mock
EmulatorConfig.DockerConfig dockerConfig;
@Mock
EmulatorConfig.TlsConfig tlsConfig;
@BeforeEach
void setUp() {
lenient().when(config.docker()).thenReturn(dockerConfig);
lenient().when(config.tls()).thenReturn(tlsConfig);
lenient().when(dockerConfig.resourceNamespace()).thenReturn(Optional.empty());
lenient().when(imageCacheService.ensureImageExists(any()))
.thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
void createAppliesDefaultLabelsToEveryContainer() {
CreateContainerCmd createCmd = stubCreateContainer();
manager().create(new ContainerSpec("busybox:stable"));
assertEquals(
Map.of("floci", "true", "floci_emulator", "floci-aws"),
capturedLabels(createCmd));
verify(imageCacheService).ensureImageExists("busybox:stable");
}
@Test
void createMergesSpecLabelsOverDefaults() {
CreateContainerCmd createCmd = stubCreateContainer();
ContainerSpec spec = specWithLabels(Map.of("floci_service", "lambda"));
manager().create(spec);
assertEquals(
Map.of("floci", "true", "floci_emulator", "floci-aws", "floci_service", "lambda"),
capturedLabels(createCmd));
}
@Test
void protectedWorkloadCannotAdministerOrSpoofItsNetwork() {
CreateContainerCmd createCmd = stubCreateContainer();
manager().create(specWithLabels(Map.of("floci.security-group-workload", "true")));
ArgumentCaptor<HostConfig> hostConfig = ArgumentCaptor.forClass(HostConfig.class);
verify(createCmd).withHostConfig(hostConfig.capture());
List<Capability> dropped = List.of(hostConfig.getValue().getCapDrop());
assertTrue(dropped.contains(Capability.NET_ADMIN));
assertTrue(dropped.contains(Capability.NET_RAW));
}
@Test
void firewallHelperGetsNetAdminInsteadOfFullPrivilege() {
CreateContainerCmd createCmd = stubCreateContainer();
manager().create(specWithLabels(Map.of("floci.security-group-helper", "true")));
ArgumentCaptor<HostConfig> hostConfig = ArgumentCaptor.forClass(HostConfig.class);
verify(createCmd).withHostConfig(hostConfig.capture());
assertEquals(List.of(Capability.NET_ADMIN), List.of(hostConfig.getValue().getCapAdd()));
assertNotEquals(Boolean.TRUE, hostConfig.getValue().getPrivileged());
}
@Test
void specLabelWinsOverDefaultOnKeyConflict() {
CreateContainerCmd createCmd = stubCreateContainer();
ContainerSpec spec = specWithLabels(Map.of("floci_emulator", "custom-value"));
manager().create(spec);
assertEquals(
Map.of("floci", "true", "floci_emulator", "custom-value"),
capturedLabels(createCmd));
}
@Test
void createUsesRequestedDockerPlatform() {
when(imageCacheService.ensureImageExists("busybox:stable", "linux/arm64"))
.thenReturn("sha256:arm64");
CreateContainerCmd createCmd = stubCreateContainer("sha256:arm64");
manager().create(new ContainerSpec("busybox:stable"), "linux/arm64");
verify(imageCacheService).ensureImageExists("busybox:stable", "linux/arm64");
verify(dockerClient).createContainerCmd("sha256:arm64");
verify(createCmd).withPlatform("linux/arm64");
}
@Test
void createUsesResolvedImageForDaemonDefaultPlatform() {
when(imageCacheService.ensureImageExists("busybox:stable"))
.thenReturn("sha256:native");
CreateContainerCmd createCmd = stubCreateContainer("sha256:native");
manager().create(new ContainerSpec("busybox:stable"));
verify(dockerClient).createContainerCmd("sha256:native");
verify(createCmd, never()).withPlatform(any());
}
@Test
void createOmitsExtraHostsForContainerNetworkMode() {
CreateContainerCmd createCmd = stubCreateContainer();
ContainerSpec spec = new ContainerSpec(
"busybox:stable", null, List.of(), null, null, null, Map.of(), List.of(),
"container:router-id", List.of(), List.of(), List.of("host.docker.internal:host-gateway"),
Map.of(), null, false, null, List.of(), null, null, List.of());
manager().create(spec);
ArgumentCaptor<HostConfig> hostConfig = ArgumentCaptor.forClass(HostConfig.class);
verify(createCmd).withHostConfig(hostConfig.capture());
assertTrue(hostConfig.getValue().getExtraHosts() == null
|| hostConfig.getValue().getExtraHosts().length == 0);
}
@Test
void createIncludesNamespaceLabelWhenConfigured() {
when(dockerConfig.resourceNamespace()).thenReturn(Optional.of("run-one"));
CreateContainerCmd createCmd = stubCreateContainer();
manager().create(new ContainerSpec("busybox:stable"));
assertEquals(
Map.of("floci", "true", "floci_emulator", "floci-aws", "floci_namespace", "run-one"),
capturedLabels(createCmd));
}
@Test
void ensureVolumeAppliesTheSameDefaultLabels() {
InspectVolumeCmd inspectVolumeCmd = mock(InspectVolumeCmd.class);
when(dockerClient.inspectVolumeCmd("volume-1")).thenReturn(inspectVolumeCmd);
when(inspectVolumeCmd.exec()).thenThrow(new NotFoundException("missing"));
CreateVolumeCmd createVolumeCmd = mock(CreateVolumeCmd.class, RETURNS_SELF);
when(dockerClient.createVolumeCmd()).thenReturn(createVolumeCmd);
manager().ensureVolume("volume-1");
ArgumentCaptor<Map<String, String>> labels = labelsCaptor();
verify(createVolumeCmd).withLabels(labels.capture());
assertEquals(Map.of("floci", "true", "floci_emulator", "floci-aws"), labels.getValue());
}
@Test
void sharedVolumeInitHelperCarriesDefaultLabels() {
InspectVolumeCmd inspectVolumeCmd = mock(InspectVolumeCmd.class);
when(dockerClient.inspectVolumeCmd("shared")).thenReturn(inspectVolumeCmd);
when(inspectVolumeCmd.exec()).thenThrow(new NotFoundException("missing"));
CreateVolumeCmd createVolumeCmd = mock(CreateVolumeCmd.class, RETURNS_SELF);
when(dockerClient.createVolumeCmd()).thenReturn(createVolumeCmd);
CreateContainerCmd createCmd = stubCreateContainer();
when(dockerClient.startContainerCmd("container-id")).thenReturn(mock(StartContainerCmd.class));
WaitContainerCmd waitCmd = mock(WaitContainerCmd.class);
when(dockerClient.waitContainerCmd("container-id")).thenReturn(waitCmd);
WaitContainerResultCallback waitCallback = mock(WaitContainerResultCallback.class);
when(waitCmd.exec(any(WaitContainerResultCallback.class))).thenReturn(waitCallback);
when(waitCallback.awaitStatusCode(anyLong(), any())).thenReturn(0);
when(dockerClient.removeContainerCmd("container-id"))
.thenReturn(mock(RemoveContainerCmd.class, RETURNS_SELF));
manager().ensureSharedVolume("shared", OptionalInt.of(1001), OptionalInt.of(1001),
Optional.of("2775"), "busybox:stable");
assertEquals(
Map.of("floci", "true", "floci_emulator", "floci-aws"),
capturedLabels(createCmd));
}
private ContainerLifecycleManager manager() {
return new ContainerLifecycleManager(
dockerClient, imageCacheService, containerDetector, portAllocator, config);
}
private static ContainerSpec specWithLabels(Map<String, String> labels) {
return new ContainerSpec(
"busybox:stable", null, List.of(), null, null, null, Map.of(), List.of(), List.of(), null,
List.of(), List.of(), List.of(), labels, null, false, null, List.of(), null,
null, List.of());
}
private CreateContainerCmd stubCreateContainer() {
return stubCreateContainer("busybox:stable");
}
private CreateContainerCmd stubCreateContainer(String image) {
CreateContainerCmd createCmd = mock(CreateContainerCmd.class, RETURNS_SELF);
when(dockerClient.createContainerCmd(image)).thenReturn(createCmd);
CreateContainerResponse response = mock(CreateContainerResponse.class);
when(response.getId()).thenReturn("container-id");
when(createCmd.exec()).thenReturn(response);
return createCmd;
}
private Map<String, String> capturedLabels(CreateContainerCmd createCmd) {
ArgumentCaptor<Map<String, String>> labels = labelsCaptor();
verify(createCmd).withLabels(labels.capture());
return labels.getValue();
}
@SuppressWarnings("unchecked")
private static ArgumentCaptor<Map<String, String>> labelsCaptor() {
return ArgumentCaptor.forClass((Class<Map<String, String>>) (Class<?>) Map.class);
}
}
@@ -0,0 +1,167 @@
package io.github.hectorvent.floci.core.common.docker;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.ConnectToNetworkCmd;
import com.github.dockerjava.api.command.CreateContainerCmd;
import com.github.dockerjava.api.command.CreateContainerResponse;
import com.github.dockerjava.api.command.DisconnectFromNetworkCmd;
import com.github.dockerjava.api.command.InspectContainerCmd;
import com.github.dockerjava.api.command.InspectContainerResponse;
import com.github.dockerjava.api.command.RemoveContainerCmd;
import com.github.dockerjava.api.command.StartContainerCmd;
import com.github.dockerjava.api.model.ContainerNetwork;
import com.github.dockerjava.api.model.NetworkSettings;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.services.lambda.launcher.ImageCacheService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.RETURNS_SELF;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ContainerLifecycleManagerNetworkTest {
private static final String LINK_LOCAL_IP = "169.254.170.31";
@Mock
DockerClient dockerClient;
@Mock
ImageCacheService imageCacheService;
@Mock
ContainerDetector containerDetector;
@Mock
PortAllocator portAllocator;
@Mock
EmulatorConfig config;
@Mock
EmulatorConfig.DockerConfig dockerConfig;
@Mock
EmulatorConfig.TlsConfig tlsConfig;
private CreateContainerCmd createCmd;
private DisconnectFromNetworkCmd disconnectCmd;
private ConnectToNetworkCmd connectCmd;
@BeforeEach
void setUp() {
lenient().when(config.docker()).thenReturn(dockerConfig);
lenient().when(config.tls()).thenReturn(tlsConfig);
lenient().when(dockerConfig.resourceNamespace()).thenReturn(Optional.empty());
lenient().when(imageCacheService.ensureImageExists(any()))
.thenAnswer(invocation -> invocation.getArgument(0));
createCmd = mock(CreateContainerCmd.class, RETURNS_SELF);
CreateContainerResponse response = mock(CreateContainerResponse.class);
disconnectCmd = mock(DisconnectFromNetworkCmd.class, RETURNS_SELF);
connectCmd = mock(ConnectToNetworkCmd.class, RETURNS_SELF);
lenient().when(dockerClient.createContainerCmd("busybox:stable")).thenReturn(createCmd);
lenient().when(createCmd.exec()).thenReturn(response);
lenient().when(response.getId()).thenReturn("container-id");
lenient().when(dockerClient.disconnectFromNetworkCmd()).thenReturn(disconnectCmd);
lenient().when(dockerClient.connectToNetworkCmd()).thenReturn(connectCmd);
}
@Test
void createReconnectsLinkLocalEndpointBeforeReturningContainer() throws Exception {
manager().create(specWithLinkLocalIp(Map.of()));
InOrder order = inOrder(createCmd, disconnectCmd, connectCmd);
order.verify(createCmd).exec();
order.verify(disconnectCmd).exec();
order.verify(connectCmd).exec();
ArgumentCaptor<ContainerNetwork> endpoint = ArgumentCaptor.forClass(ContainerNetwork.class);
verify(connectCmd).withContainerNetwork(endpoint.capture());
String serialized = new ObjectMapper().writeValueAsString(endpoint.getValue());
assertTrue(serialized.contains("\"LinkLocalIPs\":[\"" + LINK_LOCAL_IP + "\"]"), serialized);
}
@Test
void createKeepsPublishedPortsByConnectingWithoutDisconnecting() {
manager().create(specWithLinkLocalIp(Map.of(8080, 18080)));
verify(disconnectCmd, never()).exec();
verify(connectCmd).exec();
}
@Test
void createRemovesContainerWhenNetworkAttachFails() {
RemoveContainerCmd removeCmd = mock(RemoveContainerCmd.class, RETURNS_SELF);
lenient().when(dockerClient.removeContainerCmd("container-id")).thenReturn(removeCmd);
doThrow(new IllegalStateException("network gone")).when(connectCmd).exec();
assertThrows(IllegalStateException.class, () -> manager().create(specWithLinkLocalIp(Map.of())));
verify(dockerClient).removeContainerCmd("container-id");
}
@Test
void createLeavesNetworkAloneWithoutLinkLocalIps() {
manager().create(new ContainerSpec("busybox:stable"));
verify(disconnectCmd, never()).exec();
verify(connectCmd, never()).exec();
}
/**
* Pins the {@code !spec.hasNetworkConfiguration()} guard in {@code startCreated}: without it,
* a container with both published ports and a link-local address would be connected twice,
* once by {@code create}'s pre-start attach and again by {@code startCreated}'s post-start
* reconnect for port-bound containers (raised in review on #4063).
*/
@Test
void createAndStartConnectsOnceForPortsPlusLinkLocalIp() {
when(dockerClient.startContainerCmd("container-id")).thenReturn(mock(StartContainerCmd.class));
InspectContainerCmd inspectCmd = mock(InspectContainerCmd.class, RETURNS_SELF);
InspectContainerResponse inspect = mock(InspectContainerResponse.class);
NetworkSettings networkSettings = mock(NetworkSettings.class);
when(dockerClient.inspectContainerCmd("container-id")).thenReturn(inspectCmd);
when(inspectCmd.exec()).thenReturn(inspect);
when(inspect.getNetworkSettings()).thenReturn(networkSettings);
when(networkSettings.getPorts()).thenReturn(null);
manager().createAndStart(specWithLinkLocalIp(Map.of(8080, 18080)));
verify(connectCmd, times(1)).exec();
verify(disconnectCmd, never()).exec();
}
private ContainerLifecycleManager manager() {
return new ContainerLifecycleManager(dockerClient, imageCacheService, containerDetector,
portAllocator, config);
}
private static ContainerSpec specWithLinkLocalIp(Map<Integer, Integer> portBindings) {
return new ContainerSpec(
"busybox:stable", null, List.of(), null, null, null, portBindings, List.of(),
List.of(), "test-network", List.of(), List.of(), List.of(), List.of(), Map.of(), null,
false, null, List.of(), null, null, List.of(), List.of(), null, null, false,
List.of(LINK_LOCAL_IP));
}
}
@@ -0,0 +1,124 @@
package io.github.hectorvent.floci.core.common.docker;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.RemoveContainerCmd;
import com.github.dockerjava.api.command.StartContainerCmd;
import com.github.dockerjava.api.exception.DockerException;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.services.lambda.launcher.ImageCacheService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ContainerLifecycleManager#createAndStart} must not leak the created
* container when the start step fails (e.g. a host-port conflict, issue #1778):
* retrying callers would otherwise accumulate {@code Created} containers, and
* fixed-name callers would hit name conflicts on their next attempt.
*/
@ExtendWith(MockitoExtension.class)
class ContainerLifecycleManagerStartFailureTest {
@Mock
private DockerClient dockerClient;
@Mock
private ImageCacheService imageCacheService;
@Mock
private ContainerDetector containerDetector;
@Mock
private PortAllocator portAllocator;
@Mock
private EmulatorConfig config;
private ContainerLifecycleManager manager;
private final ContainerSpec spec = new ContainerSpec("busybox:latest");
@BeforeEach
void setUp() {
manager = spy(new ContainerLifecycleManager(
dockerClient, imageCacheService, containerDetector, portAllocator, config));
}
@Test
void createAndStartRemovesCreatedContainerWhenStartFails() {
RuntimeException startFailure =
new RuntimeException("Bind for 0.0.0.0:80 failed: port is already allocated");
doReturn("container-id").when(manager).create(spec);
doThrow(startFailure).when(manager).startCreated("container-id", spec);
RemoveContainerCmd removeCmd = mock(RemoveContainerCmd.class);
when(dockerClient.removeContainerCmd("container-id")).thenReturn(removeCmd);
when(removeCmd.withForce(true)).thenReturn(removeCmd);
RuntimeException thrown = assertThrows(RuntimeException.class,
() -> manager.createAndStart(spec));
assertSame(startFailure, thrown, "original start failure must propagate");
verify(removeCmd).exec();
}
@Test
void createAndStartDoesNotRemoveContainerOnSuccess() {
ContainerLifecycleManager.ContainerInfo info =
new ContainerLifecycleManager.ContainerInfo("container-id", Map.of());
doReturn("container-id").when(manager).create(spec);
doReturn(info).when(manager).startCreated("container-id", spec);
assertSame(info, manager.createAndStart(spec));
verify(dockerClient, never()).removeContainerCmd(any(String.class));
}
@Test
void startCreatedTranslatesKeyringQuotaError() {
// github.com/floci-io/floci/issues/2243: crun/runc report the kernel session-keyring
// quota (kernel.keys.maxkeys) as "Disk quota exceeded", which sends operators looking at
// disk space instead of the actual cause under high concurrent container counts.
StartContainerCmd startCmd = mock(StartContainerCmd.class);
when(dockerClient.startContainerCmd("container-id")).thenReturn(startCmd);
when(startCmd.exec()).thenThrow(new DockerException(
"crun: join keyctl '12345': Disk quota exceeded: OCI runtime error", 500));
IllegalStateException thrown = assertThrows(IllegalStateException.class,
() -> manager.startCreated("container-id", spec));
assertTrue(thrown.getMessage().contains("kernel.keys.maxkeys"),
"translated message should name the actual kernel-keyring cause: " + thrown.getMessage());
assertTrue(thrown.getMessage().contains("sysctl"),
"translated message should include the fix: " + thrown.getMessage());
}
@Test
void startCreatedPropagatesUnrelatedDockerExceptionsUnchanged() {
StartContainerCmd startCmd = mock(StartContainerCmd.class);
when(dockerClient.startContainerCmd("container-id")).thenReturn(startCmd);
DockerException portConflict = new DockerException(
"Bind for 0.0.0.0:80 failed: port is already allocated", 500);
when(startCmd.exec()).thenThrow(portConflict);
DockerException thrown = assertThrows(DockerException.class,
() -> manager.startCreated("container-id", spec));
assertSame(portConflict, thrown, "unrelated Docker errors must propagate unchanged");
}
}

Some files were not shown because too many files have changed in this diff Show More