From 01c5bcf1730714a4ac15033867866517938d86bf Mon Sep 17 00:00:00 2001 From: or-akeyless Date: Tue, 11 Aug 2026 13:30:27 +0300 Subject: [PATCH] test: add hermetic unit suite + CI pipeline Add a network-free JUnit test suite covering the provider factory dispatch, the AWS STS SigV4 signing / cloud-id envelope end-to-end (fake creds injected via system properties), Azure/GCP offline construction, plus a credentials-gated live-cloud e2e class that is skipped without secrets. Add a "Tests" GitHub Actions workflow that runs `mvn -B test` on push to all branches and on pull_request. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/test.yml | 26 ++ pom.xml | 15 ++ .../cloudid/AwsCloudIdProviderTest.java | 236 ++++++++++++++++++ .../cloudid/AzureCloudIdProviderTest.java | 41 +++ .../cloudid/CloudProviderFactoryTest.java | 89 +++++++ .../cloudid/GcpCloudIdProviderTest.java | 31 +++ .../akeyless/cloudid/LiveCloudIdE2ETest.java | 58 +++++ 7 files changed, 496 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 src/test/java/io/akeyless/cloudid/AwsCloudIdProviderTest.java create mode 100644 src/test/java/io/akeyless/cloudid/AzureCloudIdProviderTest.java create mode 100644 src/test/java/io/akeyless/cloudid/CloudProviderFactoryTest.java create mode 100644 src/test/java/io/akeyless/cloudid/GcpCloudIdProviderTest.java create mode 100644 src/test/java/io/akeyless/cloudid/LiveCloudIdE2ETest.java diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..881a4ef --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: "Tests" + +on: + push: + branches: + - "**" + pull_request: + +jobs: + java-test: + name: Java Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: "11" + distribution: "temurin" + cache: "maven" + + - name: Run tests + run: mvn -B -Drevision=1.0.0-SNAPSHOT test diff --git a/pom.xml b/pom.xml index 3bb7f0b..2a6f87a 100644 --- a/pom.xml +++ b/pom.xml @@ -73,6 +73,14 @@ netty-handler 4.1.121.Final + + + + junit + junit + 4.13.2 + test + @@ -85,6 +93,13 @@ + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + maven-assembly-plugin diff --git a/src/test/java/io/akeyless/cloudid/AwsCloudIdProviderTest.java b/src/test/java/io/akeyless/cloudid/AwsCloudIdProviderTest.java new file mode 100644 index 0000000..d275fd9 --- /dev/null +++ b/src/test/java/io/akeyless/cloudid/AwsCloudIdProviderTest.java @@ -0,0 +1,236 @@ +package io.akeyless.cloudid; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Iterator; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Fully hermetic, offline end-to-end tests for {@link AwsCloudIdProvider}. + * + *

The provider resolves credentials through the AWS SDK + * {@code DefaultCredentialsProvider} chain. The first link in that chain is the + * {@code SystemPropertyCredentialsProvider}, which reads the {@code aws.accessKeyId}, + * {@code aws.secretAccessKey} and {@code aws.sessionToken} system properties with + * no network access. By setting those properties to fake static values we + * exercise the whole "sign an STS GetCallerIdentity request and package it as a + * cloud-id token" flow without any real credentials or any outbound request. + * + *

