510 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|