// @vitest-environment node /** * request-handler.ts のユニットテスト。 * * 対象: * 1. makeExtendedCardProvider — principal あり → extended card / なし → base card * 2. mountA2aRequestHandler supertest smoke — Bearer 無し POST /a2a が 500 にならない * * Task 8 の本番 token round-trip テストは別ファイルに置く(e2e 扱い)。 */ import { describe, it, expect, beforeEach } from 'vitest'; import express from 'express'; import request from 'supertest'; import { Repository } from '../../db/repository.js'; import { buildBaseCard } from './agent-card.js'; import { makeExtendedCardProvider, mountA2aRequestHandler } from './request-handler.js'; import type { A2aPrincipal } from './token-auth.js'; // ─── seed helpers ───────────────────────────────────────────────────────────── 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(); (repo as any).db.prepare( "INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES ('s1','S1','case','u1','private')", ).run(); repo.setSpaceA2aSkills('s1', ['research']); repo.createA2aDelegation({ id: 'd1', userId: 'u1', clientId: 'cli1', grantId: 'g1', grantedSpaceIds: ['s1'], grantedSkills: ['research'], audience: 'https://m/a2a', expiresAt: null, revokedAt: null, }); } /** Full A2aPrincipal that matches the seeded delegation. */ const FAKE_PRINCIPAL: A2aPrincipal = { actingUserId: 'u1', clientId: 'cli1', grantId: 'g1', delegation: { userId: 'u1', clientId: 'cli1', grantId: 'g1', grantedSpaceIds: ['s1'], grantedSkills: ['research'], expiresAt: null, revokedAt: null, }, }; /** Minimal fake OIDC provider (no tokens → always unauthenticated). */ function fakeProvider() { return { AccessToken: { async find(_v: string) { return undefined; } } } as any; } const BASE_DEPS = { baseUrl: 'https://m', issuer: 'https://m/oidc', version: '1.0.0' }; // ─── makeExtendedCardProvider ───────────────────────────────────────────────── describe('makeExtendedCardProvider', () => { let repo: Repository; beforeEach(() => { repo = new Repository(':memory:'); seedRepo(repo); }); it('context=undefined (no call context) → base card with empty skills', async () => { const baseCard = buildBaseCard(BASE_DEPS); const provider = makeExtendedCardProvider(repo, baseCard, BASE_DEPS); const card = await provider(undefined); expect(card.skills).toEqual([]); }); it('context.user has no a2aPrincipal (UnauthenticatedUser) → base card', async () => { const baseCard = buildBaseCard(BASE_DEPS); const provider = makeExtendedCardProvider(repo, baseCard, BASE_DEPS); const ctx = { user: { isAuthenticated: false, userName: '' } } as any; const card = await provider(ctx); expect(card.skills).toEqual([]); }); it('authenticated principal with live delegation + space allowlist → extended card with skills', async () => { const baseCard = buildBaseCard(BASE_DEPS); const provider = makeExtendedCardProvider(repo, baseCard, BASE_DEPS); const ctx = { user: { a2aPrincipal: FAKE_PRINCIPAL, isAuthenticated: true, userName: 'u1' }, } as any; const card = await provider(ctx); // space s1 has allowlist ['research'], delegation grants ['research'] → intersection ['research'] expect(card.skills.map((s: { id: string }) => s.id)).toEqual(['research']); }); it('principal whose acting user does not exist → falls back to base card', async () => { const baseCard = buildBaseCard(BASE_DEPS); const provider = makeExtendedCardProvider(repo, baseCard, BASE_DEPS); const ghost: A2aPrincipal = { ...FAKE_PRINCIPAL, actingUserId: 'ghost' }; const ctx = { user: { a2aPrincipal: ghost, isAuthenticated: true, userName: 'ghost' } } as any; const card = await provider(ctx); expect(card.skills).toEqual([]); }); it('principal whose granted skill is not in any space allowlist → empty skills on extended card', async () => { const baseCard = buildBaseCard(BASE_DEPS); const provider = makeExtendedCardProvider(repo, baseCard, BASE_DEPS); const restrictedPrincipal: A2aPrincipal = { ...FAKE_PRINCIPAL, delegation: { ...FAKE_PRINCIPAL.delegation, grantedSkills: ['admin'] }, // 'admin' not in s1 allowlist }; const ctx = { user: { a2aPrincipal: restrictedPrincipal, isAuthenticated: true, userName: 'u1' } } as any; const card = await provider(ctx); expect(card.skills).toEqual([]); }); }); // ─── supertest smoke ────────────────────────────────────────────────────────── describe('mountA2aRequestHandler – smoke', () => { it('POST /a2a without Bearer token does not crash (no 500)', 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: 1, method: 'message/send', params: { message: { messageId: 'm1', role: 'user', parts: [{ kind: 'text', text: 'hello' }], }, }, }); // Handler must not 500-crash and must return a valid JSON-RPC 2.0 envelope. // message/send is NOT gated by AuthGatedRequestHandler — the executor creates // the task (returning a result), then fails it asynchronously. So the HTTP // response carries either a JSON-RPC result or error, never a 500. expect(res.status).not.toBe(500); expect(res.headers['content-type']).toMatch(/json/); expect(res.body.jsonrpc).toBe('2.0'); expect('result' in res.body || 'error' in res.body).toBe(true); }); }); // ─── body size limit ────────────────────────────────────────────────────────── describe('mountA2aRequestHandler – body size limit', () => { it('POST /a2a with body exceeding maxPayloadBytes returns HTTP 413', async () => { const repo = new Repository(':memory:'); seedRepo(repo); const app = express(); // Mount with a tiny limit (50 bytes) so a normal JSON-RPC body exceeds it. mountA2aRequestHandler(app, { provider: fakeProvider(), repo, ...BASE_DEPS, limits: { maxPayloadBytes: 50 }, }); // This body is well over 50 bytes. const bigBody = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'message/send', params: { message: { messageId: 'm1', role: 'user', parts: [{ kind: 'text', text: 'hello world — this payload deliberately exceeds the tiny limit' }], }, }, }); const res = await request(app) .post('/a2a') .set('Content-Type', 'application/json') .send(bigBody); // express.json({ limit }) calls next(err) with a PayloadTooLargeError (status 413) // before the SDK handler sees the body. Express's default error handler surfaces it. expect(res.status).toBe(413); }); });