maestro/src/bridge/a2a/request-handler-auth-gate.test.ts
oss-sync b1292e34b2
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (edc775f2)
2026-07-06 01:04:12 +00:00

169 lines
7.1 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
/**
* 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<typeof h.resubscribe> | 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
});
});