196 lines
7.6 KiB
TypeScript
196 lines
7.6 KiB
TypeScript
// @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);
|
||
});
|
||
});
|