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