maestro/src/bridge/a2a/a2a-card-e2e.test.ts
oss-sync a67c40d33c
All checks were successful
CI / build-and-test (push) Successful in 8m58s
sync: update from private repo (a5db2ab0)
2026-07-10 00:51:05 +00:00

298 lines
14 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
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import express from 'express';
import http from 'http';
import type Provider from 'oidc-provider';
import { createHash, randomBytes } from 'crypto';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Repository } from '../../db/repository.js';
import { createA2aOidcProvider, mountA2aOidc } from './oidc-provider.js';
import { createA2aRouter } from './a2a-api.js';
import { mountA2aRequestHandler } from './request-handler.js';
import { A2aLimiter, resolveA2aLimits } from './limiter.js';
import { b64url, Client } from './__e2e-helpers.js';
/**
* Plan 1実 oidc-provider+ Plan 2Ainbound trust core + agent cardの統合 e2e。
*
* 同一 http server に「実 oidc provider + consent」と「A2A agent card ルータ」を相乗りさせ、
* 本物の authorization_code + PKCE フローで発行したトークンが extended card ルートで
* 受理され、consent で選んだスペース/スキルだけにスコープされることを実証する。
* 否定経路Bearer 無し / 失効後 / 委任外スキル)も同じ実トークンで検証する。
*/
describe('A2A card e2e (real token → scoped extended card + negative paths)', () => {
let server: http.Server;
let base: string;
let audience: string;
let repo: Repository;
let provider: Provider;
const clientId = 'a2a_card_e2e';
const redirectUri = 'https://client.test/cb';
const userId = 'user-1';
const spaceId = 'space-research';
beforeAll(async () => {
repo = new Repository(':memory:');
// 公開 A2A クライアントPKCE / token_endpoint_auth_method=none
repo.createA2aClient({
clientId, name: 'Card E2E', redirectUris: [redirectUri],
secretHash: null, tokenEndpointAuthMethod: 'none',
agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin',
});
// acting userextended card ルートが getUserById で実ロール/組織を引くため実在が必要)。
(repo as any).db.prepare(
"INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " +
"VALUES (?, ?, ?, NULL, 'user', 'active', '2026-01-01', '2026-01-01')",
).run(userId, 'u1@test', 'User One');
// user-1 が所有する案件スペース + A2A 公開許可リストresearch / summarize
(repo as any).db.prepare(
"INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Research', 'case', ?, 'private')",
).run(spaceId, userId);
repo.setSpaceA2aSkills(spaceId, ['research', 'summarize']);
// クロススペース AND 交差テスト用スペースspace-a に research / space-b に analyze
// 同意は space-a のみに絞ることで、space-b の analyze がカードに漏れないことを検証する。
(repo as any).db.prepare(
"INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Space A', 'case', ?, 'private')",
).run('space-a', userId);
repo.setSpaceA2aSkills('space-a', ['research']);
(repo as any).db.prepare(
"INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Space B', 'case', ?, 'private')",
).run('space-b', userId);
repo.setSpaceA2aSkills('space-b', ['analyze']);
const secretsDir = mkdtempSync(join(tmpdir(), 'a2a-card-e2e-'));
const app = express();
// 擬似ログイン: 全リクエストに user を注入consent の req.user 用。mount より前に置く。
app.use((req, _res, next) => { (req as any).user = { id: userId, role: 'user' }; next(); });
// listen → port 確定 → issuer/audience 確定 → provider 生成 → mount の順(テストのみ並び替え)
server = http.createServer(app);
await new Promise<void>(r => server.listen(0, '127.0.0.1', r));
const port = (server.address() as any).port;
base = `http://127.0.0.1:${port}`;
audience = `${base}/a2a`;
provider = createA2aOidcProvider({
repo, secretsDir,
issuer: `${base}/oidc`,
resourceAudience: audience,
cookieKeys: ['test-cookie-key'],
});
// テスト専用: http/127.0.0.1 で cookie を non-secure にして cookie jar が機能するようにする。
(provider as any).proxy = false;
// 実 provider + consent を /oidc に、A2A card ルータ + JSON-RPC ハンドラを同じ app に
// マウント。両者に「同一」limiter を渡すことで a2a-subsystem の共有配線を再現する。
// ratePerMinute:2 は各テストが 1 grant あたり最大 2 回しか叩かないため既存ケースに
// 影響しないobtainToken ごとに新しい grantId新しいバケット
const sharedLimiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 2 }));
mountA2aOidc(app, provider, { repo, resourceAudience: audience });
app.use(createA2aRouter({
provider, repo,
baseUrl: base,
issuer: `${base}/oidc`,
version: '1.0.0-test',
limiter: sharedLimiter,
}));
mountA2aRequestHandler(app, {
provider, repo,
baseUrl: base,
issuer: `${base}/oidc`,
version: '1.0.0-test',
limiter: sharedLimiter,
});
});
afterAll(() => { server?.close(); });
/**
* authorize → consentspace/skill を選択)→ code 取得 → token 交換まで通し、
* access_token と、そのトークンに紐づく grantId を返す。
* consent confirm のボディは consent-api.ts が読む `selectedSpaces` / `selectedSkills`。
*/
async function obtainToken(
selectedSpaces: string,
selectedSkills: string,
): Promise<{ accessToken: string; grantId: string }> {
const client = new Client();
const verifier = b64url(randomBytes(32));
const challenge = b64url(createHash('sha256').update(verifier).digest());
const authUrl = `${base}/oidc/auth?` + new URLSearchParams({
client_id: clientId, response_type: 'code', redirect_uri: redirectUri,
scope: 'openid a2a.read', resource: audience,
code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
});
const authRes = await client.get(authUrl);
expect([302, 303]).toContain(authRes.status);
let loc = authRes.headers.get('location')!;
// login / consent interaction を redirect_uri に着くまでループcookie jar 経由)。
// confirm のたびに space/skill 選択を送る(最終 grant に委任が紐づく)。
for (let i = 0; i < 5; i++) {
if (loc.startsWith(redirectUri)) break;
expect(loc).toContain('/oidc/interaction/');
const uid = new URL(loc, base).pathname.split('/').pop()!;
const confirmRes = await client.postForm(`${base}/oidc/interaction/${uid}/confirm`, {
selectedSpaces, selectedSkills,
});
expect(confirmRes.status).toBe(200);
const { redirectTo } = await confirmRes.json() as { redirectTo: string };
expect(redirectTo).toContain('/oidc/auth/');
const resumeRes = await client.get(redirectTo);
expect([302, 303]).toContain(resumeRes.status);
loc = resumeRes.headers.get('location')!;
}
expect(loc.startsWith(redirectUri)).toBe(true);
const code = new URL(loc).searchParams.get('code');
expect(code).toBeTruthy();
const tokenRes = await client.postForm(`${base}/oidc/token`, {
grant_type: 'authorization_code',
code: code!, code_verifier: verifier, client_id: clientId, redirect_uri: redirectUri,
});
expect(tokenRes.status).toBe(200);
const tokenJson = await tokenRes.json() as { access_token: string; scope: string };
expect(tokenJson.access_token).toBeTruthy();
expect(tokenJson.scope.split(' ')).toContain('a2a.read');
// opaque access token から grantId を引く(失効テストで委任を特定するため)。
const at = await (provider as any).AccessToken.find(tokenJson.access_token);
expect(at?.grantId).toBeTruthy();
return { accessToken: tokenJson.access_token, grantId: at.grantId as string };
}
it('happy path: real token → extended card scoped to the single consented skill', async () => {
const { accessToken } = await obtainToken(spaceId, 'research');
const res = await fetch(`${base}/a2a/agent-card/extended`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(res.status).toBe(200);
const card = await res.json() as { skills: Array<{ id: string }> };
// research のみ consent → summarize は許可リストにあっても出ない
expect(card.skills.map(s => s.id)).toEqual(['research']);
});
it('base card is public (no auth): skills [] + supportsAuthenticatedExtendedCard', async () => {
const res = await fetch(`${base}/.well-known/agent-card.json`);
expect(res.status).toBe(200);
const card = await res.json() as { skills: unknown[]; supportsAuthenticatedExtendedCard: boolean };
expect(card.skills).toEqual([]);
expect(card.supportsAuthenticatedExtendedCard).toBe(true);
});
it('no bearer → 401 on the extended card', async () => {
const res = await fetch(`${base}/a2a/agent-card/extended`);
expect(res.status).toBe(401);
});
it('revoked delegation → 401 with the same previously-working token', async () => {
const { accessToken, grantId } = await obtainToken(spaceId, 'research');
// まず動くことを確認
const okRes = await fetch(`${base}/a2a/agent-card/extended`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(okRes.status).toBe(200);
// grantId から委任を特定して失効
const delegation = repo.getA2aDelegationByGrantId(grantId);
expect(delegation).toBeTruthy();
repo.revokeA2aDelegation(delegation!.id, new Date().toISOString());
// 同じトークンは委任が live でないため 401
const res = await fetch(`${base}/a2a/agent-card/extended`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(res.status).toBe(401);
});
it('out-of-scope skill dropped: summarize allowlisted but not consented is absent', async () => {
// research だけ同意。summarize はスペース許可リストにあるが委任に含めない。
const { accessToken } = await obtainToken(spaceId, 'research');
const res = await fetch(`${base}/a2a/agent-card/extended`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(res.status).toBe(200);
const card = await res.json() as { skills: Array<{ id: string }> };
const ids = card.skills.map(s => s.id);
expect(ids).toContain('research');
expect(ids).not.toContain('summarize');
});
it('rate limit: extended card throttles past ratePerMinute and writes no audit row on 429', async () => {
// Fresh grant → fresh token-bucket seeded to ratePerMinute:2. Third call on the same
// token exhausts it. The 429 must be enforced BEFORE the scope computation + audit write,
// so the audit_log count for a2a.card.extended must not grow across the throttled call.
const { accessToken } = await obtainToken(spaceId, 'research');
const hdr = { authorization: `Bearer ${accessToken}` };
const r1 = await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr });
const r2 = await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr });
expect(r1.status).toBe(200);
expect(r2.status).toBe(200);
const auditBefore = (repo as any).db
.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'a2a.card.extended'")
.get().n as number;
const r3 = await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr });
expect(r3.status).toBe(429);
const auditAfter = (repo as any).db
.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'a2a.card.extended'")
.get().n as number;
expect(auditAfter).toBe(auditBefore); // throttled request did not touch the DB
});
it('shared limiter: REST card drain throttles the JSON-RPC extended-card RPC on the same grant', async () => {
// Core invariant of this change: the REST route and the JSON-RPC handler share ONE limiter
// (mirrors a2a-subsystem wiring). Draining the grant's bucket via 2 REST calls must throttle
// the RPC surface too. If each surface held its own bucket, the RPC would still have 2 tokens
// and return a result — so a `rate limit` error here proves the buckets are shared.
const { accessToken } = await obtainToken(spaceId, 'research');
const hdr = { authorization: `Bearer ${accessToken}` };
expect((await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr })).status).toBe(200);
expect((await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr })).status).toBe(200);
// bucket for this grant is now empty — the RPC surface must observe it
const rpcRes = await fetch(`${base}/a2a`, {
method: 'POST',
headers: { ...hdr, 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'agent/getAuthenticatedExtendedCard' }),
});
const body = await rpcRes.json() as { result?: unknown; error?: { code: number; message: string } };
expect(body.error).toBeDefined(); // NOT a successful card result
expect(body.error!.message).toMatch(/rate limit/); // shared bucket already drained by REST
});
it('cross-space AND-intersection: non-granted space skills are absent from extended card', async () => {
// space-aresearchと space-banalyzeが両方ユーザーから見えるが、
// space-a のみに同意 → resolveEffectiveSpaces が space-b を AND 交差で排除し、
// analyze がカードに漏れないことを end-to-end で実証する(最高 IDOR リスク経路)。
const { accessToken } = await obtainToken('space-a', 'research');
const res = await fetch(`${base}/a2a/agent-card/extended`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(res.status).toBe(200);
const card = await res.json() as { skills: Array<{ id: string }> };
const ids = card.skills.map(s => s.id);
// space-a の research は同意済み → 出現する
expect(ids).toEqual(['research']);
// space-b の analyze は未同意スペースにのみ存在 → 漏れてはならない
expect(ids).not.toContain('analyze');
});
});