maestro/src/bridge/setup-api.test.ts
oss-sync a67c40d33c
All checks were successful
CI / build-and-test (push) Successful in 8m58s
sync: update from private repo (a5db2ab0)
2026-07-10 00:51:05 +00:00

442 lines
18 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, existsSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createServer, type Server } from 'http';
import { ConfigManager } from '../config-manager.js';
import {
mountSetupApi,
buildNoAuthConfigGate,
newNoAuthConfigGateState,
type NoAuthConfigGateState,
ensureSetupToken,
readSetupToken,
clearSetupToken,
setupTokenPath,
tokensMatch,
} from './setup-api.js';
import { mountConfigApi } from './config-api.js';
const UNCONFIGURED = 'config_version: 2\n';
const CONFIGURED = [
'config_version: 2',
'llm:',
' workers:',
' - id: w1',
' connection_type: direct',
' endpoint: http://localhost:11434/v1',
' model: llama3',
' roles: [auto, fast, quality, title, reflection]',
' max_concurrency: 1',
' enabled: true',
'',
].join('\n');
function makeApp(
yaml: string,
opts: { authActive?: boolean; withConfigApi?: boolean; gateMode?: NoAuthConfigGateState['mode'] } = {},
) {
const dir = mkdtempSync(join(tmpdir(), 'setup-api-'));
writeFileSync(join(dir, 'config.yaml'), yaml);
const cm = new ConfigManager(join(dir, 'config.yaml'));
const dataDir = join(dir, 'data');
const authActive = opts.authActive === true;
const gateState = newNoAuthConfigGateState();
gateState.mode = opts.gateMode ?? 'token-required';
const app = express();
if (opts.withConfigApi) {
app.use('/api/config', express.json());
if (!authActive) app.use('/api/config', buildNoAuthConfigGate(dataDir, gateState));
}
mountSetupApi(app, cm, { authActive, listenPort: 9876, dataDir, deployHint: 'docker' });
if (opts.withConfigApi) mountConfigApi(app, cm);
return { app, cm, dir, dataDir, gateState };
}
describe('setup-api: token helpers', () => {
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), 'setup-tok-'));
});
it('ensureSetupToken creates a 0600 file and reuses it on second call', () => {
const t1 = ensureSetupToken(dataDir);
expect(t1).toMatch(/^[0-9a-f]{64}$/);
const mode = statSync(setupTokenPath(dataDir)).mode & 0o777;
expect(mode).toBe(0o600);
const t2 = ensureSetupToken(dataDir);
expect(t2).toBe(t1); // reuse, no regenerate race
expect(readSetupToken(dataDir)).toBe(t1);
});
it('readSetupToken returns null when absent and clearSetupToken is idempotent', () => {
expect(readSetupToken(dataDir)).toBeNull();
expect(() => clearSetupToken(dataDir)).not.toThrow();
ensureSetupToken(dataDir);
expect(existsSync(setupTokenPath(dataDir))).toBe(true);
clearSetupToken(dataDir);
expect(existsSync(setupTokenPath(dataDir))).toBe(false);
expect(() => clearSetupToken(dataDir)).not.toThrow();
});
it('tokensMatch is true only for an exact match (length-normalized)', () => {
const t = 'a'.repeat(64);
expect(tokensMatch(t, t)).toBe(true);
expect(tokensMatch('a'.repeat(63), t)).toBe(false); // length mismatch
expect(tokensMatch('b'.repeat(64), t)).toBe(false); // same length, wrong
expect(tokensMatch('', t)).toBe(false);
});
});
describe('setup-api: GET /api/setup/status', () => {
it('reports needsSetup=true with tokenRequired when fresh + token present', async () => {
const { app, dataDir } = makeApp(UNCONFIGURED);
ensureSetupToken(dataDir);
const res = await request(app).get('/api/setup/status');
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
needsSetup: true,
authActive: false,
port: 9876,
deployHint: 'docker',
tokenRequired: true,
});
});
it('reports needsSetup=false when an LLM worker is configured', async () => {
const { app } = makeApp(CONFIGURED);
const res = await request(app).get('/api/setup/status');
expect(res.status).toBe(200);
expect(res.body.needsSetup).toBe(false);
});
it('tokenRequired=false when auth is active', async () => {
const { app } = makeApp(UNCONFIGURED, { authActive: true });
const res = await request(app).get('/api/setup/status');
expect(res.body.authActive).toBe(true);
expect(res.body.tokenRequired).toBe(false);
});
it('a2aEnabled=false by default (no a2a section)', async () => {
const { app } = makeApp(UNCONFIGURED);
const res = await request(app).get('/api/setup/status');
// The UI hides the per-user A2A Delegations settings section unless this is true.
expect(res.body.a2aEnabled).toBe(false);
});
it('a2aEnabled=true when a2a.enabled is set in config', async () => {
const { app } = makeApp('config_version: 2\na2a:\n enabled: true\n');
const res = await request(app).get('/api/setup/status');
expect(res.body.a2aEnabled).toBe(true);
});
});
describe('setup-api: token gate on /api/setup/apply', () => {
it('403 without a token header', async () => {
const { app, dataDir } = makeApp(UNCONFIGURED);
ensureSetupToken(dataDir);
const res = await request(app).post('/api/setup/apply').send({ port: 1234 });
expect(res.status).toBe(403);
});
it('403 with a wrong-length token (timingSafeEqual path)', async () => {
const { app, dataDir } = makeApp(UNCONFIGURED);
ensureSetupToken(dataDir);
const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', 'short').send({ port: 1234 });
expect(res.status).toBe(403);
});
it('410 once an LLM worker is configured (closed)', async () => {
const { app, dataDir } = makeApp(CONFIGURED);
ensureSetupToken(dataDir);
const token = readSetupToken(dataDir)!;
const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', token).send({ port: 1234 });
expect(res.status).toBe(410);
});
it('410 when auth is active', async () => {
const { app, dataDir } = makeApp(UNCONFIGURED, { authActive: true });
ensureSetupToken(dataDir);
const token = readSetupToken(dataDir)!;
const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', token).send({ port: 1234 });
expect(res.status).toBe(410);
});
});
describe('setup-api: no-auth /api/config auth-write gate (Codex P1 #2/#3)', () => {
it('blocks an auth.* PUT without a token in token-required mode', async () => {
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
ensureSetupToken(dataDir);
const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
expect(res.status).toBe(403);
});
it('allows an auth.* PUT with the correct token', async () => {
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
ensureSetupToken(dataDir);
const token = readSetupToken(dataDir)!;
const res = await request(app).put('/api/config').set('X-Setup-Token', token).send({ auth: { local: { enabled: false } } });
expect(res.status).toBe(200);
});
it('does NOT gate non-auth writes (no Settings regression) even in token-required mode', async () => {
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
ensureSetupToken(dataDir);
const res = await request(app).put('/api/config').send({ server: { port: 5555 } });
expect(res.status).toBe(200); // no token needed for a non-auth write
});
it('fails closed (503) on an auth.* write while the boot lifecycle is pending (boot TOCTOU)', async () => {
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'pending' });
ensureSetupToken(dataDir);
const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
expect(res.status).toBe(503);
});
it('refuses an auth.* write after the token is cleared (completing setup cannot reopen the hole)', async () => {
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
// token-required but no token file present (post-apply state)
expect(readSetupToken(dataDir)).toBeNull();
const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
expect(res.status).toBe(403);
});
it("allows auth.* writes in 'open' mode (existing configured no-auth deployment, no regression)", async () => {
const { app } = makeApp(CONFIGURED, { withConfigApi: true, gateMode: 'open' });
const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
expect(res.status).toBe(200);
});
it('leaves GET /api/config open', async () => {
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
ensureSetupToken(dataDir);
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
});
it('GET /api/config masks the bootstrap admin password after a local-auth apply (P1 #1)', async () => {
const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
const token = ensureSetupToken(dataDir);
const applied = await request(app)
.post('/api/setup/apply')
.set('X-Setup-Token', token)
.send({
llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
auth: { mode: 'local', local: { email: 'admin@x.io', password: 'supersecret9' } },
});
expect(applied.status).toBe(200);
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(JSON.stringify(res.body)).not.toContain('supersecret9');
});
});
describe('setup-api: POST /api/setup/apply', () => {
function freshApp() {
const ctx = makeApp(UNCONFIGURED);
const token = ensureSetupToken(ctx.dataDir);
return { ...ctx, token };
}
it('LLM-only apply reflects a camelCase worker and clears the token (restart not required)', async () => {
const { app, cm, dataDir, token } = freshApp();
const res = await request(app)
.post('/api/setup/apply')
.set('X-Setup-Token', token)
.send({ llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'qwen3' } });
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true, restartRequired: false });
const workers = cm.getConfig().provider.workers ?? [];
const w = workers.find((x) => x.model === 'qwen3');
// If the camelCase keys had been double-converted (P1 #1 regression), the
// worker's endpoint/model/roles would not survive into provider.workers.
expect(w).toBeTruthy();
expect(w!.endpoint).toBe('http://localhost:11434/v1');
expect(w!.proxy).not.toBe(true); // connectionType:'direct' → proxy omitted/false
expect(w!.roles).toEqual(expect.arrayContaining(['auto', 'fast', 'quality', 'title', 'reflection']));
// window closed: token removed so no-auth /api/config reverts to open
expect(readSetupToken(dataDir)).toBeNull();
});
it('gateway apply requires an apiKey', async () => {
const { app, token } = freshApp();
const res = await request(app)
.post('/api/setup/apply')
.set('X-Setup-Token', token)
.send({ llm: { connectionType: 'aao_gateway', endpoint: 'http://gw:4000/v1', model: 'm' } });
expect(res.status).toBe(400);
});
it('port apply sets restartRequired and keeps the token', async () => {
const { app, cm, dataDir, token } = freshApp();
const res = await request(app)
.post('/api/setup/apply')
.set('X-Setup-Token', token)
.send({ llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' }, port: 8088 });
expect(res.status).toBe(200);
expect(res.body.restartRequired).toBe(true);
expect(cm.getConfig().server?.port).toBe(8088);
expect(readSetupToken(dataDir)).not.toBeNull(); // kept until restart
});
it('rejects an incomplete local auth block (shared validator)', async () => {
const { app, token } = freshApp();
const res = await request(app)
.post('/api/setup/apply')
.set('X-Setup-Token', token)
.send({ auth: { mode: 'local', local: { email: 'a@b.c' } } }); // no password
expect(res.status).toBe(400);
});
it('rejects an incomplete oauth block', async () => {
const { app, token } = freshApp();
const res = await request(app)
.post('/api/setup/apply')
.set('X-Setup-Token', token)
.send({ auth: { mode: 'oauth', oauth: { provider: 'gitea', clientId: 'x', clientSecret: 'y', callbackUrl: 'http://h/cb' } } }); // gitea needs baseUrl
expect(res.status).toBe(400);
});
it('rejects oauth without an admin email (would brick: no admin after restart)', async () => {
const { app, token } = freshApp();
const res = await request(app)
.post('/api/setup/apply')
.set('X-Setup-Token', token)
.send({
auth: { mode: 'oauth', oauth: { provider: 'google', clientId: 'x', clientSecret: 'y', callbackUrl: 'http://h/cb' } },
});
expect(res.status).toBe(400);
expect(String(res.body.error)).toMatch(/admin email/i);
});
it('applies a complete oauth block and writes auth.adminEmails', async () => {
const { app, cm, token } = freshApp();
const res = await request(app)
.post('/api/setup/apply')
.set('X-Setup-Token', token)
.send({
llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
auth: {
mode: 'oauth',
oauth: { provider: 'google', clientId: 'cid', clientSecret: 'sec', callbackUrl: 'http://h/cb', adminEmail: 'boss@x.io' },
},
});
expect(res.status).toBe(200);
expect(res.body.restartRequired).toBe(true);
expect(res.body.adminEmail).toBe('boss@x.io');
expect(JSON.stringify(res.body)).not.toContain('sec'); // client secret not echoed
const auth = cm.getConfig().auth;
expect(auth?.adminEmails).toContain('boss@x.io');
expect(auth?.providers?.google?.clientId).toBe('cid');
});
it('applies a complete local auth block, returns adminEmail, never echoes the password', async () => {
const { app, cm, token } = freshApp();
const res = await request(app)
.post('/api/setup/apply')
.set('X-Setup-Token', token)
.send({
llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
auth: { mode: 'local', local: { email: 'admin@x.io', password: 'supersecret1' } },
});
expect(res.status).toBe(200);
expect(res.body.restartRequired).toBe(true);
expect(res.body.adminEmail).toBe('admin@x.io');
expect(JSON.stringify(res.body)).not.toContain('supersecret1');
const auth = cm.getConfig().auth;
expect(auth?.local?.enabled).toBe(true);
expect(auth?.local?.bootstrapAdmin?.email).toBe('admin@x.io');
});
it('400 when nothing to apply', async () => {
const { app, token } = freshApp();
const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', token).send({});
expect(res.status).toBe(400);
});
it('rejects a link-local / metadata LLM endpoint (apply SSRF denylist, parity with probe)', async () => {
const { app, token } = freshApp();
const res = await request(app)
.post('/api/setup/apply')
.set('X-Setup-Token', token)
.send({ llm: { connectionType: 'direct', endpoint: 'http://169.254.169.254/v1', model: 'm' } });
expect(res.status).toBe(400);
expect(String(res.body.error)).toMatch(/not allowed|blocked/);
});
it('rejects an over-long local auth password', async () => {
const { app, token } = freshApp();
const res = await request(app)
.post('/api/setup/apply')
.set('X-Setup-Token', token)
.send({
llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
auth: { mode: 'local', local: { email: 'a@b.c', password: 'x'.repeat(2000) } },
});
expect(res.status).toBe(400);
});
});
describe('setup-api: POST /api/setup/probe', () => {
function freshApp() {
const ctx = makeApp(UNCONFIGURED);
const token = ensureSetupToken(ctx.dataDir);
return { ...ctx, token };
}
it('400 on an invalid connectionType', async () => {
const { app, token } = freshApp();
const res = await request(app)
.post('/api/setup/probe')
.set('X-Setup-Token', token)
.send({ connectionType: 'bogus', endpoint: 'http://localhost:11434' });
expect(res.status).toBe(400);
});
it('400 + blocked message for the cloud metadata IP (SSRF denylist)', async () => {
const { app, token } = freshApp();
const res = await request(app)
.post('/api/setup/probe')
.set('X-Setup-Token', token)
.send({ connectionType: 'direct', endpoint: 'http://169.254.169.254/v1' });
expect(res.status).toBe(400);
expect(String(res.body.error)).toMatch(/not allowed|blocked/);
});
describe('against a stub Ollama server', () => {
let server: Server;
let url: string;
beforeEach(async () => {
server = createServer((req, res) => {
if (req.url === '/api/tags') {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ models: [{ name: 'llama3' }, { name: 'qwen3' }] }));
} else {
res.statusCode = 404;
res.end('no');
}
});
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
url = `http://127.0.0.1:${port}/v1`;
});
afterEach(async () => {
await new Promise<void>((r) => server.close(() => r()));
});
it('returns the model list from the live endpoint', async () => {
const { app, token } = freshApp();
const res = await request(app)
.post('/api/setup/probe')
.set('X-Setup-Token', token)
.send({ connectionType: 'direct', endpoint: url });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.models).toEqual(['llama3', 'qwen3']);
});
});
});