44 * SPDX-License-Identifier: Apache-2.0
55 */
66
7- import { describe , it , expect } from 'vitest' ;
7+ import { describe , it , expect , vi , beforeEach , afterEach } from 'vitest' ;
88import {
99 extractMessageText ,
1010 extractIdsFromResponse ,
1111 isTerminalState ,
1212 A2AResultReassembler ,
1313 AUTH_REQUIRED_MSG ,
14+ normalizeAgentCard ,
15+ getGrpcCredentials ,
16+ pinUrlToIp ,
17+ splitAgentCardUrl ,
1418} from './a2aUtils.js' ;
1519import type { SendMessageResult } from './a2a-client-manager.js' ;
1620import type {
@@ -22,8 +26,105 @@ import type {
2226 TaskStatusUpdateEvent ,
2327 TaskArtifactUpdateEvent ,
2428} from '@a2a-js/sdk' ;
29+ import * as dnsPromises from 'node:dns/promises' ;
30+ import type { LookupAddress } from 'node:dns' ;
31+
32+ vi . mock ( 'node:dns/promises' , ( ) => ( {
33+ lookup : vi . fn ( ) ,
34+ } ) ) ;
2535
2636describe ( 'a2aUtils' , ( ) => {
37+ beforeEach ( ( ) => {
38+ vi . clearAllMocks ( ) ;
39+ } ) ;
40+
41+ afterEach ( ( ) => {
42+ vi . restoreAllMocks ( ) ;
43+ } ) ;
44+
45+ describe ( 'getGrpcCredentials' , ( ) => {
46+ it ( 'should return secure credentials for https' , ( ) => {
47+ const credentials = getGrpcCredentials ( 'https://test.agent' ) ;
48+ expect ( credentials ) . toBeDefined ( ) ;
49+ } ) ;
50+
51+ it ( 'should return insecure credentials for http' , ( ) => {
52+ const credentials = getGrpcCredentials ( 'http://test.agent' ) ;
53+ expect ( credentials ) . toBeDefined ( ) ;
54+ } ) ;
55+ } ) ;
56+
57+ describe ( 'pinUrlToIp' , ( ) => {
58+ it ( 'should resolve and pin hostname to IP' , async ( ) => {
59+ vi . mocked (
60+ dnsPromises . lookup as unknown as (
61+ hostname : string ,
62+ options : { all : true } ,
63+ ) => Promise < LookupAddress [ ] > ,
64+ ) . mockResolvedValue ( [ { address : '93.184.216.34' , family : 4 } ] ) ;
65+
66+ const { pinnedUrl, hostname } = await pinUrlToIp (
67+ 'http://example.com:9000' ,
68+ 'test-agent' ,
69+ ) ;
70+ expect ( hostname ) . toBe ( 'example.com' ) ;
71+ expect ( pinnedUrl ) . toBe ( 'http://93.184.216.34:9000/' ) ;
72+ } ) ;
73+
74+ it ( 'should handle raw host:port strings (standard for gRPC)' , async ( ) => {
75+ vi . mocked (
76+ dnsPromises . lookup as unknown as (
77+ hostname : string ,
78+ options : { all : true } ,
79+ ) => Promise < LookupAddress [ ] > ,
80+ ) . mockResolvedValue ( [ { address : '93.184.216.34' , family : 4 } ] ) ;
81+
82+ const { pinnedUrl, hostname } = await pinUrlToIp (
83+ 'example.com:9000' ,
84+ 'test-agent' ,
85+ ) ;
86+ expect ( hostname ) . toBe ( 'example.com' ) ;
87+ expect ( pinnedUrl ) . toBe ( '93.184.216.34:9000' ) ;
88+ } ) ;
89+
90+ it ( 'should throw error if resolution fails (fail closed)' , async ( ) => {
91+ vi . mocked ( dnsPromises . lookup ) . mockRejectedValue ( new Error ( 'DNS Error' ) ) ;
92+
93+ await expect (
94+ pinUrlToIp ( 'http://unreachable.com' , 'test-agent' ) ,
95+ ) . rejects . toThrow ( "Failed to resolve host for agent 'test-agent'" ) ;
96+ } ) ;
97+
98+ it ( 'should throw error if resolved to private IP' , async ( ) => {
99+ vi . mocked (
100+ dnsPromises . lookup as unknown as (
101+ hostname : string ,
102+ options : { all : true } ,
103+ ) => Promise < LookupAddress [ ] > ,
104+ ) . mockResolvedValue ( [ { address : '10.0.0.1' , family : 4 } ] ) ;
105+
106+ await expect (
107+ pinUrlToIp ( 'http://malicious.com' , 'test-agent' ) ,
108+ ) . rejects . toThrow ( 'resolves to private IP range' ) ;
109+ } ) ;
110+
111+ it ( 'should allow localhost/127.0.0.1/::1 exceptions' , async ( ) => {
112+ vi . mocked (
113+ dnsPromises . lookup as unknown as (
114+ hostname : string ,
115+ options : { all : true } ,
116+ ) => Promise < LookupAddress [ ] > ,
117+ ) . mockResolvedValue ( [ { address : '127.0.0.1' , family : 4 } ] ) ;
118+
119+ const { pinnedUrl, hostname } = await pinUrlToIp (
120+ 'http://localhost:9000' ,
121+ 'test-agent' ,
122+ ) ;
123+ expect ( hostname ) . toBe ( 'localhost' ) ;
124+ expect ( pinnedUrl ) . toBe ( 'http://127.0.0.1:9000/' ) ;
125+ } ) ;
126+ } ) ;
127+
27128 describe ( 'isTerminalState' , ( ) => {
28129 it ( 'should return true for completed, failed, canceled, and rejected' , ( ) => {
29130 expect ( isTerminalState ( 'completed' ) ) . toBe ( true ) ;
@@ -223,6 +324,173 @@ describe('a2aUtils', () => {
223324 } as Message ) ,
224325 ) . toBe ( '' ) ;
225326 } ) ;
327+
328+ it ( 'should handle file parts with neither name nor uri' , ( ) => {
329+ const message : Message = {
330+ kind : 'message' ,
331+ role : 'user' ,
332+ messageId : '1' ,
333+ parts : [
334+ {
335+ kind : 'file' ,
336+ file : {
337+ mimeType : 'text/plain' ,
338+ } ,
339+ } as FilePart ,
340+ ] ,
341+ } ;
342+ expect ( extractMessageText ( message ) ) . toBe ( 'File: [binary/unnamed]' ) ;
343+ } ) ;
344+ } ) ;
345+
346+ describe ( 'normalizeAgentCard' , ( ) => {
347+ it ( 'should throw if input is not an object' , ( ) => {
348+ expect ( ( ) => normalizeAgentCard ( null ) ) . toThrow ( 'Agent card is missing.' ) ;
349+ expect ( ( ) => normalizeAgentCard ( undefined ) ) . toThrow (
350+ 'Agent card is missing.' ,
351+ ) ;
352+ expect ( ( ) => normalizeAgentCard ( 'not an object' ) ) . toThrow (
353+ 'Agent card is missing.' ,
354+ ) ;
355+ } ) ;
356+
357+ it ( 'should preserve unknown fields while providing defaults for mandatory ones' , ( ) => {
358+ const raw = {
359+ name : 'my-agent' ,
360+ customField : 'keep-me' ,
361+ } ;
362+
363+ const normalized = normalizeAgentCard ( raw ) ;
364+
365+ expect ( normalized . name ) . toBe ( 'my-agent' ) ;
366+ // @ts -expect-error - testing dynamic preservation
367+ expect ( normalized . customField ) . toBe ( 'keep-me' ) ;
368+ expect ( normalized . description ) . toBe ( '' ) ;
369+ expect ( normalized . skills ) . toEqual ( [ ] ) ;
370+ expect ( normalized . defaultInputModes ) . toEqual ( [ ] ) ;
371+ } ) ;
372+
373+ it ( 'should normalize and synchronize interfaces while preserving other fields' , ( ) => {
374+ const raw = {
375+ name : 'test' ,
376+ supportedInterfaces : [
377+ {
378+ url : 'grpc://test' ,
379+ protocolBinding : 'GRPC' ,
380+ protocolVersion : '1.0' ,
381+ } ,
382+ ] ,
383+ } ;
384+
385+ const normalized = normalizeAgentCard ( raw ) ;
386+
387+ // Should exist in both fields
388+ expect ( normalized . additionalInterfaces ) . toHaveLength ( 1 ) ;
389+ expect (
390+ ( normalized as unknown as Record < string , unknown > ) [
391+ 'supportedInterfaces'
392+ ] ,
393+ ) . toHaveLength ( 1 ) ;
394+
395+ const intf = normalized . additionalInterfaces ?. [ 0 ] as unknown as Record <
396+ string ,
397+ unknown
398+ > ;
399+
400+ expect ( intf [ 'transport' ] ) . toBe ( 'GRPC' ) ;
401+ expect ( intf [ 'url' ] ) . toBe ( 'grpc://test' ) ;
402+
403+ // Should fallback top-level url
404+ expect ( normalized . url ) . toBe ( 'grpc://test' ) ;
405+ } ) ;
406+
407+ it ( 'should preserve existing top-level url if present' , ( ) => {
408+ const raw = {
409+ name : 'test' ,
410+ url : 'http://existing' ,
411+ supportedInterfaces : [ { url : 'http://other' , transport : 'REST' } ] ,
412+ } ;
413+
414+ const normalized = normalizeAgentCard ( raw ) ;
415+ expect ( normalized . url ) . toBe ( 'http://existing' ) ;
416+ } ) ;
417+
418+ it ( 'should NOT prepend http:// scheme to raw IP:port strings for gRPC interfaces' , ( ) => {
419+ const raw = {
420+ name : 'raw-ip-grpc' ,
421+ supportedInterfaces : [ { url : '127.0.0.1:9000' , transport : 'GRPC' } ] ,
422+ } ;
423+
424+ const normalized = normalizeAgentCard ( raw ) ;
425+ expect ( normalized . additionalInterfaces ?. [ 0 ] . url ) . toBe ( '127.0.0.1:9000' ) ;
426+ expect ( normalized . url ) . toBe ( '127.0.0.1:9000' ) ;
427+ } ) ;
428+
429+ it ( 'should prepend http:// scheme to raw IP:port strings for REST interfaces' , ( ) => {
430+ const raw = {
431+ name : 'raw-ip-rest' ,
432+ supportedInterfaces : [ { url : '127.0.0.1:8080' , transport : 'REST' } ] ,
433+ } ;
434+
435+ const normalized = normalizeAgentCard ( raw ) ;
436+ expect ( normalized . additionalInterfaces ?. [ 0 ] . url ) . toBe (
437+ 'http://127.0.0.1:8080' ,
438+ ) ;
439+ } ) ;
440+
441+ it ( 'should NOT override existing transport if protocolBinding is also present' , ( ) => {
442+ const raw = {
443+ name : 'priority-test' ,
444+ supportedInterfaces : [
445+ { url : 'foo' , transport : 'GRPC' , protocolBinding : 'REST' } ,
446+ ] ,
447+ } ;
448+ const normalized = normalizeAgentCard ( raw ) ;
449+ expect ( normalized . additionalInterfaces ?. [ 0 ] . transport ) . toBe ( 'GRPC' ) ;
450+ } ) ;
451+ } ) ;
452+
453+ describe ( 'splitAgentCardUrl' , ( ) => {
454+ const standard = '.well-known/agent-card.json' ;
455+
456+ it ( 'should return baseUrl as-is if it does not end with standard path' , ( ) => {
457+ const url = 'http://localhost:9001/custom/path' ;
458+ expect ( splitAgentCardUrl ( url ) ) . toEqual ( { baseUrl : url } ) ;
459+ } ) ;
460+
461+ it ( 'should split correctly if URL ends with standard path' , ( ) => {
462+ const url = `http://localhost:9001/${ standard } ` ;
463+ expect ( splitAgentCardUrl ( url ) ) . toEqual ( {
464+ baseUrl : 'http://localhost:9001/' ,
465+ path : undefined ,
466+ } ) ;
467+ } ) ;
468+
469+ it ( 'should handle trailing slash in baseUrl when splitting' , ( ) => {
470+ const url = `http://example.com/api/${ standard } ` ;
471+ expect ( splitAgentCardUrl ( url ) ) . toEqual ( {
472+ baseUrl : 'http://example.com/api/' ,
473+ path : undefined ,
474+ } ) ;
475+ } ) ;
476+
477+ it ( 'should ignore hashes and query params when splitting' , ( ) => {
478+ const url = `http://localhost:9001/${ standard } ?foo=bar#baz` ;
479+ expect ( splitAgentCardUrl ( url ) ) . toEqual ( {
480+ baseUrl : 'http://localhost:9001/' ,
481+ path : undefined ,
482+ } ) ;
483+ } ) ;
484+
485+ it ( 'should return original URL if parsing fails' , ( ) => {
486+ const url = 'not-a-url' ;
487+ expect ( splitAgentCardUrl ( url ) ) . toEqual ( { baseUrl : url } ) ;
488+ } ) ;
489+
490+ it ( 'should handle standard path appearing earlier in the path' , ( ) => {
491+ const url = `http://localhost:9001/${ standard } /something-else` ;
492+ expect ( splitAgentCardUrl ( url ) ) . toEqual ( { baseUrl : url } ) ;
493+ } ) ;
226494 } ) ;
227495
228496 describe ( 'A2AResultReassembler' , ( ) => {
@@ -233,6 +501,7 @@ describe('a2aUtils', () => {
233501 reassembler . update ( {
234502 kind : 'status-update' ,
235503 taskId : 't1' ,
504+ contextId : 'ctx1' ,
236505 status : {
237506 state : 'working' ,
238507 message : {
@@ -247,6 +516,7 @@ describe('a2aUtils', () => {
247516 reassembler . update ( {
248517 kind : 'artifact-update' ,
249518 taskId : 't1' ,
519+ contextId : 'ctx1' ,
250520 append : false ,
251521 artifact : {
252522 artifactId : 'a1' ,
@@ -259,6 +529,7 @@ describe('a2aUtils', () => {
259529 reassembler . update ( {
260530 kind : 'status-update' ,
261531 taskId : 't1' ,
532+ contextId : 'ctx1' ,
262533 status : {
263534 state : 'working' ,
264535 message : {
@@ -273,6 +544,7 @@ describe('a2aUtils', () => {
273544 reassembler . update ( {
274545 kind : 'artifact-update' ,
275546 taskId : 't1' ,
547+ contextId : 'ctx1' ,
276548 append : true ,
277549 artifact : {
278550 artifactId : 'a1' ,
@@ -291,6 +563,7 @@ describe('a2aUtils', () => {
291563
292564 reassembler . update ( {
293565 kind : 'status-update' ,
566+ contextId : 'ctx1' ,
294567 status : {
295568 state : 'auth-required' ,
296569 message : {
@@ -310,6 +583,7 @@ describe('a2aUtils', () => {
310583
311584 reassembler . update ( {
312585 kind : 'status-update' ,
586+ contextId : 'ctx1' ,
313587 status : {
314588 state : 'auth-required' ,
315589 } ,
@@ -323,6 +597,7 @@ describe('a2aUtils', () => {
323597
324598 const chunk = {
325599 kind : 'status-update' ,
600+ contextId : 'ctx1' ,
326601 status : {
327602 state : 'auth-required' ,
328603 message : {
@@ -351,6 +626,8 @@ describe('a2aUtils', () => {
351626
352627 reassembler . update ( {
353628 kind : 'task' ,
629+ id : 'task-1' ,
630+ contextId : 'ctx1' ,
354631 status : { state : 'completed' } ,
355632 history : [
356633 {
@@ -369,6 +646,8 @@ describe('a2aUtils', () => {
369646
370647 reassembler . update ( {
371648 kind : 'task' ,
649+ id : 'task-1' ,
650+ contextId : 'ctx1' ,
372651 status : { state : 'working' } ,
373652 history : [
374653 {
@@ -387,6 +666,8 @@ describe('a2aUtils', () => {
387666
388667 reassembler . update ( {
389668 kind : 'task' ,
669+ id : 'task-1' ,
670+ contextId : 'ctx1' ,
390671 status : { state : 'completed' } ,
391672 artifacts : [
392673 {
0 commit comments