/**
* AP2 MandateVerifier Tests
*
* Tests the MandateVerifier class with REAL ES256 cryptographic keys.
* Every token is signed and verified using actual ECDSA P-256 key pairs.
* No crypto mocks.
*/
import { SignJWT, generateKeyPair, exportJWK } from 'jose';
import type { JWK, KeyLike } from 'jose';
import { vi } from 'vitest';
import { MandateVerifier } from '../../src/ap2/verifier.js';
// ──────────────────────────────────────────────
// Shared key material — generated once per suite
// ──────────────────────────────────────────────
let publicJwk: JWK;
let privateKey: KeyLike;
beforeAll(async () => {
const keys = await generateKeyPair('ES256', { extractable: true });
publicJwk = await exportJWK(keys.publicKey);
privateKey = keys.privateKey;
});
// ──────────────────────────────────────────────
// Helpers to build signed mandate tokens
// ──────────────────────────────────────────────
async function signMandate(
payload: Record<string, unknown>,
options?: { typ?: string; expirationTime?: string | number; key?: KeyLike },
): Promise<string> {
const builder = new SignJWT(payload)
.setProtectedHeader({
alg: 'ES256',
typ: options?.typ ?? 'mandate+jwt',
});
if (options?.expirationTime !== undefined) {
if (typeof options.expirationTime === 'number') {
builder.setExpirationTime(options.expirationTime);
} else {
builder.setExpirationTime(options.expirationTime);
}
} else {
builder.setExpirationTime('30m');
}
return builder.sign(options?.key ?? privateKey);
}
// ──────────────────────────────────────────────
// Payload factories
// ──────────────────────────────────────────────
function makeIntentPayload(overrides?: Record<string, unknown>) {
return {
id: 'mandate-intent-test',
type: 'intent',
status: 'active',
issuer: 'buyer-agent',
subject: 'buyer@test.com',
payload: { max_amount: 50000, currency: 'USD' },
signature: '',
issued_at: new Date().toISOString(),
expires_at: new Date(Date.now() + 1800000).toISOString(),
...overrides,
};
}
function makeCartPayload(
checkoutId = 'checkout-1',
cartMandateId = 'mandate-cart-test',
overrides?: Record<string, unknown>,
) {
return {
id: cartMandateId,
type: 'cart',
status: 'active',
issuer: 'merchant-1',
subject: 'buyer@test.com',
payload: {
checkout_id: checkoutId,
line_items: [
{
id: 'li-1',
product_id: 'p-1',
variant_id: 'v-1',
title: 'Widget',
quantity: 1,
unit_amount: 2000,
total_amount: 2000,
type: 'product',
},
],
totals: {
subtotal: 2000,
tax: 160,
shipping: 0,
discount: 0,
fee: 10,
total: 2170,
currency: 'USD',
},
merchant_signature: 'hmac-signature-value',
},
signature: '',
issued_at: new Date().toISOString(),
expires_at: new Date(Date.now() + 1800000).toISOString(),
...overrides,
};
}
function makePaymentPayload(
cartMandateId = 'mandate-cart-test',
overrides?: Record<string, unknown>,
) {
return {
id: 'mandate-payment-test',
type: 'payment',
status: 'active',
issuer: 'buyer-agent',
subject: 'buyer@test.com',
payload: {
checkout_id: 'checkout-1',
amount: 2170,
currency: 'USD',
payment_handler_id: 'ap2_mandate',
cart_mandate_id: cartMandateId,
},
signature: '',
issued_at: new Date().toISOString(),
expires_at: new Date(Date.now() + 1800000).toISOString(),
...overrides,
};
}
// ──────────────────────────────────────────────
// verifyIntent
// ──────────────────────────────────────────────
describe('MandateVerifier.verifyIntent', () => {
it('accepts a valid intent mandate', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makeIntentPayload());
const result = await verifier.verifyIntent(token);
expect(result.valid).toBe(true);
expect(result.mandate).not.toBeNull();
expect(result.mandate!.type).toBe('intent');
expect(result.mandate!.status).toBe('active');
expect(result.mandate!.id).toBe('mandate-intent-test');
});
it('rejects a token with wrong typ header (not mandate+jwt)', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makeIntentPayload(), { typ: 'JWT' });
const result = await verifier.verifyIntent(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('mandate+jwt');
});
it('rejects wrong mandate type (cart passed to verifyIntent)', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makeCartPayload());
const result = await verifier.verifyIntent(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('intent');
});
it('rejects an expired token', async () => {
const verifier = new MandateVerifier(publicJwk);
const payload = makeIntentPayload({
expires_at: new Date(Date.now() - 120000).toISOString(),
});
// Set JWT exp to the past so jose rejects it too
const token = await signMandate(payload, {
expirationTime: Math.floor(Date.now() / 1000) - 60,
});
const result = await verifier.verifyIntent(token);
expect(result.valid).toBe(false);
});
it('rejects inactive status', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makeIntentPayload({ status: 'revoked' }));
const result = await verifier.verifyIntent(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('revoked');
});
it('rejects max_amount <= 0', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(
makeIntentPayload({ payload: { max_amount: 0, currency: 'USD' } }),
);
const result = await verifier.verifyIntent(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('max_amount');
});
it('rejects invalid currency (not 3-letter)', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(
makeIntentPayload({ payload: { max_amount: 1000, currency: 'US' } }),
);
const result = await verifier.verifyIntent(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('currency');
});
it('rejects a completely invalid token string', async () => {
const verifier = new MandateVerifier(publicJwk);
const result = await verifier.verifyIntent('not.a.valid-token');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it('rejects a token signed with a wrong key', async () => {
const wrongKeys = await generateKeyPair('ES256', { extractable: true });
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makeIntentPayload(), { key: wrongKeys.privateKey });
const result = await verifier.verifyIntent(token);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it('rejects mandate with invalid expires_at timestamp', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(
makeIntentPayload({ expires_at: 'not-a-date' }),
{ expirationTime: '1h' },
);
const result = await verifier.verifyIntent(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid expires_at');
});
it('rejects mandate with expired expires_at but valid JWT exp', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(
makeIntentPayload({ expires_at: '2020-01-01T00:00:00.000Z' }),
{ expirationTime: '1h' },
);
const result = await verifier.verifyIntent(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('expired');
});
it('formats non-Error thrown values via String()', async () => {
const verifier = new MandateVerifier(publicJwk);
// Spy on the private verifyToken method to throw a non-Error value,
// exercising the formatError String(err) fallback path (line 334).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(verifier as any, 'verifyToken').mockRejectedValueOnce('string-error');
const token = await signMandate(makeIntentPayload());
const result = await verifier.verifyIntent(token);
expect(result.valid).toBe(false);
expect(result.error).toBe('string-error');
});
});
// ──────────────────────────────────────────────
// verifyCart
// ──────────────────────────────────────────────
describe('MandateVerifier.verifyCart', () => {
it('accepts a valid cart mandate', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makeCartPayload());
const result = await verifier.verifyCart(token);
expect(result.valid).toBe(true);
expect(result.mandate).not.toBeNull();
expect(result.mandate!.type).toBe('cart');
});
it('rejects missing merchant_signature', async () => {
const verifier = new MandateVerifier(publicJwk);
const payload = makeCartPayload();
(payload.payload as Record<string, unknown>).merchant_signature = '';
const token = await signMandate(payload);
const result = await verifier.verifyCart(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('merchant_signature');
});
it('rejects missing checkout_id', async () => {
const verifier = new MandateVerifier(publicJwk);
const payload = makeCartPayload();
(payload.payload as Record<string, unknown>).checkout_id = '';
const token = await signMandate(payload);
const result = await verifier.verifyCart(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('checkout_id');
});
it('rejects empty line_items', async () => {
const verifier = new MandateVerifier(publicJwk);
const payload = makeCartPayload();
(payload.payload as Record<string, unknown>).line_items = [];
const token = await signMandate(payload);
const result = await verifier.verifyCart(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('line item');
});
it('rejects wrong mandate type', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makeIntentPayload());
const result = await verifier.verifyCart(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('cart');
});
it('rejects inactive status', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makeCartPayload('co-1', 'mc-1', { status: 'expired' }));
const result = await verifier.verifyCart(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('expired');
});
it('rejects a cart token with wrong typ header', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makeCartPayload(), { typ: 'JWT' });
const result = await verifier.verifyCart(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('mandate+jwt');
});
it('rejects cart mandate with expired expires_at but valid JWT exp', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(
makeCartPayload('co-1', 'mc-1', { expires_at: '2020-01-01T00:00:00.000Z' }),
{ expirationTime: '1h' },
);
const result = await verifier.verifyCart(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('expired');
});
});
// ──────────────────────────────────────────────
// verifyPayment
// ──────────────────────────────────────────────
describe('MandateVerifier.verifyPayment', () => {
it('accepts a valid payment mandate', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makePaymentPayload());
const result = await verifier.verifyPayment(token);
expect(result.valid).toBe(true);
expect(result.mandate).not.toBeNull();
expect(result.mandate!.type).toBe('payment');
});
it('rejects payment amount <= 0', async () => {
const verifier = new MandateVerifier(publicJwk);
const payload = makePaymentPayload();
(payload.payload as Record<string, unknown>).amount = 0;
const token = await signMandate(payload);
const result = await verifier.verifyPayment(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('amount');
});
it('rejects missing cart_mandate_id', async () => {
const verifier = new MandateVerifier(publicJwk);
const payload = makePaymentPayload();
(payload.payload as Record<string, unknown>).cart_mandate_id = '';
const token = await signMandate(payload);
const result = await verifier.verifyPayment(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('cart_mandate_id');
});
it('rejects missing payment_handler_id', async () => {
const verifier = new MandateVerifier(publicJwk);
const payload = makePaymentPayload();
(payload.payload as Record<string, unknown>).payment_handler_id = '';
const token = await signMandate(payload);
const result = await verifier.verifyPayment(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('payment_handler_id');
});
it('rejects wrong mandate type', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makeIntentPayload());
const result = await verifier.verifyPayment(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('payment');
});
it('rejects inactive status', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makePaymentPayload('mc-1', { status: 'used' }));
const result = await verifier.verifyPayment(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('used');
});
it('rejects a payment token with wrong typ header', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(makePaymentPayload(), { typ: 'JWT' });
const result = await verifier.verifyPayment(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('mandate+jwt');
});
it('rejects payment mandate with expired expires_at but valid JWT exp', async () => {
const verifier = new MandateVerifier(publicJwk);
const token = await signMandate(
makePaymentPayload('mc-1', { expires_at: '2020-01-01T00:00:00.000Z' }),
{ expirationTime: '1h' },
);
const result = await verifier.verifyPayment(token);
expect(result.valid).toBe(false);
expect(result.error).toContain('expired');
});
});
// ──────────────────────────────────────────────
// verifyMandateChain
// ──────────────────────────────────────────────
describe('MandateVerifier.verifyMandateChain', () => {
it('accepts a valid chain (intent -> cart -> payment)', async () => {
const verifier = new MandateVerifier(publicJwk);
const intentToken = await signMandate(makeIntentPayload());
const cartToken = await signMandate(makeCartPayload());
const paymentToken = await signMandate(makePaymentPayload());
const result = await verifier.verifyMandateChain(intentToken, cartToken, paymentToken);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('rejects when cart total exceeds intent max_amount', async () => {
const verifier = new MandateVerifier(publicJwk);
// Intent with very low max_amount
const intentToken = await signMandate(
makeIntentPayload({ payload: { max_amount: 100, currency: 'USD' } }),
);
const cartToken = await signMandate(makeCartPayload());
const paymentToken = await signMandate(makePaymentPayload());
const result = await verifier.verifyMandateChain(intentToken, cartToken, paymentToken);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('exceeds'))).toBe(true);
});
it('rejects currency mismatch between intent and cart', async () => {
const verifier = new MandateVerifier(publicJwk);
const intentToken = await signMandate(
makeIntentPayload({ payload: { max_amount: 50000, currency: 'EUR' } }),
);
const cartToken = await signMandate(makeCartPayload()); // USD
const paymentToken = await signMandate(makePaymentPayload());
const result = await verifier.verifyMandateChain(intentToken, cartToken, paymentToken);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('currency'))).toBe(true);
});
it('rejects when payment amount does not match cart total', async () => {
const verifier = new MandateVerifier(publicJwk);
const intentToken = await signMandate(makeIntentPayload());
const cartToken = await signMandate(makeCartPayload());
// Payment with wrong amount
const paymentPayload = makePaymentPayload();
(paymentPayload.payload as Record<string, unknown>).amount = 9999;
const paymentToken = await signMandate(paymentPayload);
const result = await verifier.verifyMandateChain(intentToken, cartToken, paymentToken);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('amount'))).toBe(true);
});
it('rejects when payment references wrong cart mandate ID', async () => {
const verifier = new MandateVerifier(publicJwk);
const intentToken = await signMandate(makeIntentPayload());
const cartToken = await signMandate(makeCartPayload('checkout-1', 'mandate-cart-test'));
// Payment references a different cart mandate
const paymentToken = await signMandate(makePaymentPayload('wrong-cart-mandate-id'));
const result = await verifier.verifyMandateChain(intentToken, cartToken, paymentToken);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('cart_mandate_id'))).toBe(true);
});
it('fails when one individual mandate is invalid (wrong key)', async () => {
const verifier = new MandateVerifier(publicJwk);
const wrongKeys = await generateKeyPair('ES256', { extractable: true });
const intentToken = await signMandate(makeIntentPayload());
// Cart signed with the wrong key
const cartToken = await signMandate(makeCartPayload(), { key: wrongKeys.privateKey });
const paymentToken = await signMandate(makePaymentPayload());
const result = await verifier.verifyMandateChain(intentToken, cartToken, paymentToken);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('Cart mandate invalid'))).toBe(true);
});
it('accumulates multiple errors', async () => {
const verifier = new MandateVerifier(publicJwk);
// Intent with EUR and low max_amount
const intentToken = await signMandate(
makeIntentPayload({ payload: { max_amount: 100, currency: 'EUR' } }),
);
const cartToken = await signMandate(makeCartPayload()); // USD, total 2170
// Payment with wrong amount and wrong cart reference
const paymentPayload = makePaymentPayload('wrong-ref');
(paymentPayload.payload as Record<string, unknown>).amount = 9999;
(paymentPayload.payload as Record<string, unknown>).currency = 'GBP';
const paymentToken = await signMandate(paymentPayload);
const result = await verifier.verifyMandateChain(intentToken, cartToken, paymentToken);
expect(result.valid).toBe(false);
// Should have multiple errors: currency mismatch, amount exceeds, payment amount mismatch,
// payment currency mismatch, cart mandate ID mismatch
expect(result.errors.length).toBeGreaterThanOrEqual(3);
});
it('rejects when payment currency does not match cart currency', async () => {
const verifier = new MandateVerifier(publicJwk);
const intentToken = await signMandate(makeIntentPayload());
const cartToken = await signMandate(makeCartPayload());
const paymentPayload = makePaymentPayload();
(paymentPayload.payload as Record<string, unknown>).currency = 'EUR';
const paymentToken = await signMandate(paymentPayload);
const result = await verifier.verifyMandateChain(intentToken, cartToken, paymentToken);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('currency'))).toBe(true);
});
it('rejects when cart issuer is not in intent merchant_ids', async () => {
const verifier = new MandateVerifier(publicJwk);
// Intent restricts to merchant-X and merchant-Y only
const intentToken = await signMandate(
makeIntentPayload({
payload: { max_amount: 50000, currency: 'USD', merchant_ids: ['merchant-X', 'merchant-Y'] },
}),
);
// Cart issuer is 'merchant-1', which is not in the allowed list
const cartToken = await signMandate(makeCartPayload());
const paymentToken = await signMandate(makePaymentPayload());
const result = await verifier.verifyMandateChain(intentToken, cartToken, paymentToken);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('merchant_ids'))).toBe(true);
});
it('reports payment mandate invalid when payment has wrong key', async () => {
const verifier = new MandateVerifier(publicJwk);
const wrongKeys = await generateKeyPair('ES256', { extractable: true });
const intentToken = await signMandate(makeIntentPayload());
const cartToken = await signMandate(makeCartPayload());
// Payment signed with wrong key
const paymentToken = await signMandate(makePaymentPayload(), { key: wrongKeys.privateKey });
const result = await verifier.verifyMandateChain(intentToken, cartToken, paymentToken);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('Payment mandate invalid'))).toBe(true);
});
it('reports intent mandate invalid when intent has wrong key', async () => {
const verifier = new MandateVerifier(publicJwk);
const wrongKeys = await generateKeyPair('ES256', { extractable: true });
const intentToken = await signMandate(makeIntentPayload(), { key: wrongKeys.privateKey });
const cartToken = await signMandate(makeCartPayload());
const paymentToken = await signMandate(makePaymentPayload());
const result = await verifier.verifyMandateChain(intentToken, cartToken, paymentToken);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('Intent mandate invalid'))).toBe(true);
});
it('accepts when cart issuer is in intent merchant_ids', async () => {
const verifier = new MandateVerifier(publicJwk);
// Intent allows merchant-1 (the default cart issuer)
const intentToken = await signMandate(
makeIntentPayload({
payload: { max_amount: 50000, currency: 'USD', merchant_ids: ['merchant-1', 'merchant-2'] },
}),
);
const cartToken = await signMandate(makeCartPayload());
const paymentToken = await signMandate(makePaymentPayload());
const result = await verifier.verifyMandateChain(intentToken, cartToken, paymentToken);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});