From ff2853e984592e08cf13da2d22a7e30c253c1da4 Mon Sep 17 00:00:00 2001 From: Vladimir Krasnov Date: Sat, 4 Jul 2026 06:05:16 +0300 Subject: [PATCH] feat: harden spec immutability --- ...TaskStatusUpdateEventMapper_v0_3_Test.java | 57 ++++++ .../server/tasks/InMemoryTaskStoreTest.java | 109 ++++++++++++ .../sdk/server/tasks/TaskManagerTest.java | 2 - .../sdk/spec/AgentCapabilities.java | 6 + .../org/a2aproject/sdk/spec/AgentCard.java | 28 +-- .../sdk/spec/AgentCardSignature.java | 2 + .../a2aproject/sdk/spec/AgentExtension.java | 2 + .../org/a2aproject/sdk/spec/AgentSkill.java | 5 + .../org/a2aproject/sdk/spec/Artifact.java | 8 +- .../sdk/spec/AuthorizationCodeOAuthFlow.java | 2 + .../a2aproject/sdk/spec/CancelTaskParams.java | 12 +- .../sdk/spec/ClientCredentialsOAuthFlow.java | 3 +- .../java/org/a2aproject/sdk/spec/Message.java | 11 +- .../sdk/spec/MessageSendConfiguration.java | 2 + .../sdk/spec/MessageSendParams.java | 3 + .../sdk/spec/SecurityRequirement.java | 9 +- .../sdk/spec/TaskArtifactUpdateEvent.java | 2 + .../sdk/spec/TaskStatusUpdateEvent.java | 12 +- .../a2aproject/sdk/util/CollectionCopies.java | 164 ++++++++++++++++++ .../sdk/spec/AgentCapabilitiesTest.java | 26 +++ .../sdk/spec/AgentCardSignatureTest.java | 39 +++++ .../a2aproject/sdk/spec/AgentCardTest.java | 64 +++++++ .../sdk/spec/AgentExtensionTest.java | 39 +++++ .../a2aproject/sdk/spec/AgentSkillTest.java | 45 +++++ .../org/a2aproject/sdk/spec/ArtifactTest.java | 50 ++++++ .../spec/AuthorizationCodeOAuthFlowTest.java | 30 ++++ .../sdk/spec/CancelTaskParamsTest.java | 43 +++++ .../spec/ClientCredentialsOAuthFlowTest.java | 28 +++ .../spec/MessageSendConfigurationTest.java | 71 ++++++++ .../sdk/spec/MessageSendParamsTest.java | 82 +++++++++ .../sdk/spec/SecurityRequirementTest.java | 30 ++++ .../sdk/spec/TaskArtifactUpdateEventTest.java | 53 ++++++ .../sdk/spec/TaskStatusUpdateEventTest.java | 110 ++++++++++++ 33 files changed, 1114 insertions(+), 35 deletions(-) create mode 100644 compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3_Test.java create mode 100644 spec/src/main/java/org/a2aproject/sdk/util/CollectionCopies.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/AgentCapabilitiesTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/AgentCardSignatureTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/AgentCardTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/AgentExtensionTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/AgentSkillTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/ArtifactTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/AuthorizationCodeOAuthFlowTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/CancelTaskParamsTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/ClientCredentialsOAuthFlowTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/MessageSendConfigurationTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/MessageSendParamsTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/SecurityRequirementTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/TaskArtifactUpdateEventTest.java create mode 100644 spec/src/test/java/org/a2aproject/sdk/spec/TaskStatusUpdateEventTest.java diff --git a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3_Test.java b/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3_Test.java new file mode 100644 index 000000000..b72cec3f2 --- /dev/null +++ b/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3_Test.java @@ -0,0 +1,57 @@ +package org.a2aproject.sdk.compat03.conversion.mappers.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.HashMap; +import java.util.Map; + +import org.a2aproject.sdk.compat03.spec.TaskState_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskStatusUpdateEvent_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskStatus_v0_3; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.junit.jupiter.api.Test; + +class TaskStatusUpdateEventMapper_v0_3_Test { + + @Test + void toV10_preservesMetadataAndDefensiveCopy() { + Map mutableMetadata = new HashMap<>(); + mutableMetadata.put("source", "sensor"); + + TaskStatusUpdateEvent_v0_3 v03 = new TaskStatusUpdateEvent_v0_3.Builder() + .taskId("task-123") + .status(new TaskStatus_v0_3(TaskState_v0_3.WORKING)) + .contextId("context-456") + .isFinal(false) + .metadata(mutableMetadata) + .build(); + + TaskStatusUpdateEvent v10 = TaskStatusUpdateEventMapper_v0_3.INSTANCE.toV10(v03); + mutableMetadata.put("write", "should-not-leak"); + + assertEquals("task-123", v10.taskId()); + assertEquals(TaskState.TASK_STATE_WORKING, v10.status().state()); + assertEquals("context-456", v10.contextId()); + assertEquals(Map.of("source", "sensor"), v10.metadata()); + assertThrows(UnsupportedOperationException.class, () -> v10.metadata().put("another", "value")); + } + + @Test + void fromV10_preservesMetadata() { + TaskStatusUpdateEvent v10 = new TaskStatusUpdateEvent( + "task-123", + new TaskStatus(TaskState.TASK_STATE_WORKING), + "context-456", + Map.of("source", "sensor")); + + TaskStatusUpdateEvent_v0_3 v03 = TaskStatusUpdateEventMapper_v0_3.INSTANCE.fromV10(v10); + + assertEquals("task-123", v03.taskId()); + assertEquals(TaskState_v0_3.WORKING, v03.status().state()); + assertEquals("context-456", v03.contextId()); + assertEquals(Map.of("source", "sensor"), v03.metadata()); + } +} diff --git a/server-common/src/test/java/org/a2aproject/sdk/server/tasks/InMemoryTaskStoreTest.java b/server-common/src/test/java/org/a2aproject/sdk/server/tasks/InMemoryTaskStoreTest.java index e69de29bb..53da3de94 100644 --- a/server-common/src/test/java/org/a2aproject/sdk/server/tasks/InMemoryTaskStoreTest.java +++ b/server-common/src/test/java/org/a2aproject/sdk/server/tasks/InMemoryTaskStoreTest.java @@ -0,0 +1,109 @@ +package org.a2aproject.sdk.server.tasks; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.ListTasksParams; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.Test; + +public class InMemoryTaskStoreTest { + + @Test + public void testSaveAndGet() { + InMemoryTaskStore store = new InMemoryTaskStore(); + Task task = sampleTask("task-abc"); + + store.save(task, false); + + Task retrieved = store.get(task.id()); + assertSame(task, retrieved); + } + + @Test + public void testGetNonExistent() { + InMemoryTaskStore store = new InMemoryTaskStore(); + + Task retrieved = store.get("nonexistent"); + assertNull(retrieved); + } + + @Test + public void testDelete() { + InMemoryTaskStore store = new InMemoryTaskStore(); + Task task = sampleTask("task-abc"); + + store.save(task, false); + store.delete(task.id()); + + Task retrieved = store.get(task.id()); + assertNull(retrieved); + } + + @Test + public void testDeleteNonExistent() { + InMemoryTaskStore store = new InMemoryTaskStore(); + + store.delete("non-existent"); + } + + @Test + public void testListTransformsHistoryAndArtifacts() { + InMemoryTaskStore store = new InMemoryTaskStore(); + Task task = Task.builder() + .id("task-abc") + .contextId("session-xyz") + .status(new TaskStatus(TaskState.TASK_STATE_WORKING)) + .history(List.of(sampleMessage("msg-1"), sampleMessage("msg-2"))) + .artifacts(List.of(sampleArtifact("artifact-1"))) + .metadata(Map.of("origin", "test")) + .build(); + + store.save(task, false); + + ListTasksParams params = ListTasksParams.builder().build(); + List tasks = store.list(params, null).tasks(); + + assertEquals(1, tasks.size()); + Task listed = tasks.get(0); + assertEquals(task.id(), listed.id()); + assertEquals(task.contextId(), listed.contextId()); + assertTrue(listed.history().isEmpty(), "Default list() should omit history"); + assertTrue(listed.artifacts().isEmpty(), "Default list() should omit artifacts"); + assertEquals(task.metadata(), listed.metadata()); + } + + private static Task sampleTask(String id) { + return Task.builder() + .id(id) + .contextId("session-xyz") + .status(new TaskStatus(TaskState.TASK_STATE_SUBMITTED)) + .build(); + } + + private static Message sampleMessage(String messageId) { + return Message.builder() + .role(Message.Role.ROLE_USER) + .parts(List.of(new TextPart("content"))) + .messageId(messageId) + .build(); + } + + private static Artifact sampleArtifact(String artifactId) { + return Artifact.builder() + .artifactId(artifactId) + .parts(List.of(new TextPart("artifact content"))) + .build(); + } +} + diff --git a/server-common/src/test/java/org/a2aproject/sdk/server/tasks/TaskManagerTest.java b/server-common/src/test/java/org/a2aproject/sdk/server/tasks/TaskManagerTest.java index 3df19522f..75e04f2cd 100644 --- a/server-common/src/test/java/org/a2aproject/sdk/server/tasks/TaskManagerTest.java +++ b/server-common/src/test/java/org/a2aproject/sdk/server/tasks/TaskManagerTest.java @@ -95,8 +95,6 @@ public void testSaveTaskEventStatusUpdate() throws A2AServerException { assertEquals(initialTask.id(), updated.id()); assertEquals(initialTask.contextId(), updated.contextId()); - // TODO type does not get unmarshalled - //assertEquals(initialTask.getType(), updated.getType()); assertSame(newStatus, updated.status()); } diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/AgentCapabilities.java b/spec/src/main/java/org/a2aproject/sdk/spec/AgentCapabilities.java index 0503dc209..268f89f19 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/AgentCapabilities.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/AgentCapabilities.java @@ -1,6 +1,8 @@ package org.a2aproject.sdk.spec; +import org.a2aproject.sdk.util.CollectionCopies; import java.util.List; + import org.jspecify.annotations.Nullable; /** @@ -35,6 +37,10 @@ public record AgentCapabilities(boolean streaming, boolean extendedAgentCard, @Nullable List extensions) { + public AgentCapabilities { + extensions = CollectionCopies.immutableNullableList(extensions); + } + /** * Create a new Builder * diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/AgentCard.java b/spec/src/main/java/org/a2aproject/sdk/spec/AgentCard.java index a119bc7d5..723d21462 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/AgentCard.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/AgentCard.java @@ -1,13 +1,13 @@ package org.a2aproject.sdk.spec; +import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.util.CollectionCopies; +import org.jspecify.annotations.Nullable; + import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Map; -import org.a2aproject.sdk.util.Assert; -import org.jspecify.annotations.Nullable; - /** * The AgentCard is a self-describing manifest for an agent in the A2A Protocol. *

@@ -89,6 +89,14 @@ public record AgentCard( Assert.checkNotNullParam("skills", skills); Assert.checkNotNullParam("supportedInterfaces", supportedInterfaces); Assert.checkNotNullParam("version", version); + defaultInputModes = CollectionCopies.immutableList(defaultInputModes); + defaultOutputModes = CollectionCopies.immutableList(defaultOutputModes); + skills = CollectionCopies.immutableList(skills); + securitySchemes = CollectionCopies.immutableNullableMap(securitySchemes); + securityRequirements = CollectionCopies.immutableNullableList(securityRequirements); + supportedInterfaces = CollectionCopies.immutableList(supportedInterfaces); + signatures = CollectionCopies.immutableNullableList(signatures); + additionalInterfaces = CollectionCopies.immutableNullableList(additionalInterfaces); } /** @@ -184,13 +192,13 @@ private Builder(AgentCard card) { this.version = card.version(); this.documentationUrl = card.documentationUrl(); this.capabilities = card.capabilities(); - this.defaultInputModes = card.defaultInputModes() != null ? new ArrayList<>(card.defaultInputModes()) : Collections.emptyList(); - this.defaultOutputModes = card.defaultOutputModes() != null ? new ArrayList<>(card.defaultOutputModes()) : Collections.emptyList(); - this.skills = card.skills() != null ? new ArrayList<>(card.skills()) : Collections.emptyList(); - this.securitySchemes = card.securitySchemes() != null ? Map.copyOf(card.securitySchemes()) : Collections.emptyMap(); - this.securityRequirements = card.securityRequirements() != null ? new ArrayList<>(card.securityRequirements()) : Collections.emptyList(); + this.defaultInputModes = CollectionCopies.mutableListCopyOrEmpty(card.defaultInputModes()); + this.defaultOutputModes = CollectionCopies.mutableListCopyOrEmpty(card.defaultOutputModes()); + this.skills = CollectionCopies.mutableListCopyOrEmpty(card.skills()); + this.securitySchemes = CollectionCopies.mutableMapCopyOrEmpty(card.securitySchemes()); + this.securityRequirements = CollectionCopies.mutableListCopyOrEmpty(card.securityRequirements()); this.iconUrl = card.iconUrl(); - this.supportedInterfaces = card.supportedInterfaces() != null ? new ArrayList<>(card.supportedInterfaces()) : Collections.emptyList(); + this.supportedInterfaces = CollectionCopies.mutableListCopyOrEmpty(card.supportedInterfaces()); this.signatures = card.signatures() != null ? new ArrayList<>(card.signatures()) : null; this.url = card.url(); this.preferredTransport= card.preferredTransport(); diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/AgentCardSignature.java b/spec/src/main/java/org/a2aproject/sdk/spec/AgentCardSignature.java index 9d39a844f..1401c3828 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/AgentCardSignature.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/AgentCardSignature.java @@ -4,6 +4,7 @@ import com.google.gson.annotations.SerializedName; import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.util.CollectionCopies; import org.jspecify.annotations.Nullable; /** @@ -46,6 +47,7 @@ public record AgentCardSignature(@Nullable Map header, @Serializ public AgentCardSignature { Assert.checkNotNullParam("protectedHeader", protectedHeader); Assert.checkNotNullParam("signature", signature); + header = CollectionCopies.unmodifiableNullableShallowMap(header); } /** diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/AgentExtension.java b/spec/src/main/java/org/a2aproject/sdk/spec/AgentExtension.java index e5f06fbce..1d3f1029c 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/AgentExtension.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/AgentExtension.java @@ -3,6 +3,7 @@ import java.util.Map; import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.util.CollectionCopies; import org.jspecify.annotations.Nullable; /** @@ -37,6 +38,7 @@ public record AgentExtension (@Nullable String description, @Nullable Map> parts) { * @return this builder for method chaining */ public Builder parts(Part... parts) { - this.parts = List.of(parts); + this.parts = CollectionCopies.immutableList(List.of(parts)); return this; } @@ -191,7 +195,7 @@ public Builder metadata(@Nullable Map metadata) { * @return this builder for method chaining */ public Builder extensions(@Nullable List extensions) { - this.extensions = (extensions == null) ? null : List.copyOf(extensions); + this.extensions = CollectionCopies.immutableNullableList(extensions); return this; } diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/AuthorizationCodeOAuthFlow.java b/spec/src/main/java/org/a2aproject/sdk/spec/AuthorizationCodeOAuthFlow.java index 22166270f..e1b012515 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/AuthorizationCodeOAuthFlow.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/AuthorizationCodeOAuthFlow.java @@ -3,6 +3,7 @@ import java.util.Map; import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.util.CollectionCopies; /** * Configuration for the OAuth 2.0 Authorization Code flow. @@ -42,5 +43,6 @@ public record AuthorizationCodeOAuthFlow(String authorizationUrl, String refresh Assert.checkNotNullParam("authorizationUrl", authorizationUrl); Assert.checkNotNullParam("scopes", scopes); Assert.checkNotNullParam("tokenUrl", tokenUrl); + scopes = CollectionCopies.immutableMap(scopes); } } diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/CancelTaskParams.java b/spec/src/main/java/org/a2aproject/sdk/spec/CancelTaskParams.java index e823af312..3324406b0 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/CancelTaskParams.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/CancelTaskParams.java @@ -1,8 +1,9 @@ package org.a2aproject.sdk.spec; -import org.a2aproject.sdk.util.Assert; -import java.util.Collections; import java.util.Map; + +import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.util.CollectionCopies; import org.jspecify.annotations.Nullable; /** @@ -27,6 +28,7 @@ public record CancelTaskParams(String id, @Nullable String tenant, Map metadata = Collections.emptyMap(); + private Map metadata = Map.of(); /** * Creates a new Builder with all fields unset. @@ -90,7 +92,7 @@ public Builder tenant(@Nullable String tenant) { * @return this builder */ public Builder metadata(Map metadata) { - this.metadata = Map.copyOf(metadata); + this.metadata = CollectionCopies.unmodifiableShallowMap(metadata); return this; } diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/ClientCredentialsOAuthFlow.java b/spec/src/main/java/org/a2aproject/sdk/spec/ClientCredentialsOAuthFlow.java index 6d056fdb0..c4c5b8a52 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/ClientCredentialsOAuthFlow.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/ClientCredentialsOAuthFlow.java @@ -1,9 +1,9 @@ package org.a2aproject.sdk.spec; - import java.util.Map; import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.util.CollectionCopies; /** * Configuration for the OAuth 2.0 Client Credentials flow. @@ -38,6 +38,7 @@ public record ClientCredentialsOAuthFlow(String refreshUrl, Map public ClientCredentialsOAuthFlow { Assert.checkNotNullParam("scopes", scopes); Assert.checkNotNullParam("tokenUrl", tokenUrl); + scopes = CollectionCopies.immutableMap(scopes); } } diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/Message.java b/spec/src/main/java/org/a2aproject/sdk/spec/Message.java index 4587521d6..8d64332bc 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/Message.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/Message.java @@ -5,6 +5,7 @@ import java.util.UUID; import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.util.CollectionCopies; import org.jspecify.annotations.Nullable; /** @@ -68,10 +69,10 @@ public record Message(Role role, List> parts, if (parts.isEmpty()) { throw new IllegalArgumentException("Parts cannot be empty"); } - parts = List.copyOf(parts); - referenceTaskIds = referenceTaskIds != null ? List.copyOf(referenceTaskIds) : null; - metadata = (metadata != null) ? Map.copyOf(metadata) : null; - extensions = extensions != null ? List.copyOf(extensions) : null; + parts = CollectionCopies.immutableList(parts); + referenceTaskIds = CollectionCopies.immutableNullableList(referenceTaskIds); + metadata = CollectionCopies.immutableNullableMap(metadata); + extensions = CollectionCopies.immutableNullableList(extensions); } @Override @@ -272,7 +273,7 @@ public Builder metadata(@Nullable Map metadata) { * @return this builder for method chaining */ public Builder extensions(List extensions) { - this.extensions = (extensions == null) ? null : List.copyOf(extensions); + this.extensions = CollectionCopies.immutableNullableList(extensions); return this; } diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/MessageSendConfiguration.java b/spec/src/main/java/org/a2aproject/sdk/spec/MessageSendConfiguration.java index 52ff37e70..fa96bd42c 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/MessageSendConfiguration.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/MessageSendConfiguration.java @@ -2,6 +2,7 @@ import java.util.List; +import org.a2aproject.sdk.util.CollectionCopies; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -36,6 +37,7 @@ public record MessageSendConfiguration(@Nullable List acceptedOutputMode * @throws IllegalArgumentException if historyLength is negative */ public MessageSendConfiguration { + acceptedOutputModes = CollectionCopies.immutableNullableList(acceptedOutputModes); if (historyLength != null && historyLength < 0) { throw new IllegalArgumentException("Invalid history length"); } diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/MessageSendParams.java b/spec/src/main/java/org/a2aproject/sdk/spec/MessageSendParams.java index 0f4bafe5f..c79bbe8ba 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/MessageSendParams.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/MessageSendParams.java @@ -1,5 +1,7 @@ package org.a2aproject.sdk.spec; +import java.util.Collections; +import java.util.HashMap; import java.util.Map; import org.a2aproject.sdk.util.Assert; @@ -35,6 +37,7 @@ public record MessageSendParams(Message message, @Nullable MessageSendConfigurat */ public MessageSendParams { Assert.checkNotNullParam("message", message); + metadata = metadata == null ? null : Collections.unmodifiableMap(new HashMap<>(metadata)); } /** diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/SecurityRequirement.java b/spec/src/main/java/org/a2aproject/sdk/spec/SecurityRequirement.java index eca8d0d01..d22f816d3 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/SecurityRequirement.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/SecurityRequirement.java @@ -7,6 +7,7 @@ import java.util.Map; import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.util.CollectionCopies; /** * Represents a security requirement in the A2A Protocol. @@ -53,7 +54,11 @@ public record SecurityRequirement(Map> schemes) { */ public SecurityRequirement { Assert.checkNotNullParam("schemes", schemes); - schemes = unmodifiableMap(new LinkedHashMap<>(schemes)); + LinkedHashMap> copiedSchemes = new LinkedHashMap<>(); + for (Map.Entry> entry : schemes.entrySet()) { + copiedSchemes.put(entry.getKey(), CollectionCopies.immutableList(entry.getValue())); + } + schemes = unmodifiableMap(copiedSchemes); } /** @@ -94,7 +99,7 @@ private Builder() { * @return this builder for method chaining */ public Builder scheme(String schemeName, List scopes) { - this.schemes.put(schemeName, List.copyOf(scopes)); + this.schemes.put(schemeName, CollectionCopies.immutableList(scopes)); return this; } diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/TaskArtifactUpdateEvent.java b/spec/src/main/java/org/a2aproject/sdk/spec/TaskArtifactUpdateEvent.java index 459334aee..2c0e5bcc9 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/TaskArtifactUpdateEvent.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/TaskArtifactUpdateEvent.java @@ -3,6 +3,7 @@ import java.util.Map; import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.util.CollectionCopies; import org.jspecify.annotations.Nullable; /** @@ -62,6 +63,7 @@ public record TaskArtifactUpdateEvent(String taskId, Artifact artifact, String c Assert.checkNotNullParam("taskId", taskId); Assert.checkNotNullParam("artifact", artifact); Assert.checkNotNullParam("contextId", contextId); + metadata = CollectionCopies.unmodifiableNullableShallowMap(metadata); } @Override diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/TaskStatusUpdateEvent.java b/spec/src/main/java/org/a2aproject/sdk/spec/TaskStatusUpdateEvent.java index 5c2b582a2..bf8a6ec06 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/TaskStatusUpdateEvent.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/TaskStatusUpdateEvent.java @@ -1,16 +1,11 @@ package org.a2aproject.sdk.spec; -import static org.a2aproject.sdk.spec.TaskState.TASK_STATE_CANCELED; -import static org.a2aproject.sdk.spec.TaskState.TASK_STATE_COMPLETED; -import static org.a2aproject.sdk.spec.TaskState.TASK_STATE_FAILED; -import static org.a2aproject.sdk.spec.TaskState.TASK_STATE_INPUT_REQUIRED; -import static org.a2aproject.sdk.spec.TaskState.TASK_STATE_REJECTED; - -import java.util.Map; - import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.util.CollectionCopies; import org.jspecify.annotations.Nullable; +import java.util.Map; + /** * An event sent by the agent to notify the client of a change in a task's status. * This is typically used in streaming or subscription models. @@ -41,6 +36,7 @@ public record TaskStatusUpdateEvent(String taskId, TaskStatus status, String con Assert.checkNotNullParam("taskId", taskId); Assert.checkNotNullParam("status", status); Assert.checkNotNullParam("contextId", contextId); + metadata = CollectionCopies.unmodifiableNullableShallowMap(metadata); } @Override diff --git a/spec/src/main/java/org/a2aproject/sdk/util/CollectionCopies.java b/spec/src/main/java/org/a2aproject/sdk/util/CollectionCopies.java new file mode 100644 index 000000000..ea10259bd --- /dev/null +++ b/spec/src/main/java/org/a2aproject/sdk/util/CollectionCopies.java @@ -0,0 +1,164 @@ +package org.a2aproject.sdk.util; + +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Utility methods for creating defensive copies of collection fields. + *

+ * The helpers make the immutability and null-handling choices explicit at call sites: + *

    + *
  • {@code immutable*} methods are intended for record components and return immutable collections.
  • + *
  • {@code nullable*} methods preserve the {@code null} value of optional components.
  • + *
  • {@code *AllowingNulls} methods are intended for JSON-like maps such as metadata, + * params, and headers, where {@code null} values may be valid protocol data.
  • + *
  • {@code mutable*CopyOrEmpty} methods are intended for builders that need mutable copies.
  • + *
+ */ +public final class CollectionCopies { + + private CollectionCopies() { + } + + /** + * Creates an immutable defensive copy of a non-null list. + *

+ * This method uses {@link List#copyOf(java.util.Collection)} and therefore preserves + * its null-handling semantics: null elements are not allowed. + * + * @param list the source list, must not be {@code null} + * @param the element type + * @return an immutable defensive copy + */ + public static List immutableList(List list) { + return List.copyOf(list); + } + + /** + * Creates an immutable defensive copy of a nullable list. + *

+ * If the source list is {@code null}, this method returns {@code null}. Otherwise it + * uses {@link List#copyOf(java.util.Collection)} and therefore does not allow null elements. + * + * @param list the source list, or {@code null} + * @param the element type + * @return {@code null} if the source is {@code null}, otherwise an immutable defensive copy + */ + public static @Nullable List immutableNullableList(@Nullable List list) { + return list == null ? null : List.copyOf(list); + } + + /** + * Creates an immutable defensive copy of a nullable list, defaulting to an empty immutable list. + *

+ * This method is intended for fields where a missing list should be normalized to an empty list + * rather than preserving {@code null}. It uses {@link List#copyOf(java.util.Collection)} for + * non-null input and therefore does not allow null elements. + * + * @param list the source list, or {@code null} + * @param the element type + * @return an immutable empty list if the source is {@code null}, otherwise an immutable defensive copy + */ + public static List immutableListOrEmpty(@Nullable List list) { + return list == null ? List.of() : List.copyOf(list); + } + + /** + * Creates an immutable defensive copy of a non-null map. + *

+ * This method uses {@link Map#copyOf(Map)} and is intended for typed maps where + * null keys and null values are not valid data. + * + * @param map the source map, must not be {@code null} + * @param the key type + * @param the value type + * @return an immutable defensive copy + */ + public static Map immutableMap(Map map) { + return Map.copyOf(map); + } + + /** + * Creates an immutable defensive copy of a nullable map. + *

+ * If the source map is {@code null}, this method returns {@code null}. Otherwise it + * uses {@link Map#copyOf(Map)} and is intended for typed maps where null keys and + * null values are not valid data. + * + * @param map the source map, or {@code null} + * @param the key type + * @param the value type + * @return {@code null} if the source is {@code null}, otherwise an immutable defensive copy + */ + public static @Nullable Map immutableNullableMap(@Nullable Map map) { + return map == null ? null : Map.copyOf(map); + } + + /** + * Creates an unmodifiable shallow defensive copy of a non-null map while preserving null values. + *

+ * This method is intended for JSON-like maps such as metadata, params, and headers, + * where null values may be valid protocol data. It intentionally does not use + * {@link Map#copyOf(Map)}, because {@code Map.copyOf} throws {@link NullPointerException} + * when the source map contains null keys or values. + *

+ * The returned map cannot be structurally modified, but this method does not deep-copy + * mutable keys or values. + * + * @param map the source map, must not be {@code null} + * @param the key type + * @param the value type + * @return an unmodifiable shallow copy preserving null values + */ + public static Map unmodifiableShallowMap(Map map) { + return Collections.unmodifiableMap(new HashMap<>(map)); + } + + /** + * Creates an unmodifiable shallow defensive copy of a nullable map while preserving null values. + *

+ * If the source map is {@code null}, this method returns {@code null}. Otherwise it behaves like + * {@link #unmodifiableShallowMap(Map)}. + * + * @param map the source map, or {@code null} + * @param the key type + * @param the value type + * @return {@code null} if the source is {@code null}, otherwise an unmodifiable shallow copy + * preserving null values + */ + public static @Nullable Map unmodifiableNullableShallowMap(@Nullable Map map) { + return map == null ? null : unmodifiableShallowMap(map); + } + + /** + * Creates a mutable defensive copy of a nullable list. + *

+ * This method is intended for builders that need mutable collection state. + * + * @param list the source list, or {@code null} + * @param the element type + * @return a mutable defensive copy, or an empty mutable list if the source is {@code null} + */ + public static List mutableListCopyOrEmpty(@Nullable List list) { + return list == null ? new ArrayList<>() : new ArrayList<>(list); + } + + /** + * Creates a mutable defensive copy of a nullable map. + *

+ * This method is intended for builders that need mutable collection state. + * + * @param map the source map, or {@code null} + * @param the key type + * @param the value type + * @return a mutable defensive copy, or an empty mutable map if the source is {@code null} + */ + public static Map mutableMapCopyOrEmpty(@Nullable Map map) { + return map == null ? new HashMap<>() : new HashMap<>(map); + } +} \ No newline at end of file diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/AgentCapabilitiesTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/AgentCapabilitiesTest.java new file mode 100644 index 000000000..938bb4b75 --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/AgentCapabilitiesTest.java @@ -0,0 +1,26 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class AgentCapabilitiesTest { + + @Test + void testConstruction_defensivelyCopiesExtensions() { + List extensions = new ArrayList<>(); + extensions.add(new AgentExtension("Custom auth", Map.of("authType", "bearer"), true, "https://example.com/ext")); + + AgentCapabilities capabilities = new AgentCapabilities(true, false, true, extensions); + extensions.add(new AgentExtension("Another", Map.of("x", "y"), false, "https://example.com/ext2")); + + assertEquals(1, capabilities.extensions().size()); + assertThrows(UnsupportedOperationException.class, () -> capabilities.extensions().add( + new AgentExtension("Mutated", Map.of(), false, "https://example.com/ext3"))); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/AgentCardSignatureTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/AgentCardSignatureTest.java new file mode 100644 index 000000000..186dcc884 --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/AgentCardSignatureTest.java @@ -0,0 +1,39 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class AgentCardSignatureTest { + + @Test + void testConstruction_defensivelyCopiesHeader() { + Map header = new HashMap<>(); + header.put("kid", "2024-01"); + + AgentCardSignature signature = new AgentCardSignature(header, "protected", "signature"); + header.put("alg", "ES256"); + + assertEquals(Map.of("kid", "2024-01"), signature.header()); + assertThrows(UnsupportedOperationException.class, () -> signature.header().put("x", "y")); + } + + @Test + void testConstruction_preservesNullHeaderValues() { + Map header = new HashMap<>(); + header.put("foo", null); + + AgentCardSignature signature = new AgentCardSignature(header, "protected", "signature"); + + assertEquals(1, signature.header().size()); + assertTrue(signature.header().containsKey("foo")); + assertNull(signature.header().get("foo")); + assertThrows(UnsupportedOperationException.class, () -> signature.header().put("bar", "baz")); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/AgentCardTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/AgentCardTest.java new file mode 100644 index 000000000..3dc27aa19 --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/AgentCardTest.java @@ -0,0 +1,64 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class AgentCardTest { + + @Test + void testConstruction_defensivelyCopiesCollections() { + List defaultInputModes = new ArrayList<>(); + defaultInputModes.add("text"); + List defaultOutputModes = new ArrayList<>(); + defaultOutputModes.add("text"); + List skills = new ArrayList<>(); + skills.add(AgentSkill.builder() + .id("weather_query") + .name("Weather Queries") + .description("Get weather information") + .tags(List.of("weather")) + .build()); + List supportedInterfaces = new ArrayList<>(); + supportedInterfaces.add(new AgentInterface("JSONRPC", "http://localhost:9999", null, "1.0")); + + AgentCard card = new AgentCard( + "Weather Agent", + "Provides weather information", + null, + "1.0.0", + null, + AgentCapabilities.builder().streaming(true).build(), + defaultInputModes, + defaultOutputModes, + skills, + null, + null, + null, + supportedInterfaces, + null, + null, + null, + null); + + defaultInputModes.add("audio"); + defaultOutputModes.add("audio"); + skills.add(AgentSkill.builder() + .id("translate") + .name("Translation") + .description("Translate text") + .tags(List.of("translate")) + .build()); + supportedInterfaces.add(new AgentInterface("GRPC", "grpc://localhost:9090", null, "1.0")); + + assertEquals(List.of("text"), card.defaultInputModes()); + assertEquals(List.of("text"), card.defaultOutputModes()); + assertEquals(1, card.skills().size()); + assertEquals(1, card.supportedInterfaces().size()); + assertThrows(UnsupportedOperationException.class, () -> card.defaultInputModes().add("video")); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/AgentExtensionTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/AgentExtensionTest.java new file mode 100644 index 000000000..280d6d876 --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/AgentExtensionTest.java @@ -0,0 +1,39 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class AgentExtensionTest { + + @Test + void testConstruction_defensivelyCopiesParams() { + Map params = new HashMap<>(); + params.put("authType", "bearer"); + + AgentExtension extension = new AgentExtension("Custom auth", params, true, "https://example.com/ext"); + params.put("mutated", true); + + assertEquals(Map.of("authType", "bearer"), extension.params()); + assertThrows(UnsupportedOperationException.class, () -> extension.params().put("x", "y")); + } + + @Test + void testConstruction_preservesNullParamsValues() { + Map params = new HashMap<>(); + params.put("foo", null); + + AgentExtension extension = new AgentExtension("Custom auth", params, true, "https://example.com/ext"); + + assertEquals(1, extension.params().size()); + assertTrue(extension.params().containsKey("foo")); + assertNull(extension.params().get("foo")); + assertThrows(UnsupportedOperationException.class, () -> extension.params().put("bar", "baz")); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/AgentSkillTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/AgentSkillTest.java new file mode 100644 index 000000000..39e8c2c22 --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/AgentSkillTest.java @@ -0,0 +1,45 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class AgentSkillTest { + + @Test + void testConstruction_defensivelyCopiesCollections() { + List tags = new ArrayList<>(); + tags.add("weather"); + List examples = new ArrayList<>(); + examples.add("What's the weather in Tokyo?"); + List inputModes = new ArrayList<>(); + inputModes.add("text"); + List outputModes = new ArrayList<>(); + outputModes.add("text"); + + AgentSkill skill = new AgentSkill( + "weather_query", + "Weather Queries", + "Get weather information", + tags, + examples, + inputModes, + outputModes, + null); + + tags.add("mutated"); + examples.add("mutated"); + inputModes.add("audio"); + outputModes.add("audio"); + + assertEquals(List.of("weather"), skill.tags()); + assertEquals(List.of("What's the weather in Tokyo?"), skill.examples()); + assertEquals(List.of("text"), skill.inputModes()); + assertEquals(List.of("text"), skill.outputModes()); + assertThrows(UnsupportedOperationException.class, () -> skill.tags().add("x")); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/ArtifactTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/ArtifactTest.java new file mode 100644 index 000000000..a2fc45ae1 --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/ArtifactTest.java @@ -0,0 +1,50 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class ArtifactTest { + + @Test + void testConstruction_defensivelyCopiesCollections() { + List> parts = new ArrayList<>(); + parts.add(new TextPart("hello")); + Map metadata = new HashMap<>(); + metadata.put("source", "web"); + List extensions = new ArrayList<>(); + extensions.add("ext-1"); + + Artifact artifact = new Artifact("artifact-1", "name", "desc", parts, metadata, extensions); + parts.add(new TextPart("mutated")); + metadata.put("write", "should-not-leak"); + extensions.add("ext-2"); + + assertEquals(1, artifact.parts().size()); + assertEquals(Map.of("source", "web"), artifact.metadata()); + assertEquals(List.of("ext-1"), artifact.extensions()); + assertThrows(UnsupportedOperationException.class, () -> artifact.parts().add(new TextPart("x"))); + } + + @Test + void testConstruction_preservesNullMetadataValues() { + List> parts = List.of(new TextPart("hello")); + Map metadata = new HashMap<>(); + metadata.put("foo", null); + + Artifact artifact = new Artifact("artifact-2", null, null, parts, metadata, null); + + assertEquals(1, artifact.metadata().size()); + assertTrue(artifact.metadata().containsKey("foo")); + assertNull(artifact.metadata().get("foo")); + assertThrows(UnsupportedOperationException.class, () -> artifact.metadata().put("bar", "baz")); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/AuthorizationCodeOAuthFlowTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/AuthorizationCodeOAuthFlowTest.java new file mode 100644 index 000000000..884138ef0 --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/AuthorizationCodeOAuthFlowTest.java @@ -0,0 +1,30 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class AuthorizationCodeOAuthFlowTest { + + @Test + void testConstruction_defensivelyCopiesScopes() { + Map scopes = new HashMap<>(); + scopes.put("read", "Read access"); + + AuthorizationCodeOAuthFlow flow = new AuthorizationCodeOAuthFlow( + "https://auth.example.com/authorize", + "https://auth.example.com/refresh", + scopes, + "https://auth.example.com/token", + true); + + scopes.put("write", "Write access"); + + assertEquals(Map.of("read", "Read access"), flow.scopes()); + assertThrows(UnsupportedOperationException.class, () -> flow.scopes().put("x", "y")); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/CancelTaskParamsTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/CancelTaskParamsTest.java new file mode 100644 index 000000000..d009dd1b3 --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/CancelTaskParamsTest.java @@ -0,0 +1,43 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class CancelTaskParamsTest { + + @Test + void testConstruction_defensivelyCopiesMetadata() { + Map metadata = new HashMap<>(); + metadata.put("reason", "timeout"); + + CancelTaskParams params = new CancelTaskParams("task-1", "tenant-1", metadata); + metadata.put("mutated", true); + + assertEquals(Map.of("reason", "timeout"), params.metadata()); + assertThrows(UnsupportedOperationException.class, () -> params.metadata().put("x", "y")); + } + + @Test + void testBuilderPreservesNullMetadataValues() { + Map metadata = new HashMap<>(); + metadata.put("foo", null); + + CancelTaskParams params = CancelTaskParams.builder() + .id("task-1") + .tenant("tenant-1") + .metadata(metadata) + .build(); + + assertEquals(1, params.metadata().size()); + assertTrue(params.metadata().containsKey("foo")); + assertNull(params.metadata().get("foo")); + assertThrows(UnsupportedOperationException.class, () -> params.metadata().put("bar", "baz")); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/ClientCredentialsOAuthFlowTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/ClientCredentialsOAuthFlowTest.java new file mode 100644 index 000000000..59db8b961 --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/ClientCredentialsOAuthFlowTest.java @@ -0,0 +1,28 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class ClientCredentialsOAuthFlowTest { + + @Test + void testConstruction_defensivelyCopiesScopes() { + Map scopes = new HashMap<>(); + scopes.put("read", "Read access"); + + ClientCredentialsOAuthFlow flow = new ClientCredentialsOAuthFlow( + "https://auth.example.com/refresh", + scopes, + "https://auth.example.com/token"); + + scopes.put("write", "Write access"); + + assertEquals(Map.of("read", "Read access"), flow.scopes()); + assertThrows(UnsupportedOperationException.class, () -> flow.scopes().put("x", "y")); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/MessageSendConfigurationTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/MessageSendConfigurationTest.java new file mode 100644 index 000000000..614f44a86 --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/MessageSendConfigurationTest.java @@ -0,0 +1,71 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class MessageSendConfigurationTest { + + @Test + void testConstruction_withAllFields() { + List acceptedOutputModes = List.of("text", "audio"); + TaskPushNotificationConfig config = TaskPushNotificationConfig.builder() + .id("id") + .taskId("task-123") + .url("https://example.com") + .build(); + + MessageSendConfiguration result = new MessageSendConfiguration(acceptedOutputModes, 3, config, Boolean.TRUE); + + assertEquals(acceptedOutputModes, result.acceptedOutputModes()); + assertEquals(Integer.valueOf(3), result.historyLength()); + assertEquals(config, result.taskPushNotificationConfig()); + assertEquals(Boolean.TRUE, result.returnImmediately()); + } + + @Test + void testConstruction_withNullAcceptedOutputModes() { + MessageSendConfiguration result = new MessageSendConfiguration(null, null, null, null); + + assertNull(result.acceptedOutputModes()); + assertNull(result.historyLength()); + assertNull(result.taskPushNotificationConfig()); + assertNull(result.returnImmediately()); + } + + @Test + void testConstruction_negativeHistoryLength_throwsException() { + assertThrows(IllegalArgumentException.class, + () -> new MessageSendConfiguration(List.of("text"), -1, null, null)); + } + + @Test + void testAcceptedOutputModes_areDefensivelyCopied() { + List mutableModes = new ArrayList<>(); + mutableModes.add("text"); + + MessageSendConfiguration result = new MessageSendConfiguration(mutableModes, null, null, null); + mutableModes.add("audio"); + + assertEquals(List.of("text"), result.acceptedOutputModes()); + assertThrows(UnsupportedOperationException.class, () -> result.acceptedOutputModes().add("video")); + } + + @Test + void testBuilderAcceptedOutputModes_areDefensivelyCopiedByRecord() { + List mutableModes = new ArrayList<>(); + mutableModes.add("text"); + + MessageSendConfiguration result = MessageSendConfiguration.builder() + .acceptedOutputModes(mutableModes) + .build(); + mutableModes.add("audio"); + + assertEquals(List.of("text"), result.acceptedOutputModes()); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/MessageSendParamsTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/MessageSendParamsTest.java new file mode 100644 index 000000000..81b0a782f --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/MessageSendParamsTest.java @@ -0,0 +1,82 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class MessageSendParamsTest { + + @Test + void testConstruction_withAllFields() { + Message message = Message.builder() + .role(Message.Role.ROLE_USER) + .parts(new TextPart("hello")) + .build(); + MessageSendConfiguration configuration = new MessageSendConfiguration(List.of("text"), 2, null, Boolean.TRUE); + Map metadata = Map.of("source", "web"); + + MessageSendParams params = new MessageSendParams(message, configuration, metadata); + + assertEquals(message, params.message()); + assertEquals(configuration, params.configuration()); + assertEquals(metadata, params.metadata()); + assertNull(params.tenant()); + } + + @Test + void testConstruction_nullMetadata() { + Message message = Message.builder() + .role(Message.Role.ROLE_USER) + .parts(new TextPart("hello")) + .build(); + + MessageSendParams params = new MessageSendParams(message, null, null); + + assertNull(params.metadata()); + } + + @Test + void testConstruction_nullMessage_throwsException() { + assertThrows(IllegalArgumentException.class, () -> new MessageSendParams(null, null, null)); + } + + @Test + void testMetadata_isDefensivelyCopied() { + Message message = Message.builder() + .role(Message.Role.ROLE_USER) + .parts(new TextPart("hello")) + .build(); + Map mutableMetadata = new HashMap<>(); + mutableMetadata.put("source", "web"); + + MessageSendParams params = new MessageSendParams(message, null, mutableMetadata); + mutableMetadata.put("write", "should-not-leak"); + + assertEquals(Map.of("source", "web"), params.metadata()); + assertThrows(UnsupportedOperationException.class, () -> params.metadata().put("another", "value")); + } + + @Test + void testMetadata_preservesNullValues() { + Message message = Message.builder() + .role(Message.Role.ROLE_USER) + .parts(new TextPart("hello")) + .build(); + Map mutableMetadata = new HashMap<>(); + mutableMetadata.put("foo", null); + + MessageSendParams params = new MessageSendParams(message, null, mutableMetadata); + + assertEquals(1, params.metadata().size()); + assertTrue(params.metadata().containsKey("foo")); + assertNull(params.metadata().get("foo")); + assertThrows(UnsupportedOperationException.class, () -> params.metadata().put("another", "value")); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/SecurityRequirementTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/SecurityRequirementTest.java new file mode 100644 index 000000000..f178414f5 --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/SecurityRequirementTest.java @@ -0,0 +1,30 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class SecurityRequirementTest { + + @Test + void testConstruction_defensivelyCopiesNestedScopes() { + List scopes = new ArrayList<>(); + scopes.add("read"); + Map> schemes = new HashMap<>(); + schemes.put("oauth2", scopes); + + SecurityRequirement requirement = new SecurityRequirement(schemes); + scopes.add("write"); + schemes.put("apiKey", List.of()); + + assertEquals(List.of("read"), requirement.schemes().get("oauth2")); + assertThrows(UnsupportedOperationException.class, () -> requirement.schemes().put("x", List.of())); + assertThrows(UnsupportedOperationException.class, () -> requirement.schemes().get("oauth2").add("x")); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/TaskArtifactUpdateEventTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/TaskArtifactUpdateEventTest.java new file mode 100644 index 000000000..d1734863d --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/TaskArtifactUpdateEventTest.java @@ -0,0 +1,53 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class TaskArtifactUpdateEventTest { + + @Test + void testConstruction_defensivelyCopiesMetadata() { + Map metadata = new HashMap<>(); + metadata.put("source", "web"); + + TaskArtifactUpdateEvent event = new TaskArtifactUpdateEvent( + "task-1", + new Artifact("artifact-1", null, null, List.of(new TextPart("hello")), null, null), + "context-1", + true, + false, + metadata); + + metadata.put("write", "should-not-leak"); + + assertEquals(Map.of("source", "web"), event.metadata()); + assertThrows(UnsupportedOperationException.class, () -> event.metadata().put("x", "y")); + } + + @Test + void testConstruction_preservesNullMetadataValues() { + Map metadata = new HashMap<>(); + metadata.put("foo", null); + + TaskArtifactUpdateEvent event = new TaskArtifactUpdateEvent( + "task-1", + new Artifact("artifact-1", null, null, List.of(new TextPart("hello")), null, null), + "context-1", + true, + false, + metadata); + + assertEquals(1, event.metadata().size()); + assertTrue(event.metadata().containsKey("foo")); + assertNull(event.metadata().get("foo")); + assertThrows(UnsupportedOperationException.class, () -> event.metadata().put("bar", "baz")); + } +} diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/TaskStatusUpdateEventTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/TaskStatusUpdateEventTest.java new file mode 100644 index 000000000..364a14447 --- /dev/null +++ b/spec/src/test/java/org/a2aproject/sdk/spec/TaskStatusUpdateEventTest.java @@ -0,0 +1,110 @@ +package org.a2aproject.sdk.spec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class TaskStatusUpdateEventTest { + + private static final String TASK_ID = "task-123"; + private static final String CONTEXT_ID = "context-456"; + + @Test + void testConstruction_withAllFields() { + Map metadata = Map.of("source", "sensor"); + TaskStatus status = new TaskStatus(TaskState.TASK_STATE_WORKING); + + TaskStatusUpdateEvent event = new TaskStatusUpdateEvent(TASK_ID, status, CONTEXT_ID, metadata); + + assertEquals(TASK_ID, event.taskId()); + assertEquals(status, event.status()); + assertEquals(CONTEXT_ID, event.contextId()); + assertEquals(metadata, event.metadata()); + } + + @Test + void testConstruction_withNullMetadata() { + TaskStatusUpdateEvent event = new TaskStatusUpdateEvent( + TASK_ID, + new TaskStatus(TaskState.TASK_STATE_WORKING), + CONTEXT_ID, + null); + + assertNull(event.metadata()); + } + + @Test + void testConstruction_nullTaskId_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + new TaskStatusUpdateEvent(null, new TaskStatus(TaskState.TASK_STATE_WORKING), CONTEXT_ID, Map.of())); + } + + @Test + void testConstruction_nullStatus_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + new TaskStatusUpdateEvent(TASK_ID, null, CONTEXT_ID, Map.of())); + } + + @Test + void testConstruction_nullContextId_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + new TaskStatusUpdateEvent(TASK_ID, new TaskStatus(TaskState.TASK_STATE_WORKING), null, Map.of())); + } + + @Test + void testMetadataImmutability() { + Map mutableMetadata = new HashMap<>(); + mutableMetadata.put("source", "sensor"); + + TaskStatusUpdateEvent event = TaskStatusUpdateEvent.builder() + .taskId(TASK_ID) + .status(new TaskStatus(TaskState.TASK_STATE_WORKING)) + .contextId(CONTEXT_ID) + .metadata(mutableMetadata) + .build(); + + mutableMetadata.put("write", "should-not-leak"); + + assertEquals(Map.of("source", "sensor"), event.metadata()); + assertThrows(UnsupportedOperationException.class, () -> event.metadata().put("another", "value")); + } + + @Test + void testConstruction_preservesNullMetadataValues() { + Map metadata = new HashMap<>(); + metadata.put("foo", null); + + TaskStatusUpdateEvent event = new TaskStatusUpdateEvent( + TASK_ID, + new TaskStatus(TaskState.TASK_STATE_WORKING), + CONTEXT_ID, + metadata); + + assertEquals(1, event.metadata().size()); + assertTrue(event.metadata().containsKey("foo")); + assertNull(event.metadata().get("foo")); + assertThrows(UnsupportedOperationException.class, () -> event.metadata().put("bar", "baz")); + } + + @Test + void testBuilderFromExistingEvent_keepsEqualValues() { + TaskStatusUpdateEvent original = new TaskStatusUpdateEvent( + TASK_ID, + new TaskStatus(TaskState.TASK_STATE_WORKING), + CONTEXT_ID, + Map.of("source", "sensor")); + + TaskStatusUpdateEvent copied = TaskStatusUpdateEvent.builder(original).build(); + + assertEquals(original, copied); + assertEquals(original.hashCode(), copied.hashCode()); + assertNotEquals(original, null); + } +}