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