diff --git a/ans-sdk-api/src/test/java/com/godaddy/ans/sdk/model/AgentDetailsTest.java b/ans-sdk-api/src/test/java/com/godaddy/ans/sdk/model/AgentDetailsTest.java
index c75f548..e668e65 100644
--- a/ans-sdk-api/src/test/java/com/godaddy/ans/sdk/model/AgentDetailsTest.java
+++ b/ans-sdk-api/src/test/java/com/godaddy/ans/sdk/model/AgentDetailsTest.java
@@ -82,4 +82,23 @@ void addIdentityInitialisesList() {
o.addIdentitiesItem(new LinkedIdentity());
assertThat(o.getIdentities()).hasSize(1);
}
+
+ @Test
+ void identitiesAbsentYieldsEmptyList() throws Exception {
+ // Absent identities[] deserializes to an empty list, never null.
+ String json = """
+ {
+ "agentId": "11111111-1111-1111-1111-111111111111",
+ "agentDisplayName": "x",
+ "version": "x",
+ "agentHost": "x",
+ "ansName": "x",
+ "agentStatus": "PENDING_VALIDATION",
+ "endpoints": [],
+ "links": []
+ }
+ """;
+ AgentDetails back = ModelTestSupport.mapper().readValue(json, AgentDetails.class);
+ assertThat(back.getIdentities()).isNotNull().isEmpty();
+ }
}
diff --git a/ans-sdk-crypto/build.gradle.kts b/ans-sdk-crypto/build.gradle.kts
index dd238fa..352b1b0 100644
--- a/ans-sdk-crypto/build.gradle.kts
+++ b/ans-sdk-crypto/build.gradle.kts
@@ -1,4 +1,5 @@
val bouncyCastleVersion: String by project
+val nimbusJoseVersion: String by project
val slf4jVersion: String by project
val junitVersion: String by project
val mockitoVersion: String by project
@@ -12,6 +13,9 @@ dependencies {
implementation("org.bouncycastle:bcpkix-jdk18on:$bouncyCastleVersion")
implementation("org.bouncycastle:bcprov-jdk18on:$bouncyCastleVersion")
+ // Nimbus JOSE + JWT for compact-JWS control proofs (EdDSA/ES256/RS256)
+ implementation("com.nimbusds:nimbus-jose-jwt:$nimbusJoseVersion")
+
// Logging
implementation("org.slf4j:slf4j-api:$slf4jVersion")
diff --git a/ans-sdk-crypto/src/main/java/com/godaddy/ans/sdk/crypto/IdentityProofSigner.java b/ans-sdk-crypto/src/main/java/com/godaddy/ans/sdk/crypto/IdentityProofSigner.java
new file mode 100644
index 0000000..840db6d
--- /dev/null
+++ b/ans-sdk-crypto/src/main/java/com/godaddy/ans/sdk/crypto/IdentityProofSigner.java
@@ -0,0 +1,196 @@
+package com.godaddy.ans.sdk.crypto;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.OctetKeyPair;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.util.Base64URL;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.EdECPrivateKey;
+import java.security.interfaces.EdECPublicKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Arrays;
+
+/**
+ * Signs Verified-Identity control-proof challenges as compact JWS strings.
+ *
+ *
The Registration Authority (RA) serves a {@code signingInput} — the base64url of the
+ * canonical proof bytes — in the {@code 202} challenge round. This signer produces one compact
+ * JWS per proven key, suitable for the {@code signedProofs} array of a verify-control request.
+ *
+ * The served {@code signingInput} becomes the JWS payload segment verbatim: the RA checks
+ * payload equality before it checks the signature, so the client never canonicalizes or re-encodes
+ * it. The protected header always carries {@code kid} and may carry the public {@code jwk}.
+ *
+ * This signer supports only the algorithms the verifier implements: EdDSA (Ed25519), ES256
+ * (ECDSA P-256), and RS256 (RSA >= 2048). It infers the algorithm from the private key. It
+ * rejects key-agreement keys (X25519) and curves with no verifier (secp256k1, P-384, P-521)
+ * before it signs.
+ */
+public final class IdentityProofSigner {
+
+ private static final Logger LOG = LoggerFactory.getLogger(IdentityProofSigner.class);
+
+ private static final int MIN_RSA_KEY_BITS = 2048;
+ private static final int ED25519_RAW_KEY_LEN = 32;
+ private static final String ED25519 = "Ed25519";
+
+ /**
+ * Creates a new IdentityProofSigner.
+ */
+ public IdentityProofSigner() {
+ // Default constructor
+ }
+
+ /**
+ * Signs the served {@code signingInput} and returns a compact JWS with {@code kid} in the
+ * protected header.
+ *
+ * @param signingInput the base64url signing input served by the RA, used as the JWS payload verbatim
+ * @param privateKey the private key that proves control of the identifier
+ * @param kid the verification-method id claimed by this proof
+ * @return the compact JWS ({@code header.payload.signature})
+ * @throws IllegalArgumentException if an argument is missing or the key/algorithm is unsupported
+ * @throws RuntimeException if signing fails
+ */
+ public String sign(String signingInput, PrivateKey privateKey, String kid) {
+ return sign(signingInput, privateKey, kid, null);
+ }
+
+ /**
+ * Signs the served {@code signingInput} and returns a compact JWS with {@code kid} and the
+ * public {@code jwk} in the protected header.
+ *
+ * The embedded {@code jwk} is public-only. It is required by the quickstart noop resolver and
+ * ignored by the web resolver, which always uses the resolved DID document.
+ *
+ * @param signingInput the base64url signing input served by the RA, used as the JWS payload verbatim
+ * @param privateKey the private key that proves control of the identifier
+ * @param kid the verification-method id claimed by this proof
+ * @param publicKey the public key to embed as {@code jwk}, or {@code null} to omit it
+ * @return the compact JWS ({@code header.payload.signature})
+ * @throws IllegalArgumentException if an argument is missing or the key/algorithm is unsupported
+ * @throws RuntimeException if signing fails
+ */
+ public String sign(String signingInput, PrivateKey privateKey, String kid, PublicKey publicKey) {
+ if (signingInput == null || signingInput.isBlank()) {
+ throw new IllegalArgumentException("signingInput cannot be null or blank");
+ }
+ if (privateKey == null) {
+ throw new IllegalArgumentException("privateKey cannot be null");
+ }
+ if (kid == null || kid.isBlank()) {
+ throw new IllegalArgumentException("kid cannot be null or blank");
+ }
+
+ JWSAlgorithm algorithm = resolveAlgorithm(privateKey);
+ LOG.debug("Signing identity proof with algorithm {} and kid {}", algorithm, kid);
+
+ JWSHeader.Builder headerBuilder = new JWSHeader.Builder(algorithm).keyID(kid);
+ if (publicKey != null) {
+ headerBuilder.jwk(toPublicJwk(algorithm, publicKey));
+ }
+ JWSHeader header = headerBuilder.build();
+
+ String headerSegment = header.toBase64URL().toString();
+ byte[] signingInputBytes = (headerSegment + "." + signingInput).getBytes(StandardCharsets.US_ASCII);
+
+ Base64URL signature = computeSignature(algorithm, privateKey, header, signingInputBytes);
+ return headerSegment + "." + signingInput + "." + signature;
+ }
+
+ /**
+ * Resolves the JWS algorithm from the private key, rejecting unsupported keys before signing.
+ */
+ private JWSAlgorithm resolveAlgorithm(PrivateKey privateKey) {
+ if (privateKey instanceof RSAPrivateKey rsaKey) {
+ int bits = rsaKey.getModulus().bitLength();
+ if (bits < MIN_RSA_KEY_BITS) {
+ throw new IllegalArgumentException(
+ "RSA key must be at least " + MIN_RSA_KEY_BITS + " bits, was " + bits);
+ }
+ return JWSAlgorithm.RS256;
+ }
+ if (privateKey instanceof ECPrivateKey ecKey) {
+ Curve curve = Curve.forECParameterSpec(ecKey.getParams());
+ if (!Curve.P_256.equals(curve)) {
+ throw new IllegalArgumentException(
+ "Unsupported EC curve for ES256 (only P-256 is supported): " + curve);
+ }
+ return JWSAlgorithm.ES256;
+ }
+ if (privateKey instanceof EdECPrivateKey edKey) {
+ String curveName = edKey.getParams().getName();
+ if (!ED25519.equals(curveName)) {
+ throw new IllegalArgumentException(
+ "Unsupported EdDSA curve (only Ed25519 is supported): " + curveName);
+ }
+ return JWSAlgorithm.EdDSA;
+ }
+ throw new IllegalArgumentException(
+ "Unsupported key type for identity proof: " + privateKey.getAlgorithm());
+ }
+
+ /**
+ * Computes the JWS signature over the signing input bytes for the resolved algorithm.
+ */
+ private Base64URL computeSignature(JWSAlgorithm algorithm, PrivateKey privateKey,
+ JWSHeader header, byte[] signingInputBytes) {
+ try {
+ if (JWSAlgorithm.EdDSA.equals(algorithm)) {
+ // Ed25519 JCA signatures are already the raw R||S form JOSE expects, no transcoding needed.
+ Signature signature = Signature.getInstance(ED25519);
+ signature.initSign(privateKey);
+ signature.update(signingInputBytes);
+ return Base64URL.encode(signature.sign());
+ }
+ JWSSigner signer = JWSAlgorithm.RS256.equals(algorithm)
+ ? new RSASSASigner(privateKey)
+ : new ECDSASigner(privateKey, Curve.P_256);
+ return signer.sign(header, signingInputBytes);
+ } catch (GeneralSecurityException | JOSEException e) {
+ throw new IllegalStateException(
+ "Failed to sign identity proof (alg=" + algorithm + ", kid=" + header.getKeyID() + ")", e);
+ }
+ }
+
+ /**
+ * Builds a public-only JWK for the given public key and resolved algorithm.
+ */
+ private JWK toPublicJwk(JWSAlgorithm algorithm, PublicKey publicKey) {
+ try {
+ if (JWSAlgorithm.RS256.equals(algorithm)) {
+ return new RSAKey.Builder((RSAPublicKey) publicKey).build();
+ }
+ if (JWSAlgorithm.ES256.equals(algorithm)) {
+ return new ECKey.Builder(Curve.P_256, (ECPublicKey) publicKey).build();
+ }
+ // EdDSA: the raw 32-byte public key is the tail of the X.509 SubjectPublicKeyInfo encoding.
+ if (!(publicKey instanceof EdECPublicKey)) {
+ throw new IllegalArgumentException("publicKey does not match the private key algorithm");
+ }
+ byte[] encoded = publicKey.getEncoded();
+ byte[] raw = Arrays.copyOfRange(encoded, encoded.length - ED25519_RAW_KEY_LEN, encoded.length);
+ return new OctetKeyPair.Builder(Curve.Ed25519, Base64URL.encode(raw)).build();
+ } catch (ClassCastException e) {
+ throw new IllegalArgumentException("publicKey does not match the private key algorithm", e);
+ }
+ }
+}
diff --git a/ans-sdk-crypto/src/test/java/com/godaddy/ans/sdk/crypto/IdentityProofSignerTest.java b/ans-sdk-crypto/src/test/java/com/godaddy/ans/sdk/crypto/IdentityProofSignerTest.java
new file mode 100644
index 0000000..3b2c4af
--- /dev/null
+++ b/ans-sdk-crypto/src/test/java/com/godaddy/ans/sdk/crypto/IdentityProofSignerTest.java
@@ -0,0 +1,234 @@
+package com.godaddy.ans.sdk.crypto;
+
+import com.nimbusds.jose.JWSObject;
+import com.nimbusds.jose.crypto.ECDSAVerifier;
+import com.nimbusds.jose.crypto.RSASSAVerifier;
+import com.nimbusds.jose.util.Base64URL;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
+import java.security.Security;
+import java.security.Signature;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.ECGenParameterSpec;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class IdentityProofSignerTest {
+
+ private static final String KID = "did:web:identity.acme-corp.com#key-1";
+ // Base64url payload with '-' and '_' so any re-encoding would change it.
+ private static final String SIGNING_INPUT = "c2ln-bmlu_Zy1pbnB1dA";
+
+ static {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ }
+
+ private final IdentityProofSigner signer = new IdentityProofSigner();
+
+ // ==================== payload verbatim + roundtrip per alg ====================
+
+ @Test
+ void signVerifyRoundtripRs256() throws Exception {
+ assertRoundTrip(genRsa(2048));
+ }
+
+ @Test
+ void signVerifyRoundtripEs256() throws Exception {
+ assertRoundTrip(genEc("secp256r1", null));
+ }
+
+ @Test
+ void signVerifyRoundtripEddsa() throws Exception {
+ assertRoundTrip(gen("Ed25519"));
+ }
+
+ @Test
+ void payloadSegmentEqualsSigningInputVerbatim() {
+ String jws = signer.sign(SIGNING_INPUT, genRsa(2048).getPrivate(), KID);
+ String[] parts = jws.split("\\.");
+ assertThat(parts).hasSize(3);
+ assertThat(parts[1]).isEqualTo(SIGNING_INPUT);
+ }
+
+ @Test
+ void kidOnlyOverloadOmitsJwk() throws Exception {
+ KeyPair kp = genRsa(2048);
+ String jws = signer.sign(SIGNING_INPUT, kp.getPrivate(), KID);
+ JWSObject parsed = JWSObject.parse(jws);
+ assertThat(parsed.getHeader().getKeyID()).isEqualTo(KID);
+ assertThat(parsed.getHeader().getJWK()).isNull();
+ assertThat(parsed.verify(new RSASSAVerifier((RSAPublicKey) kp.getPublic()))).isTrue();
+ }
+
+ @Test
+ void kidOnlyOverloadOmitsJwkEs256() throws Exception {
+ KeyPair kp = genEc("secp256r1", null);
+ String jws = signer.sign(SIGNING_INPUT, kp.getPrivate(), KID);
+ JWSObject parsed = JWSObject.parse(jws);
+ assertThat(parsed.getHeader().getKeyID()).isEqualTo(KID);
+ assertThat(parsed.getHeader().getJWK()).isNull();
+ assertThat(parsed.verify(new ECDSAVerifier((ECPublicKey) kp.getPublic()))).isTrue();
+ }
+
+ @Test
+ void kidOnlyOverloadOmitsJwkEddsa() throws Exception {
+ KeyPair kp = gen("Ed25519");
+ String jws = signer.sign(SIGNING_INPUT, kp.getPrivate(), KID);
+ String[] parts = jws.split("\\.");
+ JWSObject parsed = JWSObject.parse(jws);
+ assertThat(parsed.getHeader().getKeyID()).isEqualTo(KID);
+ assertThat(parsed.getHeader().getJWK()).isNull();
+ assertThat(verifies(parts, kp.getPublic())).isTrue();
+ }
+
+ // ==================== unsupported key/alg → throw before signing ====================
+
+ @Test
+ void rsaBelow2048Throws() {
+ assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, genRsa(1024).getPrivate(), KID))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void secp256k1Throws() throws Exception {
+ KeyPair kp = genEc("secp256k1", BouncyCastleProvider.PROVIDER_NAME);
+ assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, kp.getPrivate(), KID))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void ecP384Throws() throws Exception {
+ KeyPair kp = genEc("secp384r1", null);
+ assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, kp.getPrivate(), KID))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void ed448Throws() throws Exception {
+ KeyPair kp = gen("Ed448");
+ assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, kp.getPrivate(), KID))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void x25519Throws() throws Exception {
+ KeyPair kp = gen("X25519");
+ assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, kp.getPrivate(), KID))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ // ==================== input validation ====================
+
+ @Test
+ void nullSigningInputThrows() {
+ assertThatThrownBy(() -> signer.sign(null, genRsa(2048).getPrivate(), KID))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void blankSigningInputThrows() {
+ assertThatThrownBy(() -> signer.sign(" ", genRsa(2048).getPrivate(), KID))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void nullPrivateKeyThrows() {
+ assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, null, KID))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void blankKidThrows() {
+ assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, genRsa(2048).getPrivate(), " "))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void mismatchedPublicKeyThrows() {
+ KeyPair rsa = genRsa(2048);
+ PublicKey ecPublic = genEcQuietly().getPublic();
+ assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, rsa.getPrivate(), KID, ecPublic))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void mismatchedPublicKeyForEddsaThrows() throws Exception {
+ KeyPair ed = gen("Ed25519");
+ PublicKey rsaPublic = genRsa(2048).getPublic();
+ assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, ed.getPrivate(), KID, rsaPublic))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ // ==================== helpers ====================
+
+ private void assertRoundTrip(KeyPair kp) throws Exception {
+ String jws = signer.sign(SIGNING_INPUT, kp.getPrivate(), KID, kp.getPublic());
+ String[] parts = jws.split("\\.");
+ assertThat(parts).hasSize(3);
+ assertThat(parts[1]).isEqualTo(SIGNING_INPUT);
+
+ JWSObject parsed = JWSObject.parse(jws);
+ assertThat(parsed.getHeader().getKeyID()).isEqualTo(KID);
+ assertThat(parsed.getHeader().getJWK()).isNotNull();
+ assertThat(parsed.getHeader().getJWK().isPrivate()).isFalse();
+ assertThat(verifies(parts, kp.getPublic())).isTrue();
+ }
+
+ /**
+ * Verifies the compact JWS signature against the public key. RSA and EC use Nimbus verifiers.
+ * Ed25519 uses JCA directly, because Nimbus's Ed25519Verifier pulls in an optional Tink dependency.
+ */
+ private boolean verifies(String[] parts, PublicKey publicKey) throws Exception {
+ if (publicKey instanceof RSAPublicKey rsa) {
+ return JWSObject.parse(String.join(".", parts)).verify(new RSASSAVerifier(rsa));
+ }
+ if (publicKey instanceof ECPublicKey ec) {
+ return JWSObject.parse(String.join(".", parts)).verify(new ECDSAVerifier(ec));
+ }
+ byte[] signingInputBytes = (parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII);
+ byte[] signatureBytes = new Base64URL(parts[2]).decode();
+ Signature verifier = Signature.getInstance("Ed25519");
+ verifier.initVerify(publicKey);
+ verifier.update(signingInputBytes);
+ return verifier.verify(signatureBytes);
+ }
+
+ private static KeyPair genRsa(int bits) {
+ try {
+ KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
+ gen.initialize(bits);
+ return gen.generateKeyPair();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static KeyPair genEc(String curve, String provider) throws Exception {
+ KeyPairGenerator gen = provider == null
+ ? KeyPairGenerator.getInstance("EC")
+ : KeyPairGenerator.getInstance("EC", provider);
+ gen.initialize(new ECGenParameterSpec(curve));
+ return gen.generateKeyPair();
+ }
+
+ private static KeyPair genEcQuietly() {
+ try {
+ return genEc("secp256r1", null);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static KeyPair gen(String algorithm) throws Exception {
+ return KeyPairGenerator.getInstance(algorithm).generateKeyPair();
+ }
+}
diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityClient.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityClient.java
new file mode 100644
index 0000000..4e70585
--- /dev/null
+++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityClient.java
@@ -0,0 +1,394 @@
+package com.godaddy.ans.sdk.registration;
+
+import com.godaddy.ans.sdk.auth.AnsCredentialsProvider;
+import com.godaddy.ans.sdk.concurrent.AnsExecutors;
+import com.godaddy.ans.sdk.config.AnsConfiguration;
+import com.godaddy.ans.sdk.config.ApiVersion;
+import com.godaddy.ans.sdk.config.Environment;
+import com.godaddy.ans.sdk.model.IdentityChallengeResponse;
+import com.godaddy.ans.sdk.model.IdentityDetails;
+import com.godaddy.ans.sdk.model.IdentityLinkRequest;
+import com.godaddy.ans.sdk.model.IdentityLinkResponse;
+import com.godaddy.ans.sdk.model.IdentityListResponse;
+import com.godaddy.ans.sdk.model.IdentityRegistrationRequest;
+import com.godaddy.ans.sdk.model.VerifyControlRequest;
+
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Client for ANS Verified-Identity management operations.
+ *
+ * An identity is a first-class object with its own lifecycle, separate from
+ * agent registration. This client covers the eight RA management operations on
+ * the {@code /v2/ans/identities} surface: register, list, get details, rotate,
+ * verify control, revoke, link to agents, and unlink.
+ *
+ * Register and rotate return a {@link IdentityChallengeResponse} (a 202 async
+ * challenge round). The identity is not sealed on that response. The caller must
+ * complete the challenge and then submit a control proof to verify-control.
+ *
+ * Example identity flow:
+ * {@code
+ * IdentityClient client = IdentityClient.builder()
+ * .environment(Environment.OTE)
+ * .credentialsProvider(new JwtCredentialsProvider(jwtToken))
+ * .build();
+ *
+ * IdentityChallengeResponse challenge = client.registerIdentity(request);
+ * // Complete the challenge, build the proof, then:
+ * IdentityDetails identity = client.verifyControl(challenge.getIdentityId(), proofRequest);
+ * }
+ *
+ * Example link and unlink:
+ * {@code
+ * IdentityLinkResponse linked = client.linkAgents(identityId,
+ * new IdentityLinkRequest().agentIds(List.of(agentId)));
+ * client.unlinkAgent(identityId, agentId);
+ * }
+ */
+public final class IdentityClient {
+
+ private final AnsConfiguration configuration;
+ private final IdentityService identityService;
+
+ private IdentityClient(AnsConfiguration configuration, AnsApiClient ansApiClient) {
+ this.configuration = configuration;
+ this.identityService = new IdentityService(ansApiClient);
+ }
+
+ /**
+ * Creates a new builder for constructing an IdentityClient.
+ *
+ * @return a new builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ // ==================== Identity Operations (Sync) ====================
+
+ /**
+ * Registers a new identity and returns the 202 challenge round.
+ *
+ * The kind ({@code did:web}, {@code did:key}, or {@code lei}) is inferred
+ * from the value. The identity is not sealed by this response. Complete the
+ * challenge and submit a control proof to verify-control.
+ *
+ * @param request the registration request
+ * @return the challenge round to complete
+ * @throws com.godaddy.ans.sdk.exception.AnsValidationException if the request is invalid
+ * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails
+ * @throws com.godaddy.ans.sdk.exception.AnsServerException if a server error occurs
+ */
+ public IdentityChallengeResponse registerIdentity(IdentityRegistrationRequest request) {
+ return identityService.register(request);
+ }
+
+ /**
+ * Lists the caller's identities, cursor-paginated.
+ *
+ * @param limit optional page size (1..100), or {@code null} for the server default
+ * @param cursor optional opaque page cursor, or {@code null} for the first page
+ * @return the page of identities plus the next cursor
+ * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails
+ */
+ public IdentityListResponse listIdentities(Integer limit, String cursor) {
+ return identityService.list(limit, cursor);
+ }
+
+ /**
+ * Gets the full details for a single identity.
+ *
+ * @param identityId the identity ID
+ * @return the identity details
+ * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found
+ * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails
+ */
+ public IdentityDetails getIdentity(String identityId) {
+ return identityService.getDetails(identityId);
+ }
+
+ /**
+ * Rotates the key material for an identity and returns a fresh 202 challenge round.
+ *
+ * Rotation is same-kind only. As with registration, the response is a
+ * challenge to complete, not a verified state.
+ *
+ * @param identityId the identity ID to rotate
+ * @param request the rotation request
+ * @return the new challenge round to complete
+ * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found
+ * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails
+ */
+ public IdentityChallengeResponse rotateIdentity(String identityId, IdentityRegistrationRequest request) {
+ return identityService.rotate(identityId, request);
+ }
+
+ /**
+ * Submits a control proof for an identity.
+ *
+ * Exactly one proof family may be present: {@code signedProofs} (JWS kinds)
+ * or {@code cesrSignature} (lei). The SDK rejects both, or neither, before the
+ * request leaves.
+ *
+ * @param identityId the identity ID
+ * @param request the control-proof request
+ * @return the updated identity details
+ * @throws IllegalArgumentException if the request carries both proof families or none
+ * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails
+ */
+ public IdentityDetails verifyControl(String identityId, VerifyControlRequest request) {
+ return identityService.verifyControl(identityId, request);
+ }
+
+ /**
+ * Revokes an identity.
+ *
+ * @param identityId the identity ID to revoke
+ * @return the updated identity details
+ * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found
+ * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails
+ */
+ public IdentityDetails revokeIdentity(String identityId) {
+ return identityService.revoke(identityId);
+ }
+
+ /**
+ * Links an identity to one or more agents as an all-or-nothing batch.
+ *
+ * @param identityId the identity ID
+ * @param request the link request carrying 1..256 agent IDs
+ * @return the link response with the count of linked agents
+ * @throws IllegalArgumentException if the batch is empty or exceeds 256 agents
+ * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails
+ */
+ public IdentityLinkResponse linkAgents(String identityId, IdentityLinkRequest request) {
+ return identityService.link(identityId, request);
+ }
+
+ /**
+ * Removes the link between an identity and a single agent.
+ *
+ * @param identityId the identity ID
+ * @param agentId the linked agent ID to remove
+ * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the link is not found
+ * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails
+ */
+ public void unlinkAgent(String identityId, String agentId) {
+ identityService.unlink(identityId, agentId);
+ }
+
+ // ==================== Identity Operations (Async) ====================
+
+ /**
+ * Registers a new identity asynchronously.
+ *
+ * @param request the registration request
+ * @return a CompletableFuture with the challenge round
+ */
+ public CompletableFuture registerIdentityAsync(IdentityRegistrationRequest request) {
+ return CompletableFuture.supplyAsync(() -> registerIdentity(request), AnsExecutors.sharedIoExecutor());
+ }
+
+ /**
+ * Lists the caller's identities asynchronously.
+ *
+ * @param limit optional page size (1..100), or {@code null} for the server default
+ * @param cursor optional opaque page cursor, or {@code null} for the first page
+ * @return a CompletableFuture with the page of identities
+ */
+ public CompletableFuture listIdentitiesAsync(Integer limit, String cursor) {
+ return CompletableFuture.supplyAsync(() -> listIdentities(limit, cursor), AnsExecutors.sharedIoExecutor());
+ }
+
+ /**
+ * Gets the full details for a single identity asynchronously.
+ *
+ * @param identityId the identity ID
+ * @return a CompletableFuture with the identity details
+ */
+ public CompletableFuture getIdentityAsync(String identityId) {
+ return CompletableFuture.supplyAsync(() -> getIdentity(identityId), AnsExecutors.sharedIoExecutor());
+ }
+
+ /**
+ * Rotates the key material for an identity asynchronously.
+ *
+ * @param identityId the identity ID to rotate
+ * @param request the rotation request
+ * @return a CompletableFuture with the new challenge round
+ */
+ public CompletableFuture rotateIdentityAsync(String identityId,
+ IdentityRegistrationRequest request) {
+ return CompletableFuture.supplyAsync(
+ () -> rotateIdentity(identityId, request), AnsExecutors.sharedIoExecutor());
+ }
+
+ /**
+ * Submits a control proof for an identity asynchronously.
+ *
+ * @param identityId the identity ID
+ * @param request the control-proof request
+ * @return a CompletableFuture with the updated identity details
+ */
+ public CompletableFuture verifyControlAsync(String identityId, VerifyControlRequest request) {
+ return CompletableFuture.supplyAsync(() -> verifyControl(identityId, request), AnsExecutors.sharedIoExecutor());
+ }
+
+ /**
+ * Revokes an identity asynchronously.
+ *
+ * @param identityId the identity ID to revoke
+ * @return a CompletableFuture with the updated identity details
+ */
+ public CompletableFuture revokeIdentityAsync(String identityId) {
+ return CompletableFuture.supplyAsync(() -> revokeIdentity(identityId), AnsExecutors.sharedIoExecutor());
+ }
+
+ /**
+ * Links an identity to one or more agents asynchronously.
+ *
+ * @param identityId the identity ID
+ * @param request the link request carrying 1..256 agent IDs
+ * @return a CompletableFuture with the link response
+ */
+ public CompletableFuture linkAgentsAsync(String identityId, IdentityLinkRequest request) {
+ return CompletableFuture.supplyAsync(() -> linkAgents(identityId, request), AnsExecutors.sharedIoExecutor());
+ }
+
+ /**
+ * Removes the link between an identity and a single agent asynchronously.
+ *
+ * @param identityId the identity ID
+ * @param agentId the linked agent ID to remove
+ * @return a CompletableFuture that completes when the link is removed
+ */
+ public CompletableFuture unlinkAgentAsync(String identityId, String agentId) {
+ return CompletableFuture.runAsync(() -> unlinkAgent(identityId, agentId), AnsExecutors.sharedIoExecutor());
+ }
+
+ /**
+ * Returns the current configuration.
+ *
+ * @return the configuration
+ */
+ public AnsConfiguration getConfiguration() {
+ return configuration;
+ }
+
+ /**
+ * Builder for constructing an IdentityClient.
+ */
+ public static final class Builder {
+
+ private final AnsConfiguration.Builder configBuilder = AnsConfiguration.builder();
+ private AnsConfiguration prebuiltConfiguration;
+
+ private Builder() {
+ }
+
+ /**
+ * Uses a pre-built configuration directly.
+ *
+ * When set, this configuration is used as-is and any values set via
+ * other builder methods are ignored.
+ *
+ * @param configuration the pre-built configuration
+ * @return this builder
+ */
+ public Builder configuration(AnsConfiguration configuration) {
+ this.prebuiltConfiguration = configuration;
+ return this;
+ }
+
+ /**
+ * Sets the environment.
+ *
+ * @param environment the environment
+ * @return this builder
+ */
+ public Builder environment(Environment environment) {
+ configBuilder.environment(environment);
+ return this;
+ }
+
+ /**
+ * Sets a custom base URL.
+ *
+ * @param baseUrl the base URL
+ * @return this builder
+ */
+ public Builder baseUrl(String baseUrl) {
+ configBuilder.baseUrl(baseUrl);
+ return this;
+ }
+
+ /**
+ * Sets the credentials provider.
+ *
+ * @param credentialsProvider the credentials provider
+ * @return this builder
+ */
+ public Builder credentialsProvider(AnsCredentialsProvider credentialsProvider) {
+ configBuilder.credentialsProvider(credentialsProvider);
+ return this;
+ }
+
+ /**
+ * Sets the connection timeout.
+ *
+ * @param timeout the connection timeout
+ * @return this builder
+ */
+ public Builder connectTimeout(Duration timeout) {
+ configBuilder.connectTimeout(timeout);
+ return this;
+ }
+
+ /**
+ * Sets the read timeout.
+ *
+ * @param timeout the read timeout
+ * @return this builder
+ */
+ public Builder readTimeout(Duration timeout) {
+ configBuilder.readTimeout(timeout);
+ return this;
+ }
+
+ /**
+ * Enables retry with the specified maximum number of attempts.
+ *
+ * @param maxRetries the maximum number of retry attempts
+ * @return this builder
+ */
+ public Builder enableRetry(int maxRetries) {
+ configBuilder.enableRetry(maxRetries);
+ return this;
+ }
+
+ /**
+ * Sets the API version lane. Defaults to {@link ApiVersion#V2}.
+ *
+ * @param apiVersion the API version
+ * @return this builder
+ */
+ public Builder apiVersion(ApiVersion apiVersion) {
+ configBuilder.apiVersion(apiVersion);
+ return this;
+ }
+
+ /**
+ * Builds the IdentityClient.
+ *
+ * @return a new IdentityClient instance
+ */
+ public IdentityClient build() {
+ AnsConfiguration config = (prebuiltConfiguration != null)
+ ? prebuiltConfiguration
+ : configBuilder.build();
+ return new IdentityClient(config, new AnsApiClient(config));
+ }
+ }
+}
diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityPaths.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityPaths.java
new file mode 100644
index 0000000..dde5a51
--- /dev/null
+++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityPaths.java
@@ -0,0 +1,109 @@
+package com.godaddy.ans.sdk.registration;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.StringJoiner;
+
+/**
+ * Builds ANS Verified-Identity API paths on the RA management host.
+ *
+ * All identity path branching lives here so the identity service never
+ * string-concatenates paths inline. Identities are a v2-only feature, so every
+ * path is rooted at the fixed {@code /v2/ans/identities} collection.
+ */
+final class IdentityPaths {
+
+ private static final String COLLECTION = "/v2/ans/identities";
+
+ private IdentityPaths() {
+ }
+
+ /**
+ * Returns the identities collection path.
+ *
+ * @return {@code /v2/ans/identities}
+ */
+ static String identitiesCollectionPath() {
+ return COLLECTION;
+ }
+
+ /**
+ * Builds the paginated identities collection path with optional query parameters.
+ *
+ * Mirrors the agent-list cursor convention: {@code limit} (1..100) and an
+ * opaque {@code cursor}. Null arguments are omitted so the server applies its
+ * defaults.
+ *
+ * @param limit optional page size, or {@code null} for the server default
+ * @param cursor optional opaque page cursor, or {@code null} for the first page
+ * @return {@code /v2/ans/identities} with an appended query string when needed
+ */
+ static String listPath(Integer limit, String cursor) {
+ StringJoiner query = new StringJoiner("&");
+ if (limit != null) {
+ query.add("limit=" + limit);
+ }
+ if (cursor != null) {
+ query.add("cursor=" + URLEncoder.encode(cursor, StandardCharsets.UTF_8));
+ }
+ return query.length() == 0 ? COLLECTION : COLLECTION + "?" + query;
+ }
+
+ /**
+ * Builds an identity-scoped path: collection + identityId + any trailing segments.
+ *
+ * @param identityId the identity ID
+ * @param segments optional trailing path segments (e.g. {@code "verify-control"})
+ * @return the joined path, e.g. {@code /v2/ans/identities/{identityId}/verify-control}
+ */
+ static String identityPath(String identityId, String... segments) {
+ StringBuilder path = new StringBuilder(COLLECTION)
+ .append('/')
+ .append(identityId);
+ for (String segment : segments) {
+ path.append('/').append(segment);
+ }
+ return path.toString();
+ }
+
+ /**
+ * Returns the control-proof submission path for an identity.
+ *
+ * @param identityId the identity ID
+ * @return {@code /v2/ans/identities/{identityId}/verify-control}
+ */
+ static String verifyControlPath(String identityId) {
+ return identityPath(identityId, "verify-control");
+ }
+
+ /**
+ * Returns the revocation path for an identity.
+ *
+ * @param identityId the identity ID
+ * @return {@code /v2/ans/identities/{identityId}/revoke}
+ */
+ static String revokePath(String identityId) {
+ return identityPath(identityId, "revoke");
+ }
+
+ /**
+ * Returns the links collection path for an identity.
+ *
+ * @param identityId the identity ID
+ * @return {@code /v2/ans/identities/{identityId}/links}
+ */
+ static String linksPath(String identityId) {
+ return identityPath(identityId, "links");
+ }
+
+ /**
+ * Returns the path for a single identity-to-agent link.
+ *
+ * @param identityId the identity ID
+ * @param agentId the linked agent ID
+ * @return {@code /v2/ans/identities/{identityId}/links/{agentId}}
+ */
+ static String linkPath(String identityId, String agentId) {
+ return identityPath(identityId, "links", agentId);
+ }
+}
diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityService.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityService.java
new file mode 100644
index 0000000..f0536a3
--- /dev/null
+++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityService.java
@@ -0,0 +1,226 @@
+package com.godaddy.ans.sdk.registration;
+
+import com.godaddy.ans.sdk.exception.AnsServerException;
+import com.godaddy.ans.sdk.model.IdentityChallengeResponse;
+import com.godaddy.ans.sdk.model.IdentityDetails;
+import com.godaddy.ans.sdk.model.IdentityLinkRequest;
+import com.godaddy.ans.sdk.model.IdentityLinkResponse;
+import com.godaddy.ans.sdk.model.IdentityListResponse;
+import com.godaddy.ans.sdk.model.IdentityRegistrationRequest;
+import com.godaddy.ans.sdk.model.VerifyControlRequest;
+
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.List;
+
+/**
+ * Internal service for ANS Verified-Identity management API calls.
+ *
+ * Covers the eight RA management operations on the {@code /v2/ans/identities}
+ * surface. All paths come from {@link IdentityPaths}; all HTTP work reuses
+ * {@link AnsApiClient}, which maps error status codes to typed exceptions.
+ */
+class IdentityService {
+
+ /** Maximum number of agents that a single link request can carry. */
+ private static final int MAX_LINK_AGENTS = 256;
+
+ private final AnsApiClient httpClient;
+
+ IdentityService(final AnsApiClient ansApiClient) {
+ this.httpClient = ansApiClient;
+ }
+
+ /**
+ * Registers a new identity and returns the 202 challenge round.
+ *
+ * The identity is not sealed by this response. The caller must complete
+ * the returned challenge and then submit a control proof to verify-control.
+ *
+ * @param request the registration request (kind is inferred from the value)
+ * @return the challenge round to complete
+ */
+ IdentityChallengeResponse register(IdentityRegistrationRequest request) {
+ String requestBody = httpClient.serializeToJson(request);
+
+ HttpRequest httpRequest = httpClient.createRequestBuilder(IdentityPaths.identitiesCollectionPath())
+ .POST(HttpRequest.BodyPublishers.ofString(requestBody))
+ .build();
+
+ HttpResponse response = httpClient.sendRequest(httpRequest);
+ return parseChallenge(response.body());
+ }
+
+ /**
+ * Lists the caller's identities, cursor-paginated.
+ *
+ * @param limit optional page size (1..100), or {@code null} for the server default
+ * @param cursor optional opaque page cursor, or {@code null} for the first page
+ * @return the page of identities plus the next cursor
+ */
+ IdentityListResponse list(Integer limit, String cursor) {
+ HttpRequest httpRequest = httpClient.createRequestBuilder(IdentityPaths.listPath(limit, cursor))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.sendRequest(httpRequest);
+ return httpClient.parseResponse(response.body(), IdentityListResponse.class);
+ }
+
+ /**
+ * Gets the full details for a single identity.
+ *
+ * @param identityId the identity ID
+ * @return the identity details
+ */
+ IdentityDetails getDetails(String identityId) {
+ HttpRequest httpRequest = httpClient.createRequestBuilder(IdentityPaths.identityPath(identityId))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.sendRequest(httpRequest);
+ return httpClient.parseResponse(response.body(), IdentityDetails.class);
+ }
+
+ /**
+ * Rotates the key material for an identity and returns a fresh 202 challenge round.
+ *
+ * Rotation is same-kind only. As with registration, the response is a
+ * challenge to complete, not a verified state.
+ *
+ * @param identityId the identity ID to rotate
+ * @param request the rotation request
+ * @return the new challenge round to complete
+ */
+ IdentityChallengeResponse rotate(String identityId, IdentityRegistrationRequest request) {
+ String requestBody = httpClient.serializeToJson(request);
+
+ HttpRequest httpRequest = httpClient.createRequestBuilder(IdentityPaths.identityPath(identityId))
+ .PUT(HttpRequest.BodyPublishers.ofString(requestBody))
+ .build();
+
+ HttpResponse response = httpClient.sendRequest(httpRequest);
+ return parseChallenge(response.body());
+ }
+
+ /**
+ * Submits a control proof for an identity.
+ *
+ * Exactly one proof family may be present: {@code signedProofs} (JWS kinds)
+ * or {@code cesrSignature} (lei). The SDK rejects both, or neither, before the
+ * request leaves.
+ *
+ * @param identityId the identity ID
+ * @param request the control-proof request
+ * @return the updated identity details
+ * @throws IllegalArgumentException if the request carries both proof families or none
+ */
+ IdentityDetails verifyControl(String identityId, VerifyControlRequest request) {
+ validateProofFamily(request);
+ String requestBody = httpClient.serializeToJson(request);
+
+ HttpRequest httpRequest = httpClient.createRequestBuilder(IdentityPaths.verifyControlPath(identityId))
+ .POST(HttpRequest.BodyPublishers.ofString(requestBody))
+ .build();
+
+ HttpResponse response = httpClient.sendRequest(httpRequest);
+ return httpClient.parseResponse(response.body(), IdentityDetails.class);
+ }
+
+ /**
+ * Revokes an identity.
+ *
+ * @param identityId the identity ID to revoke
+ * @return the updated identity details
+ */
+ IdentityDetails revoke(String identityId) {
+ HttpRequest httpRequest = httpClient.createRequestBuilder(IdentityPaths.revokePath(identityId))
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+
+ HttpResponse response = httpClient.sendRequest(httpRequest);
+ return httpClient.parseResponse(response.body(), IdentityDetails.class);
+ }
+
+ /**
+ * Links an identity to one or more agents as an all-or-nothing batch.
+ *
+ * @param identityId the identity ID
+ * @param request the link request carrying 1..256 agent IDs
+ * @return the link response with the count of linked agents
+ * @throws IllegalArgumentException if the batch is empty or exceeds 256 agents
+ */
+ IdentityLinkResponse link(String identityId, IdentityLinkRequest request) {
+ validateLinkBatch(request);
+ String requestBody = httpClient.serializeToJson(request);
+
+ HttpRequest httpRequest = httpClient.createRequestBuilder(IdentityPaths.linksPath(identityId))
+ .POST(HttpRequest.BodyPublishers.ofString(requestBody))
+ .build();
+
+ HttpResponse response = httpClient.sendRequest(httpRequest);
+ return httpClient.parseResponse(response.body(), IdentityLinkResponse.class);
+ }
+
+ /**
+ * Removes the link between an identity and a single agent.
+ *
+ * The server returns 204 with no body. Nothing is parsed.
+ *
+ * @param identityId the identity ID
+ * @param agentId the linked agent ID to remove
+ */
+ void unlink(String identityId, String agentId) {
+ HttpRequest httpRequest = httpClient.createRequestBuilder(IdentityPaths.linkPath(identityId, agentId))
+ .DELETE()
+ .build();
+
+ httpClient.sendRequest(httpRequest);
+ }
+
+ /**
+ * Parses a 202 challenge body and guards against missing required fields.
+ *
+ * Jackson does not enforce {@code required} on deserialization, so a
+ * malformed response can leave {@code identityId} or {@code nonce} null.
+ * Such a response cannot drive the verify-control round and is a server fault.
+ */
+ private IdentityChallengeResponse parseChallenge(String body) {
+ IdentityChallengeResponse challenge =
+ httpClient.parseResponse(body, IdentityChallengeResponse.class);
+
+ if (challenge.getIdentityId() == null) {
+ throw new AnsServerException("Identity challenge response missing 'identityId'", 0, null);
+ }
+ if (challenge.getNonce() == null) {
+ throw new AnsServerException("Identity challenge response missing 'nonce'", 0, null);
+ }
+ return challenge;
+ }
+
+ /**
+ * Enforces the exactly-one-proof-family rule before sending verify-control.
+ */
+ private void validateProofFamily(VerifyControlRequest request) {
+ List signedProofs = request.getSignedProofs();
+ boolean hasJws = signedProofs != null && !signedProofs.isEmpty();
+ boolean hasCesr = request.getCesrSignature() != null && !request.getCesrSignature().isEmpty();
+
+ if (hasJws == hasCesr) {
+ throw new IllegalArgumentException(
+ "verify-control requires exactly one proof family: signedProofs (JWS) or cesrSignature (lei)");
+ }
+ }
+
+ /**
+ * Enforces the 1..256 agent-batch bound before sending a link request.
+ */
+ private void validateLinkBatch(IdentityLinkRequest request) {
+ List> agentIds = request.getAgentIds();
+ int count = agentIds == null ? 0 : agentIds.size();
+ if (count < 1 || count > MAX_LINK_AGENTS) {
+ throw new IllegalArgumentException(
+ "link requires between 1 and " + MAX_LINK_AGENTS + " agentIds, got " + count);
+ }
+ }
+}
diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityClientTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityClientTest.java
new file mode 100644
index 0000000..08e6cd4
--- /dev/null
+++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityClientTest.java
@@ -0,0 +1,852 @@
+package com.godaddy.ans.sdk.registration;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.containing;
+import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.delete;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.put;
+import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.UUID;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import com.godaddy.ans.sdk.auth.ApiKeyCredentialsProvider;
+import com.godaddy.ans.sdk.config.AnsConfiguration;
+import com.godaddy.ans.sdk.config.ApiVersion;
+import com.godaddy.ans.sdk.config.Environment;
+import com.godaddy.ans.sdk.exception.AnsAuthenticationException;
+import com.godaddy.ans.sdk.exception.AnsConflictException;
+import com.godaddy.ans.sdk.exception.AnsNotFoundException;
+import com.godaddy.ans.sdk.exception.AnsServerException;
+import com.godaddy.ans.sdk.model.IdentityChallengeResponse;
+import com.godaddy.ans.sdk.model.IdentityDetails;
+import com.godaddy.ans.sdk.model.IdentityLifecycleStatus;
+import com.godaddy.ans.sdk.model.IdentityLinkRequest;
+import com.godaddy.ans.sdk.model.IdentityLinkResponse;
+import com.godaddy.ans.sdk.model.IdentityListResponse;
+import com.godaddy.ans.sdk.model.IdentityProofChallenge;
+import com.godaddy.ans.sdk.model.IdentityRegistrationRequest;
+import com.godaddy.ans.sdk.model.VLEIPresentation;
+import com.godaddy.ans.sdk.model.VerifyControlRequest;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@WireMockTest
+class IdentityClientTest {
+
+ private static final String TEST_IDENTITY_ID = "550e8400-e29b-41d4-a716-446655440000";
+ private static final String TEST_AGENT_ID = "660e8400-e29b-41d4-a716-446655440111";
+ private static final String TEST_OTHER_AGENT_ID = "770e8400-e29b-41d4-a716-446655440222";
+ private static final String TEST_API_KEY = "123e4567-e89b-12d3-a456-426614174000";
+ private static final String TEST_API_KEY_SECRET = "123e4567-e89b-12d3-a456-426614174000";
+ private static final String TEST_NONCE = "abc123";
+ private static final String DID_WEB_IDENTITY = "did:web:identity.acme-corp.com";
+
+ // ==================== Builder Tests ====================
+
+ @Test
+ @DisplayName("Should build client with environment")
+ void shouldBuildClientWithEnvironment() {
+ IdentityClient client = IdentityClient.builder()
+ .environment(Environment.OTE)
+ .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET))
+ .build();
+
+ assertThat(client).isNotNull();
+ assertThat(client.getConfiguration().getEnvironment()).isEqualTo(Environment.OTE);
+ assertThat(client.getConfiguration().getBaseUrl()).isEqualTo("https://api.ote-godaddy.com");
+ }
+
+ @Test
+ @DisplayName("Should build client with custom base URL")
+ void shouldBuildClientWithCustomBaseUrl(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ IdentityClient client = IdentityClient.builder()
+ .baseUrl(baseUrl)
+ .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET))
+ .build();
+
+ assertThat(client).isNotNull();
+ assertThat(client.getConfiguration().getBaseUrl()).isEqualTo(baseUrl);
+ }
+
+ @Test
+ @DisplayName("Should build client with custom timeouts")
+ void shouldBuildClientWithCustomTimeouts() {
+ IdentityClient client = IdentityClient.builder()
+ .environment(Environment.OTE)
+ .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET))
+ .connectTimeout(Duration.ofSeconds(5))
+ .readTimeout(Duration.ofSeconds(15))
+ .build();
+
+ assertThat(client.getConfiguration().getConnectTimeout()).isEqualTo(Duration.ofSeconds(5));
+ assertThat(client.getConfiguration().getReadTimeout()).isEqualTo(Duration.ofSeconds(15));
+ }
+
+ @Test
+ @DisplayName("Should build client with retry enabled")
+ void shouldBuildClientWithRetryEnabled() {
+ IdentityClient client = IdentityClient.builder()
+ .environment(Environment.OTE)
+ .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET))
+ .enableRetry(5)
+ .build();
+
+ assertThat(client.getConfiguration().isRetryEnabled()).isTrue();
+ assertThat(client.getConfiguration().getMaxRetries()).isEqualTo(5);
+ }
+
+ @Test
+ @DisplayName("Should use a pre-built configuration as-is")
+ void shouldBuildClientWithPrebuiltConfiguration() {
+ AnsConfiguration prebuilt = AnsConfiguration.builder()
+ .environment(Environment.OTE)
+ .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET))
+ .apiVersion(ApiVersion.V1)
+ .build();
+
+ IdentityClient client = IdentityClient.builder()
+ .baseUrl("https://ignored.example.com")
+ .configuration(prebuilt)
+ .build();
+
+ // The pre-built configuration wins; the builder's own baseUrl is ignored.
+ assertThat(client.getConfiguration()).isSameAs(prebuilt);
+ assertThat(client.getConfiguration().getApiVersion()).isEqualTo(ApiVersion.V1);
+ }
+
+ @Test
+ @DisplayName("Should build client with a custom API version lane")
+ void shouldBuildClientWithApiVersion() {
+ IdentityClient client = IdentityClient.builder()
+ .environment(Environment.OTE)
+ .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET))
+ .apiVersion(ApiVersion.V1)
+ .build();
+
+ assertThat(client.getConfiguration().getApiVersion()).isEqualTo(ApiVersion.V1);
+ }
+
+ @Test
+ @DisplayName("Should throw exception when credentials provider is null")
+ void shouldThrowExceptionWhenCredentialsProviderIsNull() {
+ assertThatThrownBy(() -> IdentityClient.builder()
+ .environment(Environment.OTE)
+ .build())
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ // ==================== Identity Registration Tests ====================
+
+ @Test
+ @DisplayName("Should register Identity successfully")
+ void shouldRegisterIdentitySuccessfully(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ // Stub the initial registration POST
+ stubFor(post(urlEqualTo("/v2/ans/identities"))
+ .willReturn(aResponse()
+ .withStatus(202)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityRegistrationResponse())));
+
+ IdentityClient client = IdentityClient.builder()
+ .environment(Environment.OTE)
+ .baseUrl(baseUrl)
+ .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET))
+ .build();
+
+ IdentityRegistrationRequest request = new IdentityRegistrationRequest()
+ .value(TEST_IDENTITY_ID);
+
+ IdentityChallengeResponse result = client.registerIdentity(request);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getIdentityId()).isEqualTo(TEST_IDENTITY_ID);
+ assertThat(result.getNonce()).isEqualTo(TEST_NONCE);
+ assertThat(result.getExpiresAt()).isEqualTo("2024-01-15T12:00:00Z");
+ assertThat(result.getValue()).isEqualTo(DID_WEB_IDENTITY);
+ assertThat(result.getStatus()).isEqualTo(IdentityLifecycleStatus.PENDING_CONTROL);
+ IdentityProofChallenge identityProofChallenge = result.getChallenges().get(0);
+ assertThat(identityProofChallenge.getKid()).isEqualTo("#key-1");
+ assertThat(identityProofChallenge.getSigningInput()).isEqualTo("abc123");
+
+ verify(postRequestedFor(urlEqualTo("/v2/ans/identities"))
+ .withRequestBody(containing("\"value\":\"" + TEST_IDENTITY_ID + "\""))
+ .withHeader("Authorization", equalTo("sso-key " + TEST_API_KEY + ":" + TEST_API_KEY_SECRET)));
+ }
+
+ @Test
+ @DisplayName("Should throw AnsServerException when challenge response missing identityId")
+ void shouldThrowWhenChallengeMissingIdentityId(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities"))
+ .willReturn(aResponse()
+ .withStatus(202)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"nonce\":\"abc123\"}")));
+
+ IdentityClient client = client(baseUrl);
+
+ assertThatThrownBy(() -> client.registerIdentity(new IdentityRegistrationRequest().value(TEST_IDENTITY_ID)))
+ .isInstanceOf(AnsServerException.class)
+ .hasMessageContaining("missing 'identityId'");
+ }
+
+ @Test
+ @DisplayName("Should throw AnsServerException when challenge response missing nonce")
+ void shouldThrowWhenChallengeMissingNonce(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities"))
+ .willReturn(aResponse()
+ .withStatus(202)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"identityId\":\"" + TEST_IDENTITY_ID + "\"}")));
+
+ IdentityClient client = client(baseUrl);
+
+ assertThatThrownBy(() -> client.registerIdentity(new IdentityRegistrationRequest().value(TEST_IDENTITY_ID)))
+ .isInstanceOf(AnsServerException.class)
+ .hasMessageContaining("missing 'nonce'");
+ }
+
+ @Test
+ @DisplayName("Should register a lei identity carrying a vLEI/CESR presentation")
+ void shouldRegisterLeiIdentityWithVleiPresentation(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities"))
+ .willReturn(aResponse()
+ .withStatus(202)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityRegistrationResponse())));
+
+ IdentityClient client = client(baseUrl);
+
+ String cesrBytes = "-VAj-AABAAA-transport-only-cesr";
+ IdentityRegistrationRequest request = new IdentityRegistrationRequest()
+ .value("5493001KJTIIGC8Y1R12")
+ .vleiPresentation(new VLEIPresentation().cesr(cesrBytes));
+
+ IdentityChallengeResponse result = client.registerIdentity(request);
+
+ assertThat(result).isNotNull();
+
+ verify(postRequestedFor(urlEqualTo("/v2/ans/identities"))
+ .withRequestBody(containing("\"vleiPresentation\""))
+ .withRequestBody(containing("\"cesr\":\"" + cesrBytes + "\""))
+ .withHeader("Authorization", equalTo("sso-key " + TEST_API_KEY + ":" + TEST_API_KEY_SECRET)));
+ }
+
+ @Test
+ @DisplayName("Should surface AnsConflictException carrying IDENTIFIER_DUPLICATE on 409")
+ void shouldSurfaceConflictOnDuplicateIdentifier(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities"))
+ .willReturn(aResponse()
+ .withStatus(409)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"status\":\"error\",\"code\":\"IDENTIFIER_DUPLICATE\","
+ + "\"message\":\"identifier already registered\"}")));
+
+ IdentityClient client = client(baseUrl);
+
+ assertThatThrownBy(() -> client.registerIdentity(new IdentityRegistrationRequest().value(DID_WEB_IDENTITY)))
+ .isInstanceOf(AnsConflictException.class)
+ .hasMessageContaining("IDENTIFIER_DUPLICATE");
+ }
+
+ @Test
+ @DisplayName("Should surface a retryable AnsServerException carrying TL_UNAVAILABLE on 503")
+ void shouldSurfaceServerErrorOnTransparencyLogUnavailable(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities"))
+ .willReturn(aResponse()
+ .withStatus(503)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"status\":\"error\",\"code\":\"TL_UNAVAILABLE\","
+ + "\"message\":\"transparency log unavailable\"}")));
+
+ IdentityClient client = client(baseUrl);
+
+ assertThatThrownBy(() -> client.registerIdentity(new IdentityRegistrationRequest().value(DID_WEB_IDENTITY)))
+ .isInstanceOf(AnsServerException.class)
+ .hasMessageContaining("TL_UNAVAILABLE")
+ .satisfies(e -> assertThat(((AnsServerException) e).isRetryable()).isTrue());
+ }
+
+ // ==================== List Identities Tests ====================
+
+ @Test
+ @DisplayName("Should list identities with limit and cursor")
+ void shouldListIdentitiesWithLimitAndCursor(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v2/ans/identities?limit=10&cursor=page-2"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityListResponse())));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityListResponse result = client.listIdentities(10, "page-2");
+
+ assertThat(result).isNotNull();
+ assertThat(result.getReturnedCount()).isEqualTo(1);
+ assertThat(result.getLimit()).isEqualTo(10);
+ assertThat(result.getNextCursor()).isEqualTo("page-3");
+ assertThat(result.getHasMore()).isTrue();
+ assertThat(result.getItems()).hasSize(1);
+ assertThat(result.getItems().get(0).getIdentityId()).isEqualTo(TEST_IDENTITY_ID);
+ }
+
+ @Test
+ @DisplayName("Should list identities with server defaults when limit and cursor are null")
+ void shouldListIdentitiesWithDefaults(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v2/ans/identities"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityListResponse())));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityListResponse result = client.listIdentities(null, null);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getItems()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("Should throw AnsAuthenticationException on 401 for listIdentities")
+ void shouldThrowAuthExceptionForListIdentities(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v2/ans/identities"))
+ .willReturn(aResponse()
+ .withStatus(401)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"status\":\"error\",\"code\":\"UNAUTHORIZED\",\"message\":\"Invalid key\"}")));
+
+ IdentityClient client = client(baseUrl);
+
+ assertThatThrownBy(() -> client.listIdentities(null, null))
+ .isInstanceOf(AnsAuthenticationException.class)
+ .hasMessageContaining("Authentication failed");
+ }
+
+ // ==================== Get Identity Tests ====================
+
+ @Test
+ @DisplayName("Should get identity by ID successfully")
+ void shouldGetIdentitySuccessfully(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityDetailsResponse(IdentityLifecycleStatus.VERIFIED))));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityDetails result = client.getIdentity(TEST_IDENTITY_ID);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getIdentityId()).isEqualTo(TEST_IDENTITY_ID);
+ assertThat(result.getKind()).isEqualTo(IdentityDetails.KindEnum.DID_WEB);
+ assertThat(result.getValue()).isEqualTo(DID_WEB_IDENTITY);
+ assertThat(result.getStatus()).isEqualTo(IdentityLifecycleStatus.VERIFIED);
+ assertThat(result.getLinkedAgents()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("Should throw AnsNotFoundException when identity not found")
+ void shouldThrowNotFoundExceptionWhenIdentityNotFound(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID))
+ .willReturn(aResponse()
+ .withStatus(404)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"status\":\"error\",\"code\":\"NOT_FOUND\",\"message\":\"Identity not found\"}")));
+
+ IdentityClient client = client(baseUrl);
+
+ assertThatThrownBy(() -> client.getIdentity(TEST_IDENTITY_ID))
+ .isInstanceOf(AnsNotFoundException.class)
+ .hasMessageContaining("not found");
+ }
+
+ // ==================== Rotate Identity Tests ====================
+
+ @Test
+ @DisplayName("Should rotate identity and return a fresh challenge")
+ void shouldRotateIdentitySuccessfully(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(put(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID))
+ .willReturn(aResponse()
+ .withStatus(202)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityRegistrationResponse())));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityRegistrationRequest request = new IdentityRegistrationRequest().value(DID_WEB_IDENTITY);
+
+ IdentityChallengeResponse result = client.rotateIdentity(TEST_IDENTITY_ID, request);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getIdentityId()).isEqualTo(TEST_IDENTITY_ID);
+ assertThat(result.getNonce()).isEqualTo(TEST_NONCE);
+
+ verify(putRequestedFor(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID))
+ .withRequestBody(containing("\"value\":\"" + DID_WEB_IDENTITY + "\""))
+ .withHeader("Authorization", equalTo("sso-key " + TEST_API_KEY + ":" + TEST_API_KEY_SECRET)));
+ }
+
+ // ==================== Verify Control Tests ====================
+
+ @Test
+ @DisplayName("Should verify control with signed JWS proofs")
+ void shouldVerifyControlWithSignedProofs(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/verify-control"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityDetailsResponse(IdentityLifecycleStatus.VERIFIED))));
+
+ IdentityClient client = client(baseUrl);
+
+ VerifyControlRequest request = new VerifyControlRequest()
+ .signedProofs(List.of("eyJhbGciOiJFZERTQSJ9.payload.sig"));
+
+ IdentityDetails result = client.verifyControl(TEST_IDENTITY_ID, request);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getStatus()).isEqualTo(IdentityLifecycleStatus.VERIFIED);
+
+ verify(postRequestedFor(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/verify-control"))
+ .withRequestBody(containing("\"signedProofs\"")));
+ }
+
+ @Test
+ @DisplayName("Should verify control with a CESR signature")
+ void shouldVerifyControlWithCesrSignature(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/verify-control"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityDetailsResponse(IdentityLifecycleStatus.VERIFIED))));
+
+ IdentityClient client = client(baseUrl);
+
+ VerifyControlRequest request = new VerifyControlRequest().cesrSignature("AABxyzsignature");
+
+ IdentityDetails result = client.verifyControl(TEST_IDENTITY_ID, request);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getStatus()).isEqualTo(IdentityLifecycleStatus.VERIFIED);
+
+ verify(postRequestedFor(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/verify-control"))
+ .withRequestBody(containing("\"cesrSignature\":\"AABxyzsignature\"")));
+ }
+
+ @Test
+ @DisplayName("Should reject verify control carrying both proof families")
+ void shouldRejectVerifyControlWithBothProofFamilies() {
+ IdentityClient client = IdentityClient.builder()
+ .environment(Environment.OTE)
+ .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET))
+ .build();
+
+ VerifyControlRequest request = new VerifyControlRequest()
+ .signedProofs(List.of("jws-proof"))
+ .cesrSignature("cesr-sig");
+
+ assertThatThrownBy(() -> client.verifyControl(TEST_IDENTITY_ID, request))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("exactly one proof family");
+ }
+
+ @Test
+ @DisplayName("Should reject verify control carrying no proof family")
+ void shouldRejectVerifyControlWithNoProofFamily() {
+ IdentityClient client = IdentityClient.builder()
+ .environment(Environment.OTE)
+ .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET))
+ .build();
+
+ assertThatThrownBy(() -> client.verifyControl(TEST_IDENTITY_ID, new VerifyControlRequest()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("exactly one proof family");
+ }
+
+ // ==================== Revoke Identity Tests ====================
+
+ @Test
+ @DisplayName("Should revoke identity successfully")
+ void shouldRevokeIdentitySuccessfully(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/revoke"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityDetailsResponse(IdentityLifecycleStatus.REVOKED))));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityDetails result = client.revokeIdentity(TEST_IDENTITY_ID);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getStatus()).isEqualTo(IdentityLifecycleStatus.REVOKED);
+
+ verify(postRequestedFor(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/revoke")));
+ }
+
+ // ==================== Link Agents Tests ====================
+
+ @Test
+ @DisplayName("Should link agents successfully")
+ void shouldLinkAgentsSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/links"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"linked\":2}")));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityLinkRequest request = new IdentityLinkRequest()
+ .agentIds(List.of(UUID.fromString(TEST_AGENT_ID), UUID.fromString(TEST_OTHER_AGENT_ID)));
+
+ IdentityLinkResponse result = client.linkAgents(TEST_IDENTITY_ID, request);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getLinked()).isEqualTo(2);
+
+ verify(postRequestedFor(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/links"))
+ .withRequestBody(containing(TEST_AGENT_ID))
+ .withHeader("Authorization", equalTo("sso-key " + TEST_API_KEY + ":" + TEST_API_KEY_SECRET)));
+ }
+
+ @Test
+ @DisplayName("Should reject link request with an empty agent batch")
+ void shouldRejectEmptyLinkBatch() {
+ IdentityClient client = IdentityClient.builder()
+ .environment(Environment.OTE)
+ .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET))
+ .build();
+
+ assertThatThrownBy(() -> client.linkAgents(TEST_IDENTITY_ID, new IdentityLinkRequest()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("between 1 and 256");
+ }
+
+ @Test
+ @DisplayName("Should reject link request exceeding the 256 agent batch limit")
+ void shouldRejectOversizedLinkBatch() {
+ IdentityClient client = IdentityClient.builder()
+ .environment(Environment.OTE)
+ .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET))
+ .build();
+
+ List tooMany = java.util.stream.Stream.generate(UUID::randomUUID)
+ .limit(257)
+ .toList();
+
+ assertThatThrownBy(() -> client.linkAgents(TEST_IDENTITY_ID, new IdentityLinkRequest().agentIds(tooMany)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("between 1 and 256");
+ }
+
+ // ==================== Unlink Agent Tests ====================
+
+ @Test
+ @DisplayName("Should unlink an agent successfully")
+ void shouldUnlinkAgentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(delete(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/links/" + TEST_AGENT_ID))
+ .willReturn(aResponse().withStatus(204)));
+
+ IdentityClient client = client(baseUrl);
+
+ client.unlinkAgent(TEST_IDENTITY_ID, TEST_AGENT_ID);
+
+ verify(deleteRequestedFor(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/links/" + TEST_AGENT_ID))
+ .withHeader("Authorization", equalTo("sso-key " + TEST_API_KEY + ":" + TEST_API_KEY_SECRET)));
+ }
+
+ @Test
+ @DisplayName("Should throw AnsNotFoundException when unlinking a missing link")
+ void shouldThrowNotFoundExceptionForUnlink(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(delete(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/links/" + TEST_AGENT_ID))
+ .willReturn(aResponse()
+ .withStatus(404)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"status\":\"error\",\"code\":\"NOT_FOUND\",\"message\":\"Link not found\"}")));
+
+ IdentityClient client = client(baseUrl);
+
+ assertThatThrownBy(() -> client.unlinkAgent(TEST_IDENTITY_ID, TEST_AGENT_ID))
+ .isInstanceOf(AnsNotFoundException.class)
+ .hasMessageContaining("not found");
+ }
+
+ // ==================== Async Tests ====================
+
+ @Test
+ @DisplayName("Should register identity asynchronously")
+ void shouldRegisterIdentityAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities"))
+ .willReturn(aResponse()
+ .withStatus(202)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityRegistrationResponse())));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityChallengeResponse result = client.registerIdentityAsync(
+ new IdentityRegistrationRequest().value(TEST_IDENTITY_ID)).get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getIdentityId()).isEqualTo(TEST_IDENTITY_ID);
+ }
+
+ @Test
+ @DisplayName("Should list identities asynchronously")
+ void shouldListIdentitiesAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v2/ans/identities"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityListResponse())));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityListResponse result = client.listIdentitiesAsync(null, null).get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getItems()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("Should get identity asynchronously")
+ void shouldGetIdentityAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityDetailsResponse(IdentityLifecycleStatus.VERIFIED))));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityDetails result = client.getIdentityAsync(TEST_IDENTITY_ID).get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getIdentityId()).isEqualTo(TEST_IDENTITY_ID);
+ }
+
+ @Test
+ @DisplayName("Should rotate identity asynchronously")
+ void shouldRotateIdentityAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(put(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID))
+ .willReturn(aResponse()
+ .withStatus(202)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityRegistrationResponse())));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityChallengeResponse result = client.rotateIdentityAsync(TEST_IDENTITY_ID,
+ new IdentityRegistrationRequest().value(DID_WEB_IDENTITY)).get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getIdentityId()).isEqualTo(TEST_IDENTITY_ID);
+ }
+
+ @Test
+ @DisplayName("Should verify control asynchronously")
+ void shouldVerifyControlAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/verify-control"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityDetailsResponse(IdentityLifecycleStatus.VERIFIED))));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityDetails result = client.verifyControlAsync(TEST_IDENTITY_ID,
+ new VerifyControlRequest().cesrSignature("AABsig")).get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getStatus()).isEqualTo(IdentityLifecycleStatus.VERIFIED);
+ }
+
+ @Test
+ @DisplayName("Should revoke identity asynchronously")
+ void shouldRevokeIdentityAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/revoke"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityDetailsResponse(IdentityLifecycleStatus.REVOKED))));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityDetails result = client.revokeIdentityAsync(TEST_IDENTITY_ID).get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getStatus()).isEqualTo(IdentityLifecycleStatus.REVOKED);
+ }
+
+ @Test
+ @DisplayName("Should link agents asynchronously")
+ void shouldLinkAgentsAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(post(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/links"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"linked\":1}")));
+
+ IdentityClient client = client(baseUrl);
+
+ IdentityLinkResponse result = client.linkAgentsAsync(TEST_IDENTITY_ID,
+ new IdentityLinkRequest().agentIds(List.of(UUID.fromString(TEST_AGENT_ID)))).get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getLinked()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("Should unlink an agent asynchronously")
+ void shouldUnlinkAgentAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(delete(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/links/" + TEST_AGENT_ID))
+ .willReturn(aResponse().withStatus(204)));
+
+ IdentityClient client = client(baseUrl);
+
+ client.unlinkAgentAsync(TEST_IDENTITY_ID, TEST_AGENT_ID).get();
+
+ verify(deleteRequestedFor(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/links/" + TEST_AGENT_ID)));
+ }
+
+ // ==================== Helper Methods ====================
+
+ private IdentityClient client(String baseUrl) {
+ return IdentityClient.builder()
+ .environment(Environment.OTE)
+ .baseUrl(baseUrl)
+ .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET))
+ .build();
+ }
+
+ private String identityListResponse() {
+ return """
+ {
+ "items": [
+ {
+ "identityId": "550e8400-e29b-41d4-a716-446655440000",
+ "kind": "did:web",
+ "value": "did:web:identity.acme-corp.com",
+ "status": "VERIFIED",
+ "proofMethod": "did-web-sig",
+ "verifiedAt": "2024-01-15T12:00:00Z",
+ "createdAt": "2024-01-15T10:00:00Z"
+ }
+ ],
+ "returnedCount": 1,
+ "limit": 10,
+ "nextCursor": "page-3",
+ "hasMore": true
+ }
+ """;
+ }
+
+ private String identityDetailsResponse(IdentityLifecycleStatus status) {
+ return String.format("""
+ {
+ "identityId": "550e8400-e29b-41d4-a716-446655440000",
+ "kind": "did:web",
+ "value": "did:web:identity.acme-corp.com",
+ "status": "%s",
+ "proofMethod": "did-web-sig",
+ "verifiedAt": "2024-01-15T12:00:00Z",
+ "createdAt": "2024-01-15T10:00:00Z",
+ "linkedAgents": [
+ {
+ "agentId": "660e8400-e29b-41d4-a716-446655440111",
+ "linkedAt": "2024-01-15T11:00:00Z"
+ }
+ ]
+ }
+ """, status.getValue());
+ }
+
+ private String identityRegistrationResponse() {
+ return """
+ {
+ "identityId": "550e8400-e29b-41d4-a716-446655440000",
+ "kind": "did:web",
+ "value": "did:web:identity.acme-corp.com",
+ "status": "PENDING_CONTROL",
+ "nonce": "abc123",
+ "expiresAt": "2024-01-15T12:00:00Z",
+ "challenges": [
+ {
+ "kid": "#key-1",
+ "signingInput": "abc123"
+ }
+ ]
+ }
+ """;
+ }
+}
\ No newline at end of file
diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityPathsTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityPathsTest.java
new file mode 100644
index 0000000..7b752aa
--- /dev/null
+++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityPathsTest.java
@@ -0,0 +1,78 @@
+package com.godaddy.ans.sdk.registration;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link IdentityPaths}, the single source of RA Verified-Identity paths.
+ *
+ * Each method is pinned to an exact string, so a typo in a path constant fails
+ * here at the source, not as an opaque WireMock stub miss. Every identity path sits
+ * under {@code /v2/ans/identities}.
+ */
+class IdentityPathsTest {
+
+ private static final String IDENTITY_ID = "id-550e8400-e29b-41d4-a716-446655440000";
+ private static final String AGENT_ID = "550e8400-e29b-41d4-a716-446655440000";
+ private static final String COLLECTION = "/v2/ans/identities";
+
+ @Test
+ @DisplayName("identitiesCollectionPath returns the identities collection root")
+ void identitiesCollectionPath() {
+ assertThat(IdentityPaths.identitiesCollectionPath()).isEqualTo(COLLECTION);
+ }
+
+ @Test
+ @DisplayName("identityPath with no trailing segments returns collection/{identityId}")
+ void identityPathNoSegments() {
+ assertThat(IdentityPaths.identityPath(IDENTITY_ID)).isEqualTo(COLLECTION + "/" + IDENTITY_ID);
+ }
+
+ @Test
+ @DisplayName("identityPath appends trailing segments in order")
+ void identityPathMultipleSegments() {
+ assertThat(IdentityPaths.identityPath(IDENTITY_ID, "links", AGENT_ID))
+ .isEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links/" + AGENT_ID);
+ }
+
+ @Test
+ @DisplayName("verifyControlPath targets the verify-control sub-resource")
+ void verifyControlPath() {
+ assertThat(IdentityPaths.verifyControlPath(IDENTITY_ID))
+ .isEqualTo(COLLECTION + "/" + IDENTITY_ID + "/verify-control");
+ }
+
+ @Test
+ @DisplayName("revokePath targets the revoke sub-resource")
+ void revokePath() {
+ assertThat(IdentityPaths.revokePath(IDENTITY_ID))
+ .isEqualTo(COLLECTION + "/" + IDENTITY_ID + "/revoke");
+ }
+
+ @Test
+ @DisplayName("linksPath targets the links collection")
+ void linksPath() {
+ assertThat(IdentityPaths.linksPath(IDENTITY_ID))
+ .isEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links");
+ }
+
+ @Test
+ @DisplayName("linkPath targets a single identity-to-agent link")
+ void linkPath() {
+ assertThat(IdentityPaths.linkPath(IDENTITY_ID, AGENT_ID))
+ .isEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links/" + AGENT_ID);
+ }
+
+ @Test
+ @DisplayName("every identity path is rooted at /v2/ans/identities")
+ void allPathsRootedAtCollection() {
+ assertThat(IdentityPaths.identitiesCollectionPath()).startsWith(COLLECTION);
+ assertThat(IdentityPaths.identityPath(IDENTITY_ID)).startsWith(COLLECTION);
+ assertThat(IdentityPaths.verifyControlPath(IDENTITY_ID)).startsWith(COLLECTION);
+ assertThat(IdentityPaths.revokePath(IDENTITY_ID)).startsWith(COLLECTION);
+ assertThat(IdentityPaths.linksPath(IDENTITY_ID)).startsWith(COLLECTION);
+ assertThat(IdentityPaths.linkPath(IDENTITY_ID, AGENT_ID)).startsWith(COLLECTION);
+ }
+}
diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityServiceTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityServiceTest.java
new file mode 100644
index 0000000..f7b2ce7
--- /dev/null
+++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityServiceTest.java
@@ -0,0 +1,496 @@
+package com.godaddy.ans.sdk.registration;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import com.godaddy.ans.sdk.auth.ApiKeyCredentialsProvider;
+import com.godaddy.ans.sdk.config.AnsConfiguration;
+import com.godaddy.ans.sdk.config.Environment;
+import com.godaddy.ans.sdk.exception.AnsAuthenticationException;
+import com.godaddy.ans.sdk.exception.AnsNotFoundException;
+import com.godaddy.ans.sdk.exception.AnsServerException;
+import com.godaddy.ans.sdk.exception.AnsValidationException;
+import com.godaddy.ans.sdk.model.IdentityChallengeResponse;
+import com.godaddy.ans.sdk.model.IdentityDetails;
+import com.godaddy.ans.sdk.model.IdentityLinkRequest;
+import com.godaddy.ans.sdk.model.IdentityLinkResponse;
+import com.godaddy.ans.sdk.model.IdentityListResponse;
+import com.godaddy.ans.sdk.model.IdentityRegistrationRequest;
+import com.godaddy.ans.sdk.model.VerifyControlRequest;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.containing;
+import static com.github.tomakehurst.wiremock.client.WireMock.delete;
+import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.put;
+import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link IdentityService}, the eight RA Verified-Identity
+ * management operations.
+ *
+ * Paths are pinned by {@link IdentityPathsTest}; here the focus is the
+ * service behaviour: wire method + body, response parsing, the 202
+ * challenge-parse guards, and the two pre-flight validators
+ * ({@code verify-control} proof family and the {@code link} batch bound).
+ */
+@WireMockTest
+class IdentityServiceTest {
+
+ private static final String IDENTITY_ID = "id-550e8400-e29b-41d4-a716-446655440000";
+ private static final String AGENT_ID = "550e8400-e29b-41d4-a716-446655440000";
+ private static final String COLLECTION = "/v2/ans/identities";
+ private static final String API_KEY = "test-api-key";
+ private static final String API_SECRET = "test-api-secret";
+
+ private IdentityService createIdentityService(WireMockRuntimeInfo wmRuntimeInfo) {
+ AnsConfiguration config = AnsConfiguration.builder()
+ .environment(Environment.OTE)
+ .baseUrl(wmRuntimeInfo.getHttpBaseUrl())
+ .credentialsProvider(new ApiKeyCredentialsProvider(API_KEY, API_SECRET))
+ .build();
+ return new IdentityService(new AnsApiClient(config));
+ }
+
+ private String challengeBody() {
+ return """
+ {
+ "identityId": "%s",
+ "kind": "did:web",
+ "value": "did:web:example.com",
+ "status": "PENDING_CONTROL",
+ "nonce": "dGVzdC1ub25jZQ",
+ "expiresAt": "2026-01-01T00:00:00Z",
+ "challenges": []
+ }
+ """.formatted(IDENTITY_ID);
+ }
+
+ private String identityDetailsBody(String status) {
+ return """
+ {
+ "identityId": "%s",
+ "kind": "did:web",
+ "value": "did:web:example.com",
+ "status": "%s",
+ "proofMethod": "did-web-sig",
+ "createdAt": "2026-01-01T00:00:00Z",
+ "linkedAgents": []
+ }
+ """.formatted(IDENTITY_ID, status);
+ }
+
+ // ==================== register ====================
+
+ @Test
+ @DisplayName("register POSTs to the collection and returns the 202 challenge")
+ void registerReturnsChallenge(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(post(urlEqualTo(COLLECTION))
+ .willReturn(aResponse()
+ .withStatus(202)
+ .withHeader("Content-Type", "application/json")
+ .withBody(challengeBody())));
+
+ IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com");
+ IdentityChallengeResponse challenge = createIdentityService(wmRuntimeInfo).register(request);
+
+ assertThat(challenge.getIdentityId()).isEqualTo(IDENTITY_ID);
+ assertThat(challenge.getNonce()).isEqualTo("dGVzdC1ub25jZQ");
+ verify(postRequestedFor(urlEqualTo(COLLECTION))
+ .withHeader("Authorization", containing("sso-key"))
+ .withRequestBody(equalToJson("{\"value\":\"did:web:example.com\"}", true, true)));
+ }
+
+ @Test
+ @DisplayName("register throws AnsServerException when the challenge omits identityId")
+ void registerRejectsMissingIdentityId(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(post(urlEqualTo(COLLECTION))
+ .willReturn(aResponse()
+ .withStatus(202)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"nonce\": \"dGVzdC1ub25jZQ\"}")));
+
+ IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com");
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).register(request))
+ .isInstanceOf(AnsServerException.class)
+ .hasMessageContaining("identityId");
+ }
+
+ @Test
+ @DisplayName("register throws AnsServerException when the challenge omits nonce")
+ void registerRejectsMissingNonce(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(post(urlEqualTo(COLLECTION))
+ .willReturn(aResponse()
+ .withStatus(202)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"identityId\": \"" + IDENTITY_ID + "\"}")));
+
+ IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com");
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).register(request))
+ .isInstanceOf(AnsServerException.class)
+ .hasMessageContaining("nonce");
+ }
+
+ @Test
+ @DisplayName("register throws AnsValidationException on 422")
+ void registerThrowsValidationOn422(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(post(urlEqualTo(COLLECTION))
+ .willReturn(aResponse()
+ .withStatus(422)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"message\": \"unrecognized identifier form\"}")));
+
+ IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("not-an-identifier");
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).register(request))
+ .isInstanceOf(AnsValidationException.class);
+ }
+
+ // ==================== list ====================
+
+ @Test
+ @DisplayName("list with no paging arguments GETs the bare collection")
+ void listNoArguments(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(get(urlEqualTo(COLLECTION))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("""
+ {"items": [], "returnedCount": 0, "limit": 100, "hasMore": false}
+ """)));
+
+ IdentityListResponse page = createIdentityService(wmRuntimeInfo).list(null, null);
+
+ assertThat(page.getItems()).isEmpty();
+ assertThat(page.getReturnedCount()).isZero();
+ assertThat(page.getHasMore()).isFalse();
+ verify(getRequestedFor(urlEqualTo(COLLECTION)));
+ }
+
+ @Test
+ @DisplayName("list carries limit and URL-encoded cursor into the query string")
+ void listWithLimitAndCursor(WireMockRuntimeInfo wmRuntimeInfo) {
+ String path = COLLECTION + "?limit=25&cursor=next%2Fpage";
+ stubFor(get(urlEqualTo(path))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("""
+ {"items": [], "returnedCount": 0, "limit": 25, "nextCursor": null, "hasMore": true}
+ """)));
+
+ IdentityListResponse page = createIdentityService(wmRuntimeInfo).list(25, "next/page");
+
+ assertThat(page.getLimit()).isEqualTo(25);
+ assertThat(page.getHasMore()).isTrue();
+ verify(getRequestedFor(urlEqualTo(path)));
+ }
+
+ // ==================== getDetails ====================
+
+ @Test
+ @DisplayName("getDetails GETs the identity resource and returns its details")
+ void getDetailsReturnsDetails(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(get(urlEqualTo(COLLECTION + "/" + IDENTITY_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityDetailsBody("VERIFIED"))));
+
+ IdentityDetails details = createIdentityService(wmRuntimeInfo).getDetails(IDENTITY_ID);
+
+ assertThat(details.getIdentityId()).isEqualTo(IDENTITY_ID);
+ assertThat(details.getValue()).isEqualTo("did:web:example.com");
+ verify(getRequestedFor(urlEqualTo(COLLECTION + "/" + IDENTITY_ID)));
+ }
+
+ @Test
+ @DisplayName("getDetails throws AnsNotFoundException on 404")
+ void getDetailsThrowsNotFoundOn404(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(get(urlEqualTo(COLLECTION + "/" + IDENTITY_ID))
+ .willReturn(aResponse()
+ .withStatus(404)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"message\": \"identity not found\"}")));
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).getDetails(IDENTITY_ID))
+ .isInstanceOf(AnsNotFoundException.class);
+ }
+
+ // ==================== rotate ====================
+
+ @Test
+ @DisplayName("rotate PUTs to the identity resource and returns a fresh challenge")
+ void rotateReturnsChallenge(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(put(urlEqualTo(COLLECTION + "/" + IDENTITY_ID))
+ .willReturn(aResponse()
+ .withStatus(202)
+ .withHeader("Content-Type", "application/json")
+ .withBody(challengeBody())));
+
+ IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com");
+ IdentityChallengeResponse challenge = createIdentityService(wmRuntimeInfo).rotate(IDENTITY_ID, request);
+
+ assertThat(challenge.getIdentityId()).isEqualTo(IDENTITY_ID);
+ verify(putRequestedFor(urlEqualTo(COLLECTION + "/" + IDENTITY_ID))
+ .withRequestBody(equalToJson("{\"value\":\"did:web:example.com\"}", true, true)));
+ }
+
+ @Test
+ @DisplayName("rotate applies the same challenge-parse guard as register")
+ void rotateRejectsMissingNonce(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(put(urlEqualTo(COLLECTION + "/" + IDENTITY_ID))
+ .willReturn(aResponse()
+ .withStatus(202)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"identityId\": \"" + IDENTITY_ID + "\"}")));
+
+ IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com");
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).rotate(IDENTITY_ID, request))
+ .isInstanceOf(AnsServerException.class)
+ .hasMessageContaining("nonce");
+ }
+
+ // ==================== verifyControl ====================
+
+ @Test
+ @DisplayName("verifyControl accepts a JWS-only request and POSTs to verify-control")
+ void verifyControlWithJws(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(post(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/verify-control"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityDetailsBody("VERIFIED"))));
+
+ VerifyControlRequest request = new VerifyControlRequest().addSignedProofsItem("eyJhbGciOiJFZERTQSJ9..sig");
+ IdentityDetails details = createIdentityService(wmRuntimeInfo).verifyControl(IDENTITY_ID, request);
+
+ assertThat(details.getStatus()).hasToString("VERIFIED");
+ verify(postRequestedFor(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/verify-control")));
+ }
+
+ @Test
+ @DisplayName("verifyControl accepts a CESR-only request")
+ void verifyControlWithCesr(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(post(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/verify-control"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityDetailsBody("VERIFIED"))));
+
+ VerifyControlRequest request = new VerifyControlRequest().signedProofs(null).cesrSignature("AABcesr...");
+ IdentityDetails details = createIdentityService(wmRuntimeInfo).verifyControl(IDENTITY_ID, request);
+
+ assertThat(details.getIdentityId()).isEqualTo(IDENTITY_ID);
+ }
+
+ @Test
+ @DisplayName("verifyControl rejects a request carrying both proof families")
+ void verifyControlRejectsBothFamilies(WireMockRuntimeInfo wmRuntimeInfo) {
+ VerifyControlRequest request = new VerifyControlRequest()
+ .addSignedProofsItem("eyJhbGciOiJFZERTQSJ9..sig")
+ .cesrSignature("AABcesr...");
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).verifyControl(IDENTITY_ID, request))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("exactly one proof family");
+ }
+
+ @Test
+ @DisplayName("verifyControl rejects a request with neither proof family")
+ void verifyControlRejectsNeitherFamily(WireMockRuntimeInfo wmRuntimeInfo) {
+ IdentityService service = createIdentityService(wmRuntimeInfo);
+
+ assertThatThrownBy(() -> service.verifyControl(IDENTITY_ID, new VerifyControlRequest()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("exactly one proof family");
+ }
+
+ @Test
+ @DisplayName("verifyControl treats an empty signedProofs list as absent")
+ void verifyControlRejectsEmptySignedProofs(WireMockRuntimeInfo wmRuntimeInfo) {
+ VerifyControlRequest request = new VerifyControlRequest().signedProofs(new ArrayList<>());
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).verifyControl(IDENTITY_ID, request))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("exactly one proof family");
+ }
+
+ @Test
+ @DisplayName("verifyControl treats a blank cesrSignature as absent")
+ void verifyControlRejectsBlankCesr(WireMockRuntimeInfo wmRuntimeInfo) {
+ VerifyControlRequest request = new VerifyControlRequest().cesrSignature("");
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).verifyControl(IDENTITY_ID, request))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("exactly one proof family");
+ }
+
+ // ==================== revoke ====================
+
+ @Test
+ @DisplayName("revoke POSTs an empty body to revoke and returns the updated details")
+ void revokeReturnsDetails(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(post(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/revoke"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(identityDetailsBody("REVOKED"))));
+
+ IdentityDetails details = createIdentityService(wmRuntimeInfo).revoke(IDENTITY_ID);
+
+ assertThat(details.getStatus()).hasToString("REVOKED");
+ verify(postRequestedFor(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/revoke")));
+ }
+
+ // ==================== link ====================
+
+ @Test
+ @DisplayName("link POSTs the batch to links and returns the linked count")
+ void linkReturnsCount(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(post(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"linked\": 2}")));
+
+ IdentityLinkRequest request = new IdentityLinkRequest()
+ .addAgentIdsItem(UUID.fromString(AGENT_ID))
+ .addAgentIdsItem(UUID.randomUUID());
+ IdentityLinkResponse response = createIdentityService(wmRuntimeInfo).link(IDENTITY_ID, request);
+
+ assertThat(response.getLinked()).isEqualTo(2);
+ verify(postRequestedFor(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links")));
+ }
+
+ @Test
+ @DisplayName("link rejects an empty batch")
+ void linkRejectsEmptyBatch(WireMockRuntimeInfo wmRuntimeInfo) {
+ IdentityLinkRequest request = new IdentityLinkRequest().agentIds(new ArrayList<>());
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).link(IDENTITY_ID, request))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("between 1 and 256");
+ }
+
+ @Test
+ @DisplayName("link rejects a null agentIds list")
+ void linkRejectsNullBatch(WireMockRuntimeInfo wmRuntimeInfo) {
+ IdentityLinkRequest request = new IdentityLinkRequest();
+ request.setAgentIds(null);
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).link(IDENTITY_ID, request))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("between 1 and 256");
+ }
+
+ @Test
+ @DisplayName("link rejects a batch larger than 256 agents")
+ void linkRejectsOversizedBatch(WireMockRuntimeInfo wmRuntimeInfo) {
+ List agentIds = new ArrayList<>();
+ for (int i = 0; i < 257; i++) {
+ agentIds.add(UUID.randomUUID());
+ }
+ IdentityLinkRequest request = new IdentityLinkRequest().agentIds(agentIds);
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).link(IDENTITY_ID, request))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("got 257");
+ }
+
+ @Test
+ @DisplayName("link accepts the single-agent lower boundary")
+ void linkAcceptsSingleAgent(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(post(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"linked\": 1}")));
+
+ IdentityLinkRequest request = new IdentityLinkRequest().addAgentIdsItem(UUID.fromString(AGENT_ID));
+ IdentityLinkResponse response = createIdentityService(wmRuntimeInfo).link(IDENTITY_ID, request);
+
+ assertThat(response.getLinked()).isEqualTo(1);
+ verify(postRequestedFor(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links")));
+ }
+
+ @Test
+ @DisplayName("link accepts the 256-agent boundary")
+ void linkAcceptsMaxBatch(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(post(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"linked\": 256}")));
+
+ List agentIds = new ArrayList<>();
+ for (int i = 0; i < 256; i++) {
+ agentIds.add(UUID.randomUUID());
+ }
+ IdentityLinkRequest request = new IdentityLinkRequest().agentIds(agentIds);
+
+ IdentityLinkResponse response = createIdentityService(wmRuntimeInfo).link(IDENTITY_ID, request);
+
+ assertThat(response.getLinked()).isEqualTo(256);
+ }
+
+ // ==================== unlink ====================
+
+ @Test
+ @DisplayName("unlink DELETEs the single link and parses no body on 204")
+ void unlinkDeletesLink(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(delete(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links/" + AGENT_ID))
+ .willReturn(aResponse().withStatus(204)));
+
+ createIdentityService(wmRuntimeInfo).unlink(IDENTITY_ID, AGENT_ID);
+
+ verify(deleteRequestedFor(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links/" + AGENT_ID))
+ .withHeader("Authorization", containing("sso-key")));
+ }
+
+ @Test
+ @DisplayName("unlink surfaces AnsNotFoundException on 404")
+ void unlinkThrowsNotFoundOn404(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(delete(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links/" + AGENT_ID))
+ .willReturn(aResponse()
+ .withStatus(404)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"message\": \"link not found\"}")));
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).unlink(IDENTITY_ID, AGENT_ID))
+ .isInstanceOf(AnsNotFoundException.class);
+ }
+
+ // ==================== shared error mapping ====================
+
+ @Test
+ @DisplayName("a 401 from any operation surfaces as AnsAuthenticationException")
+ void authenticationErrorSurfaces(WireMockRuntimeInfo wmRuntimeInfo) {
+ stubFor(get(urlEqualTo(COLLECTION + "/" + IDENTITY_ID))
+ .willReturn(aResponse()
+ .withStatus(401)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"message\": \"invalid credentials\"}")));
+
+ assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).getDetails(IDENTITY_ID))
+ .isInstanceOf(AnsAuthenticationException.class);
+ }
+}
diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/RegistrationClientTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/RegistrationClientTest.java
index 1ea8ce2..9af37cb 100644
--- a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/RegistrationClientTest.java
+++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/RegistrationClientTest.java
@@ -18,6 +18,7 @@
import com.godaddy.ans.sdk.model.AgentRevocationResponse;
import com.godaddy.ans.sdk.model.AgentStatus;
import com.godaddy.ans.sdk.model.DiscoveryProfile;
+import com.godaddy.ans.sdk.model.LinkedIdentity;
import com.godaddy.ans.sdk.model.Protocol;
import com.godaddy.ans.sdk.model.RegistrationPending;
import com.godaddy.ans.sdk.model.RevocationReason;
@@ -549,6 +550,59 @@ void shouldGetAgentByIdSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) {
assertThat(result.getAnsName()).isEqualTo("ans://v1.0.0.test-agent.example.com");
}
+ @Test
+ @DisplayName("getAgent with identities[] absent yields an empty list, never null")
+ void getAgentAbsentIdentitiesYieldsEmptyList(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ // agentDetailsResponse() carries no identities field.
+ stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(agentDetailsResponse())));
+
+ RegistrationClient client = RegistrationClient.builder()
+ .environment(Environment.OTE)
+ .baseUrl(baseUrl)
+ .credentialsProvider(new JwtCredentialsProvider(TEST_JWT_TOKEN))
+ .build();
+
+ AgentDetails result = client.getAgent(TEST_AGENT_ID);
+
+ assertThat(result.getIdentities()).isNotNull().isEmpty();
+ }
+
+ @Test
+ @DisplayName("getAgent surfaces the computed identities[] join when present")
+ void getAgentWithIdentitiesRoundTrips(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(agentDetailsWithIdentitiesResponse())));
+
+ RegistrationClient client = RegistrationClient.builder()
+ .environment(Environment.OTE)
+ .baseUrl(baseUrl)
+ .credentialsProvider(new JwtCredentialsProvider(TEST_JWT_TOKEN))
+ .build();
+
+ AgentDetails result = client.getAgent(TEST_AGENT_ID);
+
+ assertThat(result.getIdentities()).hasSize(2);
+ LinkedIdentity first = result.getIdentities().get(0);
+ assertThat(first.getIdentityId()).isEqualTo("id-web-1");
+ assertThat(first.getKind()).isEqualTo(LinkedIdentity.KindEnum.DID_WEB);
+ assertThat(first.getValue()).isEqualTo("did:web:example.com");
+ assertThat(first.getIdentityStatus()).isEqualTo(LinkedIdentity.IdentityStatusEnum.VERIFIED);
+ LinkedIdentity second = result.getIdentities().get(1);
+ assertThat(second.getKind()).isEqualTo(LinkedIdentity.KindEnum.LEI);
+ assertThat(second.getIdentityStatus()).isEqualTo(LinkedIdentity.IdentityStatusEnum.REVOKED);
+ }
+
@Test
@DisplayName("Should throw AnsNotFoundException when agent not found")
void shouldThrowNotFoundExceptionWhenAgentNotFound(WireMockRuntimeInfo wmRuntimeInfo) {
@@ -835,7 +889,7 @@ void shouldThrowWhenRegistrationHasNullLinks(WireMockRuntimeInfo wmRuntimeInfo)
void shouldThrowWhenV2RegistrationMissingAgentId(WireMockRuntimeInfo wmRuntimeInfo) {
String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
- // v2 resolves via agentId, not the self link; a response without it is a server error.
+ // v2 resolves via agentId, not the self link. A response without it is a server error.
stubFor(post(urlEqualTo("/v2/ans/agents"))
.willReturn(aResponse()
.withStatus(202)
@@ -962,6 +1016,41 @@ private String agentDetailsResponse() {
""";
}
+ private String agentDetailsWithIdentitiesResponse() {
+ return """
+ {
+ "agentId": "550e8400-e29b-41d4-a716-446655440000",
+ "agentDisplayName": "Test Agent",
+ "version": "1.0.0",
+ "agentHost": "test-agent.example.com",
+ "ansName": "ans://v1.0.0.test-agent.example.com",
+ "agentStatus": "ACTIVE",
+ "endpoints": [
+ {
+ "protocol": "A2A",
+ "agentUrl": "https://test-agent.example.com/a2a"
+ }
+ ],
+ "links": [],
+ "identities": [
+ {
+ "identityId": "id-web-1",
+ "kind": "did:web",
+ "value": "did:web:example.com",
+ "identityStatus": "VERIFIED",
+ "linkedAt": "2026-01-01T00:00:00Z"
+ },
+ {
+ "identityId": "id-lei-1",
+ "kind": "lei",
+ "value": "5493001KJTIIGC8Y1R12",
+ "identityStatus": "REVOKED"
+ }
+ ]
+ }
+ """;
+ }
+
private String agentStatusResponse(AgentLifecycleStatus status) {
return String.format("""
{
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TlLeafUncommittedException.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TlLeafUncommittedException.java
new file mode 100644
index 0000000..4060297
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TlLeafUncommittedException.java
@@ -0,0 +1,43 @@
+package com.godaddy.ans.sdk.transparency;
+
+import com.godaddy.ans.sdk.exception.AnsServerException;
+
+/**
+ * Thrown when a SCITT receipt is not yet available because the leaf is committed but no signed
+ * checkpoint covers it yet ({@code 503 TL_LEAF_UNCOMMITTED}).
+ *
+ * This is a transient, retryable condition, not a hard error. The caller retries the receipt
+ * read after the delay in {@link #getRetryAfterSeconds()} (the server's {@code Retry-After}
+ * header). {@link #isRetryable()} returns {@code true}.
+ */
+public class TlLeafUncommittedException extends AnsServerException {
+
+ /** The stable error code the transparency log returns for this condition. */
+ public static final String ERROR_CODE = "TL_LEAF_UNCOMMITTED";
+
+ /** The HTTP status this condition always carries. Shared with the response mapper in the same package. */
+ static final int STATUS_SERVICE_UNAVAILABLE = 503;
+
+ private final int retryAfterSeconds;
+
+ /**
+ * Creates a new exception for an uncommitted-leaf receipt read.
+ *
+ * @param message the error message
+ * @param retryAfterSeconds the retry delay from the {@code Retry-After} header, or 0 if absent
+ * @param requestId the request ID from the server response, may be null
+ */
+ public TlLeafUncommittedException(String message, int retryAfterSeconds, String requestId) {
+ super(message, STATUS_SERVICE_UNAVAILABLE, requestId);
+ this.retryAfterSeconds = retryAfterSeconds;
+ }
+
+ /**
+ * Returns the retry delay in seconds from the server's {@code Retry-After} header.
+ *
+ * @return the retry delay in seconds, or 0 if the server did not provide one
+ */
+ public int getRetryAfterSeconds() {
+ return retryAfterSeconds;
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyClient.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyClient.java
index 63dbb4e..db2dd83 100644
--- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyClient.java
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyClient.java
@@ -2,9 +2,11 @@
import com.godaddy.ans.sdk.concurrent.AnsExecutors;
import com.godaddy.ans.sdk.transparency.model.AgentAuditParams;
+import com.godaddy.ans.sdk.transparency.model.AgentIdentitiesResponse;
import com.godaddy.ans.sdk.transparency.model.CheckpointHistoryParams;
import com.godaddy.ans.sdk.transparency.model.CheckpointHistoryResponse;
import com.godaddy.ans.sdk.transparency.model.CheckpointResponse;
+import com.godaddy.ans.sdk.transparency.model.IdentityLinkedAgentsResponse;
import com.godaddy.ans.sdk.transparency.model.TransparencyLog;
import com.godaddy.ans.sdk.transparency.model.TransparencyLogAudit;
import com.godaddy.ans.sdk.transparency.scitt.RefreshDecision;
@@ -218,6 +220,138 @@ public byte[] getStatusToken(String agentId) {
return service.getStatusToken(agentId);
}
+ // ==================== Verified-Identity Reads (Sync) ====================
+
+ /**
+ * Retrieves the identity badge for a verified identity.
+ *
+ * The badge is the latest sealed identity event plus its computed status
+ * ({@code VERIFIED} or {@code REVOKED}), served in the same shape as an agent
+ * transparency log entry. Read the status with {@link TransparencyLog#getStatus()}.
+ *
+ * @param identityId the identity's unique identifier
+ * @return the identity badge
+ * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found
+ */
+ public TransparencyLog getIdentityBadge(String identityId) {
+ return service.getIdentityBadge(identityId);
+ }
+
+ /**
+ * Retrieves a paginated list of transparency log records for an identity.
+ *
+ * @param identityId the identity's unique identifier
+ * @param params optional pagination parameters (limit, offset)
+ * @return the audit records
+ * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found
+ */
+ public TransparencyLogAudit getIdentityAudit(String identityId, AgentAuditParams params) {
+ return service.getIdentityAudit(identityId, params);
+ }
+
+ /**
+ * Retrieves all transparency log records for an identity.
+ *
+ * @param identityId the identity's unique identifier
+ * @return the audit records
+ */
+ public TransparencyLogAudit getIdentityAudit(String identityId) {
+ return getIdentityAudit(identityId, null);
+ }
+
+ /**
+ * Retrieves the SCITT receipt for an identity's latest sealed event.
+ *
+ * A {@code 503 TL_LEAF_UNCOMMITTED} response is a transient, retryable condition.
+ * The SDK surfaces it as {@link TlLeafUncommittedException} carrying the server's
+ * {@code Retry-After} delay, not as a hard error.
+ *
+ * @param identityId the identity's unique identifier
+ * @return the raw receipt bytes (COSE_Sign1)
+ * @throws TlLeafUncommittedException if the receipt is not yet available (retryable)
+ * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found
+ */
+ public byte[] getIdentityReceipt(String identityId) {
+ return service.getIdentityReceipt(identityId);
+ }
+
+ /**
+ * Retrieves the reverse join for an identity: the agents it currently links to.
+ *
+ * Each agent carries its own computed badge status, so a reader checks both ends of the
+ * link in one response. The result is paginated. Use {@link IdentityLinkedAgentsResponse#getTotal()}
+ * for the full count before pagination.
+ *
+ * @param identityId the identity's unique identifier
+ * @param params optional pagination parameters (limit, offset)
+ * @return the linked agents plus the full count
+ * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found
+ */
+ public IdentityLinkedAgentsResponse getIdentityLinkedAgents(String identityId, AgentAuditParams params) {
+ return service.getIdentityLinkedAgents(identityId, params);
+ }
+
+ /**
+ * Retrieves all agents an identity currently links to.
+ *
+ * @param identityId the identity's unique identifier
+ * @return the linked agents plus the full count
+ */
+ public IdentityLinkedAgentsResponse getIdentityLinkedAgents(String identityId) {
+ return getIdentityLinkedAgents(identityId, null);
+ }
+
+ /**
+ * Retrieves the forward join for an agent: the identities it currently links to.
+ *
+ * This is the overflow read target for the agent badge, which caps its inline
+ * {@code identities[]} at 25 entries. Use {@link AgentIdentitiesResponse#getTotal()} for the
+ * full count before pagination.
+ *
+ * @param agentId the agent's unique identifier
+ * @param params optional pagination parameters (limit, offset)
+ * @return the linked identities plus the full count
+ * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the agent is not found
+ */
+ public AgentIdentitiesResponse getAgentIdentities(String agentId, AgentAuditParams params) {
+ return service.getAgentIdentities(agentId, params);
+ }
+
+ /**
+ * Retrieves all identities an agent currently links to.
+ *
+ * @param agentId the agent's unique identifier
+ * @return the linked identities plus the full count
+ */
+ public AgentIdentitiesResponse getAgentIdentities(String agentId) {
+ return getAgentIdentities(agentId, null);
+ }
+
+ /**
+ * Retrieves the identity link history for an agent.
+ *
+ * This is the audit trail of link and unlink events for the agent, in the same
+ * {@code {records}} envelope as the agent and identity audit trails.
+ *
+ * @param agentId the agent's unique identifier
+ * @param params optional pagination parameters (limit, offset)
+ * @return the history records
+ * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the agent is not found
+ */
+ public TransparencyLogAudit getAgentIdentityHistory(String agentId, AgentAuditParams params) {
+ return service.getAgentIdentityHistory(agentId, params);
+ }
+
+ /**
+ * Retrieves the full identity link history for an agent.
+ *
+ * @param agentId the agent's unique identifier
+ * @return the history records
+ */
+ public TransparencyLogAudit getAgentIdentityHistory(String agentId) {
+ return getAgentIdentityHistory(agentId, null);
+ }
+
/**
* Invalidates the cached root public keys.
*
@@ -336,6 +470,21 @@ public CompletableFuture getStatusTokenAsync(String agentId) {
return service.getStatusTokenAsync(agentId);
}
+ /**
+ * Retrieves the SCITT receipt for an identity's latest sealed event asynchronously.
+ *
+ * This method uses non-blocking I/O and does not occupy a thread pool
+ * thread during the HTTP request. As with the sync variant, a
+ * {@code 503 TL_LEAF_UNCOMMITTED} response completes the future exceptionally
+ * with a {@link TlLeafUncommittedException} carrying the {@code Retry-After} delay.
+ *
+ * @param identityId the identity's unique identifier
+ * @return a CompletableFuture with the raw receipt bytes (COSE_Sign1)
+ */
+ public CompletableFuture getIdentityReceiptAsync(String identityId) {
+ return service.getIdentityReceiptAsync(identityId);
+ }
+
/**
* Retrieves the SCITT root public keys asynchronously.
*
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyService.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyService.java
index 49d363d..4c7e698 100644
--- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyService.java
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyService.java
@@ -1,18 +1,22 @@
package com.godaddy.ans.sdk.transparency;
import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.godaddy.ans.sdk.exception.AnsNotFoundException;
import com.godaddy.ans.sdk.exception.AnsServerException;
import com.godaddy.ans.sdk.transparency.model.AgentAuditParams;
+import com.godaddy.ans.sdk.transparency.model.AgentIdentitiesResponse;
import com.godaddy.ans.sdk.transparency.model.CheckpointHistoryParams;
import com.godaddy.ans.sdk.transparency.model.CheckpointHistoryResponse;
import com.godaddy.ans.sdk.transparency.model.CheckpointResponse;
+import com.godaddy.ans.sdk.transparency.model.IdentityLinkedAgentsResponse;
import com.godaddy.ans.sdk.transparency.model.TransparencyLog;
import com.godaddy.ans.sdk.transparency.model.TransparencyLogAudit;
import com.godaddy.ans.sdk.transparency.model.TransparencyLogV0;
import com.godaddy.ans.sdk.transparency.model.TransparencyLogV1;
+import com.godaddy.ans.sdk.transparency.model.TransparencyLogV2;
import com.godaddy.ans.sdk.transparency.scitt.RefreshDecision;
import org.slf4j.Logger;
@@ -26,6 +30,7 @@
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.PublicKey;
+import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
@@ -97,6 +102,54 @@ TransparencyLogAudit getAgentTransparencyLogAudit(String agentId, AgentAuditPara
}
}
+ /**
+ * Gets the forward join: the identities an agent currently links to.
+ *
+ * @param agentId the agent's unique identifier
+ * @param params optional pagination parameters (limit, offset)
+ * @return the linked identities plus the full count before pagination
+ */
+ AgentIdentitiesResponse getAgentIdentities(String agentId, AgentAuditParams params) {
+ String path = "/v1/agents/" + URLEncoder.encode(agentId, StandardCharsets.UTF_8) + "/identities";
+ if (params != null) {
+ path = appendAuditParams(path, params);
+ }
+
+ HttpRequest request = createRequestBuilder(path).GET().build();
+ HttpResponse response = sendRequest(request);
+
+ try {
+ return objectMapper.readValue(response.body(), AgentIdentitiesResponse.class);
+ } catch (IOException e) {
+ throw new AnsServerException(
+ "Failed to parse agent identities response: " + e.getMessage(), 0, e, null);
+ }
+ }
+
+ /**
+ * Gets the agent's identity link history in the standard audit envelope.
+ *
+ * @param agentId the agent's unique identifier
+ * @param params optional pagination parameters (limit, offset)
+ * @return the history records
+ */
+ TransparencyLogAudit getAgentIdentityHistory(String agentId, AgentAuditParams params) {
+ String path = "/v1/agents/" + URLEncoder.encode(agentId, StandardCharsets.UTF_8) + "/identities/history";
+ if (params != null) {
+ path = appendAuditParams(path, params);
+ }
+
+ HttpRequest request = createRequestBuilder(path).GET().build();
+ HttpResponse response = sendRequest(request);
+
+ try {
+ return objectMapper.readValue(response.body(), TransparencyLogAudit.class);
+ } catch (IOException e) {
+ throw new AnsServerException(
+ "Failed to parse agent identity history response: " + e.getMessage(), 0, e, null);
+ }
+ }
+
/**
* Gets the current checkpoint.
*/
@@ -191,6 +244,187 @@ CompletableFuture getStatusTokenAsync(String agentId) {
return fetchBinaryResponseAsync(path, "application/ans-status-token+cbor");
}
+ // ==================== Verified-Identity Reads ====================
+
+ /**
+ * Gets the identity badge: the latest sealed identity event plus its computed status.
+ *
+ * The response is the same shape as an agent transparency log entry, but the payload is an
+ * identity event. This method does not run the agent V0/V1 payload parser, so the identity
+ * event stays in the raw {@link TransparencyLog#getPayload()} map.
+ *
+ * @param identityId the identity's unique identifier
+ * @return the identity badge
+ */
+ TransparencyLog getIdentityBadge(String identityId) {
+ String path = "/v1/identities/" + URLEncoder.encode(identityId, StandardCharsets.UTF_8);
+ HttpRequest request = createRequestBuilder(path).GET().build();
+ HttpResponse response = sendRequest(request);
+
+ try {
+ return objectMapper.readValue(response.body(), TransparencyLog.class);
+ } catch (IOException e) {
+ throw new AnsServerException("Failed to parse identity badge response: " + e.getMessage(), 0, e, null);
+ }
+ }
+
+ /**
+ * Gets the identity's full event chain in the standard audit envelope.
+ *
+ * @param identityId the identity's unique identifier
+ * @param params optional pagination parameters (limit, offset)
+ * @return the audit records
+ */
+ TransparencyLogAudit getIdentityAudit(String identityId, AgentAuditParams params) {
+ String path = "/v1/identities/" + URLEncoder.encode(identityId, StandardCharsets.UTF_8) + "/audit";
+ if (params != null) {
+ path = appendAuditParams(path, params);
+ }
+
+ HttpRequest request = createRequestBuilder(path).GET().build();
+ HttpResponse response = sendRequest(request);
+
+ try {
+ return objectMapper.readValue(response.body(), TransparencyLogAudit.class);
+ } catch (IOException e) {
+ throw new AnsServerException("Failed to parse identity audit response: " + e.getMessage(), 0, e, null);
+ }
+ }
+
+ /**
+ * Gets the reverse join: the agents this identity currently links to, each with its own
+ * computed badge status.
+ *
+ * @param identityId the identity's unique identifier
+ * @param params optional pagination parameters (limit, offset)
+ * @return the linked agents plus the full count before pagination
+ */
+ IdentityLinkedAgentsResponse getIdentityLinkedAgents(String identityId, AgentAuditParams params) {
+ String path = "/v1/identities/" + URLEncoder.encode(identityId, StandardCharsets.UTF_8) + "/agents";
+ if (params != null) {
+ path = appendAuditParams(path, params);
+ }
+
+ HttpRequest request = createRequestBuilder(path).GET().build();
+ HttpResponse response = sendRequest(request);
+
+ try {
+ return objectMapper.readValue(response.body(), IdentityLinkedAgentsResponse.class);
+ } catch (IOException e) {
+ throw new AnsServerException(
+ "Failed to parse identity linked-agents response: " + e.getMessage(), 0, e, null);
+ }
+ }
+
+ /**
+ * Gets the SCITT receipt for the identity's latest sealed event.
+ *
+ * A {@code 503 TL_LEAF_UNCOMMITTED} response means the leaf is committed but no signed
+ * checkpoint covers it yet. That is a retryable condition. The SDK surfaces it as
+ * {@link TlLeafUncommittedException} carrying the server's {@code Retry-After} delay.
+ *
+ * @param identityId the identity's unique identifier
+ * @return the raw receipt bytes (COSE_Sign1)
+ * @throws TlLeafUncommittedException if the receipt is not yet available (retryable)
+ */
+ byte[] getIdentityReceipt(String identityId) {
+ String path = "/v1/identities/" + URLEncoder.encode(identityId, StandardCharsets.UTF_8) + "/receipt";
+ HttpRequest request = buildBinaryRequest(path, "application/scitt-receipt+cose");
+
+ try {
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
+ return handleIdentityReceiptResponse(response);
+ } catch (IOException e) {
+ throw new AnsServerException("Network error: " + e.getMessage(), 0, e, null);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AnsServerException("Request interrupted", 0, e, null);
+ }
+ }
+
+ /**
+ * Gets the SCITT receipt for the identity's latest sealed event asynchronously.
+ *
+ * If the receipt is not yet available, the returned future completes exceptionally with a
+ * retryable {@link TlLeafUncommittedException}.
+ *
+ * @param identityId the identity's unique identifier
+ * @return a CompletableFuture with the raw receipt bytes (COSE_Sign1)
+ */
+ CompletableFuture getIdentityReceiptAsync(String identityId) {
+ String path = "/v1/identities/" + URLEncoder.encode(identityId, StandardCharsets.UTF_8) + "/receipt";
+ HttpRequest request = buildBinaryRequest(path, "application/scitt-receipt+cose");
+
+ return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray())
+ .thenApply(this::handleIdentityReceiptResponse);
+ }
+
+ /**
+ * Maps an identity-receipt HTTP response to bytes, surfacing the retryable uncommitted-leaf case.
+ *
+ * A {@code 503} whose error body has {@code code} equal to {@link TlLeafUncommittedException#ERROR_CODE}
+ * means the leaf is committed but no signed checkpoint covers it yet. That is retryable and becomes a
+ * {@link TlLeafUncommittedException}. Any other non-2xx status falls through to
+ * {@link #throwForStatus(int, String, String)}.
+ */
+ private byte[] handleIdentityReceiptResponse(HttpResponse response) {
+ int status = response.statusCode();
+ if (status >= 200 && status < 300) {
+ return response.body();
+ }
+
+ String requestId = response.headers().firstValue("X-Request-Id").orElse(null);
+ String body = new String(response.body(), StandardCharsets.UTF_8);
+ if (status == TlLeafUncommittedException.STATUS_SERVICE_UNAVAILABLE && isLeafUncommitted(body)) {
+ int retryAfter = parseRetryAfter(response.headers().firstValue("Retry-After").orElse(null));
+ throw new TlLeafUncommittedException(
+ "Identity receipt not yet available (TL_LEAF_UNCOMMITTED): " + body, retryAfter, requestId);
+ }
+ throwForStatus(status, body, requestId);
+ return response.body(); // unreachable: throwForStatus always throws for non-2xx
+ }
+
+ /**
+ * Returns true when the error body is JSON whose {@code code} field equals
+ * {@link TlLeafUncommittedException#ERROR_CODE}.
+ *
+ * A non-JSON body, or one without that code, returns false. So an unrelated {@code 503} stays a
+ * plain server error and is never mapped to the retryable case.
+ */
+ private boolean isLeafUncommitted(String body) {
+ try {
+ JsonNode code = objectMapper.readTree(body).get("code");
+ return code != null && TlLeafUncommittedException.ERROR_CODE.equals(code.asText());
+ } catch (IOException notJson) {
+ return false;
+ }
+ }
+
+ /**
+ * Parses a {@code Retry-After} header, accepting both RFC-7231 forms: delay-seconds and HTTP-date.
+ *
+ * @param headerValue the raw header value, may be null
+ * @return the delay in seconds (never negative), or 0 if absent or unparseable
+ */
+ private int parseRetryAfter(String headerValue) {
+ if (headerValue == null || headerValue.isBlank()) {
+ return 0;
+ }
+ String trimmed = headerValue.trim();
+ try {
+ return Math.max(0, Integer.parseInt(trimmed));
+ } catch (NumberFormatException notSeconds) {
+ // Fall through to the HTTP-date form.
+ }
+ try {
+ Instant deadline = Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(trimmed));
+ long seconds = Duration.between(Instant.now(), deadline).getSeconds();
+ return (int) Math.max(0, Math.min(seconds, Integer.MAX_VALUE));
+ } catch (DateTimeException notDate) {
+ return 0;
+ }
+ }
+
/**
* Returns the SCITT root public keys asynchronously, using cached values if available.
*
@@ -342,7 +576,10 @@ private void parseAndSetPayload(TransparencyLog result, String schemaVersion) {
}
try {
- if ("V1".equalsIgnoreCase(schemaVersion)) {
+ if ("V2".equalsIgnoreCase(schemaVersion)) {
+ TransparencyLogV2 v2 = objectMapper.convertValue(result.getPayload(), TransparencyLogV2.class);
+ result.setParsedPayload(v2);
+ } else if ("V1".equalsIgnoreCase(schemaVersion)) {
TransparencyLogV1 v1 = objectMapper.convertValue(result.getPayload(), TransparencyLogV1.class);
result.setParsedPayload(v1);
} else {
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/AgentIdentitiesResponse.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/AgentIdentitiesResponse.java
new file mode 100644
index 0000000..2d0a03c
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/AgentIdentitiesResponse.java
@@ -0,0 +1,62 @@
+package com.godaddy.ans.sdk.transparency.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.godaddy.ans.sdk.model.LinkedIdentity;
+
+import java.util.List;
+
+/**
+ * Paginated forward join: the identities an agent currently links to.
+ *
+ * This is the response of {@code GET /v1/agents/{agentId}/identities}. It is the overflow read
+ * target for the agent badge, which caps its inline {@code identities[]} at 25 entries. The
+ * {@code total} field carries the full count before pagination, so a caller pages the whole set
+ * even when a single page is capped by {@code limit}.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class AgentIdentitiesResponse {
+
+ @JsonProperty("identities")
+ private List identities;
+
+ @JsonProperty("total")
+ private int total;
+
+ public AgentIdentitiesResponse() {
+ }
+
+ /**
+ * Returns the identities in this page of the forward join.
+ *
+ * @return the linked identities, or null if the response carried no list
+ */
+ public List getIdentities() {
+ return identities;
+ }
+
+ public void setIdentities(List identities) {
+ this.identities = identities;
+ }
+
+ /**
+ * Returns the full count of linked identities before pagination.
+ *
+ * @return the total count
+ */
+ public int getTotal() {
+ return total;
+ }
+
+ public void setTotal(int total) {
+ this.total = total;
+ }
+
+ @Override
+ public String toString() {
+ return "AgentIdentitiesResponse{"
+ + "identities=" + (identities != null ? identities.size() : 0)
+ + ", total=" + total
+ + '}';
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/AttestationsV2.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/AttestationsV2.java
new file mode 100644
index 0000000..0655d52
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/AttestationsV2.java
@@ -0,0 +1,74 @@
+package com.godaddy.ans.sdk.transparency.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/**
+ * Attestations in V2 schema.
+ *
+ * V2 replaces the V0/V1 {@code dnsRecordsProvisioned} map with a list of typed records, and the
+ * singular {@code identityCert}/{@code serverCert} objects with {@code identityCerts}/{@code serverCerts}
+ * arrays.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class AttestationsV2 {
+
+ @JsonProperty("dnsRecordsProvisioned")
+ private List dnsRecordsProvisioned;
+
+ @JsonProperty("domainValidation")
+ private String domainValidation;
+
+ @JsonProperty("identityCerts")
+ private List identityCerts;
+
+ @JsonProperty("serverCerts")
+ private List serverCerts;
+
+ public AttestationsV2() {
+ }
+
+ public List getDnsRecordsProvisioned() {
+ return dnsRecordsProvisioned;
+ }
+
+ public void setDnsRecordsProvisioned(List dnsRecordsProvisioned) {
+ this.dnsRecordsProvisioned = dnsRecordsProvisioned;
+ }
+
+ public String getDomainValidation() {
+ return domainValidation;
+ }
+
+ public void setDomainValidation(String domainValidation) {
+ this.domainValidation = domainValidation;
+ }
+
+ public List getIdentityCerts() {
+ return identityCerts;
+ }
+
+ public void setIdentityCerts(List identityCerts) {
+ this.identityCerts = identityCerts;
+ }
+
+ public List getServerCerts() {
+ return serverCerts;
+ }
+
+ public void setServerCerts(List serverCerts) {
+ this.serverCerts = serverCerts;
+ }
+
+ @Override
+ public String toString() {
+ return "AttestationsV2{"
+ + "dnsRecordsProvisioned=" + dnsRecordsProvisioned
+ + ", domainValidation='" + domainValidation + '\''
+ + ", identityCerts=" + identityCerts
+ + ", serverCerts=" + serverCerts
+ + '}';
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/CertificateInfoV2.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/CertificateInfoV2.java
new file mode 100644
index 0000000..250fdbb
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/CertificateInfoV2.java
@@ -0,0 +1,61 @@
+package com.godaddy.ans.sdk.transparency.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.time.OffsetDateTime;
+
+/**
+ * Certificate information in V2 schema attestations.
+ *
+ * Unlike {@link CertificateInfo}, V2 carries an explicit {@code notAfter} expiry and appears
+ * inside the {@code identityCerts}/{@code serverCerts} arrays.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class CertificateInfoV2 {
+
+ @JsonProperty("fingerprint")
+ private String fingerprint;
+
+ @JsonProperty("notAfter")
+ private OffsetDateTime notAfter;
+
+ @JsonProperty("type")
+ private CertType type;
+
+ public CertificateInfoV2() {
+ }
+
+ public String getFingerprint() {
+ return fingerprint;
+ }
+
+ public void setFingerprint(String fingerprint) {
+ this.fingerprint = fingerprint;
+ }
+
+ public OffsetDateTime getNotAfter() {
+ return notAfter;
+ }
+
+ public void setNotAfter(OffsetDateTime notAfter) {
+ this.notAfter = notAfter;
+ }
+
+ public CertType getType() {
+ return type;
+ }
+
+ public void setType(CertType type) {
+ this.type = type;
+ }
+
+ @Override
+ public String toString() {
+ return "CertificateInfoV2{"
+ + "fingerprint='" + fingerprint + '\''
+ + ", notAfter=" + notAfter
+ + ", type=" + type
+ + '}';
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/DnsRecordV2.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/DnsRecordV2.java
new file mode 100644
index 0000000..974a6e7
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/DnsRecordV2.java
@@ -0,0 +1,59 @@
+package com.godaddy.ans.sdk.transparency.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * A provisioned DNS record in V2 schema attestations.
+ *
+ * V2 carries {@code dnsRecordsProvisioned} as a list of typed records, unlike the V0/V1
+ * name-to-value map.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class DnsRecordV2 {
+
+ @JsonProperty("data")
+ private String data;
+
+ @JsonProperty("name")
+ private String name;
+
+ @JsonProperty("type")
+ private String type;
+
+ public DnsRecordV2() {
+ }
+
+ public String getData() {
+ return data;
+ }
+
+ public void setData(String data) {
+ this.data = data;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ @Override
+ public String toString() {
+ return "DnsRecordV2{"
+ + "name='" + name + '\''
+ + ", type='" + type + '\''
+ + ", data='" + data + '\''
+ + '}';
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/EventV2.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/EventV2.java
new file mode 100644
index 0000000..58aa28c
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/EventV2.java
@@ -0,0 +1,163 @@
+package com.godaddy.ans.sdk.transparency.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.time.OffsetDateTime;
+
+/**
+ * Event structure in V2 schema.
+ *
+ * The event core matches V1, so it reuses {@link AgentV1} and {@link EventTypeV1}. Only the
+ * attestations shape differs, carried here as {@link AttestationsV2}.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class EventV2 {
+
+ @JsonProperty("ansId")
+ private String ansId;
+
+ @JsonProperty("ansName")
+ private String ansName;
+
+ @JsonProperty("eventType")
+ private EventTypeV1 eventType;
+
+ @JsonProperty("agent")
+ private AgentV1 agent;
+
+ @JsonProperty("attestations")
+ private AttestationsV2 attestations;
+
+ @JsonProperty("expiresAt")
+ private OffsetDateTime expiresAt;
+
+ @JsonProperty("issuedAt")
+ private OffsetDateTime issuedAt;
+
+ @JsonProperty("raId")
+ private String raId;
+
+ @JsonProperty("renewalStatus")
+ private String renewalStatus;
+
+ @JsonProperty("revocationReasonCode")
+ private RevocationReason revocationReasonCode;
+
+ @JsonProperty("revokedAt")
+ private OffsetDateTime revokedAt;
+
+ @JsonProperty("timestamp")
+ private OffsetDateTime timestamp;
+
+ public EventV2() {
+ }
+
+ public String getAnsId() {
+ return ansId;
+ }
+
+ public void setAnsId(String ansId) {
+ this.ansId = ansId;
+ }
+
+ public String getAnsName() {
+ return ansName;
+ }
+
+ public void setAnsName(String ansName) {
+ this.ansName = ansName;
+ }
+
+ public EventTypeV1 getEventType() {
+ return eventType;
+ }
+
+ public void setEventType(EventTypeV1 eventType) {
+ this.eventType = eventType;
+ }
+
+ public AgentV1 getAgent() {
+ return agent;
+ }
+
+ public void setAgent(AgentV1 agent) {
+ this.agent = agent;
+ }
+
+ public AttestationsV2 getAttestations() {
+ return attestations;
+ }
+
+ public void setAttestations(AttestationsV2 attestations) {
+ this.attestations = attestations;
+ }
+
+ public OffsetDateTime getExpiresAt() {
+ return expiresAt;
+ }
+
+ public void setExpiresAt(OffsetDateTime expiresAt) {
+ this.expiresAt = expiresAt;
+ }
+
+ public OffsetDateTime getIssuedAt() {
+ return issuedAt;
+ }
+
+ public void setIssuedAt(OffsetDateTime issuedAt) {
+ this.issuedAt = issuedAt;
+ }
+
+ public String getRaId() {
+ return raId;
+ }
+
+ public void setRaId(String raId) {
+ this.raId = raId;
+ }
+
+ public String getRenewalStatus() {
+ return renewalStatus;
+ }
+
+ public void setRenewalStatus(String renewalStatus) {
+ this.renewalStatus = renewalStatus;
+ }
+
+ public RevocationReason getRevocationReasonCode() {
+ return revocationReasonCode;
+ }
+
+ public void setRevocationReasonCode(RevocationReason revocationReasonCode) {
+ this.revocationReasonCode = revocationReasonCode;
+ }
+
+ public OffsetDateTime getRevokedAt() {
+ return revokedAt;
+ }
+
+ public void setRevokedAt(OffsetDateTime revokedAt) {
+ this.revokedAt = revokedAt;
+ }
+
+ public OffsetDateTime getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(OffsetDateTime timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public String toString() {
+ return "EventV2{"
+ + "ansId='" + ansId + '\''
+ + ", ansName='" + ansName + '\''
+ + ", eventType=" + eventType
+ + ", agent=" + agent
+ + ", issuedAt=" + issuedAt
+ + ", expiresAt=" + expiresAt
+ + '}';
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/IdentityLinkedAgentsResponse.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/IdentityLinkedAgentsResponse.java
new file mode 100644
index 0000000..3f113da
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/IdentityLinkedAgentsResponse.java
@@ -0,0 +1,60 @@
+package com.godaddy.ans.sdk.transparency.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/**
+ * Paginated reverse join: the agents an identity currently links to.
+ *
+ * This is the response of {@code GET /v1/identities/{identityId}/agents}. The {@code total}
+ * field carries the full count before pagination, so a caller pages the whole set even when a
+ * single page is capped by {@code limit}.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class IdentityLinkedAgentsResponse {
+
+ @JsonProperty("agents")
+ private List agents;
+
+ @JsonProperty("total")
+ private int total;
+
+ public IdentityLinkedAgentsResponse() {
+ }
+
+ /**
+ * Returns the agents in this page of the reverse join.
+ *
+ * @return the linked agents, or null if the response carried no list
+ */
+ public List getAgents() {
+ return agents;
+ }
+
+ public void setAgents(List agents) {
+ this.agents = agents;
+ }
+
+ /**
+ * Returns the full count of linked agents before pagination.
+ *
+ * @return the total count
+ */
+ public int getTotal() {
+ return total;
+ }
+
+ public void setTotal(int total) {
+ this.total = total;
+ }
+
+ @Override
+ public String toString() {
+ return "IdentityLinkedAgentsResponse{"
+ + "agents=" + (agents != null ? agents.size() : 0)
+ + ", total=" + total
+ + '}';
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/LinkedAgentView.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/LinkedAgentView.java
new file mode 100644
index 0000000..2bcf22b
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/LinkedAgentView.java
@@ -0,0 +1,74 @@
+package com.godaddy.ans.sdk.transparency.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * One entry of the identity reverse join: an agent that an identity currently links to.
+ *
+ * The transparency log computes this view at query time from the link index. Each entry
+ * carries the linked agent's own computed badge status, so a reader checks both ends of the
+ * link in one response.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class LinkedAgentView {
+
+ @JsonProperty("ansId")
+ private String ansId;
+
+ @JsonProperty("linkedAt")
+ private String linkedAt;
+
+ @JsonProperty("agentStatus")
+ private String agentStatus;
+
+ public LinkedAgentView() {
+ }
+
+ /**
+ * Returns the linked agent's ANS identifier.
+ *
+ * @return the agent ANS ID
+ */
+ public String getAnsId() {
+ return ansId;
+ }
+
+ public void setAnsId(String ansId) {
+ this.ansId = ansId;
+ }
+
+ /**
+ * Returns the producer timestamp of the sealed link event that bound this agent.
+ *
+ * @return the link timestamp, or null if not provided
+ */
+ public String getLinkedAt() {
+ return linkedAt;
+ }
+
+ public void setLinkedAt(String linkedAt) {
+ this.linkedAt = linkedAt;
+ }
+
+ /**
+ * Returns the linked agent's own computed badge status.
+ *
+ * @return the agent status, or null if not provided
+ */
+ public String getAgentStatus() {
+ return agentStatus;
+ }
+
+ public void setAgentStatus(String agentStatus) {
+ this.agentStatus = agentStatus;
+ }
+
+ @Override
+ public String toString() {
+ return "LinkedAgentView{"
+ + "ansId='" + ansId + '\''
+ + ", agentStatus='" + agentStatus + '\''
+ + '}';
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/ProducerV2.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/ProducerV2.java
new file mode 100644
index 0000000..c7d71be
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/ProducerV2.java
@@ -0,0 +1,55 @@
+package com.godaddy.ans.sdk.transparency.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Producer section of V2 schema.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ProducerV2 {
+
+ @JsonProperty("event")
+ private EventV2 event;
+
+ @JsonProperty("keyId")
+ private String keyId;
+
+ @JsonProperty("signature")
+ private String signature;
+
+ public ProducerV2() {
+ }
+
+ public EventV2 getEvent() {
+ return event;
+ }
+
+ public void setEvent(EventV2 event) {
+ this.event = event;
+ }
+
+ public String getKeyId() {
+ return keyId;
+ }
+
+ public void setKeyId(String keyId) {
+ this.keyId = keyId;
+ }
+
+ public String getSignature() {
+ return signature;
+ }
+
+ public void setSignature(String signature) {
+ this.signature = signature;
+ }
+
+ @Override
+ public String toString() {
+ return "ProducerV2{"
+ + "event=" + event
+ + ", keyId='" + keyId + '\''
+ + '}';
+ }
+}
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/TransparencyLog.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/TransparencyLog.java
index c47035a..649a7ca 100644
--- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/TransparencyLog.java
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/TransparencyLog.java
@@ -3,7 +3,9 @@
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.godaddy.ans.sdk.model.LinkedIdentity;
+import java.util.List;
import java.util.Map;
/**
@@ -31,6 +33,15 @@ public class TransparencyLog {
@JsonProperty("status")
private String status;
+ @JsonProperty("identities")
+ private List identities;
+
+ @JsonProperty("identitiesTotal")
+ private Integer identitiesTotal;
+
+ @JsonProperty("identitiesUnavailable")
+ private Boolean identitiesUnavailable;
+
/**
* The strongly-typed payload based on schema version.
* This is populated by the TransparencyService after parsing.
@@ -81,6 +92,53 @@ public void setStatus(String status) {
this.status = status;
}
+ /**
+ * The computed read-time join of the identities currently linked to this agent.
+ *
+ * Agent badges only. The embedded list is capped at a small safety limit. {@link #getIdentitiesTotal()}
+ * carries the full count, and {@code GET /v1/agents/{agentId}/identities}
+ * ({@code TransparencyClient.getAgentIdentities}) is the paginated overflow target when the count exceeds
+ * the cap. Absent or empty always means "no visible links", never "the join failed".
+ * {@link #getIdentitiesUnavailable()} signals a failed join explicitly.
+ *
+ * @return the linked identities, or null when the response carried no {@code identities} field
+ * (identity badges, or an agent with no visible links)
+ */
+ public List getIdentities() {
+ return identities;
+ }
+
+ public void setIdentities(List identities) {
+ this.identities = identities;
+ }
+
+ /**
+ * The full count of visible links when {@link #getIdentities()} is present and capped.
+ *
+ * @return the total count, or null when not an agent badge / no identities present
+ */
+ public Integer getIdentitiesTotal() {
+ return identitiesTotal;
+ }
+
+ public void setIdentitiesTotal(Integer identitiesTotal) {
+ this.identitiesTotal = identitiesTotal;
+ }
+
+ /**
+ * Set when the identities join could not be computed. Join failure is explicit, never silent: an absent or
+ * empty {@link #getIdentities()} means "no visible links", while this flag means "the join failed".
+ *
+ * @return true when the join was unavailable, or null when not set
+ */
+ public Boolean getIdentitiesUnavailable() {
+ return identitiesUnavailable;
+ }
+
+ public void setIdentitiesUnavailable(Boolean identitiesUnavailable) {
+ this.identitiesUnavailable = identitiesUnavailable;
+ }
+
public Object getParsedPayload() {
return parsedPayload;
}
@@ -89,6 +147,18 @@ public void setParsedPayload(Object parsedPayload) {
this.parsedPayload = parsedPayload;
}
+ /**
+ * Returns the parsed payload as a V2 schema object, or null if not V2.
+ *
+ * @return the V2 payload, or null
+ */
+ public TransparencyLogV2 getV2Payload() {
+ if (parsedPayload instanceof TransparencyLogV2) {
+ return (TransparencyLogV2) parsedPayload;
+ }
+ return null;
+ }
+
/**
* Returns the parsed payload as a V1 schema object, or null if not V1.
*
@@ -113,6 +183,15 @@ public TransparencyLogV0 getV0Payload() {
return null;
}
+ /**
+ * Returns true if this is a V2 schema entry.
+ *
+ * @return true if V2 schema
+ */
+ public boolean isV2() {
+ return "V2".equalsIgnoreCase(schemaVersion) || getV2Payload() != null;
+ }
+
/**
* Returns true if this is a V1 schema entry.
*
@@ -140,7 +219,14 @@ public boolean isV0() {
* @return the server certificate fingerprint, or null if not available
*/
public String getServerCertFingerprint() {
- if (isV1()) {
+ if (isV2()) {
+ TransparencyLogV2 v2 = getV2Payload();
+ if (v2 != null && v2.getAttestations() != null
+ && v2.getAttestations().getServerCerts() != null
+ && !v2.getAttestations().getServerCerts().isEmpty()) {
+ return v2.getAttestations().getServerCerts().get(0).getFingerprint();
+ }
+ } else if (isV1()) {
TransparencyLogV1 v1 = getV1Payload();
if (v1 != null && v1.getAttestations() != null
&& v1.getAttestations().getServerCert() != null) {
@@ -161,7 +247,14 @@ public String getServerCertFingerprint() {
* @return the identity certificate fingerprint, or null if not available
*/
public String getIdentityCertFingerprint() {
- if (isV1()) {
+ if (isV2()) {
+ TransparencyLogV2 v2 = getV2Payload();
+ if (v2 != null && v2.getAttestations() != null
+ && v2.getAttestations().getIdentityCerts() != null
+ && !v2.getAttestations().getIdentityCerts().isEmpty()) {
+ return v2.getAttestations().getIdentityCerts().get(0).getFingerprint();
+ }
+ } else if (isV1()) {
TransparencyLogV1 v1 = getV1Payload();
if (v1 != null && v1.getAttestations() != null
&& v1.getAttestations().getIdentityCert() != null) {
@@ -182,7 +275,10 @@ public String getIdentityCertFingerprint() {
* @return the ANS name, or null if not available
*/
public String getAnsName() {
- if (isV1()) {
+ if (isV2()) {
+ TransparencyLogV2 v2 = getV2Payload();
+ return v2 != null ? v2.getAnsName() : null;
+ } else if (isV1()) {
TransparencyLogV1 v1 = getV1Payload();
return v1 != null ? v1.getAnsName() : null;
} else if (isV0()) {
@@ -201,7 +297,12 @@ public String getAnsName() {
* @return the agent host, or null if not available
*/
public String getAgentHost() {
- if (isV1()) {
+ if (isV2()) {
+ TransparencyLogV2 v2 = getV2Payload();
+ if (v2 != null && v2.getEvent() != null && v2.getEvent().getAgent() != null) {
+ return v2.getEvent().getAgent().getHost();
+ }
+ } else if (isV1()) {
TransparencyLogV1 v1 = getV1Payload();
if (v1 != null && v1.getEvent() != null && v1.getEvent().getAgent() != null) {
return v1.getEvent().getAgent().getHost();
diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/TransparencyLogV2.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/TransparencyLogV2.java
new file mode 100644
index 0000000..cf9c5db
--- /dev/null
+++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/TransparencyLogV2.java
@@ -0,0 +1,83 @@
+package com.godaddy.ans.sdk.transparency.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * V2 schema for ANS Transparency Log entries.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class TransparencyLogV2 {
+
+ @JsonProperty("logId")
+ private String logId;
+
+ @JsonProperty("producer")
+ private ProducerV2 producer;
+
+ public TransparencyLogV2() {
+ }
+
+ public String getLogId() {
+ return logId;
+ }
+
+ public void setLogId(String logId) {
+ this.logId = logId;
+ }
+
+ public ProducerV2 getProducer() {
+ return producer;
+ }
+
+ public void setProducer(ProducerV2 producer) {
+ this.producer = producer;
+ }
+
+ /**
+ * Convenience method to get the event from the producer.
+ *
+ * @return the event, or null if producer is null
+ */
+ public EventV2 getEvent() {
+ return producer != null ? producer.getEvent() : null;
+ }
+
+ /**
+ * Convenience method to get attestations from the event.
+ *
+ * @return the attestations, or null if not available
+ */
+ public AttestationsV2 getAttestations() {
+ EventV2 event = getEvent();
+ return event != null ? event.getAttestations() : null;
+ }
+
+ /**
+ * Convenience method to get the ANS name.
+ *
+ * @return the ANS name, or null if not available
+ */
+ public String getAnsName() {
+ EventV2 event = getEvent();
+ return event != null ? event.getAnsName() : null;
+ }
+
+ /**
+ * Convenience method to get the event type.
+ *
+ * @return the event type, or null if not available
+ */
+ public EventTypeV1 getEventType() {
+ EventV2 event = getEvent();
+ return event != null ? event.getEventType() : null;
+ }
+
+ @Override
+ public String toString() {
+ return "TransparencyLogV2{"
+ + "logId='" + logId + '\''
+ + ", producer=" + producer
+ + '}';
+ }
+}
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyClientTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyClientTest.java
index c0f40be..6929963 100644
--- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyClientTest.java
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyClientTest.java
@@ -1,11 +1,17 @@
package com.godaddy.ans.sdk.transparency;
+import com.github.tomakehurst.wiremock.http.Fault;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import com.godaddy.ans.sdk.exception.AnsNotFoundException;
+import com.godaddy.ans.sdk.exception.AnsServerException;
+import com.godaddy.ans.sdk.model.LinkedIdentity;
import com.godaddy.ans.sdk.transparency.model.AgentAuditParams;
+import com.godaddy.ans.sdk.transparency.model.AgentIdentitiesResponse;
import com.godaddy.ans.sdk.transparency.model.CheckpointResponse;
import com.godaddy.ans.sdk.transparency.model.EventTypeV1;
+import com.godaddy.ans.sdk.transparency.model.IdentityLinkedAgentsResponse;
+import com.godaddy.ans.sdk.transparency.model.LinkedAgentView;
import com.godaddy.ans.sdk.transparency.model.TransparencyLog;
import com.godaddy.ans.sdk.transparency.model.CheckpointHistoryParams;
import com.godaddy.ans.sdk.transparency.model.CheckpointHistoryResponse;
@@ -19,7 +25,12 @@
import java.security.PublicKey;
import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
@@ -81,6 +92,59 @@ void shouldRetrieveAgentTransparencyLogV1(WireMockRuntimeInfo wmRuntimeInfo) {
assertThat(result.getAgentHost()).isEqualTo("agent.example.com");
}
+ @Test
+ @DisplayName("Agent badge exposes the computed identities[] join with overflow total")
+ void getAgentBadgeExposesLinkedIdentities(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withHeader("X-Schema-Version", "V1")
+ .withBody(v1BadgeWithIdentitiesResponse())));
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .build();
+
+ TransparencyLog result = client.getAgentTransparencyLog(TEST_AGENT_ID);
+
+ assertThat(result.getIdentities()).hasSize(2);
+ LinkedIdentity first = result.getIdentities().get(0);
+ assertThat(first.getIdentityId()).isEqualTo("id-web-1");
+ assertThat(first.getKind()).isEqualTo(LinkedIdentity.KindEnum.DID_WEB);
+ assertThat(first.getIdentityStatus()).isEqualTo(LinkedIdentity.IdentityStatusEnum.VERIFIED);
+ assertThat(result.getIdentities().get(1).getIdentityStatus())
+ .isEqualTo(LinkedIdentity.IdentityStatusEnum.REVOKED);
+ // Embedded list is capped. The full count signals the getAgentIdentities overflow read.
+ assertThat(result.getIdentitiesTotal()).isEqualTo(27);
+ assertThat(result.getIdentitiesUnavailable()).isNull();
+ }
+
+ @Test
+ @DisplayName("Agent badge without an identities field leaves identities null, no NPE")
+ void getAgentBadgeAbsentIdentitiesIsNull(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withHeader("X-Schema-Version", "V1")
+ .withBody(v1TransparencyLogResponse())));
+
+ TransparencyClient client = TransparencyClient.builder()
+ .baseUrl(baseUrl)
+ .build();
+
+ TransparencyLog result = client.getAgentTransparencyLog(TEST_AGENT_ID);
+
+ assertThat(result.getIdentities()).isNull();
+ assertThat(result.getIdentitiesTotal()).isNull();
+ assertThat(result.getIdentitiesUnavailable()).isNull();
+ }
+
@Test
@DisplayName("Should retrieve agent transparency log with V0 schema")
void shouldRetrieveAgentTransparencyLogV0(WireMockRuntimeInfo wmRuntimeInfo) {
@@ -826,6 +890,596 @@ void shouldAcceptTrustedProductionDomain() {
assertThat(oteClient.getBaseUrl()).isEqualTo("https://transparency.ans.ote-godaddy.com");
}
+ // ==================== Verified-Identity Reads ====================
+
+ private static final String TEST_IDENTITY_ID = "id-9c2f1a0b-7e44-4d21-bb90-1f2e3d4c5a6b";
+
+ @Test
+ @DisplayName("Should retrieve identity badge with computed status")
+ void shouldRetrieveIdentityBadge(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"status\": \"VERIFIED\", \"payload\": {\"identityId\": \"" + TEST_IDENTITY_ID + "\"}}")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ TransparencyLog result = client.getIdentityBadge(TEST_IDENTITY_ID);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getStatus()).isEqualTo("VERIFIED");
+ assertThat(result.getPayload()).containsEntry("identityId", TEST_IDENTITY_ID);
+ }
+
+ @Test
+ @DisplayName("Should throw AnsServerException when identity badge body is invalid")
+ void shouldThrowWhenIdentityBadgeBodyInvalid(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("not-json")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getIdentityBadge(TEST_IDENTITY_ID))
+ .isInstanceOf(com.godaddy.ans.sdk.exception.AnsServerException.class)
+ .hasMessageContaining("Failed to parse identity badge response");
+ }
+
+ @Test
+ @DisplayName("Should retrieve identity audit with pagination params")
+ void shouldRetrieveIdentityAuditWithParams(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/audit?offset=5&limit=20"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(auditResponse())));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ TransparencyLogAudit audit = client.getIdentityAudit(
+ TEST_IDENTITY_ID, AgentAuditParams.builder().offset(5).limit(20).build());
+
+ assertThat(audit).isNotNull();
+ assertThat(audit.getRecords()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("Should retrieve identity audit without params using overload")
+ void shouldRetrieveIdentityAuditWithoutParams(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/audit"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(auditResponse())));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ TransparencyLogAudit audit = client.getIdentityAudit(TEST_IDENTITY_ID);
+
+ assertThat(audit).isNotNull();
+ assertThat(audit.getRecords()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("Should throw AnsServerException when identity audit body is invalid")
+ void shouldThrowWhenIdentityAuditBodyInvalid(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/audit"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("not-json")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getIdentityAudit(TEST_IDENTITY_ID))
+ .isInstanceOf(com.godaddy.ans.sdk.exception.AnsServerException.class)
+ .hasMessageContaining("Failed to parse identity audit response");
+ }
+
+ @Test
+ @DisplayName("Should retrieve identity linked agents with pagination params")
+ void shouldRetrieveIdentityLinkedAgentsWithParams(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/agents?limit=50"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(linkedAgentsResponse())));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ IdentityLinkedAgentsResponse response = client.getIdentityLinkedAgents(
+ TEST_IDENTITY_ID, AgentAuditParams.builder().limit(50).build());
+
+ assertThat(response).isNotNull();
+ assertThat(response.getTotal()).isEqualTo(2);
+ assertThat(response.getAgents()).hasSize(1);
+ LinkedAgentView view = response.getAgents().get(0);
+ assertThat(view.getAnsId()).isEqualTo("ans://v1.0.0.agent.example.com");
+ assertThat(view.getLinkedAt()).isEqualTo("2026-08-04T12:00:00Z");
+ assertThat(view.getAgentStatus()).isEqualTo("ACTIVE");
+ }
+
+ @Test
+ @DisplayName("Should retrieve identity linked agents without params using overload")
+ void shouldRetrieveIdentityLinkedAgentsWithoutParams(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/agents"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(linkedAgentsResponse())));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ IdentityLinkedAgentsResponse response = client.getIdentityLinkedAgents(TEST_IDENTITY_ID);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getTotal()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("Should throw AnsServerException when linked-agents body is invalid")
+ void shouldThrowWhenLinkedAgentsBodyInvalid(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/agents"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("not-json")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getIdentityLinkedAgents(TEST_IDENTITY_ID))
+ .isInstanceOf(com.godaddy.ans.sdk.exception.AnsServerException.class)
+ .hasMessageContaining("Failed to parse identity linked-agents response");
+ }
+
+ @Test
+ @DisplayName("Should retrieve agent identities forward join with pagination params")
+ void shouldRetrieveAgentIdentitiesWithParams(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/identities?limit=50"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(agentIdentitiesResponse())));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ AgentIdentitiesResponse response = client.getAgentIdentities(
+ TEST_AGENT_ID, AgentAuditParams.builder().limit(50).build());
+
+ assertThat(response).isNotNull();
+ assertThat(response.getTotal()).isEqualTo(2);
+ assertThat(response.getIdentities()).hasSize(1);
+ LinkedIdentity identity = response.getIdentities().get(0);
+ assertThat(identity.getIdentityId()).isEqualTo(TEST_IDENTITY_ID);
+ assertThat(identity.getKind()).isEqualTo(LinkedIdentity.KindEnum.DID_WEB);
+ assertThat(identity.getValue()).isEqualTo("did:web:example.com");
+ assertThat(identity.getIdentityStatus()).isEqualTo(LinkedIdentity.IdentityStatusEnum.VERIFIED);
+ assertThat(identity.getLinkedAt()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("Should retrieve agent identities without params using overload")
+ void shouldRetrieveAgentIdentitiesWithoutParams(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/identities"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(agentIdentitiesResponse())));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ AgentIdentitiesResponse response = client.getAgentIdentities(TEST_AGENT_ID);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getTotal()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("Should tolerate an absent identities list without NPE (V10 overflow envelope)")
+ void shouldTolerateAbsentAgentIdentitiesList(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/identities"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"total\": 0}")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ AgentIdentitiesResponse response = client.getAgentIdentities(TEST_AGENT_ID);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getTotal()).isZero();
+ assertThat(response.getIdentities()).isNull();
+ assertThat(response.toString()).contains("identities=0");
+ }
+
+ @Test
+ @DisplayName("Should throw AnsServerException when agent identities body is invalid")
+ void shouldThrowWhenAgentIdentitiesBodyInvalid(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/identities"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("not-json")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getAgentIdentities(TEST_AGENT_ID))
+ .isInstanceOf(com.godaddy.ans.sdk.exception.AnsServerException.class)
+ .hasMessageContaining("Failed to parse agent identities response");
+ }
+
+ @Test
+ @DisplayName("Should retrieve agent identity history with pagination params")
+ void shouldRetrieveAgentIdentityHistoryWithParams(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/identities/history?offset=5&limit=20"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(auditResponse())));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ TransparencyLogAudit history = client.getAgentIdentityHistory(
+ TEST_AGENT_ID, AgentAuditParams.builder().offset(5).limit(20).build());
+
+ assertThat(history).isNotNull();
+ assertThat(history.getRecords()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("Should retrieve agent identity history without params using overload")
+ void shouldRetrieveAgentIdentityHistoryWithoutParams(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/identities/history"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(auditResponse())));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ TransparencyLogAudit history = client.getAgentIdentityHistory(TEST_AGENT_ID);
+
+ assertThat(history).isNotNull();
+ assertThat(history.getRecords()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("Should throw AnsServerException when agent identity history body is invalid")
+ void shouldThrowWhenAgentIdentityHistoryBodyInvalid(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/identities/history"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("not-json")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getAgentIdentityHistory(TEST_AGENT_ID))
+ .isInstanceOf(com.godaddy.ans.sdk.exception.AnsServerException.class)
+ .hasMessageContaining("Failed to parse agent identity history response");
+ }
+
+ @Test
+ @DisplayName("Should retrieve identity receipt bytes")
+ void shouldRetrieveIdentityReceipt(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+ byte[] receiptBytes = {0x01, 0x02, 0x03, 0x04};
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/scitt-receipt+cose")
+ .withBody(receiptBytes)));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ byte[] result = client.getIdentityReceipt(TEST_IDENTITY_ID);
+
+ assertThat(result).containsExactly(receiptBytes);
+ }
+
+ @Test
+ @DisplayName("Should throw retryable TlLeafUncommittedException on 503 with Retry-After")
+ void shouldThrowTlLeafUncommittedWithRetryAfter(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(503)
+ .withHeader("Retry-After", "30")
+ .withHeader("X-Request-Id", "req-abc-123")
+ .withBody("{\"code\": \"TL_LEAF_UNCOMMITTED\"}")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getIdentityReceipt(TEST_IDENTITY_ID))
+ .isInstanceOf(TlLeafUncommittedException.class)
+ .satisfies(t -> {
+ TlLeafUncommittedException ex = (TlLeafUncommittedException) t;
+ assertThat(ex.getRetryAfterSeconds()).isEqualTo(30);
+ assertThat(ex.getRequestId()).isEqualTo("req-abc-123");
+ assertThat(ex.isRetryable()).isTrue();
+ assertThat(ex.getStatusCode()).isEqualTo(503);
+ });
+ }
+
+ @Test
+ @DisplayName("Should default Retry-After to 0 when header is absent or unparseable")
+ void shouldDefaultRetryAfterWhenHeaderMissing(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(503)
+ .withHeader("Retry-After", "not-a-number")
+ .withBody("{\"code\": \"TL_LEAF_UNCOMMITTED\"}")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getIdentityReceipt(TEST_IDENTITY_ID))
+ .isInstanceOf(TlLeafUncommittedException.class)
+ .satisfies(t -> assertThat(((TlLeafUncommittedException) t).getRetryAfterSeconds()).isZero());
+ }
+
+ @Test
+ @DisplayName("Should throw AnsNotFoundException when identity receipt is 404")
+ void shouldThrowNotFoundForIdentityReceipt(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(404)
+ .withBody("{\"message\": \"identity not found\"}")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getIdentityReceipt(TEST_IDENTITY_ID))
+ .isInstanceOf(AnsNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("Should treat a 503 without the TL_LEAF_UNCOMMITTED code as a plain server error")
+ void shouldNotMapUnrelated503ToLeafUncommitted(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(503)
+ .withHeader("Retry-After", "5")
+ .withBody("{\"code\": \"SERVICE_UNAVAILABLE\"}")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getIdentityReceipt(TEST_IDENTITY_ID))
+ .isInstanceOf(AnsServerException.class)
+ .isNotInstanceOf(TlLeafUncommittedException.class);
+ }
+
+ @Test
+ @DisplayName("Should parse an HTTP-date Retry-After into a positive delay")
+ void shouldParseHttpDateRetryAfter(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+ String httpDate = DateTimeFormatter.RFC_1123_DATE_TIME
+ .format(Instant.now().plusSeconds(120).atZone(ZoneOffset.UTC));
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(503)
+ .withHeader("Retry-After", httpDate)
+ .withBody("{\"code\": \"TL_LEAF_UNCOMMITTED\"}")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getIdentityReceipt(TEST_IDENTITY_ID))
+ .isInstanceOf(TlLeafUncommittedException.class)
+ .satisfies(t -> assertThat(((TlLeafUncommittedException) t).getRetryAfterSeconds())
+ .isGreaterThan(0));
+ }
+
+ @Test
+ @DisplayName("Should retrieve identity receipt bytes asynchronously")
+ void shouldRetrieveIdentityReceiptAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+ byte[] receiptBytes = {0x0a, 0x0b, 0x0c};
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/scitt-receipt+cose")
+ .withBody(receiptBytes)));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ CompletableFuture future = client.getIdentityReceiptAsync(TEST_IDENTITY_ID);
+
+ assertThat(future.get()).containsExactly(receiptBytes);
+ }
+
+ @Test
+ @DisplayName("Should complete exceptionally with TlLeafUncommittedException on async 503")
+ void shouldCompleteExceptionallyOnAsyncLeafUncommitted(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(503)
+ .withHeader("Retry-After", "15")
+ .withBody("{\"code\": \"TL_LEAF_UNCOMMITTED\"}")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ CompletableFuture future = client.getIdentityReceiptAsync(TEST_IDENTITY_ID);
+
+ assertThatThrownBy(future::get)
+ .isInstanceOf(ExecutionException.class)
+ .hasCauseInstanceOf(TlLeafUncommittedException.class);
+ }
+
+ @Test
+ @DisplayName("Should default Retry-After to 0 when the header is absent")
+ void shouldDefaultRetryAfterWhenHeaderAbsent(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(503)
+ .withBody("{\"code\": \"TL_LEAF_UNCOMMITTED\"}")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getIdentityReceipt(TEST_IDENTITY_ID))
+ .isInstanceOf(TlLeafUncommittedException.class)
+ .satisfies(t -> assertThat(((TlLeafUncommittedException) t).getRetryAfterSeconds()).isZero());
+ }
+
+ @Test
+ @DisplayName("Should clamp a past HTTP-date Retry-After to 0")
+ void shouldClampPastHttpDateRetryAfterToZero(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+ String pastDate = DateTimeFormatter.RFC_1123_DATE_TIME
+ .format(Instant.now().minusSeconds(120).atZone(ZoneOffset.UTC));
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/receipt"))
+ .willReturn(aResponse()
+ .withStatus(503)
+ .withHeader("Retry-After", pastDate)
+ .withBody("{\"code\": \"TL_LEAF_UNCOMMITTED\"}")));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getIdentityReceipt(TEST_IDENTITY_ID))
+ .isInstanceOf(TlLeafUncommittedException.class)
+ .satisfies(t -> assertThat(((TlLeafUncommittedException) t).getRetryAfterSeconds()).isZero());
+ }
+
+ // ==================== Network-error mapping ====================
+
+ @Test
+ @DisplayName("getIdentityReceipt maps a transport failure to AnsServerException")
+ void shouldWrapNetworkErrorForIdentityReceipt(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID + "/receipt"))
+ .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getIdentityReceipt(TEST_IDENTITY_ID))
+ .isInstanceOf(AnsServerException.class)
+ .hasMessageContaining("Network error");
+ }
+
+ @Test
+ @DisplayName("getReceipt maps a transport failure to AnsServerException")
+ void shouldWrapNetworkErrorForReceipt(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/receipt"))
+ .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getReceipt(TEST_AGENT_ID))
+ .isInstanceOf(AnsServerException.class)
+ .hasMessageContaining("Network error");
+ }
+
+ @Test
+ @DisplayName("getAgentTransparencyLog maps a transport failure to AnsServerException")
+ void shouldWrapNetworkErrorForAgentTransparencyLog(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID))
+ .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getAgentTransparencyLog(TEST_AGENT_ID))
+ .isInstanceOf(AnsServerException.class)
+ .hasMessageContaining("Network error");
+ }
+
+ @Test
+ @DisplayName("getIdentityBadge maps a transport failure to AnsServerException")
+ void shouldWrapNetworkErrorForSendRequest(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/identities/" + TEST_IDENTITY_ID))
+ .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
+
+ TransparencyClient client = TransparencyClient.builder().baseUrl(baseUrl).build();
+
+ assertThatThrownBy(() -> client.getIdentityBadge(TEST_IDENTITY_ID))
+ .isInstanceOf(AnsServerException.class)
+ .hasMessageContaining("Network error");
+ }
+
+ private String linkedAgentsResponse() {
+ return """
+ {
+ "agents": [
+ {
+ "ansId": "ans://v1.0.0.agent.example.com",
+ "linkedAt": "2026-08-04T12:00:00Z",
+ "agentStatus": "ACTIVE"
+ }
+ ],
+ "total": 2
+ }
+ """;
+ }
+
+ private String agentIdentitiesResponse() {
+ return """
+ {
+ "identities": [
+ {
+ "identityId": "%s",
+ "kind": "did:web",
+ "value": "did:web:example.com",
+ "identityStatus": "VERIFIED",
+ "linkedAt": "2026-08-04T12:00:00Z"
+ }
+ ],
+ "total": 2
+ }
+ """.formatted(TEST_IDENTITY_ID);
+ }
+
// ==================== Test Data ====================
private String v1TransparencyLogResponse() {
@@ -870,6 +1524,48 @@ private String v1TransparencyLogResponse() {
""";
}
+ private String v1BadgeWithIdentitiesResponse() {
+ // Agent badge with the top-level computed identities[] join. The entries carry TL-view-only
+ // fields (keys/keysLogId) that LinkedIdentity does not model — they must be ignored, not fail.
+ return """
+ {
+ "status": "ACTIVE",
+ "schemaVersion": "V1",
+ "payload": {
+ "logId": "log-123",
+ "producer": {
+ "event": {
+ "ansName": "ans://v1.0.0.agent.example.com",
+ "eventType": "AGENT_REGISTERED",
+ "agent": { "host": "agent.example.com", "name": "Example Agent", "version": "v1.0.0" }
+ },
+ "keyId": "key-1",
+ "signature": "sig123"
+ }
+ },
+ "signature": "eyJhbGci...",
+ "identities": [
+ {
+ "identityId": "id-web-1",
+ "kind": "did:web",
+ "value": "did:web:example.com",
+ "identityStatus": "VERIFIED",
+ "linkedAt": "2026-01-01T00:00:00Z",
+ "keys": [{ "kty": "OKP" }],
+ "keysLogId": "keys-log-1"
+ },
+ {
+ "identityId": "id-lei-1",
+ "kind": "lei",
+ "value": "5493001KJTIIGC8Y1R12",
+ "identityStatus": "REVOKED"
+ }
+ ],
+ "identitiesTotal": 27
+ }
+ """;
+ }
+
private String v0TransparencyLogResponse() {
return """
{
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyServiceTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyServiceTest.java
index 6855492..f2f1c54 100644
--- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyServiceTest.java
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyServiceTest.java
@@ -222,6 +222,33 @@ void shouldParseV1Payload(WireMockRuntimeInfo wmRuntimeInfo) {
assertThat(result.getSchemaVersion()).isEqualTo("V1");
}
+ @Test
+ @DisplayName("Should parse V2 payload with list-shaped attestations")
+ void shouldParseV2Payload(WireMockRuntimeInfo wmRuntimeInfo) {
+ String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
+
+ stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "text/plain")
+ .withHeader("X-Schema-Version", "V2")
+ .withBody(v2Response())));
+
+ TransparencyService service = createService(baseUrl);
+ TransparencyLog result = service.getAgentTransparencyLog(TEST_AGENT_ID);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getSchemaVersion()).isEqualTo("V2");
+ assertThat(result.isV2()).isTrue();
+ assertThat(result.getV2Payload()).isNotNull();
+ assertThat(result.getV2Payload().getEventType())
+ .isEqualTo(com.godaddy.ans.sdk.transparency.model.EventTypeV1.AGENT_REGISTERED);
+ assertThat(result.getAgentHost()).isEqualTo("agent.example.com");
+ assertThat(result.getServerCertFingerprint()).isEqualTo("SHA256:server");
+ assertThat(result.getIdentityCertFingerprint()).isEqualTo("SHA256:identity");
+ assertThat(result.getV2Payload().getAttestations().getDnsRecordsProvisioned()).hasSize(1);
+ }
+
@Test
@DisplayName("Should parse V0 payload correctly")
void shouldParseV0Payload(WireMockRuntimeInfo wmRuntimeInfo) {
@@ -1021,6 +1048,47 @@ private String v0Response() {
""";
}
+ private String v2Response() {
+ return """
+ {
+ "status": "ACTIVE",
+ "schemaVersion": "V2",
+ "payload": {
+ "logId": "log-v2-123",
+ "producer": {
+ "event": {
+ "ansId": "6bf2b7a9-1383-4e33-a945-845f34af7526",
+ "ansName": "ans://v1.0.0.agent.example.com",
+ "eventType": "AGENT_REGISTERED",
+ "agent": {
+ "host": "agent.example.com",
+ "name": "Example Agent",
+ "version": "1.0.0"
+ },
+ "expiresAt": "2026-11-03T04:16:01Z",
+ "issuedAt": "2026-08-05T04:16:01Z",
+ "timestamp": "2026-08-05T04:16:01Z",
+ "attestations": {
+ "domainValidation": "ACME-DNS-01",
+ "dnsRecordsProvisioned": [
+ { "name": "agent.example.com", "type": "SVCB", "data": "1 . alpn=mcp port=443" }
+ ],
+ "identityCerts": [
+ { "fingerprint": "SHA256:identity", "type": "X509-OV-CLIENT" }
+ ],
+ "serverCerts": [
+ { "fingerprint": "SHA256:server", "type": "X509-DV-SERVER" }
+ ]
+ }
+ },
+ "keyId": "ans-ra-signer",
+ "signature": "sig"
+ }
+ }
+ }
+ """;
+ }
+
private String checkpointResponse() {
return """
{
diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java
index fc34bd1..dededb5 100644
--- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java
+++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java
@@ -1,5 +1,6 @@
package com.godaddy.ans.sdk.transparency.model;
+import com.godaddy.ans.sdk.model.LinkedIdentity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -1009,4 +1010,269 @@ void agentAuditParamsBuilderWorks() {
assertThat(params.getOffset()).isEqualTo(10);
assertThat(params.getLimit()).isEqualTo(25);
}
+
+ @Test
+ @DisplayName("LinkedAgentView getters, setters, and toString should work")
+ void linkedAgentViewGettersAndSettersWork() {
+ LinkedAgentView view = new LinkedAgentView();
+
+ view.setAnsId("ans://v1.0.0.agent.example.com");
+ view.setLinkedAt("2026-08-04T12:00:00Z");
+ view.setAgentStatus("ACTIVE");
+
+ assertThat(view.getAnsId()).isEqualTo("ans://v1.0.0.agent.example.com");
+ assertThat(view.getLinkedAt()).isEqualTo("2026-08-04T12:00:00Z");
+ assertThat(view.getAgentStatus()).isEqualTo("ACTIVE");
+ assertThat(view.toString())
+ .contains("ans://v1.0.0.agent.example.com")
+ .contains("ACTIVE");
+ }
+
+ @Test
+ @DisplayName("IdentityLinkedAgentsResponse getters, setters, and toString should work")
+ void identityLinkedAgentsResponseGettersAndSettersWork() {
+ LinkedAgentView view = new LinkedAgentView();
+ view.setAnsId("ans://v1.0.0.agent.example.com");
+
+ IdentityLinkedAgentsResponse response = new IdentityLinkedAgentsResponse();
+ response.setAgents(List.of(view));
+ response.setTotal(5);
+
+ assertThat(response.getAgents()).hasSize(1);
+ assertThat(response.getAgents().get(0).getAnsId()).isEqualTo("ans://v1.0.0.agent.example.com");
+ assertThat(response.getTotal()).isEqualTo(5);
+ assertThat(response.toString())
+ .contains("agents=1")
+ .contains("total=5");
+ }
+
+ @Test
+ @DisplayName("IdentityLinkedAgentsResponse toString should handle null agents")
+ void identityLinkedAgentsResponseToStringHandlesNullAgents() {
+ IdentityLinkedAgentsResponse response = new IdentityLinkedAgentsResponse();
+
+ assertThat(response.getAgents()).isNull();
+ assertThat(response.toString()).contains("agents=0");
+ }
+
+ @Test
+ @DisplayName("AgentIdentitiesResponse getters, setters, and toString should work")
+ void agentIdentitiesResponseGettersAndSettersWork() {
+ LinkedIdentity identity = new LinkedIdentity()
+ .identityId("id-1")
+ .kind(LinkedIdentity.KindEnum.DID_WEB)
+ .value("did:web:example.com")
+ .identityStatus(LinkedIdentity.IdentityStatusEnum.VERIFIED);
+
+ AgentIdentitiesResponse response = new AgentIdentitiesResponse();
+ response.setIdentities(List.of(identity));
+ response.setTotal(3);
+
+ assertThat(response.getIdentities()).hasSize(1);
+ assertThat(response.getIdentities().get(0).getIdentityId()).isEqualTo("id-1");
+ assertThat(response.getTotal()).isEqualTo(3);
+ assertThat(response.toString())
+ .contains("identities=1")
+ .contains("total=3");
+ }
+
+ @Test
+ @DisplayName("AgentIdentitiesResponse toString should handle null identities")
+ void agentIdentitiesResponseToStringHandlesNullIdentities() {
+ AgentIdentitiesResponse response = new AgentIdentitiesResponse();
+
+ assertThat(response.getIdentities()).isNull();
+ assertThat(response.toString()).contains("identities=0");
+ }
+
+ // ==================== V2 Model Classes ====================
+
+ @Test
+ @DisplayName("DnsRecordV2 getters, setters, and toString should work")
+ void dnsRecordV2GettersAndSettersWork() {
+ DnsRecordV2 record = new DnsRecordV2();
+ record.setName("agent.example.com");
+ record.setType("SVCB");
+ record.setData("1 . alpn=mcp port=443");
+
+ assertThat(record.getName()).isEqualTo("agent.example.com");
+ assertThat(record.getType()).isEqualTo("SVCB");
+ assertThat(record.getData()).isEqualTo("1 . alpn=mcp port=443");
+ assertThat(record.toString()).contains("agent.example.com").contains("SVCB");
+ }
+
+ @Test
+ @DisplayName("CertificateInfoV2 getters, setters, and toString should work")
+ void certificateInfoV2GettersAndSettersWork() {
+ OffsetDateTime notAfter = OffsetDateTime.now().plusYears(1);
+ CertificateInfoV2 info = new CertificateInfoV2();
+ info.setFingerprint("SHA256:abc123");
+ info.setNotAfter(notAfter);
+ info.setType(CertType.X509_OV_CLIENT);
+
+ assertThat(info.getFingerprint()).isEqualTo("SHA256:abc123");
+ assertThat(info.getNotAfter()).isEqualTo(notAfter);
+ assertThat(info.getType()).isEqualTo(CertType.X509_OV_CLIENT);
+ assertThat(info.toString()).contains("SHA256:abc123").contains("X509_OV_CLIENT");
+ }
+
+ @Test
+ @DisplayName("AttestationsV2 getters, setters, and toString should work")
+ void attestationsV2GettersAndSettersWork() {
+ AttestationsV2 attestations = new AttestationsV2();
+ DnsRecordV2 dns = new DnsRecordV2();
+ CertificateInfoV2 identityCert = new CertificateInfoV2();
+ identityCert.setFingerprint("SHA256:identity");
+ CertificateInfoV2 serverCert = new CertificateInfoV2();
+ serverCert.setFingerprint("SHA256:server");
+
+ attestations.setDomainValidation("ACME-DNS-01");
+ attestations.setDnsRecordsProvisioned(List.of(dns));
+ attestations.setIdentityCerts(List.of(identityCert));
+ attestations.setServerCerts(List.of(serverCert));
+
+ assertThat(attestations.getDomainValidation()).isEqualTo("ACME-DNS-01");
+ assertThat(attestations.getDnsRecordsProvisioned()).containsExactly(dns);
+ assertThat(attestations.getIdentityCerts()).containsExactly(identityCert);
+ assertThat(attestations.getServerCerts()).containsExactly(serverCert);
+ assertThat(attestations.toString()).contains("ACME-DNS-01");
+ }
+
+ @Test
+ @DisplayName("EventV2 getters, setters, and toString should work")
+ void eventV2GettersAndSettersWork() {
+ EventV2 event = new EventV2();
+ AgentV1 agent = new AgentV1();
+ AttestationsV2 attestations = new AttestationsV2();
+ OffsetDateTime now = OffsetDateTime.now();
+
+ event.setAnsId("ans-123");
+ event.setAnsName("ans://v1.0.0.agent.example");
+ event.setEventType(EventTypeV1.AGENT_REGISTERED);
+ event.setAgent(agent);
+ event.setAttestations(attestations);
+ event.setIssuedAt(now);
+ event.setExpiresAt(now.plusYears(1));
+ event.setRaId("ans-ra-local");
+ event.setRenewalStatus("valid");
+ event.setRevocationReasonCode(RevocationReason.KEY_COMPROMISE);
+ event.setRevokedAt(now);
+ event.setTimestamp(now);
+
+ assertThat(event.getAnsId()).isEqualTo("ans-123");
+ assertThat(event.getAnsName()).isEqualTo("ans://v1.0.0.agent.example");
+ assertThat(event.getEventType()).isEqualTo(EventTypeV1.AGENT_REGISTERED);
+ assertThat(event.getAgent()).isSameAs(agent);
+ assertThat(event.getAttestations()).isSameAs(attestations);
+ assertThat(event.getIssuedAt()).isEqualTo(now);
+ assertThat(event.getExpiresAt()).isEqualTo(now.plusYears(1));
+ assertThat(event.getRaId()).isEqualTo("ans-ra-local");
+ assertThat(event.getRenewalStatus()).isEqualTo("valid");
+ assertThat(event.getRevocationReasonCode()).isEqualTo(RevocationReason.KEY_COMPROMISE);
+ assertThat(event.getRevokedAt()).isEqualTo(now);
+ assertThat(event.getTimestamp()).isEqualTo(now);
+ assertThat(event.toString()).contains("ans-123");
+ }
+
+ @Test
+ @DisplayName("ProducerV2 getters, setters, and toString should work")
+ void producerV2GettersAndSettersWork() {
+ ProducerV2 producer = new ProducerV2();
+ EventV2 event = new EventV2();
+
+ producer.setEvent(event);
+ producer.setKeyId("key-123");
+ producer.setSignature("sig-456");
+
+ assertThat(producer.getEvent()).isSameAs(event);
+ assertThat(producer.getKeyId()).isEqualTo("key-123");
+ assertThat(producer.getSignature()).isEqualTo("sig-456");
+ assertThat(producer.toString()).contains("key-123");
+ }
+
+ @Test
+ @DisplayName("TransparencyLogV2 convenience methods should extract from producer event")
+ void transparencyLogV2ConvenienceMethodsWork() {
+ TransparencyLogV2 log = new TransparencyLogV2();
+ ProducerV2 producer = new ProducerV2();
+ EventV2 event = new EventV2();
+ AttestationsV2 attestations = new AttestationsV2();
+ event.setEventType(EventTypeV1.AGENT_REGISTERED);
+ event.setAnsName("ans://v1.0.0.agent.example");
+ event.setAttestations(attestations);
+ producer.setEvent(event);
+ log.setLogId("log-v2-123");
+ log.setProducer(producer);
+
+ assertThat(log.getLogId()).isEqualTo("log-v2-123");
+ assertThat(log.getProducer()).isSameAs(producer);
+ assertThat(log.getEvent()).isSameAs(event);
+ assertThat(log.getEventType()).isEqualTo(EventTypeV1.AGENT_REGISTERED);
+ assertThat(log.getAnsName()).isEqualTo("ans://v1.0.0.agent.example");
+ assertThat(log.getAttestations()).isSameAs(attestations);
+ assertThat(log.toString()).contains("log-v2-123");
+ }
+
+ @Test
+ @DisplayName("TransparencyLogV2 convenience methods return null when no producer")
+ void transparencyLogV2ConvenienceMethodsReturnNullWhenNoProducer() {
+ TransparencyLogV2 log = new TransparencyLogV2();
+
+ assertThat(log.getEvent()).isNull();
+ assertThat(log.getEventType()).isNull();
+ assertThat(log.getAnsName()).isNull();
+ assertThat(log.getAttestations()).isNull();
+ }
+
+ @Test
+ @DisplayName("TransparencyLog convenience methods should work for V2")
+ void transparencyLogConvenienceMethodsShouldWorkForV2() {
+ TransparencyLog log = new TransparencyLog();
+ log.setSchemaVersion("V2");
+
+ TransparencyLogV2 v2 = new TransparencyLogV2();
+ ProducerV2 producer = new ProducerV2();
+ EventV2 event = new EventV2();
+ AgentV1 agent = new AgentV1();
+ agent.setHost("agent.example.com");
+ AttestationsV2 attestations = new AttestationsV2();
+ CertificateInfoV2 serverCert = new CertificateInfoV2();
+ serverCert.setFingerprint("SHA256:server");
+ CertificateInfoV2 identityCert = new CertificateInfoV2();
+ identityCert.setFingerprint("SHA256:identity");
+ attestations.setServerCerts(List.of(serverCert));
+ attestations.setIdentityCerts(List.of(identityCert));
+ event.setAgent(agent);
+ event.setAttestations(attestations);
+ event.setAnsName("ans://v1.0.0.agent.example");
+ producer.setEvent(event);
+ v2.setProducer(producer);
+ log.setParsedPayload(v2);
+
+ assertThat(log.isV2()).isTrue();
+ assertThat(log.isV1()).isFalse();
+ assertThat(log.isV0()).isFalse();
+ assertThat(log.getV2Payload()).isSameAs(v2);
+ assertThat(log.getServerCertFingerprint()).isEqualTo("SHA256:server");
+ assertThat(log.getIdentityCertFingerprint()).isEqualTo("SHA256:identity");
+ assertThat(log.getAgentHost()).isEqualTo("agent.example.com");
+ assertThat(log.getAnsName()).isEqualTo("ans://v1.0.0.agent.example");
+ }
+
+ @Test
+ @DisplayName("TransparencyLog V2 fingerprint getters return null for empty cert lists")
+ void transparencyLogV2FingerprintNullForEmptyCertLists() {
+ TransparencyLog log = new TransparencyLog();
+ TransparencyLogV2 v2 = new TransparencyLogV2();
+ ProducerV2 producer = new ProducerV2();
+ EventV2 event = new EventV2();
+ event.setAttestations(new AttestationsV2());
+ producer.setEvent(event);
+ v2.setProducer(producer);
+ log.setParsedPayload(v2);
+
+ assertThat(log.isV2()).isTrue();
+ assertThat(log.getServerCertFingerprint()).isNull();
+ assertThat(log.getIdentityCertFingerprint()).isNull();
+ }
}
diff --git a/gradle.properties b/gradle.properties
index 547270f..8cfee32 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,6 +6,7 @@ reactorVersion=3.6.0
mcpSdkVersion=1.1.0
caffeineVersion=3.1.8
cborVersion=4.5.4
+nimbusJoseVersion=10.0.2
# Test versions
junitVersion=5.10.1