-
Notifications
You must be signed in to change notification settings - Fork 2
test: add hermetic unit suite + CI pipeline #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
236 changes: 236 additions & 0 deletions
236
src/test/java/io/akeyless/cloudid/AwsCloudIdProviderTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}. | ||
| * | ||
| * <p>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 | ||
| * <em>no</em> 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. | ||
| * | ||
| * <p>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<Map.Entry<String, JsonNode>> fields = headers.fields(); | ||
| while (fields.hasNext()) { | ||
| Map.Entry<String, JsonNode> 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 <access-key>/<yyyymmdd>/<region>/<service>/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")); | ||
| } | ||
| } |
41 changes: 41 additions & 0 deletions
41
src/test/java/io/akeyless/cloudid/AzureCloudIdProviderTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}. | ||
| * | ||
| * <p>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); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: akeylesslabs/akeyless-java-cloud-id
Length of output: 2706
🌐 Web query:
actions/checkout v4 persist-credentials default true README GitHub token permissions contents read workflow💡 Result:
In the actions/checkout action (including v4), the persist-credentials input defaults to true [1][2][3]. When persist-credentials is set to true, the action configures the provided token or SSH key in the local git configuration [1][4][5]. This allows subsequent git commands run in your workflow steps to use these credentials for authenticated operations [1][6]. The action automatically removes these credentials during the post-job cleanup [1][4][5]. Regarding permissions: 1. Basic checkout: For the actions/checkout action to successfully clone the repository, the GITHUB_TOKEN requires read access to the repository's contents [7]. In workflows where you explicitly define permissions, you must set permissions: contents: read [7]. If you do not specify this, the default permissions for the GITHUB_TOKEN may be set to none (depending on your organization/repository settings), which will cause the checkout to fail [7]. 2. Pushing changes: If you intend to use the persisted credentials to perform git push operations, the GITHUB_TOKEN must have write access to the repository's contents [6]. In this case, you must set permissions: contents: write in your workflow configuration [6]. If persist-credentials is set to false, the action will not configure the token or SSH key in the git config, and subsequent git commands will not be authenticated using those credentials [6].
Citations:
Disable checkout credential persistence
Because Maven runs repository-controlled code, set
persist-credentials: falseonactions/checkout@v4. Add top-levelpermissions: contents: read.🧰 Tools
🪛 zizmor (1.29.0)
[warning] 15-16: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
🤖 Prompt for AI Agents
Source: Linters/SAST tools