Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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("handles 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")
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
})
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("echoes 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("wraps a JWT in an A2A data-part message", () => {
const jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.test.sig"

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

it("respects the role parameter", () => {
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("produces 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("preserves 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("merges the signature into existing metadata without clobbering", 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("signs a 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("embeds 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 } }),
)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
})
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