442 lines
18 KiB
TypeScript
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']);
|
|
});
|
|
});
|
|
});
|