- 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).
117 lines
4.5 KiB
TypeScript
117 lines
4.5 KiB
TypeScript
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);
|
|
});
|
|
});
|