SPB Git

Repository Public

projects.test.ts 11.0 KB 173 lines typescript

1import fs from 'node:fs';2import net from 'node:net';3import os from 'node:os';4import path from 'node:path';5import { afterAll, beforeAll, describe, expect, it } from 'vitest';6import { ProjectDto } from '@aos/shared';7import { Client, FAKE_ANTHROPIC_KEY, FAKE_OPENAI_KEY, createHarness, insertOtherUser, seedMission, setupAdmin, type Harness } from '../../testkit/harness.js';8import { gitEnv } from './git.js';910let h: Harness;11let admin: Client;12let outside: string;1314beforeAll(async () => {15  h = await createHarness();16  ({ client: admin } = await setupAdmin(h));17  outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'aos-outside-')));18});19afterAll(async () => {20  await h.close();21  fs.rmSync(outside, { recursive: true, force: true });22});2324describe('create', () => {25  it('mode create: mkdir + git init under the workspace root, default path from the name', async () => {26    const res = await admin.post('/projects', { name: 'My App', mode: 'create', description: 'd', instructions: 'be careful' });27    expect(res.statusCode).toBe(201);28    const p = ProjectDto.parse(res.json());29    expect(p).toMatchObject({ name: 'My App', path: 'my-app', description: 'd', instructions: 'be careful', gitRemote: null, missions: 0 });30    expect(fs.statSync(path.join(h.workspace, 'my-app', '.git')).isDirectory()).toBe(true);31    expect(p.gitBranch).toMatch(/^[A-Za-z0-9._/-]+$/); // whatever init.defaultBranch is on this host32    expect(JSON.stringify(p)).not.toContain(h.workspace); // never an absolute host path33    const audit = await h.db.one<{ detail: unknown }>("SELECT detail FROM audit_events WHERE action = 'project.create' ORDER BY id DESC LIMIT 1");34    expect(audit?.detail).toEqual({ mode: 'create', path: 'my-app' });35  });36  it('explicit nested path; an existing destination or a registered path is a 409', async () => {37    const res = await admin.post('/projects', { name: 'Nested', mode: 'create', path: 'group/sub//nested/' });38    expect(res.statusCode).toBe(201);39    expect(res.json().path).toBe('group/sub/nested');40    expect((await admin.post('/projects', { name: 'Again', mode: 'create', path: 'group/sub/nested' })).json().error.code).toBe('exists');41    fs.mkdirSync(path.join(h.workspace, 'taken'));42    const taken = await admin.post('/projects', { name: 'Taken', mode: 'create', path: 'taken' });43    expect([taken.statusCode, taken.json().error.code]).toEqual([409, 'path_exists']);44    expect(fs.existsSync(path.join(h.workspace, 'taken', '.git'))).toBe(false);45  });46  it.each([47    ['../escape'], ['/etc'], ['a/../../b'], ['~/x'], ['a\0b'], ['.git'], ['x/.git/hooks'], ['.aos'], ['-oops'], ['a\\b'],48  ])('refuses path %j', async (p) => {49    for (const mode of ['create', 'existing', 'clone']) {50      const res = await admin.post('/projects', { name: 'Bad', mode, path: p, gitUrl: mode === 'clone' ? 'https://example.invalid/x.git' : undefined });51      expect([mode, res.statusCode, res.json().error.code]).toEqual([mode, 400, 'invalid_path']);52    }53  });54  it('refuses symlinks that lead out of the workspace root (existing, create and clone)', async () => {55    fs.mkdirSync(path.join(outside, 'repo'));56    fs.symlinkSync(outside, path.join(h.workspace, 'link'));57    for (const body of [58      { name: 'L', mode: 'existing', path: 'link' },59      { name: 'L', mode: 'existing', path: 'link/repo' },60      { name: 'L', mode: 'create', path: 'link/new' },61      { name: 'L', mode: 'clone', path: 'link/cloned', gitUrl: 'https://example.invalid/x.git' },62    ]) {63      const res = await admin.post('/projects', body);64      expect([body.path, res.statusCode, res.json().error.code]).toEqual([body.path, 400, 'path_outside_root']);65    }66    expect(fs.readdirSync(outside)).toEqual(['repo']); // nothing was created outside67    expect((await h.db.q("SELECT 1 FROM projects WHERE rel_path LIKE 'link%'")).length).toBe(0);68  });69  it('mode existing: the directory must exist; the origin remote is read without credentials', async () => {70    expect((await admin.post('/projects', { name: 'E', mode: 'existing' })).statusCode).toBe(400);71    const missing = await admin.post('/projects', { name: 'E', mode: 'existing', path: 'nope' });72    expect([missing.statusCode, missing.json().error.code]).toEqual([404, 'path_not_found']);73    const dir = path.join(h.workspace, 'legacy');74    fs.mkdirSync(path.join(dir, '.git'), { recursive: true });75    fs.writeFileSync(path.join(dir, '.git', 'HEAD'), 'ref: refs/heads/trunk\n');76    fs.writeFileSync(path.join(dir, '.git', 'config'), '[core]\n\tbare = false\n[remote "origin"]\n\turl = https://bot:ghp_TOKENVALUE@github.com/org/legacy.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n');77    const res = await admin.post('/projects', { name: 'Legacy', mode: 'existing', path: 'legacy' });78    expect(res.statusCode).toBe(201);79    expect(res.json()).toMatchObject({ path: 'legacy', gitBranch: 'trunk', gitRemote: 'https://github.com/org/legacy.git' });80    expect(res.body).not.toContain('ghp_TOKENVALUE');81    expect((await h.db.one<{ git_remote: string }>("SELECT git_remote FROM projects WHERE rel_path = 'legacy'"))?.git_remote).toBe('https://github.com/org/legacy.git');82    const plain = path.join(h.workspace, 'plain');83    fs.mkdirSync(plain);84    expect((await admin.post('/projects', { name: 'Plain', mode: 'existing', path: 'plain' })).json()).toMatchObject({ gitBranch: null, gitRemote: null });85  });86});8788describe('clone', () => {89  it('only whitelisted URLs; gitUrl is refused with other modes', async () => {90    for (const gitUrl of [undefined, '', 'file:///etc', '/tmp/repo', 'ext::sh -c id', '--upload-pack=id', 'http://plain.example/x.git', 'git://host/x']) {91      const res = await admin.post('/projects', { name: 'C', mode: 'clone', path: 'c1', gitUrl });92      expect([gitUrl, res.statusCode, res.json().error.code]).toEqual([gitUrl, 400, 'invalid_git_url']);93    }94    expect((await admin.post('/projects', { name: 'C', mode: 'create', path: 'c1', gitUrl: 'https://example.org/x.git' })).statusCode).toBe(400);95    expect(fs.existsSync(path.join(h.workspace, 'c1'))).toBe(false);96  });97  it('a failed clone leaves no directory and no row, and never echoes the URL', async () => {98    const url = 'https://user:CLONESECRET@127.0.0.1:1/nope.git'; // nothing listens on port 199    const res = await admin.post('/projects', { name: 'Unreachable', mode: 'clone', path: 'deep/unreachable', gitUrl: url });100    expect([res.statusCode, res.json().error.code]).toEqual([422, 'clone_failed']);101    expect(res.body).not.toContain('CLONESECRET');102    expect(fs.existsSync(path.join(h.workspace, 'deep', 'unreachable'))).toBe(false);103    expect((await h.db.q("SELECT 1 FROM projects WHERE rel_path = 'deep/unreachable'")).length).toBe(0);104    expect(h.logs.join('')).not.toContain('CLONESECRET');105  });106  it('a clone that hangs is killed at the timeout and cleaned up', async () => {107    const sockets: net.Socket[] = [];108    const blackhole = net.createServer((s) => sockets.push(s)); // accepts, never answers the TLS handshake109    await new Promise<void>((r) => blackhole.listen(0, '127.0.0.1', r));110    const port = (blackhole.address() as net.AddressInfo).port;111    const before = h.config.cloneTimeoutMs;112    h.config.cloneTimeoutMs = 700;113    try {114      const t0 = Date.now();115      const res = await admin.post('/projects', { name: 'Hang', mode: 'clone', path: 'hang', gitUrl: `https://127.0.0.1:${port}/x.git` });116      expect([res.statusCode, res.json().error.code]).toEqual([504, 'clone_timeout']);117      expect(Date.now() - t0).toBeLessThan(10_000);118      expect(fs.existsSync(path.join(h.workspace, 'hang'))).toBe(false);119    } finally {120      h.config.cloneTimeoutMs = before;121      for (const s of sockets) s.destroy();122      await new Promise((r) => blackhole.close(r));123    }124  });125  it('git runs with a minimal environment: no prompts, no model API keys', () => {126    const env = gitEnv();127    expect(env.GIT_TERMINAL_PROMPT).toBe('0');128    expect(Object.keys(env).sort()).toEqual(expect.arrayContaining(['GIT_ASKPASS', 'GIT_SSH_COMMAND', 'GIT_TERMINAL_PROMPT', 'HOME', 'PATH']));129    expect(process.env.ANTHROPIC_API_KEY).toBe(FAKE_ANTHROPIC_KEY); // present in the server process…130    const dump = JSON.stringify(env);131    expect(dump).not.toContain(FAKE_ANTHROPIC_KEY); // …but never handed to git132    expect(dump).not.toContain(FAKE_OPENAI_KEY);133    expect(Object.keys(env).some((k) => /API_KEY|AOS_SECRET|DATABASE_URL/.test(k))).toBe(false);134  });135});136137describe('read, update, delete', () => {138  it('lists with mission counts, updates the editable fields only, validates', async () => {139    const { projectId } = await seedMission(h, admin, { project: 'Counted' });140    const list = (await admin.get('/projects')).json<ProjectDto[]>();141    expect(list.find((p) => p.id === projectId)?.missions).toBe(1);142    const patched = await admin.req('PATCH', `/projects/${projectId}`, { name: 'Renamed', instructions: 'new notes', path: 'elsewhere' });143    expect(patched.statusCode).toBe(200);144    expect(patched.json()).toMatchObject({ name: 'Renamed', instructions: 'new notes', path: 'counted' }); // path is immutable145    expect((await admin.req('PATCH', `/projects/${projectId}`, { name: '' })).statusCode).toBe(400);146    expect((await admin.get(`/projects/${projectId}`)).json().name).toBe('Renamed');147    expect((await admin.get('/projects/not-a-uuid')).statusCode).toBe(400);148  });149  it('delete unregisters only: refused while a mission is live, the directory is left untouched', async () => {150    const { projectId, missionId } = await seedMission(h, admin, { project: 'Doomed' });151    const busy = await admin.req('DELETE', `/projects/${projectId}`);152    expect([busy.statusCode, busy.json().error.code]).toEqual([409, 'project_busy']);153    expect((await admin.post(`/missions/${missionId}/cancel`)).statusCode).toBe(200);154    expect((await admin.req('DELETE', `/projects/${projectId}`)).json()).toEqual({ ok: true });155    expect(fs.existsSync(path.join(h.workspace, 'doomed', '.git'))).toBe(true);156    expect((await admin.get(`/projects/${projectId}`)).statusCode).toBe(404);157    // the mission survives, detached from the project158    expect((await admin.get(`/missions/${missionId}`)).json()).toMatchObject({ projectId: null, projectName: null });159    expect((await admin.req('DELETE', `/projects/${projectId}`)).statusCode).toBe(404);160  });161  it("another owner's project is invisible and untouchable", async () => {162    const other = await insertOtherUser(h);163    const row = await h.db.one<{ id: string }>("INSERT INTO projects (owner_id, name, rel_path) VALUES ($1, 'Theirs', 'theirs') RETURNING id", [other]);164    const id = row?.id as string;165    expect((await admin.get('/projects')).json<ProjectDto[]>().some((p) => p.id === id)).toBe(false);166    expect((await admin.get(`/projects/${id}`)).statusCode).toBe(404);167    expect((await admin.req('PATCH', `/projects/${id}`, { name: 'Mine' })).statusCode).toBe(404);168    expect((await admin.req('DELETE', `/projects/${id}`)).statusCode).toBe(404);169    expect((await admin.post('/missions', { projectId: id, objective: 'x' })).statusCode).toBe(404);170    expect((await admin.post('/policy/rules', { projectId: id, decision: 'allow', tool: 'shell', match: {}, note: '' })).statusCode).toBe(404);171  });172});173
Files main
Menu

Theme