diff --git a/src/main/java/com/cyberark/conjur/api/Conjur.java b/src/main/java/com/cyberark/conjur/api/Conjur.java index 493a2b1..09b76f5 100644 --- a/src/main/java/com/cyberark/conjur/api/Conjur.java +++ b/src/main/java/com/cyberark/conjur/api/Conjur.java @@ -1,6 +1,8 @@ package com.cyberark.conjur.api; import javax.net.ssl.SSLContext; +import java.util.List; +import java.util.Map; /** * Entry point for the Conjur API client. @@ -105,4 +107,57 @@ 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); + } + + /** + * List all resources visible to the authenticated identity. + * + * @return list of all resources + */ + public List listResources() { + return variables.listResources(); + } + + /** + * List resources filtered by kind. + * + * @param kind the resource kind (e.g. "variable", "host") + * @return resources matching the given kind + */ + public List listResources(String kind) { + return variables.listResources(kind); + } + + /** + * List resources with full query parameter control. + * + * @param kind resource kind filter (null for all kinds) + * @param search text search filter (null for no search) + * @param limit max results (null for server default) + * @param offset pagination offset (null for no offset) + * @return resources matching the query + */ + public List listResources(String kind, String search, Integer limit, Integer offset) { + return variables.listResources(kind, search, limit, offset); + } + + /** + * Count resources visible to the authenticated identity. + * + * @param kind resource kind filter (null for all kinds) + * @param search text search filter (null for no search) + * @return the number of matching resources + */ + public int countResources(String kind, String search) { + return variables.countResources(kind, search); + } } diff --git a/src/main/java/com/cyberark/conjur/api/ConjurResource.java b/src/main/java/com/cyberark/conjur/api/ConjurResource.java new file mode 100644 index 0000000..0a81cf4 --- /dev/null +++ b/src/main/java/com/cyberark/conjur/api/ConjurResource.java @@ -0,0 +1,119 @@ +package com.cyberark.conjur.api; + +import java.util.List; +import java.util.Map; + +/** + * Represents a Conjur resource returned by the + * List Resources API. + * + *

A resource has an {@code id} in the form {@code {account}:{kind}:{identifier}}, + * along with metadata such as owner, policy, creation time, permissions, and annotations.

+ */ +public class ConjurResource { + + private String created_at; + private String id; + private String owner; + private String policy; + private List permissions; + private List annotations; + private List> secrets; + private List> policy_versions; + + /** @return the creation timestamp */ + public String getCreatedAt() { return created_at; } + + /** + * The fully-qualified resource ID: {@code {account}:{kind}:{identifier}} + * @return the resource ID + */ + public String getId() { return id; } + + /** @return the owner resource ID */ + public String getOwner() { return owner; } + + /** @return the policy resource ID this resource belongs to */ + public String getPolicy() { return policy; } + + /** @return the list of permissions on this resource */ + public List getPermissions() { return permissions; } + + /** @return the list of annotations on this resource */ + public List getAnnotations() { return annotations; } + + /** @return secret version info (only for variables) */ + public List> getSecrets() { return secrets; } + + /** @return policy version info (only for policies) */ + public List> getPolicyVersions() { return policy_versions; } + + /** + * Extract the resource kind from the fully-qualified ID. + * For {@code myorg:variable:db/password}, returns {@code variable}. + * + * @return the kind portion of the ID, or null if the ID is malformed + */ + public String getKind() { + if (id == null) return null; + int first = id.indexOf(':'); + int second = id.indexOf(':', first + 1); + if (first < 0 || second < 0) return null; + return id.substring(first + 1, second); + } + + /** + * Extract the identifier (without account and kind prefix) from the fully-qualified ID. + * For {@code myorg:variable:db/password}, returns {@code db/password}. + * + * @return the identifier portion of the ID, or null if the ID is malformed + */ + public String getIdentifier() { + if (id == null) return null; + int first = id.indexOf(':'); + int second = id.indexOf(':', first + 1); + if (first < 0 || second < 0) return null; + return id.substring(second + 1); + } + + @Override + public String toString() { + return "ConjurResource{id='" + id + "'}"; + } + + /** + * Represents a permission entry on a resource. + */ + public static class Permission { + private String privilege; + private String role; + private String policy; + + public String getPrivilege() { return privilege; } + public String getRole() { return role; } + public String getPolicy() { return policy; } + + @Override + public String toString() { + return "Permission{privilege='" + privilege + "', role='" + role + "'}"; + } + } + + /** + * Represents an annotation (key-value metadata) on a resource. + */ + public static class Annotation { + private String name; + private String value; + private String policy; + + public String getName() { return name; } + public String getValue() { return value; } + public String getPolicy() { return policy; } + + @Override + public String toString() { + return "Annotation{name='" + name + "', value='" + value + "'}"; + } + } +} diff --git a/src/main/java/com/cyberark/conjur/api/Endpoints.java b/src/main/java/com/cyberark/conjur/api/Endpoints.java index 5db826b..653e5fd 100644 --- a/src/main/java/com/cyberark/conjur/api/Endpoints.java +++ b/src/main/java/com/cyberark/conjur/api/Endpoints.java @@ -7,61 +7,110 @@ 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:

