Skip to content

Commit e20a312

Browse files
authored
feat: Add support for notification tokens in BasePushNotificationSender (#308)
# Description Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [x] Follow the [`CONTRIBUTING` Guide](../CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - Important Prefixes for [release-please](https://github.com/googleapis/release-please): - `fix:` which represents bug fixes, and correlates to a [SemVer](https://semver.org/) patch. - `feat:` represents a new feature, and correlates to a SemVer minor. - `feat!:`, or `fix!:`, `refactor!:`, etc., which represent a breaking change (indicated by the `!`) and will result in a SemVer major. - [x] Ensure the tests pass - [x] Appropriate READMEs were updated (if necessary) Fixes #307 🦕
1 parent 3f89c77 commit e20a312

File tree

4 files changed

+107
-14
lines changed

4 files changed

+107
-14
lines changed

common/src/main/java/io/a2a/common/A2AHeaders.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ public final class A2AHeaders {
1010
* Used to communicate which extensions are requested by the client.
1111
*/
1212
public static final String X_A2A_EXTENSIONS = "X-A2A-Extensions";
13+
14+
/**
15+
* HTTP header name for a push notification token.
16+
*/
17+
public static final String X_A2A_NOTIFICATION_TOKEN = "X-A2A-Notification-Token";
1318

1419
private A2AHeaders() {
1520
// Utility class

server-common/src/main/java/io/a2a/server/tasks/BasePushNotificationSender.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.a2a.server.tasks;
22

3+
import static io.a2a.common.A2AHeaders.X_A2A_NOTIFICATION_TOKEN;
34
import jakarta.enterprise.context.ApplicationScoped;
45
import jakarta.inject.Inject;
56

@@ -68,10 +69,12 @@ private CompletableFuture<Boolean> dispatch(Task task, PushNotificationConfig pu
6869

6970
private boolean dispatchNotification(Task task, PushNotificationConfig pushInfo) {
7071
String url = pushInfo.url();
72+
String token = pushInfo.token();
7173

72-
// TODO: Implement authentication and token header support
73-
// The Python implementation adds X-A2A-Notification-Token header when pushInfo.token is present
74-
// See: https://github.com/a2aproject/a2a-python/blob/main/src/a2a/server/tasks/base_push_notification_sender.py#L55-57
74+
A2AHttpClient.PostBuilder postBuilder = httpClient.createPost();
75+
if (token != null && !token.isBlank()) {
76+
postBuilder.addHeader(X_A2A_NOTIFICATION_TOKEN, token);
77+
}
7578

7679
String body;
7780
try {
@@ -85,7 +88,7 @@ private boolean dispatchNotification(Task task, PushNotificationConfig pushInfo)
8588
}
8689

8790
try {
88-
httpClient.createPost()
91+
postBuilder
8992
.url(url)
9093
.body(body)
9194
.post();

server-common/src/test/java/io/a2a/server/tasks/InMemoryPushNotificationConfigStoreTest.java

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@
1414
import java.util.List;
1515

1616
import org.junit.jupiter.api.BeforeEach;
17-
import org.junit.jupiter.api.Disabled;
1817
import org.junit.jupiter.api.Test;
1918
import org.mockito.Mock;
2019
import org.mockito.MockitoAnnotations;
2120

2221
import io.a2a.client.http.A2AHttpClient;
2322
import io.a2a.client.http.A2AHttpResponse;
23+
import io.a2a.common.A2AHeaders;
2424
import io.a2a.spec.PushNotificationConfig;
2525
import io.a2a.spec.Task;
2626
import io.a2a.spec.TaskState;
@@ -47,6 +47,29 @@ public void setUp() {
4747
notificationSender = new BasePushNotificationSender(configStore, mockHttpClient);
4848
}
4949

50+
private void setupBasicMockHttpResponse() throws Exception {
51+
when(mockHttpClient.createPost()).thenReturn(mockPostBuilder);
52+
when(mockPostBuilder.url(any(String.class))).thenReturn(mockPostBuilder);
53+
when(mockPostBuilder.body(any(String.class))).thenReturn(mockPostBuilder);
54+
when(mockPostBuilder.post()).thenReturn(mockHttpResponse);
55+
when(mockHttpResponse.success()).thenReturn(true);
56+
}
57+
58+
private void verifyHttpCallWithoutToken(PushNotificationConfig config, Task task, String expectedToken) throws Exception {
59+
ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class);
60+
verify(mockHttpClient).createPost();
61+
verify(mockPostBuilder).url(config.url());
62+
verify(mockPostBuilder).body(bodyCaptor.capture());
63+
verify(mockPostBuilder).post();
64+
// Verify that addHeader was never called for authentication token
65+
verify(mockPostBuilder, never()).addHeader(A2AHeaders.X_A2A_NOTIFICATION_TOKEN, expectedToken);
66+
67+
// Verify the request body contains the task data
68+
String sentBody = bodyCaptor.getValue();
69+
assertTrue(sentBody.contains(task.getId()));
70+
assertTrue(sentBody.contains(task.getStatus().state().asString()));
71+
}
72+
5073
private Task createSampleTask(String taskId, TaskState state) {
5174
return new Task.Builder()
5275
.id(taskId)
@@ -229,7 +252,6 @@ public void testSendNotificationSuccess() throws Exception {
229252
}
230253

231254
@Test
232-
@Disabled("Token authentication is not yet implemented in BasePushNotificationSender (TODO auth)")
233255
public void testSendNotificationWithToken() throws Exception {
234256
String taskId = "task_send_with_token";
235257
Task task = createSampleTask(taskId, TaskState.COMPLETED);
@@ -240,21 +262,19 @@ public void testSendNotificationWithToken() throws Exception {
240262
when(mockHttpClient.createPost()).thenReturn(mockPostBuilder);
241263
when(mockPostBuilder.url(any(String.class))).thenReturn(mockPostBuilder);
242264
when(mockPostBuilder.body(any(String.class))).thenReturn(mockPostBuilder);
265+
when(mockPostBuilder.addHeader(any(String.class), any(String.class))).thenReturn(mockPostBuilder);
243266
when(mockPostBuilder.post()).thenReturn(mockHttpResponse);
244267
when(mockHttpResponse.success()).thenReturn(true);
245268

246269
notificationSender.sendNotification(task);
247270

248-
// TODO: Once token authentication is implemented, verify that:
249-
// 1. The token is included in request headers (e.g., X-A2A-Notification-Token)
250-
// 2. The HTTP client is called with proper authentication
251-
// 3. The token from the config is actually used
252-
253-
// For now, just verify basic HTTP client interaction
271+
// Verify HTTP client was called with proper authentication
254272
ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class);
255273
verify(mockHttpClient).createPost();
256274
verify(mockPostBuilder).url(config.url());
257275
verify(mockPostBuilder).body(bodyCaptor.capture());
276+
// Verify that the token is included in request headers as X-A2A-Notification-Token
277+
verify(mockPostBuilder).addHeader(A2AHeaders.X_A2A_NOTIFICATION_TOKEN, config.token());
258278
verify(mockPostBuilder).post();
259279

260280
// Verify the request body contains the task data
@@ -274,6 +294,30 @@ public void testSendNotificationNoConfig() throws Exception {
274294
verify(mockHttpClient, never()).createPost();
275295
}
276296

297+
@Test
298+
public void testSendNotificationWithEmptyToken() throws Exception {
299+
String taskId = "task_send_empty_token";
300+
Task task = createSampleTask(taskId, TaskState.COMPLETED);
301+
PushNotificationConfig config = createSamplePushConfig("http://notify.me/here", "cfg1", "");
302+
configStore.setInfo(taskId, config);
303+
304+
setupBasicMockHttpResponse();
305+
notificationSender.sendNotification(task);
306+
verifyHttpCallWithoutToken(config, task, "");
307+
}
308+
309+
@Test
310+
public void testSendNotificationWithBlankToken() throws Exception {
311+
String taskId = "task_send_blank_token";
312+
Task task = createSampleTask(taskId, TaskState.COMPLETED);
313+
PushNotificationConfig config = createSamplePushConfig("http://notify.me/here", "cfg1", " ");
314+
configStore.setInfo(taskId, config);
315+
316+
setupBasicMockHttpResponse();
317+
notificationSender.sendNotification(task);
318+
verifyHttpCallWithoutToken(config, task, " ");
319+
}
320+
277321
@Test
278322
public void testMultipleConfigsForSameTask() {
279323
String taskId = "task_multiple";

server-common/src/test/java/io/a2a/server/tasks/PushNotificationSenderTest.java

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import io.a2a.client.http.A2AHttpClient;
2020
import io.a2a.client.http.A2AHttpResponse;
21+
import io.a2a.common.A2AHeaders;
2122
import io.a2a.util.Utils;
2223
import io.a2a.spec.PushNotificationConfig;
2324
import io.a2a.spec.Task;
@@ -133,6 +134,33 @@ public void setUp() {
133134
sender = new BasePushNotificationSender(configStore, testHttpClient);
134135
}
135136

137+
private void testSendNotificationWithInvalidToken(String token, String testName) throws InterruptedException {
138+
String taskId = testName;
139+
Task taskData = createSampleTask(taskId, TaskState.COMPLETED);
140+
PushNotificationConfig config = createSamplePushConfig("http://notify.me/here", "cfg1", token);
141+
142+
// Set up the configuration in the store
143+
configStore.setInfo(taskId, config);
144+
145+
// Set up latch to wait for async completion
146+
testHttpClient.latch = new CountDownLatch(1);
147+
148+
sender.sendNotification(taskData);
149+
150+
// Wait for the async operation to complete
151+
assertTrue(testHttpClient.latch.await(5, TimeUnit.SECONDS), "HTTP call should complete within 5 seconds");
152+
153+
// Verify the task was sent via HTTP
154+
assertEquals(1, testHttpClient.tasks.size());
155+
Task sentTask = testHttpClient.tasks.get(0);
156+
assertEquals(taskData.getId(), sentTask.getId());
157+
158+
// Verify that no authentication header was sent (invalid token should not add header)
159+
assertEquals(1, testHttpClient.headers.size());
160+
Map<String, String> sentHeaders = testHttpClient.headers.get(0);
161+
assertTrue(sentHeaders.isEmpty(), "No headers should be sent when token is invalid");
162+
}
163+
136164
private Task createSampleTask(String taskId, TaskState state) {
137165
return new Task.Builder()
138166
.id(taskId)
@@ -196,8 +224,11 @@ public void testSendNotificationWithTokenSuccess() throws InterruptedException {
196224
Task sentTask = testHttpClient.tasks.get(0);
197225
assertEquals(taskData.getId(), sentTask.getId());
198226

199-
// TODO: When authentication is implemented in BasePushNotificationSender, verify that the
200-
// X-A2A-Notification-Token header is sent.
227+
// Verify that the X-A2A-Notification-Token header is sent with the correct token
228+
assertEquals(1, testHttpClient.headers.size());
229+
Map<String, String> sentHeaders = testHttpClient.headers.get(0);
230+
assertTrue(sentHeaders.containsKey(A2AHeaders.X_A2A_NOTIFICATION_TOKEN));
231+
assertEquals(config.token(), sentHeaders.get(A2AHeaders.X_A2A_NOTIFICATION_TOKEN));
201232
}
202233

203234
@Test
@@ -212,6 +243,16 @@ public void testSendNotificationNoConfig() {
212243
assertEquals(0, testHttpClient.tasks.size());
213244
}
214245

246+
@Test
247+
public void testSendNotificationWithEmptyToken() throws InterruptedException {
248+
testSendNotificationWithInvalidToken("", "task_send_empty_token");
249+
}
250+
251+
@Test
252+
public void testSendNotificationWithBlankToken() throws InterruptedException {
253+
testSendNotificationWithInvalidToken(" ", "task_send_blank_token");
254+
}
255+
215256
@Test
216257
public void testSendNotificationMultipleConfigs() throws InterruptedException {
217258
String taskId = "task_multiple_configs";

0 commit comments

Comments
 (0)