Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/ack-id/src/a2a/random.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest"

import { generateRandomJti, generateRandomNonce } from "./random"

const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/

describe("generateRandomJti", () => {
it("returns a UUID", () => {
expect(generateRandomJti()).toMatch(uuidPattern)
})

it("returns unique values", () => {
expect(generateRandomJti()).not.toBe(generateRandomJti())
})
})

describe("generateRandomNonce", () => {
it("returns a UUID", () => {
expect(generateRandomNonce()).toMatch(uuidPattern)
})

it("returns unique values", () => {
expect(generateRandomNonce()).not.toBe(generateRandomNonce())
})
})
27 changes: 27 additions & 0 deletions packages/ack-id/src/a2a/service-endpoints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest"

import { createAgentCardServiceEndpoint } from "./service-endpoints"

describe("createAgentCardServiceEndpoint", () => {
it("creates a service endpoint linking a DID to its agent card URL", () => {
const endpoint = createAgentCardServiceEndpoint(
"did:web:example.com",
"https://example.com/.well-known/agent.json",
)

expect(endpoint).toEqual({
id: "did:web:example.com#agent-card",
type: "AgentCard",
serviceEndpoint: "https://example.com/.well-known/agent.json",
})
})

it("creates correct id for DIDs with colon-separated path components", () => {
const endpoint = createAgentCardServiceEndpoint(
"did:web:example.com:agents:my-agent",
"https://example.com/agents/my-agent/agent.json",
)

expect(endpoint.id).toBe("did:web:example.com:agents:my-agent#agent-card")
})
})
152 changes: 152 additions & 0 deletions packages/ack-id/src/a2a/sign-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { JwtSigner } from "@agentcommercekit/jwt"
import { createJwtSigner } from "@agentcommercekit/jwt"
import { generateKeypair } from "@agentcommercekit/keys"
import { beforeEach, describe, expect, it, vi } from "vitest"

import {
createA2AHandshakeMessage,
createA2AHandshakeMessageFromJwt,
createA2AHandshakePayload,
createSignedA2AMessage,
} from "./sign-message"
import {
agentDid,
makeTextMessage,
testCredential,
userDid,
} from "./test-fixtures"

vi.mock("uuid", () => ({
v4: vi.fn(() => "test-uuid-1234"),
}))

vi.mock("./random", async () => {
const actual = await vi.importActual<typeof import("./random")>("./random")
return {
...actual,
generateRandomJti: vi.fn(() => "test-jti-1234"),
generateRandomNonce: vi.fn(() => "test-nonce-1234"),
}
})

describe("createA2AHandshakePayload", () => {
it("creates a payload addressed to the recipient with a fresh nonce", () => {
const payload = createA2AHandshakePayload({
recipient: userDid,
vc: testCredential,
})

expect(payload.aud).toBe(userDid)
expect(payload.nonce).toBe("test-nonce-1234")
expect(payload.vc).toBe(testCredential)
expect(payload).not.toHaveProperty("replyNonce")
})

it("returns the request nonce and generates a new reply nonce for responses", () => {
const payload = createA2AHandshakePayload({
recipient: userDid,
vc: testCredential,
requestNonce: "original-nonce",
})

// The initiator's nonce becomes ours so they can correlate the reply
expect(payload.nonce).toBe("original-nonce")
// We generate a fresh nonce for the next leg of the handshake
expect(payload.replyNonce).toBe("test-nonce-1234")
})
})

describe("createA2AHandshakeMessageFromJwt", () => {
it("creates an A2A data-part message from a JWT", () => {
const jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.test.sig"

expect(createA2AHandshakeMessageFromJwt("agent", jwt)).toEqual({
kind: "message",
messageId: "test-uuid-1234",
role: "agent",
parts: [{ kind: "data", data: { jwt } }],
})
})

it("returns the correct role in the message", () => {
const message = createA2AHandshakeMessageFromJwt("user", "any.jwt")
expect(message.role).toBe("user")
})
})

