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); }); }); });