SigV4 signing is a purely local computation, so these tests never contact STS. + */ +public class AwsCloudIdProviderTest { + + private static final String FAKE_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE"; + private static final String FAKE_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + private static final String FAKE_SESSION_TOKEN = "FAKESESSIONTOKEN/akeyless-test//////////wEXAMPLE"; + + private static final String PROP_ACCESS_KEY = "aws.accessKeyId"; + private static final String PROP_SECRET_KEY = "aws.secretAccessKey"; + private static final String PROP_SESSION_TOKEN = "aws.sessionToken"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private String savedAccessKey; + private String savedSecretKey; + private String savedSessionToken; + + @Before + public void injectFakeCredentials() { + // Remember whatever the host/CI environment had so we can restore it. + savedAccessKey = System.getProperty(PROP_ACCESS_KEY); + savedSecretKey = System.getProperty(PROP_SECRET_KEY); + savedSessionToken = System.getProperty(PROP_SESSION_TOKEN); + + System.setProperty(PROP_ACCESS_KEY, FAKE_ACCESS_KEY); + System.setProperty(PROP_SECRET_KEY, FAKE_SECRET_KEY); + // Session token is set per-test where needed; make sure we start clean. + System.clearProperty(PROP_SESSION_TOKEN); + } + + @After + public void restoreCredentials() { + restore(PROP_ACCESS_KEY, savedAccessKey); + restore(PROP_SECRET_KEY, savedSecretKey); + restore(PROP_SESSION_TOKEN, savedSessionToken); + } + + private static void restore(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + + // ---- The decoded token model ------------------------------------------------- + + /** Decodes the outer base64+JSON envelope and the inner base64 fields. */ + private static DecodedToken decode(String cloudId) throws Exception { + assertNotNull("cloud id must not be null", cloudId); + byte[] outer = Base64.getDecoder().decode(cloudId); + JsonNode root = MAPPER.readTree(new String(outer, StandardCharsets.UTF_8)); + + DecodedToken token = new DecodedToken(); + token.method = root.get("sts_request_method").asText(); + token.url = decodeField(root, "sts_request_url"); + token.body = decodeField(root, "sts_request_body"); + token.headersJson = decodeField(root, "sts_request_headers"); + token.headers = MAPPER.readTree(token.headersJson); + return token; + } + + private static String decodeField(JsonNode root, String field) { + JsonNode node = root.get(field); + assertNotNull("missing token field: " + field, node); + return new String(Base64.getDecoder().decode(node.asText()), StandardCharsets.UTF_8); + } + + private static final class DecodedToken { + String method; + String url; + String body; + String headersJson; + JsonNode headers; + + /** Case-insensitive header lookup; headers are serialized as name -> [values]. */ + String header(String name) { + Iterator> fields = headers.fields(); + while (fields.hasNext()) { + Map.Entry e = fields.next(); + if (e.getKey().equalsIgnoreCase(name)) { + JsonNode v = e.getValue(); + return v.isArray() && v.size() > 0 ? v.get(0).asText() : v.asText(); + } + } + return null; + } + + boolean hasHeader(String name) { + return header(name) != null; + } + } + + private static DecodedToken getDecodedToken() throws Exception { + return decode(new AwsCloudIdProvider().getCloudId()); + } + + // ---- Envelope / payload shape ------------------------------------------------ + + @Test + public void tokenIsBase64EncodedJsonBundleWithAllFields() throws Exception { + String cloudId = new AwsCloudIdProvider().getCloudId(); + assertFalse("cloud id must not be empty", cloudId.isEmpty()); + + // Outer layer must be valid base64 of a JSON object carrying the 4 STS fields. + JsonNode root = MAPPER.readTree(new String(Base64.getDecoder().decode(cloudId), StandardCharsets.UTF_8)); + assertTrue(root.isObject()); + assertTrue(root.has("sts_request_method")); + assertTrue(root.has("sts_request_url")); + assertTrue(root.has("sts_request_body")); + assertTrue(root.has("sts_request_headers")); + } + + @Test + public void methodIsPost() throws Exception { + assertEquals("POST", getDecodedToken().method); + } + + @Test + public void urlDecodesToGlobalStsEndpoint() throws Exception { + assertEquals("https://sts.amazonaws.com/", getDecodedToken().url); + } + + @Test + public void bodyIsGetCallerIdentityAction() throws Exception { + assertEquals("Action=GetCallerIdentity&Version=2011-06-15", getDecodedToken().body); + } + + // ---- SigV4 signing correctness ---------------------------------------------- + + @Test + public void authorizationHeaderIsSigV4() throws Exception { + String auth = getDecodedToken().header("Authorization"); + assertNotNull("Authorization header must be present", auth); + assertTrue("Authorization must use SigV4 (AWS4-HMAC-SHA256), was: " + auth, + auth.startsWith("AWS4-HMAC-SHA256")); + assertTrue("Authorization must carry a Signature component", auth.contains("Signature=")); + assertTrue("Authorization must carry SignedHeaders", auth.contains("SignedHeaders=")); + } + + @Test + public void credentialScopeTargetsUsEast1StsService() throws Exception { + String auth = getDecodedToken().header("Authorization"); + assertNotNull(auth); + // Credential scope is ////aws4_request + assertTrue("credential scope must target us-east-1/sts, was: " + auth, + auth.contains("/us-east-1/sts/aws4_request")); + assertTrue("Authorization must reference the fake access key id", + auth.contains("Credential=" + FAKE_ACCESS_KEY + "/")); + } + + @Test + public void hostHeaderIsGlobalStsHost() throws Exception { + assertEquals("sts.amazonaws.com", getDecodedToken().header("Host")); + } + + @Test + public void xAmzDateHeaderIsPresentAndWellFormed() throws Exception { + String date = getDecodedToken().header("X-Amz-Date"); + assertNotNull("X-Amz-Date header must be present", date); + // Format is basic ISO8601: yyyyMMdd'T'HHmmss'Z' + assertTrue("X-Amz-Date must match yyyyMMddTHHmmssZ, was: " + date, + date.matches("^\\d{8}T\\d{6}Z$")); + } + + @Test + public void contentTypeHeaderIsFormUrlEncoded() throws Exception { + String contentType = getDecodedToken().header("Content-Type"); + assertNotNull(contentType); + assertTrue("Content-Type must be form-urlencoded, was: " + contentType, + contentType.startsWith("application/x-www-form-urlencoded")); + } + + // ---- Session-token handling -------------------------------------------------- + + @Test + public void includesSecurityTokenWhenSessionCredentialsUsed() throws Exception { + System.setProperty(PROP_SESSION_TOKEN, FAKE_SESSION_TOKEN); + DecodedToken token = getDecodedToken(); + assertEquals("X-Amz-Security-Token must echo the session token", + FAKE_SESSION_TOKEN, token.header("X-Amz-Security-Token")); + // Session-token requests must also fold the token into the signature. + assertTrue(token.header("Authorization").contains("x-amz-security-token")); + } + + @Test + public void omitsSecurityTokenForStaticCredentials() throws Exception { + // injectFakeCredentials() clears the session token, so basic creds are used. + DecodedToken token = getDecodedToken(); + assertFalse("X-Amz-Security-Token must be absent without a session token", + token.hasHeader("X-Amz-Security-Token")); + } + + // ---- Determinism / independence --------------------------------------------- + + @Test + public void structureIsDeterministicAcrossCalls() throws Exception { + DecodedToken a = getDecodedToken(); + DecodedToken b = getDecodedToken(); + // Method/URL/body are fixed inputs and must never vary between invocations. + assertEquals(a.method, b.method); + assertEquals(a.url, b.url); + assertEquals(a.body, b.body); + assertEquals(a.header("Host"), b.header("Host")); + } +} diff --git a/src/test/java/io/akeyless/cloudid/AzureCloudIdProviderTest.java b/src/test/java/io/akeyless/cloudid/AzureCloudIdProviderTest.java new file mode 100644 index 0000000..680335b --- /dev/null +++ b/src/test/java/io/akeyless/cloudid/AzureCloudIdProviderTest.java @@ -0,0 +1,41 @@ +package io.akeyless.cloudid; + +import com.azure.identity.DefaultAzureCredential; +import com.azure.identity.DefaultAzureCredentialBuilder; +import org.junit.Test; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Offline tests for {@link AzureCloudIdProvider}. + * + *

The provider obtains a token from {@code DefaultAzureCredential}, which requires + * live Azure AD / IMDS access. Per the task constraints we do NOT drive a real token + * fetch here; we only assert what is provable offline: that the provider is + * constructible, is wired into the factory, and that the underlying Azure credential + * builder used by {@code getCloudId()} constructs without any network call. + */ +public class AzureCloudIdProviderTest { + + @Test + public void providerIsConstructibleAndImplementsInterface() { + AzureCloudIdProvider provider = new AzureCloudIdProvider(); + assertNotNull(provider); + assertTrue(provider instanceof CloudIdProvider); + } + + @Test + public void factoryReturnsAzureProvider() { + CloudIdProvider provider = CloudProviderFactory.getCloudIdProvider("azure_ad"); + assertTrue(provider instanceof AzureCloudIdProvider); + } + + @Test + public void defaultAzureCredentialBuilderConstructsWithoutNetwork() { + // Mirrors the credential construction performed inside getCloudId(). Building + // the credential is a local operation; no token request is issued here. + DefaultAzureCredential credential = new DefaultAzureCredentialBuilder().build(); + assertNotNull("DefaultAzureCredential should build offline", credential); + } +} diff --git a/src/test/java/io/akeyless/cloudid/CloudProviderFactoryTest.java b/src/test/java/io/akeyless/cloudid/CloudProviderFactoryTest.java new file mode 100644 index 0000000..09262ab --- /dev/null +++ b/src/test/java/io/akeyless/cloudid/CloudProviderFactoryTest.java @@ -0,0 +1,89 @@ +package io.akeyless.cloudid; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Hermetic tests for {@link CloudProviderFactory} dispatch logic. + * These tests never touch the network or real credentials; they only assert + * that the factory maps access-type strings to the correct provider class and + * rejects everything else. + */ +public class CloudProviderFactoryTest { + + @Test + public void mapsAwsIamToAwsProvider() { + CloudIdProvider provider = CloudProviderFactory.getCloudIdProvider("aws_iam"); + assertNotNull(provider); + assertTrue("expected an AwsCloudIdProvider", provider instanceof AwsCloudIdProvider); + assertTrue("provider must implement CloudIdProvider", provider instanceof CloudIdProvider); + } + + @Test + public void mapsAzureAdToAzureProvider() { + CloudIdProvider provider = CloudProviderFactory.getCloudIdProvider("azure_ad"); + assertNotNull(provider); + assertTrue("expected an AzureCloudIdProvider", provider instanceof AzureCloudIdProvider); + } + + @Test + public void mapsGcpToGcpProvider() { + CloudIdProvider provider = CloudProviderFactory.getCloudIdProvider("gcp"); + assertNotNull(provider); + assertTrue("expected a GcpCloudIdProvider", provider instanceof GcpCloudIdProvider); + } + + @Test + public void returnsFreshInstanceEachCall() { + CloudIdProvider first = CloudProviderFactory.getCloudIdProvider("aws_iam"); + CloudIdProvider second = CloudProviderFactory.getCloudIdProvider("aws_iam"); + assertNotSame("factory must not cache/share provider instances", first, second); + } + + @Test + public void rejectsUnknownType() { + try { + CloudProviderFactory.getCloudIdProvider("kubernetes"); + fail("expected RuntimeException for unsupported type"); + } catch (RuntimeException e) { + assertEquals("Unsupported type: kubernetes", e.getMessage()); + } + } + + @Test + public void rejectsEmptyType() { + try { + CloudProviderFactory.getCloudIdProvider(""); + fail("expected RuntimeException for empty type"); + } catch (RuntimeException e) { + assertEquals("Unsupported type: ", e.getMessage()); + } + } + + @Test + public void rejectsNullType() { + try { + CloudProviderFactory.getCloudIdProvider(null); + fail("expected RuntimeException for null type"); + } catch (RuntimeException e) { + assertEquals("Unsupported type: null", e.getMessage()); + } + } + + @Test + public void typeMatchIsCaseSensitive() { + // The factory uses exact string equality, so a differently-cased value + // must be rejected rather than silently mapped. + try { + CloudProviderFactory.getCloudIdProvider("AWS_IAM"); + fail("expected RuntimeException for wrong-case type"); + } catch (RuntimeException e) { + assertEquals("Unsupported type: AWS_IAM", e.getMessage()); + } + } +} diff --git a/src/test/java/io/akeyless/cloudid/GcpCloudIdProviderTest.java b/src/test/java/io/akeyless/cloudid/GcpCloudIdProviderTest.java new file mode 100644 index 0000000..6c212d1 --- /dev/null +++ b/src/test/java/io/akeyless/cloudid/GcpCloudIdProviderTest.java @@ -0,0 +1,31 @@ +package io.akeyless.cloudid; + +import org.junit.Test; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Offline tests for {@link GcpCloudIdProvider}. + * + *

{@code getCloudId()} relies on {@code GoogleCredentials.getApplicationDefault()} + * and a live GCP identity-token exchange, which cannot be exercised hermetically. + * We therefore only assert the offline-provable facts: the provider is constructible, + * implements the common interface, and is wired into the factory. A real end-to-end + * exchange is covered (credentials-gated) in {@link LiveCloudIdE2ETest}. + */ +public class GcpCloudIdProviderTest { + + @Test + public void providerIsConstructibleAndImplementsInterface() { + GcpCloudIdProvider provider = new GcpCloudIdProvider(); + assertNotNull(provider); + assertTrue(provider instanceof CloudIdProvider); + } + + @Test + public void factoryReturnsGcpProvider() { + CloudIdProvider provider = CloudProviderFactory.getCloudIdProvider("gcp"); + assertTrue(provider instanceof GcpCloudIdProvider); + } +} diff --git a/src/test/java/io/akeyless/cloudid/LiveCloudIdE2ETest.java b/src/test/java/io/akeyless/cloudid/LiveCloudIdE2ETest.java new file mode 100644 index 0000000..f8e00b6 --- /dev/null +++ b/src/test/java/io/akeyless/cloudid/LiveCloudIdE2ETest.java @@ -0,0 +1,58 @@ +package io.akeyless.cloudid; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeNotNull; + +/** + * Credentials-gated, live-cloud end-to-end tests. + * + *

Every test here is SKIPPED (via JUnit {@code Assume}) unless the corresponding + * real cloud credentials are present in the environment. This means the class is + * always safe to run in CI with no secrets — it simply reports the tests as skipped — + * yet it exercises the real provider flow whenever credentials are available. + * + *

Gating environment variables: + *

    + *
  • AWS - {@code AWS_ACCESS_KEY_ID}
  • + *
  • Azure - {@code AZURE_CLIENT_ID}
  • + *
  • GCP - {@code GOOGLE_APPLICATION_CREDENTIALS}
  • + *
+ */ +public class LiveCloudIdE2ETest { + + private static void assertNonEmptyBase64(String cloudId) { + assertNotNull("cloud id must not be null", cloudId); + assertTrue("cloud id must not be empty", cloudId.length() > 0); + // Must be valid base64 (throws IllegalArgumentException otherwise). + byte[] decoded = Base64.getDecoder().decode(cloudId); + assertTrue("decoded cloud id must not be empty", + new String(decoded, StandardCharsets.UTF_8).length() > 0); + } + + @Test + public void awsLiveGetCloudId() throws Exception { + assumeNotNull(System.getenv("AWS_ACCESS_KEY_ID")); + String cloudId = CloudProviderFactory.getCloudIdProvider("aws_iam").getCloudId(); + assertNonEmptyBase64(cloudId); + } + + @Test + public void azureLiveGetCloudId() throws Exception { + assumeNotNull(System.getenv("AZURE_CLIENT_ID")); + String cloudId = CloudProviderFactory.getCloudIdProvider("azure_ad").getCloudId(); + assertNonEmptyBase64(cloudId); + } + + @Test + public void gcpLiveGetCloudId() throws Exception { + assumeNotNull(System.getenv("GOOGLE_APPLICATION_CREDENTIALS")); + String cloudId = CloudProviderFactory.getCloudIdProvider("gcp").getCloudId(); + assertNonEmptyBase64(cloudId); + } +}