Files
Codeman/test/settings-admin-only-multiuser.test.ts
T

143 lines
6.2 KiB
TypeScript
Raw Normal View History

/**
* @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);
});
});