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:
45
packages/server/test/project.test.ts
Normal file
45
packages/server/test/project.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
116
packages/server/test/projects-routes.test.ts
Normal file
116
packages/server/test/projects-routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user