From 5891292be7b178442624f5f44bda25a6916c1761 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Thu, 30 Jul 2026 15:57:44 +1000 Subject: [PATCH 1/2] feat: sync with ans Discovery Profiles Signed-off-by: James Hateley --- .../sdk/model/AgentRegistrationRequest.java | 7 +- .../ans/sdk/registration/AnsApiClient.java | 27 ------ .../sdk/registration/RegistrationService.java | 31 ++++++- .../sdk/registration/AnsApiClientTest.java | 83 ------------------- .../registration/ApiVersionRoutingTest.java | 20 ++++- .../registration/RegistrationClientTest.java | 72 +++++++++++++++- 6 files changed, 117 insertions(+), 123 deletions(-) delete mode 100644 ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/AnsApiClientTest.java diff --git a/ans-sdk-api/src/main/java/com/godaddy/ans/sdk/model/AgentRegistrationRequest.java b/ans-sdk-api/src/main/java/com/godaddy/ans/sdk/model/AgentRegistrationRequest.java index b755899..37de56b 100644 --- a/ans-sdk-api/src/main/java/com/godaddy/ans/sdk/model/AgentRegistrationRequest.java +++ b/ans-sdk-api/src/main/java/com/godaddy/ans/sdk/model/AgentRegistrationRequest.java @@ -6,7 +6,6 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import java.util.ArrayList; -import java.util.Arrays; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -78,7 +77,7 @@ public class AgentRegistrationRequest { public static final String JSON_PROPERTY_DISCOVERY_PROFILES = "discoveryProfiles"; @Nullable - private Set discoveryProfiles = new LinkedHashSet<>(Arrays.asList(DiscoveryProfile.ANS_DNSAID)); + private Set discoveryProfiles = new LinkedHashSet<>(); public AgentRegistrationRequest() { } @@ -301,7 +300,7 @@ public AgentRegistrationRequest discoveryProfiles(@Nullable Set(Arrays.asList(DiscoveryProfile.ANS_DNSAID)); + this.discoveryProfiles = new LinkedHashSet<>(); } this.discoveryProfiles.add(discoveryProfilesItem); return this; @@ -324,7 +323,7 @@ public AgentRegistrationRequest addDiscoveryProfilesItem(DiscoveryProfile discov */ @Nullable @JsonProperty(value = JSON_PROPERTY_DISCOVERY_PROFILES, required = false) - @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + @JsonInclude(value = JsonInclude.Include.NON_EMPTY) public Set getDiscoveryProfiles() { return discoveryProfiles; } diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AnsApiClient.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AnsApiClient.java index 9bea8c8..c3abdfd 100644 --- a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AnsApiClient.java +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AnsApiClient.java @@ -2,9 +2,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.godaddy.ans.sdk.auth.AnsCredentials; import com.godaddy.ans.sdk.config.AnsConfiguration; @@ -168,31 +166,6 @@ String serializeToJson(Object object) { } } - /** - * Serializes an object to JSON with the named top-level field removed. - * - *

Used to strip v2-only fields (e.g. {@code discoveryProfiles}) from the - * wire body when targeting the v1 lane, without mutating the caller's object. - * Operates on the JSON tree and reuses this client's {@link ObjectMapper}.

- * - * @param object the object to serialize - * @param fieldName the top-level field to remove - * @return the JSON string without the named field - * @throws AnsServerException if serialization fails - */ - String serializeToJsonWithoutField(Object object, String fieldName) { - try { - JsonNode tree = objectMapper.valueToTree(object); - if (tree instanceof ObjectNode objectNode) { - objectNode.remove(fieldName); - } - - return objectMapper.writeValueAsString(tree); - } catch (IOException e) { - throw new AnsServerException("Failed to serialize request: " + e.getMessage(), 0, e, null); - } - } - ApiVersion getApiVersion() { return configuration.getApiVersion(); } diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationService.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationService.java index d413c4c..43311b5 100644 --- a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationService.java +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationService.java @@ -7,11 +7,13 @@ import com.godaddy.ans.sdk.model.AgentRevocationRequest; 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.Link; import com.godaddy.ans.sdk.model.RegistrationPending; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.Set; import java.util.UUID; /** @@ -35,10 +37,12 @@ class RegistrationService { * the 'self' link is followed instead (HATEOAS).

*/ AgentDetails register(AgentRegistrationRequest request) { - // discoveryProfiles is a v2-only field; strip it from the wire body on v1. - String requestBody = (apiVersion == ApiVersion.V1) - ? httpClient.serializeToJsonWithoutField(request, "discoveryProfiles") - : httpClient.serializeToJson(request); + // discoveryProfiles is a v2-only field. The v1 lane ignores it server-side, so an + // explicit selection on v1 is a client misconfiguration: reject it rather than drop + // it silently. An empty set (the default) carries no selection and is omitted on the wire. + rejectDiscoveryProfilesOnV1(request); + + String requestBody = httpClient.serializeToJson(request); HttpRequest httpRequest = httpClient.createRequestBuilder(AgentPaths.registerPath(apiVersion)) .POST(HttpRequest.BodyPublishers.ofString(requestBody)) @@ -50,6 +54,25 @@ AgentDetails register(AgentRegistrationRequest request) { return getAgentDetails(resolveAgentDetailsPath(pending)); } + /** + * Rejects a discoveryProfiles selection on the v1 lane. + * + *

discoveryProfiles is a v2-only field. The v1 lane ignores it server-side, so an + * explicit selection is a client misconfiguration. The default (empty) set carries no + * selection and is allowed on either lane.

+ */ + private void rejectDiscoveryProfilesOnV1(AgentRegistrationRequest request) { + if (apiVersion != ApiVersion.V1) { + return; + } + Set profiles = request.getDiscoveryProfiles(); + if (profiles != null && !profiles.isEmpty()) { + throw new IllegalArgumentException( + "discoveryProfiles requires ApiVersion.V2; the v1 lane ignores the field. " + + "Remove the profile selection or build the client with ApiVersion.V2."); + } + } + /** * Resolves the path to fetch full agent details after registration. * diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/AnsApiClientTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/AnsApiClientTest.java deleted file mode 100644 index 508d5aa..0000000 --- a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/AnsApiClientTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.godaddy.ans.sdk.registration; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.godaddy.ans.sdk.auth.ApiKeyCredentialsProvider; -import com.godaddy.ans.sdk.config.AnsConfiguration; -import com.godaddy.ans.sdk.config.Environment; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.LinkedHashMap; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Unit tests for {@link AnsApiClient} JSON serialization helpers. - */ -class AnsApiClientTest { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private AnsApiClient client() { - AnsConfiguration config = AnsConfiguration.builder() - .environment(Environment.OTE) - .credentialsProvider(new ApiKeyCredentialsProvider("test-api-key", "test-api-secret")) - .build(); - return new AnsApiClient(config); - } - - @Test - @DisplayName("serializeToJsonWithoutField removes the named top-level field") - void removesNamedTopLevelField() throws Exception { - Map object = new LinkedHashMap<>(); - object.put("agentDisplayName", "Test Agent"); - object.put("discoveryProfiles", "v2-only"); - - String json = client().serializeToJsonWithoutField(object, "discoveryProfiles"); - - JsonNode tree = MAPPER.readTree(json); - assertThat(tree.has("discoveryProfiles")).isFalse(); - assertThat(tree.get("agentDisplayName").asText()).isEqualTo("Test Agent"); - } - - @Test - @DisplayName("serializeToJsonWithoutField is a no-op when the named field is absent") - void noOpWhenFieldAbsent() throws Exception { - Map object = new LinkedHashMap<>(); - object.put("agentDisplayName", "Test Agent"); - object.put("version", "1.0.0"); - - String json = client().serializeToJsonWithoutField(object, "discoveryProfiles"); - - JsonNode tree = MAPPER.readTree(json); - assertThat(tree.get("agentDisplayName").asText()).isEqualTo("Test Agent"); - assertThat(tree.get("version").asText()).isEqualTo("1.0.0"); - } - - @Test - @DisplayName("serializeToJsonWithoutField only removes the top-level field, leaving nested fields intact") - void leavesNestedFieldIntact() throws Exception { - Map nested = new LinkedHashMap<>(); - nested.put("discoveryProfiles", "keep-me"); - - Map object = new LinkedHashMap<>(); - object.put("discoveryProfiles", "remove-me"); - object.put("nested", nested); - - String json = client().serializeToJsonWithoutField(object, "discoveryProfiles"); - - JsonNode tree = MAPPER.readTree(json); - assertThat(tree.has("discoveryProfiles")).isFalse(); - assertThat(tree.get("nested").get("discoveryProfiles").asText()).isEqualTo("keep-me"); - } - - @Test - @DisplayName("serializeToJsonWithoutField returns \"null\" for null input without throwing") - void handlesNullInput() { - String json = client().serializeToJsonWithoutField(null, "discoveryProfiles"); - - assertThat(json).isEqualTo("null"); - } -} diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/ApiVersionRoutingTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/ApiVersionRoutingTest.java index 2449139..0ff3a3a 100644 --- a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/ApiVersionRoutingTest.java +++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/ApiVersionRoutingTest.java @@ -13,12 +13,14 @@ import com.godaddy.ans.sdk.model.AgentEndpoint; import com.godaddy.ans.sdk.model.AgentRegistrationRequest; import com.godaddy.ans.sdk.model.AgentRevocationRequest; +import com.godaddy.ans.sdk.model.DiscoveryProfile; import com.godaddy.ans.sdk.model.Protocol; import com.godaddy.ans.sdk.model.RevocationReason; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.net.URI; +import java.util.Set; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.absent; @@ -88,8 +90,8 @@ void v1RegisterFollowsSelfLink(WireMockRuntimeInfo wm) { } @Test - @DisplayName("v1 register omits the v2-only discoveryProfiles field from the wire body") - void v1RegisterStripsDiscoveryProfiles(WireMockRuntimeInfo wm) { + @DisplayName("v1 register omits the empty discoveryProfiles default from the wire body") + void v1RegisterOmitsDefaultDiscoveryProfiles(WireMockRuntimeInfo wm) { stubFor(post(urlEqualTo("/v1/agents/register")) .willReturn(aResponse() .withStatus(202) @@ -109,6 +111,20 @@ void v1RegisterStripsDiscoveryProfiles(WireMockRuntimeInfo wm) { .withRequestBody(matchingJsonPath("$.discoveryProfiles", absent()))); } + @Test + @DisplayName("v1 register rejects an explicit discoveryProfiles selection") + void v1RegisterRejectsExplicitDiscoveryProfiles(WireMockRuntimeInfo wm) { + AgentRegistrationRequest request = sampleRequest() + .discoveryProfiles(Set.of(DiscoveryProfile.ANS_TXT)); + + assertThatThrownBy(() -> v1Client(wm).registerAgent(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ApiVersion.V2"); + + // The request must fail before any HTTP call reaches the register endpoint. + verify(0, postRequestedFor(urlEqualTo("/v1/agents/register"))); + } + @Test @DisplayName("v1 register throws when self link href is null") void shouldThrowWhenSelfLinkHrefIsNull(WireMockRuntimeInfo wm) { 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 63a7f24..e6e2911 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 @@ -17,6 +17,7 @@ import com.godaddy.ans.sdk.model.AgentRevocationRequest; 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.Protocol; import com.godaddy.ans.sdk.model.RegistrationPending; import com.godaddy.ans.sdk.model.RevocationReason; @@ -25,11 +26,14 @@ import java.net.URI; import java.time.Duration; +import java.util.Set; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.absent; import static com.github.tomakehurst.wiremock.client.WireMock.containing; 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.matchingJsonPath; 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.stubFor; @@ -174,12 +178,62 @@ void shouldRegisterAgentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { verify(postRequestedFor(urlEqualTo("/v2/ans/agents")) .withRequestBody(containing("\"agentDisplayName\":\"Test Agent\"")) - // discoveryProfiles is v2-only and defaults to [ANS_DNSAID] on the wire - .withRequestBody(containing("\"discoveryProfiles\"")) - .withRequestBody(containing("ANS_DNSAID")) .withHeader("Authorization", equalTo("sso-jwt " + TEST_JWT_TOKEN))); } + @Test + @DisplayName("v2 register sends an explicit discoveryProfiles selection on the wire") + void v2RegisterSendsExplicitDiscoveryProfiles(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(post(urlEqualTo("/v2/ans/agents")) + .willReturn(aResponse().withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(registrationPendingResponse()))); + 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(); + + client.registerAgent(sampleRegistrationRequest() + .discoveryProfiles(Set.of(DiscoveryProfile.ANS_TXT))); + + verify(postRequestedFor(urlEqualTo("/v2/ans/agents")) + .withRequestBody(matchingJsonPath("$.discoveryProfiles[?(@ == 'ANS_TXT')]"))); + } + + @Test + @DisplayName("v2 register omits the empty discoveryProfiles default from the wire") + void v2RegisterOmitsDefaultDiscoveryProfiles(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(post(urlEqualTo("/v2/ans/agents")) + .willReturn(aResponse().withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(registrationPendingResponse()))); + 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(); + + client.registerAgent(sampleRegistrationRequest()); + + verify(postRequestedFor(urlEqualTo("/v2/ans/agents")) + .withRequestBody(matchingJsonPath("$.discoveryProfiles", absent()))); + } + @Test @DisplayName("Should throw AnsValidationException on 422") void shouldThrowValidationExceptionOn422(WireMockRuntimeInfo wmRuntimeInfo) { @@ -784,6 +838,18 @@ void shouldThrowWhenV2RegistrationMissingAgentId(WireMockRuntimeInfo wmRuntimeIn // ==================== Helper Methods ==================== + private AgentRegistrationRequest sampleRegistrationRequest() { + return new AgentRegistrationRequest() + .agentDisplayName("Test Agent") + .version("1.0.0") + .agentHost("test-agent.example.com") + .addEndpointsItem(new AgentEndpoint() + .protocol(Protocol.A2_A) + .agentUrl(URI.create("https://test-agent.example.com/a2a"))) + .identityCsrPEM("-----BEGIN CERTIFICATE REQUEST-----\ntest\n-----END CERTIFICATE REQUEST-----") + .serverCsrPEM("-----BEGIN CERTIFICATE REQUEST-----\ntest\n-----END CERTIFICATE REQUEST-----"); + } + private String registrationPendingResponse() { return """ { From cfd94ffcf089ee8c0d58c3237cc994c46508acdb Mon Sep 17 00:00:00 2001 From: James Hateley Date: Fri, 31 Jul 2026 11:05:40 +1000 Subject: [PATCH 2/2] feat: test for null profile cases Signed-off-by: James Hateley --- .../registration/ApiVersionRoutingTest.java | 22 +++++++++++++++ .../registration/RegistrationClientTest.java | 27 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/ApiVersionRoutingTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/ApiVersionRoutingTest.java index 0ff3a3a..e5af3f6 100644 --- a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/ApiVersionRoutingTest.java +++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/ApiVersionRoutingTest.java @@ -125,6 +125,28 @@ void v1RegisterRejectsExplicitDiscoveryProfiles(WireMockRuntimeInfo wm) { verify(0, postRequestedFor(urlEqualTo("/v1/agents/register"))); } + @Test + @DisplayName("v1 register allows null discoveryProfiles") + void v1RegisterAllowsNullDiscoveryProfiles(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/register")) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(v1PendingWithSelfLink()))); + + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(agentDetails()))); + + v1Client(wm).registerAgent(sampleRequest().discoveryProfiles(null)); + + verify(postRequestedFor(urlEqualTo("/v1/agents/register")) + .withRequestBody(containing("\"agentDisplayName\":\"Test Agent\"")) + .withRequestBody(matchingJsonPath("$.discoveryProfiles", absent()))); + } + @Test @DisplayName("v1 register throws when self link href is null") void shouldThrowWhenSelfLinkHrefIsNull(WireMockRuntimeInfo wm) { 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 e6e2911..1ea8ce2 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 @@ -234,6 +234,33 @@ void v2RegisterOmitsDefaultDiscoveryProfiles(WireMockRuntimeInfo wmRuntimeInfo) .withRequestBody(matchingJsonPath("$.discoveryProfiles", absent()))); } + @Test + @DisplayName("v2 register allows null discoveryProfiles") + void v2RegisterAllowsNullDiscoveryProfiles(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(post(urlEqualTo("/v2/ans/agents")) + .willReturn(aResponse().withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(registrationPendingResponse()))); + 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(); + + client.registerAgent(sampleRegistrationRequest() + .discoveryProfiles(null)); + + verify(postRequestedFor(urlEqualTo("/v2/ans/agents")) + .withRequestBody(matchingJsonPath("$.discoveryProfiles", absent()))); + } + @Test @DisplayName("Should throw AnsValidationException on 422") void shouldThrowValidationExceptionOn422(WireMockRuntimeInfo wmRuntimeInfo) {