feat: création de projet (nouveau dossier + session) & en-tête de session enrichi

- POST /api/v1/projects : crée <root>/<name> (mkdir non récursif, 409 si existant),
  git init optionnel, puis lance une session dedans. Anti-traversal sur le nom (core/project.ts).
- Web : NewProjectModal (racine préremplie scanRoots[0], case git init) + bouton dans SessionsListView.
- SessionContextBar : badge du groupe (cliquable) + repos couverts avec leur branche dans l'en-tête
  de session ; session simple = repo+branche déduits du cwd.
- 14 tests (unitaires + route).
This commit is contained in:
2026-06-23 18:52:22 +02:00
parent 06a400acc7
commit 8b9060e0c0
14 changed files with 571 additions and 2 deletions

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { isSafeProjectName, resolveProjectDir } from '../src/core/project.js';
describe('isSafeProjectName', () => {
it('accepte un segment simple', () => {
expect(isSafeProjectName('mon-projet')).toBe(true);
expect(isSafeProjectName('Projet_42.v2')).toBe(true);
expect(isSafeProjectName(' espaces-trim ')).toBe(true); // trim interne
});
it('refuse vide / espaces seuls', () => {
expect(isSafeProjectName('')).toBe(false);
expect(isSafeProjectName(' ')).toBe(false);
});
it('refuse `.` et `..`', () => {
expect(isSafeProjectName('.')).toBe(false);
expect(isSafeProjectName('..')).toBe(false);
});
it('refuse les séparateurs et le NUL (anti-traversal)', () => {
expect(isSafeProjectName('a/b')).toBe(false);
expect(isSafeProjectName('../evil')).toBe(false);
expect(isSafeProjectName('a\\b')).toBe(false);
expect(isSafeProjectName('a\0b')).toBe(false);
});
it('refuse un nom trop long (> 255)', () => {
expect(isSafeProjectName('x'.repeat(256))).toBe(false);
expect(isSafeProjectName('x'.repeat(255))).toBe(true);
});
});
describe('resolveProjectDir', () => {
it('joint root + name (trim) en chemin absolu', () => {
expect(resolveProjectDir('/home/johan/dev', 'mon-projet')).toBe('/home/johan/dev/mon-projet');
expect(resolveProjectDir('/home/johan/dev', ' mon-projet ')).toBe('/home/johan/dev/mon-projet');
});
it('lève (400) sur un nom invalide', () => {
expect(() => resolveProjectDir('/home/johan/dev', '..')).toThrow();
expect(() => resolveProjectDir('/home/johan/dev', 'a/b')).toThrow();
expect(() => resolveProjectDir('/home/johan/dev', '')).toThrow();
});
});

View File

@@ -0,0 +1,116 @@
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);
});
});