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