Files
Codeman/test/routes/system-routes-settings-admin-only.test.ts
T
Codeman maintainer cb928aefe2 test(multi-user): pin admin-only settings writes, server and browser
- test/routes/system-routes-settings-admin-only.test.ts (app.inject): a
  non-admin PUT /api/settings is 403 FORBIDDEN before the body is parsed, and
  nothing is written, no watcher toggled, no tunnel started, no history limit
  set; the same gate on PUT /api/execution/model-config, PUT /api/config and
  PUT /api/cases/order; admins and single-user mode write as before; GET stays
  readable.
- test/settings-admin-only-multiuser.test.ts (live server, port 0, real
  multi-user auth): settings.json is byte-identical after a non-admin's PUTs,
  and a non-admin's quick-start or Ralph loop no longer records lastUsedCase.
- test/settings-admin-only-ui.test.ts (real index.html in JSDOM): a non-admin
  App Settings save sends no PUT, keeps per-device keys, keeps synced keys at
  the server value, and toasts once only on a real synced change; the
  background writes are skipped; admins still PUT; zh-CN for both toasts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 12:16:34 +02:00

242 lines
9.8 KiB
TypeScript

/**
* @fileoverview Multi-user mode: server-wide settings writes are admin-only.
*
* `~/.codeman/settings.json` is ONE file for the whole server (feature switches, the
* plan-usage collection switch, run mode, respawn presets, the tunnel). Before this,
* any signed-in NON-ADMIN could rewrite it through PUT /api/settings, although
* docs/multi-user-plan.md makes settings writes admin-only. Pinned here:
* - a non-admin PUT /api/settings is 403 FORBIDDEN before anything is parsed (an
* invalid body is still 403, not 400), written, toggled or started (watchers, the
* tunnel, the tmux history limit);
* - the same gate on the other server-wide writes: PUT /api/execution/model-config,
* PUT /api/config and PUT /api/cases/order;
* - an admin, and single-user mode, write as before; GET /api/settings stays readable.
*
* Uses app.inject(), so no real HTTP port is needed. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
const { STORED, subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
const makeWatcher = (running: boolean) => {
let isOn = running;
return {
isRunning: vi.fn(() => isOn),
start: vi.fn(() => {
isOn = true;
}),
stop: vi.fn(() => {
isOn = false;
}),
getStats: vi.fn(() => ({})),
watchSession: vi.fn(),
getRecentRunSummaries: vi.fn(() => []),
__reset: () => {
isOn = running;
},
};
};
return {
// The persisted settings.json these tests start from.
STORED: { subagentTrackingEnabled: false, imageWatcherEnabled: true, cliManagementEnabled: false },
subagentWatcher: makeWatcher(false),
imageWatcher: makeWatcher(true),
workflowRunWatcher: makeWatcher(false),
};
});
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => JSON.stringify(STORED)),
writeFile: vi.fn(async () => undefined),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
});
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
import fs from 'node:fs/promises';
const writeFile = vi.mocked(fs.writeFile);
const NON_ADMIN = { username: 'bob', role: 'user' } as const;
const ADMIN = { username: 'root', role: 'admin' } as const;
function makeTunnelManager() {
return {
start: vi.fn(),
stop: vi.fn(),
isRunning: vi.fn(() => false),
getUrl: vi.fn(() => null),
getStatus: vi.fn(() => ({ running: false })),
};
}
/** A body that would touch everything PUT /api/settings can act on. */
const EVERYTHING = {
tunnelEnabled: true,
subagentTrackingEnabled: true,
imageWatcherEnabled: false,
showUltracodeAgents: true,
tmuxHistoryLimit: 50000,
cliManagementEnabled: true,
runMode: 'shell',
};
describe('server-wide settings writes are admin-only in multi-user mode', () => {
const savedMultiUser = process.env.CODEMAN_MULTIUSER;
let harness: RouteTestHarness | undefined;
let tunnel: ReturnType<typeof makeTunnelManager>;
async function as(user: typeof NON_ADMIN | typeof ADMIN | null, register = registerSystemRoutes) {
if (user) process.env.CODEMAN_MULTIUSER = '1';
else delete process.env.CODEMAN_MULTIUSER;
harness = await createRouteTestHarness(register, user ? { authUser: user } : undefined);
tunnel = makeTunnelManager();
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
return harness;
}
beforeEach(() => {
vi.clearAllMocks();
for (const w of [subagentWatcher, imageWatcher, workflowRunWatcher]) w.__reset();
});
afterEach(async () => {
await harness?.app.close();
harness = undefined;
if (savedMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = savedMultiUser;
});
describe('PUT /api/settings', () => {
it('refuses a non-admin with 403 FORBIDDEN, and nothing is written, toggled or started', async () => {
const { app, ctx } = await as(NON_ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/settings', payload: EVERYTHING });
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('FORBIDDEN');
expect(writeFile).not.toHaveBeenCalled();
for (const w of [subagentWatcher, imageWatcher, workflowRunWatcher]) {
expect(w.start).not.toHaveBeenCalled();
expect(w.stop).not.toHaveBeenCalled();
}
expect(tunnel.start).not.toHaveBeenCalled();
expect(ctx.mux.setHistoryLimit).not.toHaveBeenCalled();
});
it('refuses a non-admin before the body is even parsed (an invalid body is 403, not 400)', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/settings', payload: { notASetting: 1 } });
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
expect(writeFile).not.toHaveBeenCalled();
});
it('still lets a non-admin READ the settings', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'GET', url: '/api/settings' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual(STORED);
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`writes, reconciles watchers and starts the tunnel for ${label}`, async () => {
const { app, ctx } = await as(user);
// Single-user mode with no CODEMAN_PASSWORD needs the COD-55 tunnel acknowledgement
// (multi-user mode counts as authenticated); that guard is not what this file tests.
const payload = user ? EVERYTHING : { ...EVERYTHING, acknowledgeUnauthTunnel: true };
const res = await app.inject({ method: 'PUT', url: '/api/settings', payload });
expect(res.statusCode).toBe(200);
expect(writeFile).toHaveBeenCalledTimes(1);
const written = JSON.parse(String(writeFile.mock.calls[0][1]));
expect(written).toMatchObject({ ...STORED, ...EVERYTHING });
expect(subagentWatcher.start).toHaveBeenCalledTimes(1);
expect(imageWatcher.stop).toHaveBeenCalledTimes(1);
expect(workflowRunWatcher.start).toHaveBeenCalledTimes(1);
expect(tunnel.start).toHaveBeenCalledTimes(1);
expect(ctx.mux.setHistoryLimit).toHaveBeenCalledTimes(1);
});
}
});
describe('PUT /api/execution/model-config', () => {
const config = { defaultModel: 'opus', agentTypeOverrides: { explore: 'sonnet' } };
it('refuses a non-admin with 403 FORBIDDEN and writes nothing', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/execution/model-config', payload: config });
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
expect(writeFile).not.toHaveBeenCalled();
});
it('writes it for an admin', async () => {
const { app } = await as(ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/execution/model-config', payload: config });
expect(res.statusCode).toBe(200);
expect(JSON.parse(String(writeFile.mock.calls[0][1])).modelConfig).toEqual(config);
});
it('stays readable by a non-admin', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'GET', url: '/api/execution/model-config' });
expect(res.statusCode).toBe(200);
});
});
describe('PUT /api/config', () => {
it('refuses a non-admin with 403 FORBIDDEN and changes nothing', async () => {
const { app, ctx } = await as(NON_ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/config', payload: { maxConcurrentSessions: 50 } });
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
expect(ctx.store.setConfig).not.toHaveBeenCalled();
});
it('updates it for an admin', async () => {
const { app, ctx } = await as(ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/config', payload: { maxConcurrentSessions: 10 } });
expect(res.statusCode).toBe(200);
expect(ctx.store.setConfig).toHaveBeenCalledTimes(1);
});
});
describe('PUT /api/cases/order (the one global caseOrder in settings.json)', () => {
it('refuses a non-admin with 403 FORBIDDEN and writes nothing', async () => {
const { app, ctx } = await as(NON_ADMIN, registerCaseRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/cases/order', payload: { order: ['b', 'a'] } });
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
expect(writeFile).not.toHaveBeenCalled();
expect(ctx.broadcast).not.toHaveBeenCalled();
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`writes it for ${label}`, async () => {
const { app } = await as(user, registerCaseRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/cases/order', payload: { order: ['b', 'a'] } });
expect(res.statusCode).toBe(200);
expect(JSON.parse(String(writeFile.mock.calls[0][1])).caseOrder).toEqual(['b', 'a']);
});
}
});
});