+ *
    + *
  • Authentication: {@code {applianceUrl}/authn/{account}}
  • + *
  • Secrets (single): {@code {applianceUrl}/secrets/{account}/variable}
  • + *
  • Secrets (batch): {@code {applianceUrl}/secrets}
  • + *
  • Resources: {@code {applianceUrl}/resources/{account}}
  • + *
+ * + *

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"); + } + + /** + * Returns the URI for listing resources: {@code {applianceUrl}/resources/{account}} + * + * @return the resources URI + */ + public URI getResourcesUri() { + return URI.create(String.format("%s/resources/%s", applianceUrl, account)); } - 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..7591400 100644 --- a/src/main/java/com/cyberark/conjur/api/ResourceProvider.java +++ b/src/main/java/com/cyberark/conjur/api/ResourceProvider.java @@ -1,5 +1,8 @@ package com.cyberark.conjur.api; +import java.util.List; +import java.util.Map; + /** * Provides methods for retrieving and setting Conjur resources */ @@ -19,4 +22,60 @@ 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"); + } + + /** + * List resources visible to the authenticated identity. + * + * @return all resources + * @see List Resources + */ + default List listResources() { + throw new UnsupportedOperationException("List resources not supported"); + } + + /** + * List resources filtered by kind. + * + * @param kind the resource kind to filter by (e.g. "variable", "host", "user", "group", "layer", "policy", "webservice") + * @return resources matching the given kind + */ + default List listResources(String kind) { + throw new UnsupportedOperationException("List resources not supported"); + } + + /** + * List resources with full query parameter control. + * + * @param kind resource kind filter (null for all kinds) + * @param search text search filter (null for no search) + * @param limit max results (null for server default) + * @param offset pagination offset (null for no offset) + * @return resources matching the query + */ + default List listResources(String kind, String search, Integer limit, Integer offset) { + throw new UnsupportedOperationException("List resources not supported"); + } + + /** + * Count resources visible to the authenticated identity. + * + * @param kind resource kind filter (null for all kinds) + * @param search text search filter (null for no search) + * @return the number of matching resources + */ + default int countResources(String kind, String search) { + throw new UnsupportedOperationException("Count resources 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..669578b 100644 --- a/src/main/java/com/cyberark/conjur/api/Variables.java +++ b/src/main/java/com/cyberark/conjur/api/Variables.java @@ -1,6 +1,8 @@ package com.cyberark.conjur.api; import javax.net.ssl.SSLContext; +import java.util.List; +import java.util.Map; import com.cyberark.conjur.api.clients.ResourceClient; @@ -32,4 +34,57 @@ 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); + } + + /** + * List all resources visible to the authenticated identity. + * + * @return list of all resources + */ + public List listResources() { + return resourceClient.listResources(); + } + + /** + * List resources filtered by kind. + * + * @param kind the resource kind (e.g. "variable", "host") + * @return resources matching the given kind + */ + public List listResources(String kind) { + return resourceClient.listResources(kind); + } + + /** + * List resources with full query parameter control. + * + * @param kind resource kind filter (null for all kinds) + * @param search text search filter (null for no search) + * @param limit max results (null for server default) + * @param offset pagination offset (null for no offset) + * @return resources matching the query + */ + public List listResources(String kind, String search, Integer limit, Integer offset) { + return resourceClient.listResources(kind, search, limit, offset); + } + + /** + * Count resources visible to the authenticated identity. + * + * @param kind resource kind filter (null for all kinds) + * @param search text search filter (null for no search) + * @return the number of matching resources + */ + public int countResources(String kind, String search) { + return resourceClient.countResources(kind, search); + } } 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..779626c 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,17 @@ import jakarta.ws.rs.client.WebTarget; import jakarta.ws.rs.core.Response; +import java.lang.reflect.Type; +import java.net.URI; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import com.cyberark.conjur.api.ConjurResource; import com.cyberark.conjur.api.Configuration; import com.cyberark.conjur.api.Credentials; import com.cyberark.conjur.api.Endpoints; @@ -21,6 +32,13 @@ */ public class ResourceClient implements ResourceProvider { + private static final Type MAP_STRING_STRING_TYPE = + new TypeToken>(){}.getType(); + private static final Type LIST_RESOURCE_TYPE = + new TypeToken>(){}.getType(); + private static final Gson GSON = new Gson(); + + private Client client; private WebTarget secrets; private final Endpoints endpoints; @@ -50,6 +68,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 +91,177 @@ 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; + } + + /** + * List all resources visible to the authenticated identity. + * + * @return list of all resources + * @see List Resources + */ + @Override + public List listResources() { + return listResources(null, null, null, null); + } + + /** + * List resources filtered by kind. + * + * @param kind the resource kind (e.g. "variable", "host", "user", "group", "layer", "policy", "webservice") + * @return resources matching the given kind + */ + @Override + public List listResources(String kind) { + return listResources(kind, null, null, null); + } + + /** + * List resources with full query parameter control. + * + * @param kind resource kind filter (null for all kinds) + * @param search text search filter (null for no search) + * @param limit max results per page (null for server default, max 1000) + * @param offset pagination offset (null for no offset) + * @return resources matching the query + * @throws WebApplicationException if the server returns an error response + */ + @Override + public List listResources(String kind, String search, Integer limit, Integer offset) { + URI resourcesUri = endpoints.getResourcesUri(); + StringBuilder uriBuilder = new StringBuilder(resourcesUri.toString()); + String separator = "?"; + + if (kind != null && !kind.isEmpty()) { + uriBuilder.append(separator).append("kind=").append(encodeVariableId(kind)); + separator = "&"; + } + if (search != null && !search.isEmpty()) { + uriBuilder.append(separator).append("search=").append(encodeVariableId(search)); + separator = "&"; + } + if (limit != null) { + uriBuilder.append(separator).append("limit=").append(limit); + separator = "&"; + } + if (offset != null) { + uriBuilder.append(separator).append("offset=").append(offset); + } + + URI targetUri = URI.create(uriBuilder.toString()); + Response response = client.target(targetUri).request().get(Response.class); + validateResponse(response); + + String json = response.readEntity(String.class); + List resources = GSON.fromJson(json, LIST_RESOURCE_TYPE); + return resources != null ? resources : Collections.emptyList(); + } + + /** + * Count resources visible to the authenticated identity. + * + * @param kind resource kind filter (null for all kinds) + * @param search text search filter (null for no search) + * @return the number of matching resources + * @throws WebApplicationException if the server returns an error response + */ + @Override + public int countResources(String kind, String search) { + URI resourcesUri = endpoints.getResourcesUri(); + StringBuilder uriBuilder = new StringBuilder(resourcesUri.toString()); + uriBuilder.append("?count=true"); + + if (kind != null && !kind.isEmpty()) { + uriBuilder.append("&kind=").append(encodeVariableId(kind)); + } + if (search != null && !search.isEmpty()) { + uriBuilder.append("&search=").append(encodeVariableId(search)); + } + + URI targetUri = URI.create(uriBuilder.toString()); + Response response = client.target(targetUri).request().get(Response.class); + validateResponse(response); + + String body = response.readEntity(String.class).trim(); + + // The server may return a plain integer or a JSON object like {"count":N} + if (body.startsWith("{")) { + @SuppressWarnings("unchecked") + Map parsed = GSON.fromJson(body, Map.class); + Double count = parsed.get("count"); + if (count == null) { + throw new IllegalStateException("Unexpected count response: " + body); + } + return count.intValue(); + } + return Integer.parseInt(body); + } + + /** + * 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 +285,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 +301,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..6f36d9b --- /dev/null +++ b/src/test/java/com/cyberark/conjur/api/clients/ResourceClientTest.java @@ -0,0 +1,704 @@ +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.List; +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.ConjurResource; +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()); + } + + @Test + void resourcesUriDerivedCorrectly() { + Endpoints ep = new Endpoints("https://conjur.example.com", "myorg"); + assertEquals( + URI.create("https://conjur.example.com/resources/myorg"), + ep.getResourcesUri() + ); + } + + @Test + void resourcesUriWithPort() { + Endpoints ep = new Endpoints("https://conjur.example.com:8443", "myorg"); + assertEquals( + URI.create("https://conjur.example.com:8443/resources/myorg"), + ep.getResourcesUri() + ); + } + } + + // ======================================================================== + // List Resources Tests + // ======================================================================== + + @Nested + class ListResources { + + private static final String SAMPLE_RESOURCES_JSON = "[" + + "{" + + " \"created_at\": \"2024-01-15T10:30:00Z\"," + + " \"id\": \"myaccount:variable:demo/db-password\"," + + " \"owner\": \"myaccount:policy:demo\"," + + " \"policy\": \"myaccount:policy:root\"," + + " \"permissions\": [{\"privilege\": \"read\", \"role\": \"myaccount:host:demo/app\", \"policy\": \"myaccount:policy:demo\"}]," + + " \"annotations\": [{\"name\": \"description\", \"value\": \"Database password\", \"policy\": \"myaccount:policy:demo\"}]," + + " \"secrets\": [{\"version\": 1}]" + + "}," + + "{" + + " \"created_at\": \"2024-01-15T10:31:00Z\"," + + " \"id\": \"myaccount:variable:demo/api-key\"," + + " \"owner\": \"myaccount:policy:demo\"," + + " \"policy\": \"myaccount:policy:root\"," + + " \"permissions\": []," + + " \"annotations\": []," + + " \"secrets\": []" + + "}" + + "]"; + + private static final String SINGLE_RESOURCE_JSON = "[" + + "{" + + " \"created_at\": \"2024-01-15T10:30:00Z\"," + + " \"id\": \"myaccount:host:demo/app/web\"," + + " \"owner\": \"myaccount:policy:demo\"," + + " \"policy\": \"myaccount:policy:root\"," + + " \"permissions\": []," + + " \"annotations\": []" + + "}" + + "]"; + + @Test + void listAllResources() { + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(SAMPLE_RESOURCES_JSON); + + List result = resourceClient.listResources(); + + assertEquals(2, result.size()); + assertEquals("myaccount:variable:demo/db-password", result.get(0).getId()); + assertEquals("myaccount:variable:demo/api-key", result.get(1).getId()); + + // Verify the correct URI was used — no query params + verify(mockClient).target(argThat((URI uri) -> + uri.toString().equals("https://conjur.example.com/resources/myaccount") + )); + } + + @Test + void listResourcesByKind() { + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(SAMPLE_RESOURCES_JSON); + + List result = resourceClient.listResources("variable"); + + assertEquals(2, result.size()); + + verify(mockClient).target(argThat((URI uri) -> + uri.toString().equals("https://conjur.example.com/resources/myaccount?kind=variable") + )); + } + + @Test + void listResourcesByKindHost() { + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(SINGLE_RESOURCE_JSON); + + List result = resourceClient.listResources("host"); + + assertEquals(1, result.size()); + assertEquals("host", result.get(0).getKind()); + assertEquals("demo/app/web", result.get(0).getIdentifier()); + + verify(mockClient).target(argThat((URI uri) -> + uri.toString().equals("https://conjur.example.com/resources/myaccount?kind=host") + )); + } + + @Test + void listResourcesWithSearch() { + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(SAMPLE_RESOURCES_JSON); + + List result = resourceClient.listResources("variable", "demo", null, null); + + assertEquals(2, result.size()); + + verify(mockClient).target(argThat((URI uri) -> + uri.toString().equals("https://conjur.example.com/resources/myaccount?kind=variable&search=demo") + )); + } + + @Test + void listResourcesWithLimitAndOffset() { + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(SINGLE_RESOURCE_JSON); + + List result = resourceClient.listResources(null, null, 10, 20); + + assertEquals(1, result.size()); + + verify(mockClient).target(argThat((URI uri) -> + uri.toString().equals("https://conjur.example.com/resources/myaccount?limit=10&offset=20") + )); + } + + @Test + void listResourcesWithAllParams() { + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(SAMPLE_RESOURCES_JSON); + + List result = resourceClient.listResources("variable", "api", 5, 0); + + assertEquals(2, result.size()); + + verify(mockClient).target(argThat((URI uri) -> + uri.toString().equals("https://conjur.example.com/resources/myaccount?kind=variable&search=api&limit=5&offset=0") + )); + } + + @Test + void listResourcesEmptyResult() { + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn("[]"); + + List result = resourceClient.listResources(); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void listResourcesError401() { + when(mockBatchResponse.getStatus()).thenReturn(401); + when(mockBatchResponse.readEntity(String.class)).thenReturn("Unauthorized"); + + WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> resourceClient.listResources()); + + assertTrue(ex.getMessage().contains("401")); + } + + @Test + void listResourcesError403() { + when(mockBatchResponse.getStatus()).thenReturn(403); + when(mockBatchResponse.readEntity(String.class)).thenReturn("Forbidden"); + + WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> resourceClient.listResources("variable")); + + assertTrue(ex.getMessage().contains("403")); + } + + @Test + void resourceFieldsParsedCorrectly() { + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn(SAMPLE_RESOURCES_JSON); + + List result = resourceClient.listResources(); + + ConjurResource first = result.get(0); + assertEquals("2024-01-15T10:30:00Z", first.getCreatedAt()); + assertEquals("myaccount:variable:demo/db-password", first.getId()); + assertEquals("myaccount:policy:demo", first.getOwner()); + assertEquals("myaccount:policy:root", first.getPolicy()); + assertEquals("variable", first.getKind()); + assertEquals("demo/db-password", first.getIdentifier()); + + // Permissions + assertNotNull(first.getPermissions()); + assertEquals(1, first.getPermissions().size()); + assertEquals("read", first.getPermissions().get(0).getPrivilege()); + assertEquals("myaccount:host:demo/app", first.getPermissions().get(0).getRole()); + + // Annotations + assertNotNull(first.getAnnotations()); + assertEquals(1, first.getAnnotations().size()); + assertEquals("description", first.getAnnotations().get(0).getName()); + assertEquals("Database password", first.getAnnotations().get(0).getValue()); + + // Secrets + assertNotNull(first.getSecrets()); + assertEquals(1, first.getSecrets().size()); + } + + @Test + void searchWithSpecialCharactersEncoded() { + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn("[]"); + + resourceClient.listResources(null, "foo/bar", null, null); + + verify(mockClient).target(argThat((URI uri) -> + uri.toString().contains("search=foo%2Fbar") + )); + } + } + + // ======================================================================== + // Count Resources Tests + // ======================================================================== + + @Nested + class CountResources { + + @Test + void countAllResourcesJsonFormat() { + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn("{\"count\":42}"); + + int count = resourceClient.countResources(null, null); + + assertEquals(42, count); + + verify(mockClient).target(argThat((URI uri) -> + uri.toString().equals("https://conjur.example.com/resources/myaccount?count=true") + )); + } + + @Test + void countAllResourcesPlainFormat() { + // Some Conjur versions may return a plain integer + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn("42"); + + int count = resourceClient.countResources(null, null); + + assertEquals(42, count); + + verify(mockClient).target(argThat((URI uri) -> + uri.toString().equals("https://conjur.example.com/resources/myaccount?count=true") + )); + } + + @Test + void countByKind() { + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn("{\"count\":7}"); + + int count = resourceClient.countResources("variable", null); + + assertEquals(7, count); + + verify(mockClient).target(argThat((URI uri) -> + uri.toString().equals("https://conjur.example.com/resources/myaccount?count=true&kind=variable") + )); + } + + @Test + void countWithSearch() { + when(mockBatchResponse.getStatus()).thenReturn(200); + when(mockBatchResponse.readEntity(String.class)).thenReturn("{\"count\":3}"); + + int count = resourceClient.countResources("variable", "demo"); + + assertEquals(3, count); + + verify(mockClient).target(argThat((URI uri) -> + uri.toString().equals("https://conjur.example.com/resources/myaccount?count=true&kind=variable&search=demo") + )); + } + + @Test + void countError401() { + when(mockBatchResponse.getStatus()).thenReturn(401); + when(mockBatchResponse.readEntity(String.class)).thenReturn("Unauthorized"); + + WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> resourceClient.countResources(null, null)); + + assertTrue(ex.getMessage().contains("401")); + } + } +}