maestro/src/bridge/a2a/a2a-revocation-e2e.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

441 lines
16 KiB
TypeScript

/**
* Task 5 — executor in-flight revocation recheck (+ 2C-1 metadata fix).
*
* Uses MaestroA2aExecutor directly with injectable sleep/now/pollMs deps,
* a minimal fake Repository, and a fake ExecutionEventBus — no HTTP server needed.
*
* Tests:
* A1. Delegation revoked after first poll → executor publishes terminal `canceled`,
* calls eventBus.finished(), calls cancelA2aLinkedJob(jobId).
* A2. Happy path: live delegation → job completes normally (no premature cancel).
* B. Initial submitted Task metadata: a2aDelegationId = delegation.id,
* a2aGrantId = grantId (different values, different semantics).
*/
import { describe, it, expect } from 'vitest';
import { MaestroA2aExecutor } from './executor.js';
import { A2aTaskReconciler } from './reconciler.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
import type { TaskStatusUpdateEvent } from '@a2a-js/sdk';
// ── Fake helpers ──────────────────────────────────────────────────────────────
function makeFakeRepo(opts: {
delegationId: string;
grantId: string;
/** number of getJob calls after which delegation switches to revoked (0 = revoked from the start of poll 1) */
revokeAfterPollN?: number;
/** poll number after which job status becomes 'succeeded' */
succeedAfterPollN?: number;
}) {
const {
delegationId,
grantId,
revokeAfterPollN = Infinity,
succeedAfterPollN = Infinity,
} = opts;
let pollCount = 0;
const cancelCalls: string[] = [];
const baseDelegRow = {
id: delegationId,
userId: 'user-1',
clientId: 'client-1',
grantId,
grantedSpaceIds: ['space-1'],
grantedSkills: ['research'],
audience: null as string | null,
expiresAt: null as string | null,
revokedAt: null as string | null,
};
const repo = {
// computeEffectiveScope → resolveOrgIds needs these
getUserById: (_id: string) => ({
id: 'user-1', status: 'active', role: 'user' as const, name: 'Test User',
}),
listUserGiteaOrgs: (_userId: string) => [] as Array<{ orgId: string }>,
listUserLocalOrgs: (_userId: string) => [] as Array<{ orgId: string }>,
listSpaces: async (_opts: unknown) => [{ id: 'space-1' }],
getSpaceA2aSkills: (_spaceId: string) => ['research'],
// Job creation
createLocalTask: async (_opts: unknown) => ({ id: 1, workspacePath: null }),
createJob: async (_opts: unknown) => undefined,
getLatestJobForIssue: async (_repoName: string, _issueNum: number) => ({
id: 'job-test-1',
status: 'queued',
currentActivity: null,
errorSummary: null,
abortReason: null,
worktreePath: null,
}),
addAuditLog: async (..._args: unknown[]) => undefined,
// Poll loop: getJob
getJob: async (_id: string) => {
pollCount++;
const status = pollCount > succeedAfterPollN ? 'succeeded' : 'running';
return {
id: 'job-test-1',
status,
currentActivity: null,
errorSummary: null,
abortReason: null,
worktreePath: null,
};
},
// Poll loop: revocation recheck (called right after getJob in the same iteration)
getA2aDelegationByGrantId: (_grantId: string) => {
// pollCount was just incremented by getJob in the same iteration.
// After revokeAfterPollN polls, delegation is revoked.
if (pollCount > revokeAfterPollN) {
return { ...baseDelegRow, revokedAt: '2026-07-02T00:00:00.000Z' };
}
return { ...baseDelegRow };
},
// Job cancel — executor uses cancelA2aLinkedJob (covers queued/retry/running/etc.)
cancelA2aLinkedJob: (jobId: string) => {
cancelCalls.push(jobId);
return true;
},
// handleTerminal (succeeded path)
getLocalTask: async (_id: number) => ({ id: 1, workspacePath: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
return {
repo,
cancelCalls,
getPollCount: () => pollCount,
};
}
/** Fake eventBus that collects all published events. Uses object reference to avoid stale closure. */
function makeFakeEventBus() {
const events: unknown[] = [];
const tracker = { finishedCount: 0 };
const eventBus: ExecutionEventBus = {
publish: (event: unknown) => { events.push(event); },
finished: () => { tracker.finishedCount++; },
};
const statusEvents = () =>
events.filter(
(e): e is TaskStatusUpdateEvent =>
typeof e === 'object' &&
e !== null &&
(e as Record<string, unknown>).kind === 'status-update',
);
return { eventBus, events, tracker, statusEvents };
}
function makeFakeRequestContext(opts: {
grantId: string;
delegationId: string;
taskId?: string;
contextId?: string;
}) {
const { grantId, delegationId, taskId = 'a2a-task-1', contextId = 'ctx-1' } = opts;
const principal = {
actingUserId: 'user-1',
clientId: 'client-1',
grantId,
delegation: {
id: delegationId,
userId: 'user-1',
clientId: 'client-1',
grantId,
grantedSpaceIds: ['space-1'],
grantedSkills: ['research'],
expiresAt: null,
revokedAt: null,
},
};
return {
taskId,
contextId,
context: { user: { a2aPrincipal: principal } },
userMessage: {
parts: [{ kind: 'text', text: 'do research' }],
metadata: { skillId: 'research' },
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('A2A executor revocation recheck (Task 5)', () => {
// Fixed clock for determinism
const nowFn = () => '2026-07-02T00:00:00.000Z';
it('A1: revoked delegation after first poll → executor cancels and publishes terminal canceled', async () => {
const GRANT_ID = 'grant-revoke-test';
const DELEG_ID = 'deleg-pk-001';
const { repo, cancelCalls } = makeFakeRepo({
delegationId: DELEG_ID,
grantId: GRANT_ID,
// delegation is live for poll #1 (pollCount=1 <= 1),
// revoked starting poll #2 (pollCount=2 > 1)
revokeAfterPollN: 1,
});
const { eventBus, tracker, statusEvents } = makeFakeEventBus();
const requestContext = makeFakeRequestContext({ grantId: GRANT_ID, delegationId: DELEG_ID });
const executor = new MaestroA2aExecutor(repo, {
pollMs: 0,
sleep: async () => {},
now: nowFn,
maxPollIterations: 10,
});
await executor.execute(requestContext, eventBus);
// Must have called cancelA2aLinkedJob (covers all non-terminal statuses including retry)
expect(cancelCalls).toContain('job-test-1');
// Must have published a terminal 'canceled' status update
const allStatusEvents = statusEvents();
const terminalEvents = allStatusEvents.filter(e => e.final === true);
expect(terminalEvents.length).toBeGreaterThanOrEqual(1);
const canceledEvent = terminalEvents.find(e => (e.status as Record<string, unknown>)?.state === 'canceled');
expect(canceledEvent).toBeDefined();
expect((canceledEvent!.status as Record<string, unknown>).state).toBe('canceled');
// Must have called eventBus.finished() exactly once
expect(tracker.finishedCount).toBe(1);
});
it('A2: live delegation throughout → job completes normally without premature cancel', async () => {
const GRANT_ID = 'grant-live-test';
const DELEG_ID = 'deleg-pk-002';
const { repo, cancelCalls } = makeFakeRepo({
delegationId: DELEG_ID,
grantId: GRANT_ID,
revokeAfterPollN: Infinity, // delegation stays live forever
succeedAfterPollN: 2, // job succeeds after 2 polls
});
const { eventBus, tracker, statusEvents } = makeFakeEventBus();
const requestContext = makeFakeRequestContext({ grantId: GRANT_ID, delegationId: DELEG_ID });
const executor = new MaestroA2aExecutor(repo, {
pollMs: 0,
sleep: async () => {},
now: nowFn,
maxPollIterations: 10,
});
await executor.execute(requestContext, eventBus);
// Must NOT have called cancelA2aLinkedJob — delegation is still live
expect(cancelCalls).toHaveLength(0);
// Must have published 'completed' terminal event (not 'canceled')
const terminalEvents = statusEvents().filter(e => e.final === true);
expect(terminalEvents.length).toBeGreaterThanOrEqual(1);
const completedEvent = terminalEvents.find(
e => (e.status as Record<string, unknown>)?.state === 'completed',
);
expect(completedEvent).toBeDefined();
expect((completedEvent!.status as Record<string, unknown>).state).toBe('completed');
// No 'canceled' event in the terminal events
const canceledEvent = terminalEvents.find(
e => (e.status as Record<string, unknown>)?.state === 'canceled',
);
expect(canceledEvent).toBeUndefined();
// eventBus.finished() must have been called exactly once
expect(tracker.finishedCount).toBe(1);
});
it('B: initial submitted Task metadata has a2aDelegationId = delegation.id and a2aGrantId = grantId', async () => {
const GRANT_ID = 'grant-metadata-test';
const DELEG_ID = 'deleg-pk-real-003'; // deliberately different from GRANT_ID
const { repo } = makeFakeRepo({
delegationId: DELEG_ID,
grantId: GRANT_ID,
// revoke immediately so execute() exits quickly after the first poll
revokeAfterPollN: 0,
});
const { eventBus, events } = makeFakeEventBus();
const requestContext = makeFakeRequestContext({ grantId: GRANT_ID, delegationId: DELEG_ID });
const executor = new MaestroA2aExecutor(repo, {
pollMs: 0,
sleep: async () => {},
now: nowFn,
maxPollIterations: 10,
});
await executor.execute(requestContext, eventBus);
// The first published event must be the initial 'submitted' Task (kind: 'task')
const taskEvents = events.filter(
(e): e is Record<string, unknown> =>
typeof e === 'object' && e !== null && (e as Record<string, unknown>).kind === 'task',
);
expect(taskEvents.length).toBeGreaterThanOrEqual(1);
const initialTask = taskEvents[0];
const metadata = initialTask.metadata as Record<string, unknown>;
// a2aDelegationId must equal the real delegation PK (not grantId)
expect(metadata.a2aDelegationId).toBe(DELEG_ID);
// a2aGrantId must equal the OAuth grant id
expect(metadata.a2aGrantId).toBe(GRANT_ID);
// Both values must differ — verifies we're not using grantId for both
expect(metadata.a2aDelegationId).not.toBe(metadata.a2aGrantId);
});
});
// ── Task 6: Reconciler revocation sweep helpers ───────────────────────────────
const RECONCILER_TERMINAL_STATES = new Set(['completed', 'failed', 'canceled', 'rejected']);
function makeReconcilerFakeRepo(opts: {
grantId: string;
delegationRevoked: boolean;
initialJobStatus?: string;
initialTaskState?: string;
}) {
let currentJobStatus = opts.initialJobStatus ?? 'running';
let currentPayload: Record<string, unknown> = {
contextId: 'ctx-r',
status: { state: opts.initialTaskState ?? 'working' },
metadata: { a2aGrantId: opts.grantId },
};
const cancelCalls: string[] = [];
const delegRow = {
id: 'deleg-r-001',
userId: 'user-1',
clientId: 'client-1',
grantId: opts.grantId,
grantedSpaceIds: ['space-1'],
grantedSkills: ['research'],
audience: null as string | null,
expiresAt: null as string | null,
revokedAt: opts.delegationRevoked ? '2026-07-01T00:00:00.000Z' : null as string | null,
};
const repo = {
listNonTerminalA2aTasks: (_limit: number) => {
const state = (currentPayload.status as Record<string, unknown> | undefined)?.state as string | undefined;
if (state && RECONCILER_TERMINAL_STATES.has(state)) return [];
return [{ id: 'a2a-task-r1', jobId: 'job-r1', payload: { ...currentPayload } }];
},
getA2aDelegationByGrantId: (_grantId: string) => delegRow,
cancelA2aLinkedJob: (jobId: string) => {
cancelCalls.push(jobId);
currentJobStatus = 'cancelled';
return true;
},
saveA2aTask: (row: { payload: object }) => {
currentPayload = { ...(row.payload as Record<string, unknown>) };
},
getJob: async (_id: string) => ({
id: 'job-r1',
status: currentJobStatus,
currentActivity: null,
errorSummary: null,
abortReason: null,
worktreePath: null,
}),
getLocalTask: async (id: number) => ({ id, workspacePath: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
return {
repo,
cancelCalls,
getCurrentPayload: () => currentPayload,
getCurrentJobStatus: () => currentJobStatus,
};
}
// ── Task 6: Reconciler revocation sweep tests ─────────────────────────────────
describe('A2A reconciler revocation sweep (Task 6)', () => {
const nowFn = () => '2026-07-02T00:00:00.000Z';
it('R1: revoked delegation + running linked job → job cancelled and task finalized to canceled', async () => {
const GRANT_ID = 'grant-revoked-r1';
const { repo, cancelCalls, getCurrentPayload } = makeReconcilerFakeRepo({
grantId: GRANT_ID,
delegationRevoked: true,
initialJobStatus: 'running',
});
const reconciler = new A2aTaskReconciler({ repo, now: nowFn });
const result = await reconciler.reconcileOnce();
// Job must have been cancelled via cancelA2aLinkedJob
expect(cancelCalls).toContain('job-r1');
// Task payload must be finalized to A2A 'canceled' state
const savedState = (getCurrentPayload().status as Record<string, unknown>)?.state;
expect(savedState).toBe('canceled');
// Reconciler must report at least one finalization
expect(result.finalized).toBeGreaterThanOrEqual(1);
});
it('R2: live delegation → task left untouched (still working, job not cancelled)', async () => {
const GRANT_ID = 'grant-live-r2';
const { repo, cancelCalls, getCurrentPayload } = makeReconcilerFakeRepo({
grantId: GRANT_ID,
delegationRevoked: false,
initialJobStatus: 'running',
});
const reconciler = new A2aTaskReconciler({ repo, now: nowFn });
await reconciler.reconcileOnce();
// cancelA2aLinkedJob must NOT have been called
expect(cancelCalls).toHaveLength(0);
// Task must remain in its original non-terminal state (saveA2aTask was not called)
const state = (getCurrentPayload().status as Record<string, unknown>)?.state;
expect(state).toBe('working');
});
it('R3: idempotency — second reconcileOnce() after R1 makes no further changes', async () => {
const GRANT_ID = 'grant-revoked-r3';
const { repo, cancelCalls, getCurrentPayload } = makeReconcilerFakeRepo({
grantId: GRANT_ID,
delegationRevoked: true,
initialJobStatus: 'running',
});
const reconciler = new A2aTaskReconciler({ repo, now: nowFn });
// First run — equivalent to R1
await reconciler.reconcileOnce();
expect(cancelCalls).toHaveLength(1);
expect((getCurrentPayload().status as Record<string, unknown>)?.state).toBe('canceled');
// Second run — task is now terminal so listNonTerminalA2aTasks returns nothing
const result = await reconciler.reconcileOnce();
// No additional cancel calls
expect(cancelCalls).toHaveLength(1);
// Payload stays canceled
expect((getCurrentPayload().status as Record<string, unknown>)?.state).toBe('canceled');
// No new rows scanned or finalized
expect(result.finalized).toBe(0);
});
});