maestro/src/bridge/config-api.test.ts
2026-06-03 05:08:00 +00:00

510 lines
17 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { ConfigManager } from '../config-manager.js';
import { mountConfigApi, __clearWorkerBackendsCache } from './config-api.js';
describe('Config API', () => {
let app: express.Application;
let cm: ConfigManager;
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'config-api-'));
writeFileSync(join(tempDir, 'config.yaml'), [
'config_version: 2',
'llm:',
' workers:',
' - id: w1',
' connection_type: direct',
' endpoint: http://localhost:11434/v1',
' model: test-model',
' roles: [auto, fast, quality]',
' max_concurrency: 1',
' enabled: true',
].join('\n'));
cm = new ConfigManager(join(tempDir, 'config.yaml'));
app = express();
app.use(express.json());
mountConfigApi(app, cm);
});
it('GET /api/config returns v2 shape with etag', async () => {
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(res.body.config.configVersion).toBe(2);
expect(res.body.config.llm.workers[0].model).toBe('test-model');
expect(res.headers.etag).toBeDefined();
});
it('GET /api/config omits legacy provider block and flat storage keys', async () => {
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(res.body.config.provider).toBeUndefined();
expect(res.body.config.worktreeDir).toBeUndefined();
expect(res.body.config.customPiecesDir).toBeUndefined();
expect(res.body.config.userFolderRoot).toBeUndefined();
});
it('GET /api/config exposes storage.* block when set', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'config_version: 2',
'llm:',
' workers:',
' - id: w1',
' connection_type: direct',
' endpoint: http://x/v1',
' model: m',
'storage:',
' worktree_dir: /tmp/wt',
' custom_pieces_dir: /tmp/pieces',
' task_upload_max_size_mb: 25',
' trash_retention_days: 14',
].join('\n'));
cm.reloadFromFile();
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(res.body.config.storage).toEqual({
worktreeDir: '/tmp/wt',
customPiecesDir: '/tmp/pieces',
taskUploadMaxSizeMb: 25,
trashRetentionDays: 14,
});
// Legacy tools.taskUploadMaxSizeMb / tools.trashRetentionDays must not
// appear under tools.* in v2 output.
expect(res.body.config.tools?.taskUploadMaxSizeMb).toBeUndefined();
expect(res.body.config.tools?.trashRetentionDays).toBeUndefined();
});
it('PUT /api/config updates llm.workers and round-trips through YAML', async () => {
const getRes = await request(app).get('/api/config');
const etag = getRes.headers.etag;
const res = await request(app)
.put('/api/config')
.set('If-Match', etag)
.send({
llm: {
workers: [{
id: 'w1',
connectionType: 'direct',
endpoint: 'http://localhost:11434/v1',
model: 'updated-model',
roles: ['auto', 'fast', 'quality'],
maxConcurrency: 1,
enabled: true,
}],
},
});
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(cm.getConfig().llm?.workers[0]?.model).toBe('updated-model');
// YAML on disk: snake_case + config_version: 2 stamped + no legacy provider block
const yaml = readFileSync(join(tempDir, 'config.yaml'), 'utf-8');
expect(yaml).toContain('updated-model');
expect(yaml).toContain('config_version: 2');
expect(yaml).toContain('llm:');
expect(yaml).not.toMatch(/^provider:/m);
expect(yaml).not.toMatch(/connectionType/);
});
it('PUT /api/config force-stamps config_version=2 when omitted', async () => {
const getRes = await request(app).get('/api/config');
const etag = getRes.headers.etag;
const res = await request(app)
.put('/api/config')
.set('If-Match', etag)
.send({ concurrency: 4 });
expect(res.status).toBe(200);
const yaml = readFileSync(join(tempDir, 'config.yaml'), 'utf-8');
expect(yaml).toContain('config_version: 2');
});
it('PUT /api/config rejects body with legacy provider block (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ provider: { model: 'x' } });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('provider');
expect(res.body.error).toMatch(/llm\.\*/);
});
it('PUT /api/config rejects body with flat worktreeDir key (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ worktreeDir: '/tmp/wt' });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('worktreeDir');
expect(res.body.error).toMatch(/storage\.worktreeDir/);
});
it('PUT /api/config rejects body with flat customPiecesDir key (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ customPiecesDir: '/tmp/p' });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('customPiecesDir');
});
it('PUT /api/config rejects body with flat userFolderRoot key (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ userFolderRoot: '/tmp/users' });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('userFolderRoot');
});
it('PUT /api/config rejects body with tools.taskUploadMaxSizeMb (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ tools: { taskUploadMaxSizeMb: 50 } });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('tools.taskUploadMaxSizeMb');
expect(res.body.error).toMatch(/storage\.taskUploadMaxSizeMb/);
});
it('PUT /api/config rejects body with tools.trashRetentionDays (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ tools: { trashRetentionDays: 7 } });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('tools.trashRetentionDays');
});
it('PUT /api/config writes storage.* round-trip cleanly', async () => {
const res = await request(app)
.put('/api/config')
.send({
storage: {
worktreeDir: '/var/lib/aao',
customPiecesDir: '/etc/aao/pieces',
},
});
expect(res.status).toBe(200);
const yaml = readFileSync(join(tempDir, 'config.yaml'), 'utf-8');
expect(yaml).toContain('storage:');
expect(yaml).toContain('worktree_dir: /var/lib/aao');
expect(yaml).toContain('custom_pieces_dir: /etc/aao/pieces');
// No flat legacy storage keys should appear at top level
expect(yaml).not.toMatch(/^worktree_dir:/m);
expect(yaml).not.toMatch(/^custom_pieces_dir:/m);
});
it('PUT /api/config returns 409 on stale etag', async () => {
const res = await request(app)
.put('/api/config')
.set('If-Match', 'stale-etag')
.send({ concurrency: 2 });
expect(res.status).toBe(409);
});
it('POST /api/config/reload reloads from file', async () => {
const res = await request(app).post('/api/config/reload');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
describe('GET /api/workers', () => {
it('returns the synthesized default worker when no workers are configured', async () => {
// Override the beforeEach v2 fixture with a v1-style empty provider
// to exercise loadConfig's "no workers" auto-gen path.
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' model: test-model',
].join('\n'));
cm.reloadFromFile();
const res = await request(app).get('/api/workers');
expect(res.status).toBe(200);
expect(res.body.workers).toHaveLength(1);
expect(res.body.workers[0].id).toBe('default');
});
it('returns workers from config with allowlisted fields only', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' model: shared-model',
' workers:',
' - id: gpu1',
' endpoint: http://10.0.0.10:11434/v1',
' model: qwen3:8b',
' roles: [auto, fast]',
' enabled: true',
' api_key: super-secret-do-not-leak',
' - id: gpu2',
' endpoint: http://10.0.0.10:11434/v1',
' enabled: false',
' retry:',
' max_attempts: 1',
].join('\n'));
cm.reloadFromFile();
const res = await request(app).get('/api/workers');
expect(res.status).toBe(200);
expect(res.body.workers).toHaveLength(2);
const [w1, w2] = res.body.workers;
expect(w1).toEqual({
id: 'gpu1',
endpoint: 'http://10.0.0.10:11434/v1',
model: 'qwen3:8b',
roles: ['auto', 'fast'],
enabled: true,
proxy: false,
});
expect(w2.id).toBe('gpu2');
expect(w2.enabled).toBe(false);
// sensitive fields must not leak
const serialized = JSON.stringify(res.body);
expect(serialized).not.toContain('api_key');
expect(serialized).not.toContain('super-secret');
});
it('exposes proxy + proxyType on proxy workers (no api_key leak)', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' model: shared-model',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
' api_key: team-tok-do-not-leak',
' retry:',
' max_attempts: 1',
].join('\n'));
cm.reloadFromFile();
const res = await request(app).get('/api/workers');
expect(res.status).toBe(200);
expect(res.body.workers[0]).toEqual({
id: 'team-pool',
endpoint: 'http://litellm:4000/v1',
model: null,
roles: ['auto', 'fast', 'quality'],
enabled: true,
proxy: true,
proxyType: 'litellm',
});
expect(JSON.stringify(res.body)).not.toContain('team-tok-do-not-leak');
});
});
describe('GET /api/workers/:workerId/backends', () => {
beforeEach(() => {
__clearWorkerBackendsCache();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
__clearWorkerBackendsCache();
});
it('returns 404 for an unknown worker', async () => {
const res = await request(app).get('/api/workers/nope/backends');
expect(res.status).toBe(404);
});
it('returns source=direct with empty backends for non-proxy workers', async () => {
// beforeEach now seeds a v2 worker named w1, so probe its id.
const res = await request(app).get('/api/workers/w1/backends');
expect(res.status).toBe(200);
expect(res.body).toEqual({ source: 'direct', backends: [] });
});
it('fetches /v1/models from a proxy worker and returns the deployment list', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
' api_key: tok-xyz',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
data: [
{ id: 'gpu-rtx-a', object: 'model' },
{ id: 'gpu-h100-b', object: 'model' },
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(200);
expect(res.body).toEqual({
source: 'proxy',
proxyType: 'litellm',
backends: [
{ id: 'gpu-rtx-a', model: 'gpu-rtx-a', online: true },
{ id: 'gpu-h100-b', model: 'gpu-h100-b', online: true },
],
});
// Should have called the upstream with the worker's api_key
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0]!;
expect(call[0]).toBe('http://litellm:4000/v1/models');
expect((call[1] as RequestInit).headers).toMatchObject({
Authorization: 'Bearer tok-xyz',
});
});
it('caches successful proxy results across calls (60s TTL)', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: 'gpu-x' }] }), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const r1 = await request(app).get('/api/workers/team-pool/backends');
const r2 = await request(app).get('/api/workers/team-pool/backends');
expect(r1.body).toEqual(r2.body);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('rejects file:// endpoint scheme without leaking apiKey to fetch', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: file:///etc/passwd',
' proxy: true',
' api_key: tok-leak-target',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/unsupported endpoint scheme/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects data: endpoint scheme without leaking apiKey to fetch', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: "data:text/plain,hi"',
' proxy: true',
' api_key: tok-leak-target',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/unsupported endpoint scheme/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects malformed endpoint URLs without calling fetch', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: "not-a-url"',
' proxy: true',
' api_key: tok-leak-target',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/invalid endpoint URL/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns 502 with error payload when the upstream proxy fails', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(502);
expect(res.body.source).toBe('proxy');
expect(res.body.backends).toEqual([]);
expect(res.body.error).toContain('ECONNREFUSED');
});
});
describe('/api/workers auth guard', () => {
// server.ts wires `app.use('/api/workers', requireAuth)` when auth is
// active. config-api.ts itself doesn't mount the middleware (separation
// of concerns), so this suite asserts the same middleware shape works
// when callers mount it the way server.ts does.
let guardedApp: express.Application;
let isAuthed: boolean;
const fakeRequireAuth: express.RequestHandler = (_req, res, next) => {
if (isAuthed) {
next();
} else {
res.status(401).json({ error: 'unauthenticated' });
}
};
beforeEach(() => {
isAuthed = true;
guardedApp = express();
guardedApp.use(express.json());
guardedApp.use('/api/workers', fakeRequireAuth);
mountConfigApi(guardedApp, cm);
});
it('returns 401 for unauthenticated GET /api/workers', async () => {
isAuthed = false;
const res = await request(guardedApp).get('/api/workers');
expect(res.status).toBe(401);
});
it('returns 401 for unauthenticated GET /api/workers/:id/backends', async () => {
isAuthed = false;
const res = await request(guardedApp).get('/api/workers/default/backends');
expect(res.status).toBe(401);
});
it('returns 200 for authenticated GET /api/workers', async () => {
isAuthed = true;
const res = await request(guardedApp).get('/api/workers');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.workers)).toBe(true);
});
});
});