mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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>
This commit is contained in:
1 parent
a798c461db
commit
cb928aefe2
3 files changed
+784
No files matched your search
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @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']);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @fileoverview Multi-user mode, end to end: a non-admin cannot change the server-wide
|
||||
* settings.json (live server, ephemeral port, real multi-user auth, real file on disk).
|
||||
*
|
||||
* The route tests (test/routes/system-routes-settings-admin-only.test.ts) pin each
|
||||
* gate with a mocked filesystem. This one runs the real auth middleware and reads the
|
||||
* real file back, so "settings.json is unchanged" is asserted on bytes:
|
||||
* - PUT /api/settings, PUT /api/execution/model-config and PUT /api/cases/order are
|
||||
* 403 FORBIDDEN for a non-admin, and the file is byte-identical afterwards;
|
||||
* - GET /api/settings stays readable by the non-admin;
|
||||
* - a non-admin's quick-start does not record its case as `lastUsedCase` (a key every
|
||||
* user can read through GET /api/settings), while an admin's still does;
|
||||
* - an admin's PUT /api/settings still writes.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
||||
const bob = { Authorization: basic('bob', 'bobpass1234') };
|
||||
const root = { Authorization: basic('root', 'rootpass123') };
|
||||
|
||||
let server: WebServer;
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
|
||||
|
||||
const INITIAL = {
|
||||
autoNameSessions: false,
|
||||
cliManagementEnabled: false,
|
||||
lastUsedCase: 'rootcase',
|
||||
caseOrder: ['rootcase'],
|
||||
modelConfig: { defaultModel: 'sonnet' },
|
||||
};
|
||||
const readSettingsBytes = () => fs.readFile(SETTINGS_PATH, 'utf-8');
|
||||
|
||||
async function send(method: string, path: string, headers: Record<string, string>, body?: unknown) {
|
||||
const res = await fetch(url(path), {
|
||||
method,
|
||||
headers: { ...headers, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: (await res.json()) as Record<string, any> };
|
||||
}
|
||||
|
||||
/** The quick-start write of lastUsedCase is fire-and-forget: poll for it briefly. */
|
||||
async function settleSettings(predicate: (s: Record<string, unknown>) => boolean): Promise<Record<string, unknown>> {
|
||||
let last: Record<string, unknown> = {};
|
||||
for (let i = 0; i < 40; i++) {
|
||||
last = JSON.parse(await readSettingsBytes());
|
||||
if (predicate(last)) return last;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const k of ['CODEMAN_MULTIUSER', 'CODEMAN_PASSWORD', 'CODEMAN_USERNAME']) saved[k] = process.env[k];
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
invalidateUsersCache();
|
||||
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
||||
await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' });
|
||||
|
||||
await fs.mkdir(dirname(SETTINGS_PATH), { recursive: true });
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(INITIAL, null, 2));
|
||||
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
invalidateUsersCache();
|
||||
});
|
||||
|
||||
describe('a non-admin and the server-wide settings.json', () => {
|
||||
it('cannot write it through any settings route, and the file stays byte-identical', async () => {
|
||||
const before = await readSettingsBytes();
|
||||
for (const [path, body] of [
|
||||
['/api/settings', { autoNameSessions: true, cliManagementEnabled: true, runMode: 'shell' }],
|
||||
['/api/execution/model-config', { defaultModel: 'opus' }],
|
||||
['/api/cases/order', { order: ['bobcase'] }],
|
||||
] as const) {
|
||||
const res = await send('PUT', path, bob, body);
|
||||
expect(res.status, path).toBe(403);
|
||||
expect(res.body.success, path).toBe(false);
|
||||
expect(res.body.errorCode, path).toBe('FORBIDDEN');
|
||||
}
|
||||
expect(await readSettingsBytes()).toBe(before);
|
||||
});
|
||||
|
||||
it('can still read it', async () => {
|
||||
const res = await send('GET', '/api/settings', bob);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.autoNameSessions).toBe(false);
|
||||
});
|
||||
|
||||
it("does not get its quick-start case recorded as everyone's lastUsedCase; an admin's still is", async () => {
|
||||
const mine = await send('POST', '/api/quick-start', bob, { caseName: 'bobproj' });
|
||||
expect(mine.status).toBe(200);
|
||||
expect(mine.body.success).toBe(true);
|
||||
// Give a (wrong) fire-and-forget write every chance to land before checking.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(JSON.parse(await readSettingsBytes()).lastUsedCase).toBe('rootcase');
|
||||
|
||||
const theirs = await send('POST', '/api/quick-start', root, { caseName: 'rootproj2' });
|
||||
expect(theirs.status).toBe(200);
|
||||
expect((await settleSettings((s) => s.lastUsedCase === 'rootproj2')).lastUsedCase).toBe('rootproj2');
|
||||
});
|
||||
|
||||
it('does not get its Ralph loop case recorded as lastUsedCase either; an admin still does', async () => {
|
||||
const mine = await send('POST', '/api/ralph-loop/start', bob, { caseName: 'bobralph', taskDescription: 'x' });
|
||||
expect(mine.status).toBe(200);
|
||||
expect(mine.body.success).toBe(true);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(JSON.parse(await readSettingsBytes()).lastUsedCase).toBe('rootproj2');
|
||||
|
||||
const theirs = await send('POST', '/api/ralph-loop/start', root, { caseName: 'rootralph', taskDescription: 'x' });
|
||||
expect(theirs.status).toBe(200);
|
||||
expect((await settleSettings((s) => s.lastUsedCase === 'rootralph')).lastUsedCase).toBe('rootralph');
|
||||
});
|
||||
});
|
||||
|
||||
describe('an admin and the server-wide settings.json', () => {
|
||||
it('still writes it', async () => {
|
||||
const res = await send('PUT', '/api/settings', root, { autoNameSessions: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(JSON.parse(await readSettingsBytes()).autoNameSessions).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* @fileoverview Multi-user mode: a NON-ADMIN's browser and the admin-only settings writes.
|
||||
*
|
||||
* In multi-user mode PUT /api/settings (and PUT /api/execution/model-config,
|
||||
* PUT /api/cases/order) answer 403 FORBIDDEN for a non-admin (system-routes.ts,
|
||||
* case-routes.ts), because settings.json is one file for the whole server. The page
|
||||
* must not break on that:
|
||||
* - App Settings Save/Apply keeps saving the per-device keys to localStorage exactly
|
||||
* as before, sends no settings PUT and no model-config PUT, keeps every SYNCED key
|
||||
* at the server's value, and shows ONE "only an admin" toast, only when the user
|
||||
* really changed a synced setting (an untouched synced key that no admin ever
|
||||
* stored must not read as a change: that is what the form baseline is for).
|
||||
* - The background writes (run mode, respawn presets, last used case, case order,
|
||||
* the respawn-preset migration on load, the tunnel toggles) are not sent.
|
||||
* - Admins and single-user mode send everything as before.
|
||||
* - Both toasts read in Chinese under zh-CN.
|
||||
*
|
||||
* The real modules run inside a JSDOM window (runScripts: 'outside-only') with the
|
||||
* real index.html, so the form is the real App Settings form. Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
const INDEX = read('index.html');
|
||||
const I18N = read('i18n.js');
|
||||
const SETTINGS_UI = read('settings-ui.js');
|
||||
const SESSION_UI = read('session-ui.js');
|
||||
const RESPAWN_UI = read('respawn-ui.js');
|
||||
const SCRIPTS = [read('constants.js'), read('app.js'), SETTINGS_UI];
|
||||
|
||||
const DESKTOP_KEY = 'codeman-app-settings';
|
||||
const SAVE_TOAST = 'Saved on this device. Server-wide settings can only be changed by an admin.';
|
||||
const ADMIN_TOAST = 'Server-wide settings can only be changed by an admin';
|
||||
|
||||
type Settings = Record<string, unknown>;
|
||||
type Who = 'non-admin' | 'admin' | 'single-user';
|
||||
const IDENTITY: Record<Who, Settings> = {
|
||||
'non-admin': { username: 'bob', role: 'user', multiUser: true },
|
||||
admin: { username: 'root', role: 'admin', multiUser: true },
|
||||
'single-user': { username: 'admin', role: 'admin', multiUser: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Methods that run for real. Every other method is a no-op: openAppSettings() and
|
||||
* saveAppSettings() fan out into panels (voice, webhook, tunnel, push, header) this
|
||||
* file is not about.
|
||||
*/
|
||||
const REAL = new Set([
|
||||
'openAppSettings',
|
||||
'saveAppSettings',
|
||||
'applyAppSettings',
|
||||
'loadAppSettingsFromServer',
|
||||
'loadAppSettingsFromStorage',
|
||||
'saveAppSettingsToStorage',
|
||||
'getSettingsStorageKey',
|
||||
'getDefaultSettings',
|
||||
'tabCliLogosEnabled',
|
||||
'resolveTabArrangement',
|
||||
'resolveTabStateOrder',
|
||||
'resolveHeaderStatsStyle',
|
||||
'resolveSessionSidebarFontSize',
|
||||
'planUsageChipEnabled',
|
||||
'planUsageCollectionFlip',
|
||||
'loadModelConfigForSettings',
|
||||
'saveModelConfigFromSettings',
|
||||
'_modelConfigFromSettingsForm',
|
||||
'_settingsReadOnly',
|
||||
'_captureSettingsFormBaseline',
|
||||
'_keepServerSyncedSettings',
|
||||
]);
|
||||
|
||||
/** Methods the load/save paths call that other modules add to the prototype. */
|
||||
const ELSEWHERE = [
|
||||
'_updateLocalEchoState',
|
||||
'renderProjectInsightsPanel',
|
||||
'updateSubagentWindowVisibility',
|
||||
'showWelcome',
|
||||
'_applyRunMode',
|
||||
];
|
||||
|
||||
const windows: { close(): void }[] = [];
|
||||
afterEach(() => {
|
||||
for (const win of windows.splice(0)) win.close();
|
||||
});
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/**
|
||||
* Boot index.html with constants.js, app.js and settings-ui.js, as `who`, with `stored` as
|
||||
* this device's blob and `server` as GET /api/settings (merged in by the real loader, which
|
||||
* is also what publishes the display-key list the non-admin save reads).
|
||||
*/
|
||||
async function boot(who: Who, stored: Settings, server: Settings) {
|
||||
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
|
||||
const win = dom.window as unknown as Window &
|
||||
typeof globalThis & { eval(src: string): void; __App: any; __codemanUser: Settings; fetch: unknown };
|
||||
windows.push(win);
|
||||
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
|
||||
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
|
||||
win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(stored));
|
||||
const fetchCalls: { url: string; method: string; body?: string }[] = [];
|
||||
win.fetch = vi.fn(async (url: string, opts: { method?: string; body?: string } = {}) => {
|
||||
fetchCalls.push({ url, method: opts.method || 'GET', body: opts.body });
|
||||
const data = url === '/api/execution/model-config' ? { defaultModel: 'sonnet', showRecommendations: true } : {};
|
||||
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
|
||||
});
|
||||
win.eval(
|
||||
[
|
||||
`var MobileDetection = {
|
||||
getDeviceType: () => 'desktop',
|
||||
isHandheldDevice: () => false,
|
||||
isMobile: () => false,
|
||||
isTouchDevice: () => false,
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
'window.__App = CodemanApp;',
|
||||
].join('\n')
|
||||
);
|
||||
win.__codemanUser = IDENTITY[who];
|
||||
const target = Object.create(win.__App.prototype);
|
||||
target.sessions = new Map();
|
||||
target.sessionOrder = [];
|
||||
target._apiPut = vi.fn(async () => ({ ok: true }));
|
||||
target.showToast = vi.fn();
|
||||
target.closeAppSettings = vi.fn();
|
||||
const noop = () => undefined;
|
||||
for (const name of ELSEWHERE) target[name] = noop;
|
||||
const app = new Proxy(target, {
|
||||
get(t, key, receiver) {
|
||||
const value = Reflect.get(t, key, receiver);
|
||||
const stubbed =
|
||||
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
|
||||
return stubbed ? noop : value;
|
||||
},
|
||||
}) as any;
|
||||
await app.loadAppSettingsFromServer(Promise.resolve(server));
|
||||
const doc = win.document;
|
||||
const box = (id: string) => doc.getElementById(id) as HTMLInputElement;
|
||||
const storedBlob = () => JSON.parse(win.localStorage.getItem(DESKTOP_KEY) || '{}') as Settings;
|
||||
const toasts = () => (target.showToast.mock.calls as unknown[][]).map((c) => c[0]);
|
||||
const puts = () => fetchCalls.filter((c) => c.method === 'PUT');
|
||||
return { win, doc, app, target, box, storedBlob, toasts, puts };
|
||||
}
|
||||
|
||||
// What the server already holds for this instance: an admin has saved App Settings before.
|
||||
const SERVER: Settings = { autoNameSessions: false, workspaceHooksEnabled: true, claudeMode: 'auto' };
|
||||
|
||||
describe('a non-admin in multi-user mode saving App Settings', () => {
|
||||
it('keeps the per-device keys, sends no PUT, keeps the synced key at the server value, and toasts once', async () => {
|
||||
const { app, target, box, storedBlob, toasts, puts } = await boot('non-admin', { ...SERVER }, SERVER);
|
||||
await open_(app);
|
||||
box('appSettingsTabTwoRows').checked = true; // per-device (displayKeys)
|
||||
box('appSettingsAutoNameSessions').checked = true; // synced, server-wide
|
||||
await app.saveAppSettings();
|
||||
|
||||
expect(target._apiPut).not.toHaveBeenCalled();
|
||||
expect(puts()).toEqual([]);
|
||||
expect(storedBlob().tabTwoRows).toBe(true);
|
||||
expect(storedBlob().autoNameSessions).toBe(false);
|
||||
expect(toasts()).toEqual([SAVE_TOAST]);
|
||||
});
|
||||
|
||||
it('says "Settings saved" (no admin toast) when only per-device keys changed', async () => {
|
||||
const { app, target, box, storedBlob, toasts, puts } = await boot('non-admin', { ...SERVER }, SERVER);
|
||||
await open_(app);
|
||||
box('appSettingsTabTwoRows').checked = true;
|
||||
await app.saveAppSettings();
|
||||
|
||||
expect(target._apiPut).not.toHaveBeenCalled();
|
||||
expect(puts()).toEqual([]);
|
||||
expect(storedBlob().tabTwoRows).toBe(true);
|
||||
expect(toasts()).toEqual(['Settings saved']);
|
||||
});
|
||||
|
||||
it('does not read untouched synced keys no admin ever stored as a change', async () => {
|
||||
// A fresh instance: the server holds nothing, so the blob has no synced keys while the
|
||||
// form shows their defaults. Comparing against the blob would toast on every save.
|
||||
const { app, box, storedBlob, toasts, puts } = await boot('non-admin', {}, {});
|
||||
await open_(app);
|
||||
box('appSettingsTabTwoRows').checked = true;
|
||||
await app.saveAppSettings();
|
||||
expect(toasts()).toEqual(['Settings saved']);
|
||||
expect(puts()).toEqual([]);
|
||||
// The synced keys the form read stay absent (the server default), never the form's copy.
|
||||
expect('autoNameSessions' in storedBlob()).toBe(false);
|
||||
expect('claudeMode' in storedBlob()).toBe(false);
|
||||
expect(storedBlob().tabTwoRows).toBe(true);
|
||||
|
||||
// And a real change there still gets the one admin toast.
|
||||
await open_(app);
|
||||
box('appSettingsAutoNameSessions').checked = true;
|
||||
await app.saveAppSettings();
|
||||
expect(toasts()).toEqual(['Settings saved', SAVE_TOAST]);
|
||||
expect('autoNameSessions' in storedBlob()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not send the model config either, and folds a change to it into the same one toast', async () => {
|
||||
const { app, doc, toasts, puts } = await boot('non-admin', { ...SERVER }, SERVER);
|
||||
await open_(app);
|
||||
(doc.getElementById('appSettingsDefaultModel') as HTMLSelectElement).value = 'opus';
|
||||
await app.saveAppSettings();
|
||||
expect(puts()).toEqual([]);
|
||||
expect(toasts()).toEqual([SAVE_TOAST]);
|
||||
});
|
||||
|
||||
it('Apply behaves the same: nothing sent, the modal stays open', async () => {
|
||||
const { app, target, box, toasts, puts } = await boot('non-admin', { ...SERVER }, SERVER);
|
||||
await open_(app);
|
||||
box('appSettingsAutoNameSessions').checked = true;
|
||||
await app.applyAppSettings();
|
||||
expect(target._apiPut).not.toHaveBeenCalled();
|
||||
expect(puts()).toEqual([]);
|
||||
expect(target.closeAppSettings).not.toHaveBeenCalled();
|
||||
expect(toasts()).toEqual([SAVE_TOAST]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an admin, and single-user mode, saving App Settings', () => {
|
||||
for (const who of ['admin', 'single-user'] as const) {
|
||||
it(`${who}: sends the settings PUT and the model config as before`, async () => {
|
||||
const { app, target, box, storedBlob, toasts, puts } = await boot(who, { ...SERVER }, SERVER);
|
||||
await open_(app);
|
||||
box('appSettingsAutoNameSessions').checked = true;
|
||||
await app.saveAppSettings();
|
||||
|
||||
expect(target._apiPut).toHaveBeenCalledTimes(1);
|
||||
const [path, body] = target._apiPut.mock.calls[0] as [string, Settings];
|
||||
expect(path).toBe('/api/settings');
|
||||
expect(body.autoNameSessions).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
||||
expect(puts().map((p) => p.url)).toEqual(['/api/execution/model-config']);
|
||||
expect(storedBlob().autoNameSessions).toBe(true);
|
||||
expect(toasts()).toEqual(['Settings saved']);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** Open App Settings and let its async model-config read land, as a user would. */
|
||||
async function open_(app: { openAppSettings(): void }) {
|
||||
app.openAppSettings();
|
||||
await tick();
|
||||
await tick();
|
||||
}
|
||||
|
||||
// ─── Background writes (vm harness: settings-ui.js + session-ui.js + respawn-ui.js) ───
|
||||
|
||||
function loadBackground(who: Who) {
|
||||
const storage = new Map<string, string>();
|
||||
const elements: Record<string, any> = {
|
||||
quickStartCase: { value: 'mine' },
|
||||
welcomeTunnelBtn: { classList: { contains: () => false }, disabled: false },
|
||||
runModeMenu: { classList: { remove: () => {} } },
|
||||
runBtn: { className: '', nextElementSibling: { className: '' } },
|
||||
runBtnLabel: { textContent: '' },
|
||||
};
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: {} }) }));
|
||||
const confirmMock = vi.fn(() => true);
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
VoiceInput: {},
|
||||
window: { __codemanUser: IDENTITY[who] },
|
||||
localStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
},
|
||||
document: { getElementById: (id: string) => elements[id] ?? null },
|
||||
fetch: fetchMock,
|
||||
confirm: confirmMock,
|
||||
console,
|
||||
});
|
||||
vm.runInContext(SETTINGS_UI, context, { filename: 'settings-ui.js' });
|
||||
vm.runInContext(SESSION_UI, context, { filename: 'session-ui.js' });
|
||||
vm.runInContext(RESPAWN_UI, context, { filename: 'respawn-ui.js' });
|
||||
const app = new (CodemanApp as any)();
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.saveAppSettingsToStorage = () => {};
|
||||
app._apiPut = vi.fn(() => Promise.resolve({ ok: true }));
|
||||
app.showToast = vi.fn();
|
||||
app.loadQuickStartCases = vi.fn(async () => {});
|
||||
app.renderCaseManageList = vi.fn();
|
||||
app.closeTunnelPanel = vi.fn();
|
||||
app._showTunnelConnecting = vi.fn();
|
||||
app._pollTunnelStatus = vi.fn();
|
||||
app._dismissTunnelConnecting = vi.fn();
|
||||
app._updateWelcomeTunnelBtn = vi.fn();
|
||||
app._updateTunnelIndicator = vi.fn();
|
||||
return { app, fetchMock, confirmMock, storage };
|
||||
}
|
||||
|
||||
const settingsPuts = (b: ReturnType<typeof loadBackground>) => [
|
||||
...b.app._apiPut.mock.calls.filter((c: unknown[]) => c[0] === '/api/settings'),
|
||||
...b.fetchMock.mock.calls.filter((c: unknown[]) => c[0] === '/api/settings' || c[0] === '/api/cases/order'),
|
||||
];
|
||||
|
||||
describe('background writes', () => {
|
||||
it('a non-admin sends none of them, and the device keeps its own copy', async () => {
|
||||
const b = loadBackground('non-admin');
|
||||
b.app.setRunMode('codex');
|
||||
expect(b.storage.get('codeman_runMode')).toBe('codex');
|
||||
b.app.saveRespawnPresets([{ id: 'p', name: 'mine' }]);
|
||||
expect(JSON.parse(b.storage.get('codeman-respawn-presets')!)).toEqual([{ id: 'p', name: 'mine' }]);
|
||||
await b.app.saveLastUsedCase('mine');
|
||||
await b.app.toggleTunnelFromWelcome();
|
||||
await b.app._tunnelPanelToggle(true);
|
||||
expect(settingsPuts(b)).toEqual([]);
|
||||
expect(b.app.showToast.mock.calls.map((c: unknown[]) => c[0])).toEqual([ADMIN_TOAST, ADMIN_TOAST]);
|
||||
});
|
||||
|
||||
it('a non-admin case reorder is not sent: one toast, and the list goes back to the server order', async () => {
|
||||
const b = loadBackground('non-admin');
|
||||
await b.app.saveCaseOrder(['b', 'a']);
|
||||
expect(settingsPuts(b)).toEqual([]);
|
||||
expect(b.app.showToast.mock.calls.map((c: unknown[]) => c[0])).toEqual([ADMIN_TOAST]);
|
||||
expect(b.app.loadQuickStartCases).toHaveBeenCalledWith('mine');
|
||||
expect(b.app.renderCaseManageList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a non-admin never runs the respawn-preset migration PUT on page load', async () => {
|
||||
const b = loadBackground('non-admin');
|
||||
b.storage.set('codeman-respawn-presets', JSON.stringify([{ id: 'p' }]));
|
||||
await b.app.loadAppSettingsFromServer(Promise.resolve({ autoNameSessions: false }));
|
||||
expect(settingsPuts(b)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a 403 FORBIDDEN on a tunnel enable is the admin-only answer, not the no-password prompt', async () => {
|
||||
const b = loadBackground('admin');
|
||||
const refused = await b.app._handleTunnelEnableRefusal({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ success: false, error: 'You do not have permission', errorCode: 'FORBIDDEN' }),
|
||||
});
|
||||
expect(refused).toBe(true);
|
||||
expect(b.confirmMock).not.toHaveBeenCalled();
|
||||
expect(b.fetchMock).not.toHaveBeenCalled();
|
||||
expect(b.app.showToast.mock.calls.map((c: unknown[]) => c[0])).toEqual([ADMIN_TOAST]);
|
||||
});
|
||||
|
||||
for (const who of ['admin', 'single-user'] as const) {
|
||||
it(`${who}: every background write is still sent`, async () => {
|
||||
const b = loadBackground(who);
|
||||
b.app.setRunMode('codex');
|
||||
b.app.saveRespawnPresets([{ id: 'p' }]);
|
||||
await b.app.saveLastUsedCase('mine');
|
||||
await b.app.saveCaseOrder(['b', 'a']);
|
||||
expect(b.app._apiPut.mock.calls.map((c: unknown[]) => c[0])).toEqual(['/api/settings', '/api/settings']);
|
||||
expect(b.fetchMock.mock.calls.map((c: unknown[]) => c[0])).toEqual(['/api/settings', '/api/cases/order']);
|
||||
b.storage.set('codeman-respawn-presets', JSON.stringify([{ id: 'p' }]));
|
||||
b.app._apiPut.mockClear();
|
||||
await b.app.loadAppSettingsFromServer(Promise.resolve({}));
|
||||
expect(b.app._apiPut).toHaveBeenCalledWith('/api/settings', { respawnPresets: [{ id: 'p' }] });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── zh-CN ───
|
||||
|
||||
describe('the admin-only toasts in zh-CN', () => {
|
||||
type Api = { t(s: string): string; configure(o: Record<string, string>): void };
|
||||
function translator(language: string) {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
windows.push(dom.window);
|
||||
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||
api.configure({ language });
|
||||
return api;
|
||||
}
|
||||
|
||||
it('finds both toasts in the code that shows them', () => {
|
||||
expect(SETTINGS_UI).toContain(`this.showToast('${SAVE_TOAST}', 'warning')`);
|
||||
expect(SETTINGS_UI).toContain(`this.showToast('${ADMIN_TOAST}', 'warning')`);
|
||||
expect(SESSION_UI).toContain(`this.showToast('${ADMIN_TOAST}', 'warning')`);
|
||||
});
|
||||
|
||||
it('translates both, and leaves English as it is', () => {
|
||||
const zh = translator('zh-CN');
|
||||
const en = translator('en');
|
||||
for (const text of [SAVE_TOAST, ADMIN_TOAST]) {
|
||||
const t = zh.t(text);
|
||||
expect(t, text).not.toBe(text);
|
||||
expect(/[A-Za-z]/.test(t), t).toBe(false);
|
||||
expect(en.t(text)).toBe(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user