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((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((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']); }); }); });