maestro/src/bridge/a2a/request-handler.test.ts
oss-sync 77ee3bc426
Some checks are pending
CI / build-and-test (push) Waiting to run
sync: update from private repo (ddadfd71)
2026-07-08 23:35:00 +00:00

196 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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);
});
});