SPB Git

Repository Public

projects-files-tasks.test.ts 18.9 KB 290 lines typescript

1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import type { AgentTask, Project } from '@cr/shared';3import { Client, createHarness, setupAdmin, until, type Harness } from '../../testkit/harness.js';45let h: Harness;6let admin: Client;7let project: Project;8const UUID = '00000000-0000-4000-8000-000000000000';9const audits = async (action: string): Promise<{ target: string | null; ok: boolean; detail: Record<string, unknown> }[]> =>10  h.server.ctx.db.q('SELECT target, ok, detail FROM audit_events WHERE action = $1 ORDER BY id', [action]);1112beforeAll(async () => {13  h = await createHarness({ env: { CR_EXEC_HOSTS: 'M3U96a, M2U64' } });14  ({ client: admin } = await setupAdmin(h));15});16afterAll(async () => {17  await h.close();18});1920describe('projects', () => {21  it('lists workspace roots by label only', async () => {22    const res = await admin.get('/workspace/roots');23    expect(res.json()).toEqual([{ label: 'ws' }]);24    expect(res.body).not.toContain(h.workspace);25  });26  it('create mode makes the directory through the runtime and never exposes absolute paths', async () => {27    const res = await admin.post('/projects', { name: 'My Project', mode: 'create', tags: ['a'] });28    expect(res.statusCode, res.body).toBe(201);29    project = res.json<Project>();30    expect(project).toMatchObject({ name: 'My Project', rootLabel: 'ws', path: 'my-project', gitBranch: 'main', gitDirty: false, activeSessions: 0, tags: ['a'] });31    expect(res.body).not.toContain(h.workspace);32    expect(h.rt.files.get(`${h.workspace}/my-project`)).toBeNull();33    const call = h.rt.calls.find((c) => c.url === '/fs/op');34    expect(call?.body).toEqual({ root: h.workspace, op: 'mkdir', path: 'my-project' });35    expect((await admin.post('/projects', { name: 'dup', mode: 'existing', path: 'my-project' })).statusCode).toBe(409);36  });37  it.each([38    ['..'], ['../escape'], ['a/../../b'], ['/etc'], ['/'], ['a\u0000b'], ['~/x'],39  ])('rejects path %j before anything reaches the runtime', async (p) => {40    const before = h.rt.calls.length;41    for (const mode of ['create', 'existing', 'clone']) {42      const res = await admin.post('/projects', { name: 'x', mode, path: p, gitUrl: 'https://example.org/r.git' });43      expect(res.statusCode).toBe(400);44      expect(res.json().error.code).toBe('invalid_path');45    }46    expect(h.rt.calls.length).toBe(before);47  });48  it('existing mode requires the directory; unknown roots are refused', async () => {49    expect((await admin.post('/projects', { name: 'ghost', mode: 'existing', path: 'does-not-exist' })).statusCode).toBe(400);50    expect((await admin.post('/projects', { name: 'x', mode: 'create', rootLabel: 'nope' })).statusCode).toBe(400);51    h.rt.mkdir(`${h.workspace}/already`);52    const res = await admin.post('/projects', { name: 'Already', mode: 'existing', path: 'already' });53    expect(res.statusCode).toBe(201);54  });55  it('clone accepts only safe URL forms and scrubs credentials', async () => {56    for (const gitUrl of ['--upload-pack=touch /tmp/x', '-oProxyCommand=x', 'file:///etc', 'http://example.org/r.git', 'ext::sh -c id', '']) {57      const res = await admin.post('/projects', { name: 'c', mode: 'clone', path: 'c1', gitUrl });58      expect([gitUrl, res.statusCode]).toEqual([gitUrl, 400]);59    }60    expect(h.rt.calls.some((c) => c.url === '/git/clone')).toBe(false);61    const res = await admin.post('/projects', { name: 'cloned', mode: 'clone', path: 'cloned', gitUrl: 'https://user:tok3n@example.org/r.git' });62    expect(res.statusCode, res.body).toBe(201);63    expect(res.json().gitRemote).toBe('https://example.org/r.git');64    expect(res.body).not.toContain('tok3n');65    expect(JSON.stringify(await audits('project.create'))).not.toContain('tok3n');66  });67  it('list tolerates runtime errors (git ⇒ null) and counts live sessions', async () => {68    const s = (await admin.post('/sessions', { projectId: project.id, kind: 'shell', cwd: 'sub' })).json<{ id: string; projectName: string }>();69    expect(s.projectName).toBe('My Project');70    expect(h.rt.specs.get(s.id)?.cwd).toBe(`${h.workspace}/my-project/sub`);71    const list = (await admin.get('/projects')).json<Project[]>();72    expect(list.find((p) => p.id === project.id)?.activeSessions).toBe(1);73    await h.rt.stop();74    const h2list = await admin.get('/projects'); // cached git info is still served75    expect(h2list.statusCode).toBe(200);76    await h.rt.start();77    await admin.post(`/sessions/${s.id}/terminate`);78  });79  it('update, markup round-trip, delete unregisters only', async () => {80    const name = '<script>alert(1)</script>';81    const up = await admin.req('PATCH', `/projects/${project.id}`, { name, description: '"><img onerror=x>', pinned: true });82    expect(up.json()).toMatchObject({ name, description: '"><img onerror=x>', pinned: true });83    expect(up.headers['content-type']).toContain('application/json');84    await admin.req('PATCH', `/projects/${project.id}`, { name: 'My Project' });85    const tmp = (await admin.post('/projects', { name: 'Temp', mode: 'create' })).json<Project>();86    const before = h.rt.calls.length;87    expect((await admin.req('DELETE', `/projects/${tmp.id}`)).statusCode).toBe(200);88    expect(h.rt.calls.length).toBe(before); // no runtime call: files are never deleted89    expect(h.rt.files.has(`${h.workspace}/temp`)).toBe(true);90    expect((await admin.get(`/projects/${tmp.id}`)).statusCode).toBe(404);91    expect((await admin.get(`/projects/${project.id}/conversations`)).json()).toHaveLength(1);92  });93});9495describe('files', () => {96  const root = (): string => `${h.workspace}/my-project`;97  it('write / read / list map onto the project root, with sha conflict detection', async () => {98    const w = await admin.req('PUT', `/projects/${project.id}/file`, { path: 'src/a.txt', content: 'hello', create: true });99    expect(w.statusCode, w.body).toBe(200);100    expect(h.rt.files.get(`${root()}/src/a.txt`)?.toString()).toBe('hello');101    const r = (await admin.get(`/projects/${project.id}/file?path=src/a.txt`)).json<{ content: string; sha256: string }>();102    expect(r.content).toBe('hello');103    const stale = await admin.req('PUT', `/projects/${project.id}/file`, { path: 'src/a.txt', content: 'x', baseSha256: '0'.repeat(64) });104    expect(stale.statusCode).toBe(409);105    expect((await admin.req('PUT', `/projects/${project.id}/file`, { path: 'src/a.txt', content: 'v2', baseSha256: r.sha256 })).statusCode).toBe(200);106    h.rt.mkdir(`${root()}/src`);107    expect((await admin.get(`/projects/${project.id}/files?path=src`)).json()).toEqual([{ name: 'a.txt', path: 'src/a.txt', type: 'file', size: 2, mtimeMs: 1 }]);108    expect((await admin.get(`/projects/${project.id}/search?q=a.txt`)).json()).toEqual([{ path: 'src/a.txt', line: null, preview: null }]);109    expect((await admin.get(`/projects/${project.id}/git/status`)).json().isRepo).toBe(true);110    expect((await admin.get(`/projects/${project.id}/file?path=missing.txt`)).statusCode).toBe(404);111  });112  it('rejects traversal on every file endpoint without calling the runtime', async () => {113    const before = h.rt.calls.length;114    const id = project.id;115    const bad = encodeURIComponent('../../etc/passwd');116    const results = await Promise.all([117      admin.get(`/projects/${id}/files?path=${bad}`),118      admin.get(`/projects/${id}/file?path=${bad}`),119      admin.get(`/projects/${id}/download?path=${bad}`),120      admin.get(`/projects/${id}/git/diff?path=${bad}`),121      admin.get(`/projects/${id}/file?path=${encodeURIComponent('/etc/passwd')}`),122      admin.get(`/projects/${id}/file?path=a%00b`),123      admin.req('PUT', `/projects/${id}/file`, { path: '../x', content: '' }),124      admin.post(`/projects/${id}/fs`, { op: 'mkdir', path: '../x' }),125      admin.post(`/projects/${id}/fs`, { op: 'rename', from: 'a', to: '../../b' }),126      admin.post(`/projects/${id}/fs`, { op: 'delete', path: '..', confirm: true }),127      admin.req('POST', `/projects/${id}/upload?path=${bad}`, undefined, { raw: Buffer.from('x'), headers: { 'content-type': 'application/octet-stream' } }),128    ]);129    expect(results.map((r) => r.statusCode)).toEqual(results.map(() => 400));130    expect(h.rt.calls.length).toBe(before);131    expect((await admin.post(`/projects/${id}/fs`, { op: 'delete', path: 'src/a.txt' })).statusCode).toBe(400); // confirm:true is mandatory132    expect((await admin.get(`/projects/${UUID}/files`)).statusCode).toBe(404);133  });134  it('upload and download stream binary intact; download is an attachment; audit rows carry paths only', async () => {135    const payload = Buffer.alloc(3 * 1024 * 1024);136    for (let i = 0; i < payload.length; i++) payload[i] = (i * 31) & 0xff;137    const up = await admin.req('POST', `/projects/${project.id}/upload?path=bin/data.bin`, undefined, { raw: payload, headers: { 'content-type': 'application/octet-stream' } });138    expect(up.statusCode, up.body).toBe(201);139    expect(h.rt.files.get(`${root()}/bin/data.bin`)?.equals(payload)).toBe(true);140    // a JSON-typed upload is stored verbatim, not parsed141    const json = await admin.req('POST', `/projects/${project.id}/upload?path=cfg.json`, undefined, { raw: '{"a": 1 }', headers: { 'content-type': 'application/json' } });142    expect(json.statusCode).toBe(201);143    expect(h.rt.files.get(`${root()}/cfg.json`)?.toString()).toBe('{"a": 1 }');144    const down = await admin.get(`/projects/${project.id}/download?path=bin/data.bin`);145    expect(down.statusCode).toBe(200);146    expect(down.rawPayload.equals(payload)).toBe(true);147    expect(down.headers['content-disposition']).toBe(`attachment; filename="data.bin"; filename*=UTF-8''data.bin`);148    expect(down.headers['content-type']).toBe('application/octet-stream');149    h.rt.files.set(`${root()}/we"ird\nname é.html`, Buffer.from('<script>1</script>'));150    const odd = await admin.get(`/projects/${project.id}/download?path=${encodeURIComponent('we"ird\nname é.html')}`);151    expect(odd.headers['content-disposition']).toBe(`attachment; filename="we_ird_name _.html"; filename*=UTF-8''we%22ird%0Aname%20%C3%A9.html`);152    expect(odd.headers['content-type']).toBe('application/octet-stream'); // never rendered as HTML153    await admin.post(`/projects/${project.id}/fs`, { op: 'rename', from: 'cfg.json', to: 'cfg2.json' });154    await admin.post(`/projects/${project.id}/fs`, { op: 'delete', path: 'cfg2.json', confirm: true });155    expect(h.rt.files.has(`${root()}/cfg2.json`)).toBe(false);156    expect((await audits('fs.upload')).map((a) => a.detail)).toEqual([{ path: 'bin/data.bin' }, { path: 'cfg.json' }]);157    expect((await audits('fs.write')).filter((a) => a.ok).map((a) => a.detail)).toEqual([{ path: 'src/a.txt' }, { path: 'src/a.txt' }]);158    expect((await audits('fs.op')).map((a) => a.detail)).toEqual([{ op: 'rename', from: 'cfg.json', to: 'cfg2.json' }, { op: 'delete', path: 'cfg2.json' }]);159    const all = JSON.stringify(await h.server.ctx.db.q('SELECT detail FROM audit_events'));160    expect(all).not.toContain('hello');161  });162});163164describe('tasks', () => {165  const mk = async (body: Record<string, unknown> = {}): Promise<AgentTask> => {166    const res = await admin.post('/tasks', { title: 'build', command: 'make all', ...body });167    expect(res.statusCode, res.body).toBe(201);168    return res.json<AgentTask>();169  };170  const get = async (id: string): Promise<AgentTask> => (await admin.get(`/tasks/${id}`)).json<AgentTask>();171  beforeAll(async () => {172    h.server.startBackground({ timers: false });173    await until(() => h.rt.eventSubscribers === 1);174  });175176  it('create ⇒ QUEUED row ⇒ runtime ⇒ RUNNING', async () => {177    const t = await mk({ projectId: project.id, cwd: 'src', timeoutSec: 60 });178    expect(t).toMatchObject({ state: 'RUNNING', host: 'local', cwd: 'src', exitCode: null, retryOf: null, idempotent: false, timeoutSec: 60 });179    expect(t.startedAt).not.toBeNull();180    expect(h.rt.taskSpecs.get(t.id)).toMatchObject({ id: t.id, command: 'make all', cwd: `${h.workspace}/my-project/src`, host: 'local', timeoutSec: 60 });181    expect(JSON.stringify(t)).not.toContain(h.workspace);182  });183  it('host allowlist: local or CR_EXEC_HOSTS, anything else is 403 and never reaches the runtime', async () => {184    const ok = await mk({ host: 'M3U96a' });185    expect(h.rt.taskSpecs.get(ok.id)?.host).toBe('M3U96a');186    for (const host of ['M5M36', 'evil.example', '-oProxyCommand=x', 'm3u96a']) {187      const before = h.rt.taskSpecs.size;188      const res = await admin.post('/tasks', { title: 'x', command: 'id', host });189      expect([host, res.statusCode]).toEqual([host, 403]);190      expect(res.json().error.code).toBe('host_not_allowed');191      expect(h.rt.taskSpecs.size).toBe(before);192    }193    expect((await admin.post('/tasks', { title: 'x', command: 'id', cwd: '../..' })).statusCode).toBe(400);194  });195  it('runtime events drive the state; success collects artifacts and notifies', async () => {196    const t = await mk({ projectId: project.id, artifactGlobs: ['out/*.csv', 'report.pdf', '../secret', 'deep/**/*.log'] });197    const cwd = `${h.workspace}/my-project`;198    for (const d of ['out', 'out/nested', 'deep', 'deep/a', 'deep/a/b']) h.rt.mkdir(`${cwd}/${d}`);199    h.rt.files.set(`${cwd}/out/a.csv`, Buffer.from('1,2'));200    h.rt.files.set(`${cwd}/out/b.txt`, Buffer.from('no'));201    h.rt.files.set(`${cwd}/out/nested/c.csv`, Buffer.from('not matched by out/*.csv'));202    h.rt.files.set(`${cwd}/report.pdf`, Buffer.from('%PDF'));203    h.rt.files.set(`${cwd}/deep/a/b/x.log`, Buffer.from('log'));204    h.rt.emit({ type: 'task.updated', task: { id: t.id, state: 'SUCCEEDED', exitCode: 0, startedAtMs: Date.now() - 1000, endedAtMs: Date.now(), logBytes: 42, cpuSec: 0.5, maxRssMb: 12 } });205    // The terminal state is committed first; artifacts and the notification follow (then task.updated).206    await until(async () => (await get(t.id)).state === 'SUCCEEDED' && (await get(t.id)).artifacts.length === 3);207    const done = await get(t.id);208    expect(done).toMatchObject({ exitCode: 0, logBytes: 42, cpuSec: 0.5, maxRssMb: 12 });209    expect(done.endedAt).not.toBeNull();210    expect(done.artifacts.map((a) => [a.name, a.size])).toEqual([['a.csv', 3], ['report.pdf', 4], ['x.log', 3]]);211    const art = done.artifacts.find((a) => a.name === 'a.csv') as { id: string };212    const dl = await admin.get(`/tasks/${t.id}/artifacts/${art.id}`);213    expect(dl.body).toBe('1,2');214    expect(dl.headers['content-disposition']).toContain('attachment; filename="a.csv"');215    expect((await admin.get(`/tasks/${t.id}/artifacts/${UUID}`)).statusCode).toBe(404);216    const listNotes = async (): Promise<{ kind: string; body: string }[]> => (await admin.get('/notifications')).json();217    await until(async () => (await listNotes()).some((n) => n.kind === 'task.succeeded'));218    expect((await listNotes()).find((n) => n.kind === 'task.succeeded')).toMatchObject({ body: '' });219    // a late/duplicate event cannot move a finished task220    h.rt.emit({ type: 'task.updated', task: { id: t.id, state: 'FAILED', exitCode: 1, startedAtMs: null, endedAtMs: null, logBytes: 0, cpuSec: null, maxRssMb: null } });221    await new Promise((r) => setTimeout(r, 150));222    expect((await get(t.id)).state).toBe('SUCCEEDED');223  });224  it('failure and timeout are recorded with a notification', async () => {225    const a = await mk();226    const b = await mk();227    h.rt.emit({ type: 'task.updated', task: { id: a.id, state: 'FAILED', exitCode: 2, startedAtMs: null, endedAtMs: null, logBytes: 0, cpuSec: null, maxRssMb: null } });228    h.rt.emit({ type: 'task.updated', task: { id: b.id, state: 'TIMED_OUT', exitCode: null, startedAtMs: null, endedAtMs: null, logBytes: 0, cpuSec: null, maxRssMb: null } });229    await until(async () => (await get(a.id)).state === 'FAILED' && (await get(b.id)).state === 'TIMED_OUT');230    expect((await get(a.id)).exitCode).toBe(2);231    await until(async () => (await admin.get('/notifications')).json<{ kind: string }[]>().filter((n) => n.kind === 'task.failed').length >= 2);232  });233  it('cancel', async () => {234    const t = await mk();235    const res = await admin.post(`/tasks/${t.id}/cancel`);236    expect(res.json().state).toBe('CANCELLED');237    expect(h.rt.tasks.get(t.id)?.state).toBe('CANCELLED');238    expect((await admin.post(`/tasks/${t.id}/cancel`)).statusCode).toBe(409);239  });240  it('retry only when idempotent and finished; creates a new row with retryOf; never automatic', async () => {241    const plain = await mk();242    await admin.post(`/tasks/${plain.id}/cancel`);243    expect((await admin.post(`/tasks/${plain.id}/retry`)).statusCode).toBe(409);244    const idem = await mk({ idempotent: true, title: 'again' });245    expect((await admin.post(`/tasks/${idem.id}/retry`)).statusCode).toBe(409); // still running246    h.rt.emit({ type: 'task.updated', task: { id: idem.id, state: 'FAILED', exitCode: 1, startedAtMs: null, endedAtMs: null, logBytes: 0, cpuSec: null, maxRssMb: null } });247    await until(async () => (await get(idem.id)).state === 'FAILED');248    const count = async (): Promise<number> => (await h.server.ctx.db.one<{ n: number }>("SELECT count(*)::int AS n FROM agent_tasks WHERE title = 'again'"))?.n ?? 0;249    await h.server.reconcile();250    expect(await count()).toBe(1); // a failure alone never re-runs anything251    const retry = await admin.post(`/tasks/${idem.id}/retry`);252    expect(retry.statusCode).toBe(201);253    const next = retry.json<AgentTask>();254    expect(next).toMatchObject({ retryOf: idem.id, state: 'RUNNING', idempotent: true, title: 'again' });255    expect(next.id).not.toBe(idem.id);256    expect(h.rt.taskSpecs.get(next.id)?.command).toBe('make all');257    expect(await count()).toBe(2);258  });259  it('reconciler: runtime down ⇒ unchanged; runtime LOST or task unknown ⇒ LOST', async () => {260    const a = await mk();261    const b = await mk();262    await h.rt.stop();263    expect(await h.server.reconcile()).toBe(false);264    expect((await get(a.id)).state).toBe('RUNNING');265    expect((await admin.post(`/tasks/${a.id}/cancel`)).statusCode).toBe(503);266    expect((await get(a.id)).state).toBe('RUNNING');267    await h.rt.start();268    await until(() => h.rt.eventSubscribers === 1, 15_000);269    (h.rt.tasks.get(a.id) as { state: string }).state = 'LOST';270    h.rt.tasks.delete(b.id);271    expect(await h.server.reconcile()).toBe(true);272    expect((await get(a.id)).state).toBe('LOST');273    expect((await get(b.id)).state).toBe('LOST');274    expect((await h.server.ctx.db.q("SELECT 1 FROM agent_tasks WHERE retry_of = ANY($1::uuid[])", [[a.id, b.id]])).length).toBe(0);275  });276  it('log proxy honours the offset and passes X-Log-Size; commands are never logged or audited', async () => {277    const t = await mk({ command: 'echo SUPER-SECRET-COMMAND' });278    h.rt.taskLogs.set(t.id, Buffer.from('0123456789'));279    const res = await admin.get(`/tasks/${t.id}/log?offset=4`);280    expect(res.body).toBe('456789');281    expect(res.headers['x-log-size']).toBe('10');282    expect(res.headers['content-type']).toBe('application/octet-stream');283    expect((await admin.get(`/tasks/${UUID}/log`)).statusCode).toBe(404);284    await new Promise((r) => setTimeout(r, 50));285    expect(h.logs.join('')).not.toContain('SUPER-SECRET-COMMAND');286    expect(JSON.stringify(await h.server.ctx.db.q('SELECT detail, target FROM audit_events'))).not.toContain('SUPER-SECRET-COMMAND');287    expect((await admin.get('/tasks?limit=3')).json()).toHaveLength(3);288  });289});290
Files main
Menu

Theme