describe("createSignedA2AMessage", () => {
let jwtSigner: JwtSigner

beforeEach(async () => {
const keypair = await generateKeypair("secp256k1")
jwtSigner = createJwtSigner(keypair)
})

it("creates a JWT signature and attaches it to message metadata", async () => {
const result = await createSignedA2AMessage(makeTextMessage(), {
did: agentDid,
jwtSigner,
})

expect(result.sig).toEqual(expect.any(String))
expect(result.jti).toBe("test-jti-1234")
expect(result.message.metadata?.sig).toBe(result.sig)
})

it("returns original message content alongside the signature", async () => {
const original = makeTextMessage("agent")
const result = await createSignedA2AMessage(original, {
did: agentDid,
jwtSigner,
})

expect(result.message.kind).toBe("message")
expect(result.message.role).toBe("agent")
expect(result.message.parts).toEqual(original.parts)
})

it("creates metadata with signature merged into existing fields", async () => {
const message = makeTextMessage("user", { traceId: "abc" })
const result = await createSignedA2AMessage(message, {
did: agentDid,
jwtSigner,
})

expect(result.message.metadata?.sig).toBe(result.sig)
expect(result.message.metadata?.traceId).toBe("abc")
})
})

describe("createA2AHandshakeMessage", () => {
let jwtSigner: JwtSigner

beforeEach(async () => {
const keypair = await generateKeypair("secp256k1")
jwtSigner = createJwtSigner(keypair)
})

it("creates a signed credential handshake and returns the nonce for correlation", async () => {
const result = await createA2AHandshakeMessage(
"agent",
{ recipient: userDid, vc: testCredential },
{ did: agentDid, jwtSigner },
)

expect(result.sig).toEqual(expect.any(String))
expect(result.jti).toBe("test-jti-1234")
expect(result.nonce).toBe("test-nonce-1234")
expect(result.message.role).toBe("agent")
})

it("returns the signed JWT in the message data part", async () => {
const result = await createA2AHandshakeMessage(
"agent",
{ recipient: userDid, vc: testCredential },
{ did: agentDid, jwtSigner },
)

expect(result.message.parts[0]).toEqual(
expect.objectContaining({ kind: "data", data: { jwt: result.sig } }),
)
})
})
101 changes: 101 additions & 0 deletions packages/ack-id/src/a2a/test-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Shared fixtures for A2A test suites.
*
* Provides identity constants, message builders, and mock factories so
* individual test files can focus on behavior rather than setup.
*/
import type { DidUri } from "@agentcommercekit/did"
import type { JwtVerified } from "@agentcommercekit/jwt"
import type { W3CCredential } from "@agentcommercekit/vc"

// --- Identity constants ---

export const agentDid = "did:web:agent.example.com" as DidUri
export const userDid = "did:web:user.example.com" as DidUri

export const testCredential: W3CCredential = {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential", "ControllerCredential"],
issuer: { id: "did:web:issuer.example.com" },
issuanceDate: "2025-01-01T00:00:00.000Z",
credentialSubject: { id: "did:web:subject.example.com" },
}

// --- Message builders ---

/** A text message, optionally with a specific role or pre-existing metadata. */
export function makeTextMessage(
role: "agent" | "user" = "user",
metadata?: Record<string, unknown>,
) {
return {
kind: "message" as const,
messageId: "msg-1",
role,
parts: [{ kind: "text" as const, text: "hello" }],
...(metadata && { metadata }),
}
}

/** A handshake message carrying a JWT in its data part. */
export function handshakeMessage(jwt = "valid.jwt.token") {
return {
kind: "message" as const,
messageId: "msg-1",
role: "user" as const,
parts: [{ kind: "data" as const, data: { jwt } }],
}
}

/** A signed message with text content and a signature in metadata. */
export function signedMessage(text = "hello", sig = "valid.jwt.signature") {
return {
kind: "message" as const,
messageId: "msg-1",
role: "user" as const,
parts: [{ kind: "text" as const, text }],
metadata: { sig },
}
}

/** A message with no signature — for testing rejection of unsigned input. */
export function unsignedMessage(text = "hello") {
return {
kind: "message" as const,
messageId: "msg-1",
role: "user" as const,
parts: [{ kind: "text" as const, text }],
}
}

/**
* The expected JWT payload for a signed message with the given text.
* Derives from the same shape as signedMessage() so they can't drift apart.
*/
export function expectedSignedPayload(text = "hello") {
const { metadata: _, ...content } = signedMessage(text)
return { message: content }
}

// --- Mock factories ---

/** Builds a JwtVerified result with sensible defaults, overriding only the payload. */
export function mockVerifiedJwt(payload: Record<string, unknown>): JwtVerified {
return {
verified: true,
payload: { iss: "did:web:issuer.example.com", ...payload },
didResolutionResult: {
didResolutionMetadata: {},
didDocument: null,
didDocumentMetadata: {},
},
issuer: "did:web:issuer.example.com",
signer: {
id: "did:web:issuer.example.com#key-1",
type: "Multikey",
controller: "did:web:issuer.example.com",
publicKeyHex: "02...",
},
jwt: "mock.jwt.token",
}
}
Loading