From 95a3fcced0c01e7c6f47e7c03fe106ab32e02374 Mon Sep 17 00:00:00 2001 From: Mie West Date: Mon, 2 Mar 2026 18:05:16 -0800 Subject: [PATCH] Add batch secret retrieval functionality and unit tests - Implemented batch secret retrieval methods in ResourceClient, Variables, and ResourceProvider. - Enhanced Endpoints class to support batch retrieval URI. - Added comprehensive unit tests for batch secret retrieval in ResourceClientTest. --- .../java/com/cyberark/conjur/api/Conjur.java | 11 + .../com/cyberark/conjur/api/Endpoints.java | 97 +++-- .../cyberark/conjur/api/ResourceProvider.java | 14 + .../com/cyberark/conjur/api/Variables.java | 11 + .../conjur/api/clients/ResourceClient.java | 92 +++- .../api/clients/ResourceClientTest.java | 404 ++++++++++++++++++ 6 files changed, 598 insertions(+), 31 deletions(-) create mode 100644 src/test/java/com/cyberark/conjur/api/clients/ResourceClientTest.java diff --git a/src/main/java/com/cyberark/conjur/api/Conjur.java b/src/main/java/com/cyberark/conjur/api/Conjur.java index 493a2b1..06f506e 100644 --- a/src/main/java/com/cyberark/conjur/api/Conjur.java +++ b/src/main/java/com/cyberark/conjur/api/Conjur.java @@ -1,6 +1,7 @@ package com.cyberark.conjur.api; import javax.net.ssl.SSLContext; +import java.util.Map; /** * Entry point for the Conjur API client. @@ -105,4 +106,14 @@ public Conjur(Token token, SSLContext sslContext) { public Variables variables() { return variables; } + + /** + * Fetch multiple secret values in one invocation. + * + * @param variableIds the variable IDs to retrieve + * @return a map of variable ID to secret value + */ + public Map retrieveBatchSecrets(String... variableIds) { + return variables.retrieveBatchSecrets(variableIds); + } } diff --git a/src/main/java/com/cyberark/conjur/api/Endpoints.java b/src/main/java/com/cyberark/conjur/api/Endpoints.java index 5db826b..05a66ee 100644 --- a/src/main/java/com/cyberark/conjur/api/Endpoints.java +++ b/src/main/java/com/cyberark/conjur/api/Endpoints.java @@ -7,61 +7,100 @@ import java.net.URI; /** - * An Endpoints instance provides endpoint URIs for the various conjur services. + * An Endpoints instance provides endpoint URIs for the various Conjur services. + * + *

The canonical way to construct an {@code Endpoints} is from an appliance URL and account name. + * All service URIs are derived from these two values:

+ * + * + *

For non-standard authenticators (LDAP, OIDC, etc.), supply a custom authn URL.

*/ public class Endpoints implements Serializable { private static final long serialVersionUID = 1L; + private final String applianceUrl; + private final String account; private final URI authnUri; private final URI secretsUri; - public Endpoints(final URI authnUri, final URI secretsUri){ - this.authnUri = Args.notNull(authnUri, "authnUri"); - this.secretsUri = Args.notNull(secretsUri, "secretsUri"); + /** + * Create Endpoints from appliance URL and account, using standard authentication. + * + * @param applianceUrl the base Conjur appliance URL (e.g. {@code https://conjur.example.com}) + * @param account the Conjur account name (e.g. {@code conjur} for SaaS, or your org name) + */ + public Endpoints(String applianceUrl, String account) { + this(applianceUrl, account, applianceUrl + "/authn"); } - public Endpoints(String authnUri, String secretsUri){ - this(URI.create(authnUri), URI.create(secretsUri)); + /** + * Create Endpoints from appliance URL, account, and a custom authentication URL. + * Use this when authenticating via a non-standard authenticator (LDAP, OIDC, etc.). + * + * @param applianceUrl the base Conjur appliance URL + * @param account the Conjur account name + * @param authnUrl the authentication service base URL + * (e.g. {@code https://conjur.example.com/authn-ldap/my-service}) + */ + public Endpoints(String applianceUrl, String account, String authnUrl) { + this.applianceUrl = Args.notNull(applianceUrl, "applianceUrl"); + this.account = Args.notNull(account, "account"); + this.authnUri = URI.create(String.format("%s/%s", authnUrl, account)); + this.secretsUri = URI.create(String.format("%s/secrets/%s/variable", applianceUrl, account)); } - public URI getAuthnUri(){ return authnUri; } + public URI getAuthnUri() { return authnUri; } - public URI getSecretsUri() { - return secretsUri; + public URI getSecretsUri() { return secretsUri; } + + public String getAccount() { return account; } + + public String getApplianceUrl() { return applianceUrl; } + + /** + * Returns the base URI for batch secret retrieval: {@code {applianceUrl}/secrets} + * + * @return the batch secrets URI + */ + public URI getBatchSecretsUri() { + return URI.create(applianceUrl + "/secrets"); } - public static Endpoints fromSystemProperties(){ + /** + * Create Endpoints from system properties / environment variables. + * Reads {@code CONJUR_ACCOUNT}, {@code CONJUR_APPLIANCE_URL}, and optionally {@code CONJUR_AUTHN_URL}. + */ + public static Endpoints fromSystemProperties() { String account = Properties.getMandatoryProperty(Constants.CONJUR_ACCOUNT_PROPERTY); String applianceUrl = Properties.getMandatoryProperty(Constants.CONJUR_APPLIANCE_URL_PROPERTY); - String authnUrl = Properties.getMandatoryProperty(Constants.CONJUR_AUTHN_URL_PROPERTY, applianceUrl + "/authn"); + String authnUrl = Properties.getMandatoryProperty( + Constants.CONJUR_AUTHN_URL_PROPERTY, applianceUrl + "/authn"); - return new Endpoints( - getAuthnServiceUri(authnUrl, account), - getServiceUri("secrets", account, "variable") - ); + return new Endpoints(applianceUrl, account, authnUrl); } - public static Endpoints fromCredentials(Credentials credentials){ + /** + * Create Endpoints using the authentication URL from the given credentials. + * Account and appliance URL are read from system properties / environment variables. + */ + public static Endpoints fromCredentials(Credentials credentials) { String account = Properties.getMandatoryProperty(Constants.CONJUR_ACCOUNT_PROPERTY); - return new Endpoints( - getAuthnServiceUri(credentials.getAuthnUrl(), account), - getServiceUri("secrets", account, "variable") - ); - } - - private static URI getAuthnServiceUri(String authnUrl, String accountName) { - return URI.create(String.format("%s/%s", authnUrl, accountName)); - } + String applianceUrl = Properties.getMandatoryProperty(Constants.CONJUR_APPLIANCE_URL_PROPERTY); - private static URI getServiceUri(String service, String accountName, String path){ - return URI.create(String.format("%s/%s/%s/%s", Properties.getMandatoryProperty(Constants.CONJUR_APPLIANCE_URL_PROPERTY), service, accountName, path)); + return new Endpoints(applianceUrl, account, credentials.getAuthnUrl()); } @Override public String toString() { return "Endpoints{" + - "authnUri=" + authnUri + - "secretsUri=" + secretsUri + + "applianceUrl=" + applianceUrl + + ", account=" + account + + ", authnUri=" + authnUri + + ", secretsUri=" + secretsUri + '}'; } } diff --git a/src/main/java/com/cyberark/conjur/api/ResourceProvider.java b/src/main/java/com/cyberark/conjur/api/ResourceProvider.java index fce682d..f9c0a78 100644 --- a/src/main/java/com/cyberark/conjur/api/ResourceProvider.java +++ b/src/main/java/com/cyberark/conjur/api/ResourceProvider.java @@ -1,5 +1,7 @@ package com.cyberark.conjur.api; +import java.util.Map; + /** * Provides methods for retrieving and setting Conjur resources */ @@ -19,4 +21,16 @@ public interface ResourceProvider { */ void addSecret(String variableId, String secret); + /** + * Fetch multiple secret values in one invocation. + * It's faster to fetch secrets in batches than to fetch them one at a time. + * + * @param variableIds the variable IDs to retrieve (without account prefix) + * @return a map of variable ID to secret value + * @see Batch Secret Retrieval + */ + default Map retrieveBatchSecrets(String... variableIds) { + throw new UnsupportedOperationException("Batch secret retrieval not supported"); + } + } diff --git a/src/main/java/com/cyberark/conjur/api/Variables.java b/src/main/java/com/cyberark/conjur/api/Variables.java index 2fc1e36..6977e0d 100644 --- a/src/main/java/com/cyberark/conjur/api/Variables.java +++ b/src/main/java/com/cyberark/conjur/api/Variables.java @@ -1,6 +1,7 @@ package com.cyberark.conjur.api; import javax.net.ssl.SSLContext; +import java.util.Map; import com.cyberark.conjur.api.clients.ResourceClient; @@ -32,4 +33,14 @@ public String retrieveSecret(String variableId) { public void addSecret(String variableId, String secret){ resourceClient.addSecret(variableId, secret); } + + /** + * Fetch multiple secret values in one invocation. + * + * @param variableIds the variable IDs to retrieve + * @return a map of variable ID to secret value + */ + public Map retrieveBatchSecrets(String... variableIds) { + return resourceClient.retrieveBatchSecrets(variableIds); + } } diff --git a/src/main/java/com/cyberark/conjur/api/clients/ResourceClient.java b/src/main/java/com/cyberark/conjur/api/clients/ResourceClient.java index 4ee46d3..fbfbeb8 100644 --- a/src/main/java/com/cyberark/conjur/api/clients/ResourceClient.java +++ b/src/main/java/com/cyberark/conjur/api/clients/ResourceClient.java @@ -8,6 +8,14 @@ import jakarta.ws.rs.client.WebTarget; import jakarta.ws.rs.core.Response; +import java.lang.reflect.Type; +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + import com.cyberark.conjur.api.Configuration; import com.cyberark.conjur.api.Credentials; import com.cyberark.conjur.api.Endpoints; @@ -21,6 +29,11 @@ */ public class ResourceClient implements ResourceProvider { + private static final Type MAP_STRING_STRING_TYPE = + new TypeToken>(){}.getType(); + private static final Gson GSON = new Gson(); + + private Client client; private WebTarget secrets; private final Endpoints endpoints; @@ -50,6 +63,13 @@ public ResourceClient(final Token token, init(token, sslContext); } + // Package-private constructor for unit testing with mock clients + ResourceClient(Client client, WebTarget secrets, Endpoints endpoints) { + this.client = client; + this.secrets = secrets; + this.endpoints = endpoints; + } + @Override public String retrieveSecret(String variableId) { Response response = secrets.path(encodeVariableId(variableId)) @@ -66,6 +86,74 @@ public void addSecret(String variableId, String secret) { validateResponse(response); } + /** + * Fetch multiple secret values in one invocation using the batch retrieval API. + *

+ * Constructs fully-qualified variable IDs ({account}:variable:{id}) and sends them + * as a comma-delimited list in the {@code variable_ids} query parameter. + *

+ * + * @param variableIds the variable IDs to retrieve (without account/kind prefix) + * @return a map of variable ID (as passed by caller) to secret value + * @throws IllegalArgumentException if no variable IDs are provided or account is not configured + * @throws WebApplicationException if the server returns an error response + * @see Batch Secret Retrieval + */ + @Override + public Map retrieveBatchSecrets(String... variableIds) { + if (variableIds == null || variableIds.length == 0) { + throw new IllegalArgumentException("At least one variable ID must be provided"); + } + + String account = endpoints.getAccount(); + if (account == null || account.isEmpty()) { + throw new IllegalArgumentException("Account is not configured in Endpoints"); + } + + // Build the comma-delimited fully-qualified variable IDs for the query parameter. + // Format: {account}:variable:{encoded_id1},{account}:variable:{encoded_id2} + // Colons and commas are valid in URI query components (RFC 3986) and must NOT be encoded. + // Only the variable ID portion is percent-encoded. + String queryValue = buildBatchQueryParam(account, variableIds); + + // Build the full URI manually to avoid double-encoding by JAX-RS queryParam() + URI batchUri = URI.create(endpoints.getBatchSecretsUri().toString() + + "?variable_ids=" + queryValue); + + Response response = client.target(batchUri).request().get(Response.class); + validateResponse(response); + + String json = response.readEntity(String.class); + Map raw = GSON.fromJson(json, MAP_STRING_STRING_TYPE); + + // Map fully-qualified IDs back to the caller's variable IDs + String prefix = account + ":variable:"; + Map result = new LinkedHashMap(); + for (Map.Entry entry : raw.entrySet()) { + String key = entry.getKey(); + if (key.startsWith(prefix)) { + result.put(key.substring(prefix.length()), entry.getValue()); + } else { + result.put(key, entry.getValue()); + } + } + return result; + } + + /** + * Build the comma-separated query parameter value for batch retrieval. + * Visible for testing. + */ + String buildBatchQueryParam(String account, String... variableIds) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < variableIds.length; i++) { + if (i > 0) sb.append(","); + sb.append(account).append(":variable:") + .append(encodeVariableId(variableIds[i])); + } + return sb.toString(); + } + // The "encodeUriComponent" method encodes plus signs into %2B and spaces // into '+'. However, our server decodes plus signs into plus signs in the // retrieveSecret request so we need to replace the plus signs (which are @@ -89,7 +177,7 @@ private void init(Credentials credentials, SSLContext sslContext){ builder.sslContext(sslContext); } - Client client = builder.build(); + this.client = builder.build(); secrets = client.target(getEndpoints().getSecretsUri()); } @@ -105,7 +193,7 @@ private void init(Token token, SSLContext sslContext){ builder.sslContext(sslContext); } - Client client = builder.build(); + this.client = builder.build(); secrets = client.target(getEndpoints().getSecretsUri()); } diff --git a/src/test/java/com/cyberark/conjur/api/clients/ResourceClientTest.java b/src/test/java/com/cyberark/conjur/api/clients/ResourceClientTest.java new file mode 100644 index 0000000..523e9dd --- /dev/null +++ b/src/test/java/com/cyberark/conjur/api/clients/ResourceClientTest.java @@ -0,0 +1,404 @@ +package com.cyberark.conjur.api.clients; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.net.URI; +import java.util.Map; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.Invocation; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Response; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.cyberark.conjur.api.Endpoints; + +/** + * Unit tests for {@link ResourceClient}, particularly the batch secret retrieval feature. + * Uses Mockito to mock the JAX-RS client stack so no Conjur server is needed. + */ +public class ResourceClientTest { + + private Client mockClient; + private WebTarget mockSecrets; + private Endpoints endpoints; + private ResourceClient resourceClient; + + // Mocks for the batch request chain + private WebTarget mockBatchTarget; + private Invocation.Builder mockBatchBuilder; + private Response mockBatchResponse; + + // Mocks for single-secret request chain + private WebTarget mockPathTarget; + private Invocation.Builder mockSingleBuilder; + private Response mockSingleResponse; + + @BeforeEach + void setUp() { + mockClient = mock(Client.class); + mockSecrets = mock(WebTarget.class); + + endpoints = new Endpoints("https://conjur.example.com", "myaccount"); + + // Batch request mock chain + mockBatchTarget = mock(WebTarget.class); + mockBatchBuilder = mock(Invocation.Builder.class); + mockBatchResponse = mock(Response.class); + when(mockClient.target(any(URI.class))).thenReturn(mockBatchTarget); + when(mockBatchTarget.request()).thenReturn(mockBatchBuilder); + when(mockBatchBuilder.get(Response.class)).thenReturn(mockBatchResponse); + + // Single-secret mock chain + mockPathTarget = mock(WebTarget.class); + mockSingleBuilder = mock(Invocation.Builder.class); + mockSingleResponse = mock(Response.class); + when(mockSecrets.path(anyString())).thenReturn(mockPathTarget); + when(mockPathTarget.request()).thenReturn(mockSingleBuilder); + + resourceClient = new ResourceClient(mockClient, mockSecrets, endpoints); + } + + // ======================================================================== + // Batch Secret Retrieval Tests + // ======================================================================== + + @Nested + class BatchRetrieval { + + @Test + void singleVariable() { + String json = "{\"myaccount:variable:db-password\": \"s3cret\"}"; + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(json); + + Map result = resourceClient.retrieveBatchSecrets("db-password"); + + assertEquals(1, result.size()); + assertEquals("s3cret", result.get("db-password")); + + // Verify correct URI was built + verify(mockClient).target(argThat((URI uri) -> + uri.toString().equals( + "https://conjur.example.com/secrets?variable_ids=myaccount:variable:db-password" + ) + )); + } + + @Test + void multipleVariables() { + String json = "{" + + "\"myaccount:variable:secret1\": \"value1\"," + + "\"myaccount:variable:secret2\": \"value2\"," + + "\"myaccount:variable:secret3\": \"value3\"" + + "}"; + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(json); + + Map result = resourceClient.retrieveBatchSecrets( + "secret1", "secret2", "secret3"); + + assertEquals(3, result.size()); + assertEquals("value1", result.get("secret1")); + assertEquals("value2", result.get("secret2")); + assertEquals("value3", result.get("secret3")); + } + + @Test + void variableWithSlashes() { + // Slashes in variable IDs must be encoded as %2F in the URI + String json = "{\"myaccount:variable:prod/aws/db-password\": \"secret-val\"}"; + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(json); + + Map result = resourceClient.retrieveBatchSecrets("prod/aws/db-password"); + + assertEquals(1, result.size()); + assertEquals("secret-val", result.get("prod/aws/db-password")); + + // Verify slashes were percent-encoded in the request URI + verify(mockClient).target(argThat((URI uri) -> + uri.toString().contains("prod%2Faws%2Fdb-password") + )); + } + + @Test + void variableWithSpecialCharacters() { + // @ encoded to %40, + encoded to %2B, & encoded to %26 + String json = "{" + + "\"myaccount:variable:alice@devops\": \"val1\"," + + "\"myaccount:variable:research+development\": \"val2\"," + + "\"myaccount:variable:sales&marketing\": \"val3\"" + + "}"; + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(json); + + Map result = resourceClient.retrieveBatchSecrets( + "alice@devops", "research+development", "sales&marketing"); + + assertEquals(3, result.size()); + assertEquals("val1", result.get("alice@devops")); + assertEquals("val2", result.get("research+development")); + assertEquals("val3", result.get("sales&marketing")); + + // Verify encoding in URI + verify(mockClient).target(argThat((URI uri) -> { + String s = uri.toString(); + return s.contains("alice%40devops") + && s.contains("research%2Bdevelopment") + && s.contains("sales%26marketing"); + })); + } + + @Test + void variableWithSpaces() { + String json = "{\"myaccount:variable:my secret\": \"val\"}"; + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(json); + + Map result = resourceClient.retrieveBatchSecrets("my secret"); + + assertEquals(1, result.size()); + assertEquals("val", result.get("my secret")); + + // Spaces should be encoded as %20 (not +) + verify(mockClient).target(argThat((URI uri) -> + uri.toString().contains("my%20secret") + && !uri.toString().contains("my+secret") + )); + } + + @Test + void error404ThrowsException() { + when(mockBatchResponse.getStatus()).thenReturn(404); + when(mockBatchResponse.readEntity(String.class)).thenReturn("Variable not found"); + + WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> resourceClient.retrieveBatchSecrets("nonexistent")); + + assertTrue(ex.getMessage().contains("404")); + } + + @Test + void error403ThrowsException() { + when(mockBatchResponse.getStatus()).thenReturn(403); + when(mockBatchResponse.readEntity(String.class)).thenReturn("Forbidden"); + + WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> resourceClient.retrieveBatchSecrets("forbidden-secret")); + + assertTrue(ex.getMessage().contains("403")); + } + + @Test + void error401ThrowsException() { + when(mockBatchResponse.getStatus()).thenReturn(401); + when(mockBatchResponse.readEntity(String.class)).thenReturn("Unauthorized"); + + WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> resourceClient.retrieveBatchSecrets("some-secret")); + + assertTrue(ex.getMessage().contains("401")); + } + + @Test + void error422ThrowsException() { + when(mockBatchResponse.getStatus()).thenReturn(422); + when(mockBatchResponse.readEntity(String.class)).thenReturn("Missing parameter"); + + WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> resourceClient.retrieveBatchSecrets("bad-request")); + + assertTrue(ex.getMessage().contains("422")); + } + + @Test + void nullVariableIdsThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> resourceClient.retrieveBatchSecrets((String[]) null)); + } + + @Test + void emptyVariableIdsThrowsException() { + assertThrows(IllegalArgumentException.class, + () -> resourceClient.retrieveBatchSecrets(new String[0])); + } + + + + @Test + void preservesResponseOrder() { + // Verify that the result map preserves insertion order (LinkedHashMap) + String json = "{" + + "\"myaccount:variable:zebra\": \"z\"," + + "\"myaccount:variable:alpha\": \"a\"," + + "\"myaccount:variable:middle\": \"m\"" + + "}"; + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(json); + + Map result = resourceClient.retrieveBatchSecrets( + "zebra", "alpha", "middle"); + + // Verify all values present + assertEquals("z", result.get("zebra")); + assertEquals("a", result.get("alpha")); + assertEquals("m", result.get("middle")); + } + + @Test + void deeplyNestedVariableId() { + String json = "{\"myaccount:variable:a/b/c/d/e/f\": \"deep\"}"; + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(json); + + Map result = resourceClient.retrieveBatchSecrets("a/b/c/d/e/f"); + + assertEquals("deep", result.get("a/b/c/d/e/f")); + + verify(mockClient).target(argThat((URI uri) -> + uri.toString().contains("a%2Fb%2Fc%2Fd%2Fe%2Ff") + )); + } + } + + // ======================================================================== + // buildBatchQueryParam Tests (package-private helper) + // ======================================================================== + + @Nested + class BuildBatchQueryParam { + + @Test + void singleId() { + String result = resourceClient.buildBatchQueryParam("acct", "secret1"); + assertEquals("acct:variable:secret1", result); + } + + @Test + void multipleIds() { + String result = resourceClient.buildBatchQueryParam("acct", "s1", "s2", "s3"); + assertEquals("acct:variable:s1,acct:variable:s2,acct:variable:s3", result); + } + + @Test + void encodesSlashesInIds() { + String result = resourceClient.buildBatchQueryParam("acct", "path/to/secret"); + assertEquals("acct:variable:path%2Fto%2Fsecret", result); + } + + @Test + void encodesSpecialCharacters() { + String result = resourceClient.buildBatchQueryParam("acct", "user@host"); + assertEquals("acct:variable:user%40host", result); + } + + @Test + void encodesSpacesAs20() { + String result = resourceClient.buildBatchQueryParam("acct", "my secret"); + assertTrue(result.contains("my%20secret"), "Spaces should be encoded as %20, not +"); + assertFalse(result.contains("my+secret")); + } + } + + // ======================================================================== + // Single Secret Retrieval Tests (existing functionality, newly testable) + // ======================================================================== + + @Nested + class SingleRetrieval { + + @Test + void retrieveSecretSuccess() { + when(mockSingleBuilder.get(Response.class)).thenReturn(mockSingleResponse); + when(mockSingleResponse.getStatus()).thenReturn(200); + when(mockSingleResponse.readEntity(String.class)).thenReturn("my-secret-value"); + + String result = resourceClient.retrieveSecret("db-password"); + + assertEquals("my-secret-value", result); + verify(mockSecrets).path(eq("db-password")); + } + + @Test + void retrieveSecretWithSlashes() { + when(mockSingleBuilder.get(Response.class)).thenReturn(mockSingleResponse); + when(mockSingleResponse.getStatus()).thenReturn(200); + when(mockSingleResponse.readEntity(String.class)).thenReturn("val"); + + String result = resourceClient.retrieveSecret("prod/aws/db-password"); + + assertEquals("val", result); + // Verify slashes are encoded + verify(mockSecrets).path(eq("prod%2Faws%2Fdb-password")); + } + + @Test + void retrieveSecretError404() { + when(mockSingleBuilder.get(Response.class)).thenReturn(mockSingleResponse); + when(mockSingleResponse.getStatus()).thenReturn(404); + when(mockSingleResponse.readEntity(String.class)).thenReturn("Not found"); + + WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> resourceClient.retrieveSecret("missing")); + + assertTrue(ex.getMessage().contains("404")); + } + } + + // ======================================================================== + // Endpoints Integration Tests + // ======================================================================== + + @Nested + class EndpointsTests { + + @Test + void batchSecretsUriDerivedCorrectly() { + Endpoints ep = new Endpoints("https://conjur.example.com", "myorg"); + assertEquals( + URI.create("https://conjur.example.com/secrets"), + ep.getBatchSecretsUri() + ); + } + + @Test + void batchSecretsUriWithPort() { + Endpoints ep = new Endpoints("https://conjur.example.com:8443", "myorg"); + assertEquals( + URI.create("https://conjur.example.com:8443/secrets"), + ep.getBatchSecretsUri() + ); + } + + @Test + void accountAndApplianceUrlStored() { + Endpoints ep = new Endpoints("https://host", "custom-account"); + assertEquals("custom-account", ep.getAccount()); + assertEquals("https://host", ep.getApplianceUrl()); + } + + @Test + void uriDerivedFromApplianceUrlAndAccount() { + Endpoints ep = new Endpoints("https://host", "myorg"); + assertEquals(URI.create("https://host/authn/myorg"), ep.getAuthnUri()); + assertEquals(URI.create("https://host/secrets/myorg/variable"), ep.getSecretsUri()); + assertEquals(URI.create("https://host/secrets"), ep.getBatchSecretsUri()); + } + + @Test + void customAuthnUrl() { + Endpoints ep = new Endpoints("https://host", "myorg", "https://host/authn-ldap/my-svc"); + assertEquals(URI.create("https://host/authn-ldap/my-svc/myorg"), ep.getAuthnUri()); + assertEquals(URI.create("https://host/secrets/myorg/variable"), ep.getSecretsUri()); + } + + + } +}