ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: fred <[email protected]>
219 lines
7.2 KiB
TypeScript
219 lines
7.2 KiB
TypeScript
import { Command } from 'commander';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { registerAgentCommand, runEnroll, runGetEnrollment } from './agent.js';
|
|
import { enrollAgent, fetchEnrollment } from '../tui/gateway-api.js';
|
|
|
|
/**
|
|
* CLI-parity witness for the agent enrollment family (design
|
|
* docs/plans/2026-08-29-agent-enrollment-command-design.md §5 item 10;
|
|
* contract 5 §4.5): `mosaic agent enroll` / `mosaic agent enrollment <id>`
|
|
* invoke the same gateway commands with the same request/result/error
|
|
* contracts the web client uses. The gateway side of the same routes is
|
|
* witnessed in apps/gateway/src/enrollment/enrollment-commands.integration.test.ts.
|
|
*/
|
|
|
|
const gateway = 'https://gateway.example.test';
|
|
const auth = { gateway, cookie: 'session=test' };
|
|
|
|
const agentBody = {
|
|
id: '3fca4f6a-1111-4222-8333-444455556666',
|
|
name: 'Nova',
|
|
provider: 'anthropic',
|
|
model: 'claude-test',
|
|
status: 'idle',
|
|
harness: 'fake-harness',
|
|
persona: null,
|
|
ownerId: 'user-1',
|
|
enrolledAt: '2026-08-29T00:00:00.000Z',
|
|
createdAt: '2026-08-29T00:00:00.000Z',
|
|
};
|
|
|
|
const okResponse = (correlationId: string) =>
|
|
new Response(JSON.stringify({ ok: true, correlationId, agent: agentBody }), {
|
|
status: 201,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
process.exitCode = undefined;
|
|
});
|
|
|
|
describe('agent enrollment CLI registration', (): void => {
|
|
it('registers enroll and enrollment subcommands under `mosaic agent`', () => {
|
|
const program = new Command();
|
|
const cmd = registerAgentCommand(program);
|
|
const names = cmd.commands.map((c) => c.name());
|
|
expect(names).toContain('enroll');
|
|
expect(names).toContain('enrollment');
|
|
});
|
|
|
|
it('the enroll subcommand exposes no argv flag that carries a credential value', () => {
|
|
const program = new Command();
|
|
const cmd = registerAgentCommand(program);
|
|
const enroll = cmd.commands.find((c) => c.name() === 'enroll');
|
|
expect(enroll).toBeDefined();
|
|
const flags = (enroll as Command).options.map((o) => o.flags);
|
|
// --credential selects the MODE only; the intake value arrives via stdin.
|
|
expect(flags).toContain('--credential <mode>');
|
|
for (const flag of flags) {
|
|
expect(flag).not.toMatch(/value|key <secret>|api-key/i);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('agent.enroll CLI parity', (): void => {
|
|
it('posts the §3.1 request shape for reference mode and surfaces the typed result', async () => {
|
|
const fetchMock = vi.fn(async () => okResponse('corr-ref'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
await runEnroll(auth, {
|
|
gateway,
|
|
harness: 'fake-harness',
|
|
name: 'Nova',
|
|
model: 'claude-test',
|
|
provider: 'anthropic',
|
|
credential: 'reference',
|
|
idempotencyKey: '9c1a26be-0000-4000-8000-000000000001',
|
|
correlationId: 'corr-ref',
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
expect(url).toBe(`${gateway}/api/enrollment/agents`);
|
|
expect(init.method).toBe('POST');
|
|
expect(JSON.parse(init.body as string)).toEqual({
|
|
harness: 'fake-harness',
|
|
name: 'Nova',
|
|
model: 'claude-test',
|
|
provider: 'anthropic',
|
|
credential: { mode: 'reference' },
|
|
idempotencyKey: '9c1a26be-0000-4000-8000-000000000001',
|
|
correlationId: 'corr-ref',
|
|
});
|
|
expect(log.mock.calls.flat().join('\n')).toContain(agentBody.id);
|
|
expect(process.exitCode).toBeUndefined();
|
|
});
|
|
|
|
it('intake mode reads the credential from the injected stdin reader, never argv', async () => {
|
|
const fetchMock = vi.fn(async () => okResponse('corr-intake'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
await runEnroll(
|
|
auth,
|
|
{
|
|
gateway,
|
|
harness: 'fake-harness',
|
|
name: 'Nova',
|
|
model: 'claude-test',
|
|
provider: 'anthropic',
|
|
credential: 'intake',
|
|
idempotencyKey: '9c1a26be-0000-4000-8000-000000000002',
|
|
},
|
|
async () => 'stdin-provided-key',
|
|
);
|
|
|
|
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
const body = JSON.parse(init.body as string) as { credential: Record<string, string> };
|
|
expect(body.credential).toEqual({
|
|
mode: 'intake',
|
|
type: 'api_key',
|
|
value: 'stdin-provided-key',
|
|
});
|
|
});
|
|
|
|
it('an empty intake credential refuses locally with no gateway call', async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
await runEnroll(
|
|
auth,
|
|
{
|
|
gateway,
|
|
harness: 'fake-harness',
|
|
name: 'Nova',
|
|
model: 'claude-test',
|
|
provider: 'anthropic',
|
|
credential: 'intake',
|
|
},
|
|
async () => '',
|
|
);
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it('preserves the gateway refusal contract (closed error enum + correlation) in the CLI error', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
statusCode: 409,
|
|
error: 'conflict',
|
|
message: 'idempotency conflict',
|
|
correlationId: 'corr-409',
|
|
}),
|
|
{ status: 409, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
enrollAgent(gateway, auth.cookie, {
|
|
harness: 'fake-harness',
|
|
name: 'Nova',
|
|
model: 'claude-test',
|
|
provider: 'anthropic',
|
|
credential: { mode: 'reference' },
|
|
idempotencyKey: '9c1a26be-0000-4000-8000-000000000003',
|
|
}),
|
|
).rejects.toThrow(
|
|
'Failed to enroll agent (409): {"statusCode":409,"error":"conflict",' +
|
|
'"message":"idempotency conflict","correlationId":"corr-409"}',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('agent.enrollment.get CLI parity', (): void => {
|
|
it('reads one enrollment with the correlation envelope on the query string', async () => {
|
|
const fetchMock = vi.fn(async () => okResponse('corr-get'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
await runGetEnrollment(auth, agentBody.id, 'corr-get');
|
|
|
|
const [url] = fetchMock.mock.calls[0] as unknown as [string];
|
|
expect(url).toBe(`${gateway}/api/enrollment/agents/${agentBody.id}?correlationId=corr-get`);
|
|
expect(log.mock.calls.flat().join('\n')).toContain('corr-get');
|
|
});
|
|
|
|
it('preserves the folded not_found contract in the CLI error', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
statusCode: 404,
|
|
error: 'not_found',
|
|
message: 'agent not found',
|
|
correlationId: 'corr-404',
|
|
}),
|
|
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
),
|
|
);
|
|
|
|
await expect(fetchEnrollment(gateway, auth.cookie, agentBody.id)).rejects.toThrow(
|
|
'Failed to get enrollment (404): {"statusCode":404,"error":"not_found",' +
|
|
'"message":"agent not found","correlationId":"corr-404"}',
|
|
);
|
|
});
|
|
});
|