import { mkdtempSync, existsSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { buildApp, type AppBundle } from '../src/app.js'; import { openDb, type Db } from '../src/db/index.js'; import type { Config } from '../src/config.js'; import type { CreateProjectResponse } from '@arboretum/shared'; // On NE mocke PAS node:child_process : on veut un vrai `git init` (testé via command:'bash', qui // n'a pas besoin de résoudre le binaire claude). Seul le PTY est simulé (pas de vrai process en CI). vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => { class FakePty { pid = 424242; write = vi.fn(); resize = vi.fn(); pause = vi.fn(); resume = vi.fn(); kill = vi.fn(); onData(): { dispose: () => void } { return { dispose: () => {} }; } onExit(): { dispose: () => void } { return { dispose: () => {} }; } } return { default: { spawn: (): FakePty => new FakePty() } }; }); process.env.ARBORETUM_LOG = 'silent'; let dir: string; let root: string; let bundle: AppBundle; let db: Db; let token: string; const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` }); beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'arboretum-projects-')); root = dir; // la racine existe déjà const dbPath = join(dir, 'projects.db'); db = openDb(dbPath); const config: Config = { port: 7317, bind: '127.0.0.1', dbPath, dataDir: dir, allowedOrigins: [], printToken: false, claudeProjectsDir: join(dir, 'claude', 'projects'), claudeSessionsDir: join(dir, 'claude', 'sessions'), vapidContact: 'mailto:test@localhost', }; bundle = buildApp(config, db, '0.0.0-test'); const t = bundle.auth.ensureBootstrapToken(); if (!t) throw new Error('bootstrap token attendu sur une base vierge'); token = t; }); afterAll(async () => { await bundle.app.close(); db.close(); rmSync(dir, { recursive: true, force: true }); }); describe('POST /api/v1/projects', () => { it('crée le dossier et lance une session (sans git)', async () => { const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name: 'plain', command: 'bash' } }); expect(res.statusCode).toBe(201); const body = res.json() as CreateProjectResponse; expect(body.path).toBe(join(root, 'plain')); expect(body.gitInitialized).toBe(false); expect(existsSync(join(root, 'plain'))).toBe(true); expect(existsSync(join(root, 'plain', '.git'))).toBe(false); expect(body.session.cwd).toBe(join(root, 'plain')); }); it('gitInit:true crée un dépôt git', async () => { const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name: 'withgit', gitInit: true, command: 'bash' } }); expect(res.statusCode).toBe(201); const body = res.json() as CreateProjectResponse; expect(body.gitInitialized).toBe(true); expect(existsSync(join(root, 'withgit', '.git'))).toBe(true); }); it('nom avec `..` ou séparateur → 400 (anti-traversal)', async () => { for (const name of ['..', 'a/b', '../evil']) { const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name, command: 'bash' } }); expect(res.statusCode).toBe(400); } }); it('dossier déjà existant → 409 PROJECT_EXISTS', async () => { const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name: 'plain', command: 'bash' } }); expect(res.statusCode).toBe(409); expect(res.json()).toMatchObject({ error: { code: 'PROJECT_EXISTS' } }); }); it('racine inexistante → 404', async () => { const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root: join(root, 'nope-xyz'), name: 'x', command: 'bash' } }); expect(res.statusCode).toBe(404); }); it('root relatif → 400', async () => { const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root: 'relatif/x', name: 'x', command: 'bash' } }); expect(res.statusCode).toBe(400); }); it('sans authentification → 401', async () => { const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', payload: { root, name: 'noauth', command: 'bash' } }); expect(res.statusCode).toBe(401); }); });