// @vitest-environment node /** * AuthGatedRequestHandler — unit + E2E tests. * * What is tested: * - getTask / cancelTask / resubscribe reject UnauthenticatedUser (or missing context) * with A2AError code -32600 (INVALID_REQUEST). * - Authenticated contexts pass the auth gate and reach the underlying store * (which throws taskNotFound -32001, not the auth error). * - E2E via supertest: unauthenticated `tasks/get` HTTP request → proper JSON-RPC * error response (not 500). * * Test approach: unit-test AuthGatedRequestHandler directly with InMemoryTaskStore * and a stub executor (fastest, no HTTP round-trip needed for unit assertions). * One E2E supertest case covers the full HTTP path. */ import { describe, it, expect } from 'vitest'; import express from 'express'; import request from 'supertest'; import { A2AError, InMemoryTaskStore, ServerCallContext, UnauthenticatedUser, } from '@a2a-js/sdk/server'; import { Repository } from '../../db/repository.js'; import { buildBaseCard } from './agent-card.js'; import { AuthGatedRequestHandler, mountA2aRequestHandler } from './request-handler.js'; import { A2aAuthenticatedUser } from './user-builder.js'; import type { A2aPrincipal } from './token-auth.js'; // ─── fixtures ───────────────────────────────────────────────────────────────── const BASE_DEPS = { baseUrl: 'https://m', issuer: 'https://m/oidc', version: '1.0.0' }; const FAKE_PRINCIPAL: A2aPrincipal = { actingUserId: 'u1', clientId: 'cli1', grantId: 'g1', delegation: { id: 'd1', userId: 'u1', clientId: 'cli1', grantId: 'g1', grantedSpaceIds: ['s1'], grantedSkills: ['research'], expiresAt: null, revokedAt: null, }, }; /** Helper — construct AuthGatedRequestHandler with an in-memory store. */ function makeHandler() { const store = new InMemoryTaskStore(); const fakeExecutor = { execute: async () => {} } as any; const baseCard = buildBaseCard(BASE_DEPS); return new AuthGatedRequestHandler(baseCard, store, fakeExecutor); } const UNAUTH_CTX = new ServerCallContext(undefined, new UnauthenticatedUser()); const AUTH_CTX = new ServerCallContext(undefined, new A2aAuthenticatedUser(FAKE_PRINCIPAL)); // ─── getTask ────────────────────────────────────────────────────────────────── describe('AuthGatedRequestHandler.getTask', () => { it('rejects UnauthenticatedUser with A2AError', async () => { const h = makeHandler(); await expect(h.getTask({ id: 'x' }, UNAUTH_CTX)).rejects.toBeInstanceOf(A2AError); }); it('rejected error carries INVALID_REQUEST code (-32600)', async () => { const h = makeHandler(); await expect(h.getTask({ id: 'x' }, UNAUTH_CTX)) .rejects.toMatchObject({ code: -32600 }); }); it('fail-closed: undefined context also rejects', async () => { const h = makeHandler(); await expect(h.getTask({ id: 'x' }, undefined)).rejects.toMatchObject({ code: -32600 }); }); it('authenticated context passes auth gate (taskNotFound, not auth error)', async () => { const h = makeHandler(); const err = await h.getTask({ id: 'nonexistent' }, AUTH_CTX).catch(e => e); expect(err).toBeInstanceOf(A2AError); // -32001 = TASK_NOT_FOUND (auth gate was cleared, underlying store was reached) expect(err.code).not.toBe(-32600); }); }); // ─── cancelTask ─────────────────────────────────────────────────────────────── describe('AuthGatedRequestHandler.cancelTask', () => { it('rejects UnauthenticatedUser with INVALID_REQUEST (-32600)', async () => { const h = makeHandler(); await expect(h.cancelTask({ id: 'x' }, UNAUTH_CTX)) .rejects.toMatchObject({ code: -32600 }); }); it('fail-closed: undefined context rejects', async () => { const h = makeHandler(); await expect(h.cancelTask({ id: 'x' }, undefined)).rejects.toMatchObject({ code: -32600 }); }); it('authenticated context passes auth gate (taskNotFound, not auth error)', async () => { const h = makeHandler(); const err = await h.cancelTask({ id: 'nonexistent' }, AUTH_CTX).catch(e => e); expect(err).toBeInstanceOf(A2AError); expect(err.code).not.toBe(-32600); }); }); // ─── resubscribe ────────────────────────────────────────────────────────────── describe('AuthGatedRequestHandler.resubscribe', () => { it('rejects UnauthenticatedUser synchronously (throws before returning generator)', () => { const h = makeHandler(); expect(() => h.resubscribe({ id: 'x' }, UNAUTH_CTX)).toThrow(A2AError); }); it('thrown error carries INVALID_REQUEST code (-32600)', () => { const h = makeHandler(); let caught: unknown; try { h.resubscribe({ id: 'x' }, UNAUTH_CTX); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(A2AError); expect((caught as A2AError).code).toBe(-32600); }); it('fail-closed: undefined context throws synchronously', () => { const h = makeHandler(); expect(() => h.resubscribe({ id: 'x' }, undefined)).toThrow(A2AError); }); it('authenticated context returns an AsyncGenerator without throwing', () => { const h = makeHandler(); let gen: ReturnType | undefined; expect(() => { gen = h.resubscribe({ id: 'x' }, AUTH_CTX); }).not.toThrow(); expect(gen).toBeDefined(); expect(typeof gen![Symbol.asyncIterator]).toBe('function'); gen!.return?.(undefined); // clean up }); }); // ─── E2E: tasks/get via HTTP → JSON-RPC error when unauthenticated ─────────── describe('mountA2aRequestHandler – tasks/get auth gate (E2E)', () => { function seedRepo(repo: Repository) { (repo as any).db.prepare( "INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " + "VALUES ('u1','u1@test','User One',NULL,'user','active','2026-01-01','2026-01-01')", ).run(); } function fakeProvider() { return { AccessToken: { async find(_v: string) { return undefined; } } } as any; } it('unauthenticated tasks/get returns JSON-RPC error (not 500, code=-32600)', async () => { const repo = new Repository(':memory:'); seedRepo(repo); const app = express(); mountA2aRequestHandler(app, { provider: fakeProvider(), repo, ...BASE_DEPS }); const res = await request(app) .post('/a2a') .set('Content-Type', 'application/json') .send({ jsonrpc: '2.0', id: 99, method: 'tasks/get', params: { id: 'some-task-id' } }); expect(res.status).not.toBe(500); expect(res.headers['content-type']).toMatch(/json/); expect(res.body.jsonrpc).toBe('2.0'); expect(res.body).toHaveProperty('error'); expect(res.body.error.code).toBe(-32600); // INVALID_REQUEST = unauthorized }); });