Scan borné du système de fichiers (racines configurables, défaut home ; profondeur/nombre/timeout bornés ; symlinks non suivis ; exclusions node_modules/dotdirs) qui auto-enregistre les nouveaux dépôts. Insertion atomique ON CONFLICT DO NOTHING (idempotence + anti-résurrection d'un dépôt masqué + anti-course). Scan au démarrage + bouton manuel + re-scan périodique (RepoDiscoveryService, démarré dans runDaemon). Colonne repos.hidden : masquer = conservé en DB mais exclu du dashboard et jamais ré-ajouté ; supprimer = re-découvrable. UI : bouton œil par dépôt + bascule afficher-les-masqués sur le dashboard, section Découverte dans les Réglages (racines + intervalle, allow-list stricte). Robustesse : listAllWorktrees tolère l'échec git par dépôt ; flag --no-discover (escape hatch + hermétisme des acceptations). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
414 lines
17 KiB
TypeScript
414 lines
17 KiB
TypeScript
import { mkdtempSync, mkdirSync, writeFileSync, 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 { munge } from '../src/core/jsonl-discovery.js';
|
||
import { readProcStart } from '../src/core/session-registry.js';
|
||
import type { Config } from '../src/config.js';
|
||
import type { DiscoverReposResponse, RepoResponse, ReposListResponse } from '@arboretum/shared';
|
||
|
||
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||
|
||
// Aucun vrai PTY en CI : le module node-pty est remplacé par un faux inerte.
|
||
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(_cb: (d: string) => void): { dispose: () => void } {
|
||
return { dispose: () => {} };
|
||
}
|
||
onExit(_cb: (e: { exitCode: number; signal?: number }) => void): { dispose: () => void } {
|
||
return { dispose: () => {} };
|
||
}
|
||
}
|
||
return { default: { spawn: (): FakePty => new FakePty() } };
|
||
});
|
||
|
||
process.env.ARBORETUM_LOG = 'silent';
|
||
|
||
const PORT = 7317;
|
||
const ALLOWED_ORIGIN = 'https://arboretum.test-tailnet.example';
|
||
|
||
interface TestApp {
|
||
bundle: AppBundle;
|
||
db: Db;
|
||
token: string;
|
||
}
|
||
|
||
let dir: string;
|
||
const apps: TestApp[] = [];
|
||
|
||
function makeApp(name: string): TestApp {
|
||
const dbPath = join(dir, `${name}.db`);
|
||
const db = openDb(dbPath);
|
||
const config: Config = {
|
||
port: PORT,
|
||
bind: '127.0.0.1',
|
||
dbPath,
|
||
dataDir: dir,
|
||
allowedOrigins: [ALLOWED_ORIGIN],
|
||
printToken: false,
|
||
// ~/.claude jetable, propre à chaque app (jamais le vrai HOME).
|
||
claudeProjectsDir: join(dir, `${name}-claude`, 'projects'),
|
||
claudeSessionsDir: join(dir, `${name}-claude`, 'sessions'),
|
||
};
|
||
const bundle = buildApp(config, db, '0.0.0-test');
|
||
const token = bundle.auth.ensureBootstrapToken();
|
||
if (!token) throw new Error('bootstrap token attendu sur une base vierge');
|
||
const app: TestApp = { bundle, db, token };
|
||
apps.push(app);
|
||
return app;
|
||
}
|
||
|
||
beforeAll(() => {
|
||
dir = mkdtempSync(join(tmpdir(), 'arboretum-e2e-'));
|
||
});
|
||
|
||
afterAll(async () => {
|
||
for (const a of apps) await a.bundle.app.close();
|
||
for (const a of apps) a.db.close();
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
describe('app e2e — auth, origin et sessions', () => {
|
||
let t: TestApp;
|
||
let cookieValue: string;
|
||
|
||
// Le rate limiter du login est GLOBAL à l'app : un seul login réussi ici,
|
||
// toutes les variantes d'échec vivent dans le describe rate limit (app dédiée).
|
||
beforeAll(async () => {
|
||
t = makeApp('main');
|
||
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
|
||
expect(res.statusCode).toBe(200);
|
||
expect(res.json()).toEqual({ ok: true, label: 'initial' });
|
||
const cookie = res.cookies.find((c) => c.name === 'arb_session');
|
||
expect(cookie).toBeDefined();
|
||
expect(cookie?.httpOnly).toBe(true);
|
||
expect(cookie?.sameSite).toBe('Strict');
|
||
// Login local (http, pas de x-forwarded-proto) → pas de Secure, sinon le cookie casserait en localhost.
|
||
expect(cookie?.secure).toBeFalsy();
|
||
cookieValue = cookie!.value;
|
||
});
|
||
|
||
it('cookie Secure posé derrière un front HTTPS (x-forwarded-proto)', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/auth/login',
|
||
headers: { 'x-forwarded-proto': 'https' },
|
||
payload: { token: t.token },
|
||
});
|
||
expect(res.statusCode).toBe(200);
|
||
const cookie = res.cookies.find((c) => c.name === 'arb_session');
|
||
expect(cookie?.secure).toBe(true);
|
||
expect(cookie?.httpOnly).toBe(true);
|
||
expect(cookie?.sameSite).toBe('Strict');
|
||
});
|
||
|
||
it('routes API sans auth → 401', async () => {
|
||
for (const url of ['/api/v1/sessions', '/api/v1/auth/me']) {
|
||
const res = await t.bundle.app.inject({ method: 'GET', url });
|
||
expect(res.statusCode).toBe(401);
|
||
expect(res.json()).toMatchObject({ error: { code: 'UNAUTHORIZED' } });
|
||
}
|
||
const post = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions', payload: { cwd: tmpdir() } });
|
||
expect(post.statusCode).toBe(401);
|
||
});
|
||
|
||
it('cookie émis au login → /me identifié', async () => {
|
||
const me = await t.bundle.app.inject({
|
||
method: 'GET',
|
||
url: '/api/v1/auth/me',
|
||
cookies: { arb_session: cookieValue },
|
||
});
|
||
expect(me.statusCode).toBe(200);
|
||
expect(me.json()).toMatchObject({ ok: true, tokenLabel: 'initial', serverVersion: '0.0.0-test' });
|
||
expect(typeof (me.json() as { tokenId: string }).tokenId).toBe('string');
|
||
});
|
||
|
||
it('cookie altéré → 401', async () => {
|
||
const tampered = cookieValue.slice(0, -1) + (cookieValue.endsWith('a') ? 'b' : 'a');
|
||
const me = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', cookies: { arb_session: tampered } });
|
||
expect(me.statusCode).toBe(401);
|
||
});
|
||
|
||
it('Bearer token accepté sur les routes API', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'GET',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
});
|
||
expect(res.statusCode).toBe(200);
|
||
expect(res.json()).toEqual({ sessions: [] });
|
||
});
|
||
|
||
it('Origin interdite → 403 même avec un Bearer valide, et même sur la route publique de login', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'GET',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}`, origin: 'https://evil.example' },
|
||
});
|
||
expect(res.statusCode).toBe(403);
|
||
expect(res.json()).toMatchObject({ error: { code: 'BAD_ORIGIN' } });
|
||
|
||
const login = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/auth/login',
|
||
headers: { origin: 'https://evil.example' },
|
||
payload: { token: t.token },
|
||
});
|
||
expect(login.statusCode).toBe(403);
|
||
});
|
||
|
||
it('Origins autorisées : loopback du port configuré et --allow-origin', async () => {
|
||
for (const origin of [`http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`, ALLOWED_ORIGIN]) {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'GET',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}`, origin },
|
||
});
|
||
expect(res.statusCode, `origin ${origin}`).toBe(200);
|
||
}
|
||
});
|
||
|
||
it('POST /sessions : cwd relatif → 400', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
payload: { cwd: 'relative/path' },
|
||
});
|
||
expect(res.statusCode).toBe(400);
|
||
expect(res.json()).toMatchObject({ error: { code: 'BAD_REQUEST' } });
|
||
});
|
||
|
||
it('POST /sessions : cwd absent ou non-string → 400', async () => {
|
||
const res1 = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
payload: {},
|
||
});
|
||
expect(res1.statusCode).toBe(400);
|
||
const res2 = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
payload: { cwd: 42 },
|
||
});
|
||
expect(res2.statusCode).toBe(400);
|
||
});
|
||
|
||
it('POST /sessions : cwd inexistant → 400 SPAWN_FAILED', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
payload: { cwd: '/definitely/not/a/dir' },
|
||
});
|
||
expect(res.statusCode).toBe(400);
|
||
expect(res.json()).toMatchObject({ error: { code: 'SPAWN_FAILED' } });
|
||
});
|
||
|
||
it('POST /sessions : command inconnue → 400', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
payload: { cwd: tmpdir(), command: 'zsh' },
|
||
});
|
||
expect(res.statusCode).toBe(400);
|
||
});
|
||
|
||
it('POST /sessions valide (bash, pty mocké) → 201 puis listée', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
payload: { cwd: tmpdir(), command: 'bash' },
|
||
});
|
||
expect(res.statusCode).toBe(201);
|
||
const { session } = res.json() as { session: { id: string; cwd: string; command: string; live: boolean; status: string } };
|
||
expect(session).toMatchObject({ cwd: tmpdir(), command: 'bash', live: true, status: 'running' });
|
||
|
||
const list = await t.bundle.app.inject({
|
||
method: 'GET',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
});
|
||
const sessions = (list.json() as { sessions: Array<{ id: string }> }).sessions;
|
||
expect(sessions.some((s) => s.id === session.id)).toBe(true);
|
||
});
|
||
|
||
it('DELETE /sessions/:id inconnu → 404', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'DELETE',
|
||
url: '/api/v1/sessions/unknown-id',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
});
|
||
expect(res.statusCode).toBe(404);
|
||
});
|
||
|
||
it('logout → cookie effacé', async () => {
|
||
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/logout', cookies: { arb_session: cookieValue } });
|
||
expect(res.statusCode).toBe(200);
|
||
const cleared = res.cookies.find((c) => c.name === 'arb_session');
|
||
expect(cleared?.value).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('app e2e — rate limit du login', () => {
|
||
it('mauvais token et bodies invalides → 401, puis 429 dès le 4e essai (backoff après 3 échecs consécutifs)', async () => {
|
||
const t = makeApp('ratelimit-failures');
|
||
const bad = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_badbadbadbad' } });
|
||
expect(bad.statusCode).toBe(401);
|
||
expect(bad.json()).toMatchObject({ error: { code: 'BAD_TOKEN' } });
|
||
const noToken = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: {} });
|
||
expect(noToken.statusCode).toBe(401);
|
||
const numToken = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 42 } });
|
||
expect(numToken.statusCode).toBe(401);
|
||
|
||
// 3 échecs consécutifs → verrou exponentiel : le backoff bloque AVANT la fenêtre 5/min
|
||
const blocked = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_badbadbadbad' } });
|
||
expect(blocked.statusCode).toBe(429);
|
||
expect(blocked.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } });
|
||
// même un login avec le BON token est bloqué pendant le backoff
|
||
const goodButLocked = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
|
||
expect(goodButLocked.statusCode).toBe(429);
|
||
});
|
||
|
||
it('limite 5 tentatives/minute : la 6e → 429 même avec le bon token', async () => {
|
||
const t = makeApp('ratelimit-window');
|
||
for (let i = 0; i < 5; i++) {
|
||
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
|
||
expect(res.statusCode, `tentative ${i + 1}`).toBe(200);
|
||
}
|
||
const blocked = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
|
||
expect(blocked.statusCode).toBe(429);
|
||
expect(blocked.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } });
|
||
});
|
||
});
|
||
|
||
describe('app e2e — découverte, resume & fork (P2)', () => {
|
||
let t: TestApp;
|
||
let projectsDir: string;
|
||
let sessionsDir: string;
|
||
|
||
const bearer = (): Record<string, string> => ({ authorization: `Bearer ${t.token}` });
|
||
|
||
function writeDiscoveredJsonl(cwd: string, sid: string): void {
|
||
const d = join(projectsDir, munge(cwd));
|
||
mkdirSync(d, { recursive: true });
|
||
writeFileSync(join(d, `${sid}.jsonl`), `${JSON.stringify({ type: 'user', sessionId: sid, cwd, message: { content: `prompt ${sid}` } })}\n`);
|
||
}
|
||
|
||
beforeAll(() => {
|
||
t = makeApp('resume');
|
||
projectsDir = join(dir, 'resume-claude', 'projects');
|
||
sessionsDir = join(dir, 'resume-claude', 'sessions');
|
||
mkdirSync(sessionsDir, { recursive: true });
|
||
});
|
||
|
||
it('resume / fork d’un id inconnu → 404', async () => {
|
||
const r = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/resume', headers: bearer() });
|
||
expect(r.statusCode).toBe(404);
|
||
const f = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/fork', headers: bearer() });
|
||
expect(f.statusCode).toBe(404);
|
||
});
|
||
|
||
it('session morte découverte → listée puis resume 201 (PTY claude dans son cwd)', async () => {
|
||
writeDiscoveredJsonl(dir, 'dead-e2e'); // cwd = dir (existe), donc le spawn de resume réussit
|
||
await t.bundle.discovery.refresh();
|
||
|
||
const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/sessions', headers: bearer() });
|
||
const found = (list.json() as { sessions: Array<{ id: string; source: string; resumable: boolean }> }).sessions.find(
|
||
(s) => s.id === 'dead-e2e',
|
||
);
|
||
expect(found).toMatchObject({ source: 'discovered', resumable: true });
|
||
|
||
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/dead-e2e/resume', headers: bearer() });
|
||
expect(res.statusCode).toBe(201);
|
||
expect((res.json() as { session: { command: string; source: string } }).session).toMatchObject({ command: 'claude', source: 'managed' });
|
||
});
|
||
|
||
it('session vivante découverte → resume refusé (409), fork autorisé (201)', async () => {
|
||
const sid = 'live-e2e';
|
||
writeDiscoveredJsonl(dir, sid);
|
||
const ps = readProcStart(process.pid) as string;
|
||
writeFileSync(
|
||
join(sessionsDir, `${process.pid}.json`),
|
||
JSON.stringify({ pid: process.pid, procStart: ps, sessionId: sid, cwd: dir, status: 'busy' }),
|
||
);
|
||
await t.bundle.discovery.refresh();
|
||
|
||
const resume = await t.bundle.app.inject({ method: 'POST', url: `/api/v1/sessions/${sid}/resume`, headers: bearer() });
|
||
expect(resume.statusCode).toBe(409);
|
||
expect(resume.json()).toMatchObject({ error: { code: 'SESSION_LIVE' } });
|
||
|
||
const fork = await t.bundle.app.inject({ method: 'POST', url: `/api/v1/sessions/${sid}/fork`, headers: bearer() });
|
||
expect(fork.statusCode).toBe(201);
|
||
});
|
||
});
|
||
|
||
describe('app e2e — découverte auto des repos & masquage', () => {
|
||
let t: TestApp;
|
||
let scanRoot: string;
|
||
const bearer = (): Record<string, string> => ({ authorization: `Bearer ${t.token}` });
|
||
|
||
beforeAll(() => {
|
||
t = makeApp('repos-discover');
|
||
// un « repo » côté scanner = un dossier avec .git (le scanner ne lance pas git).
|
||
scanRoot = join(dir, 'scan-root');
|
||
mkdirSync(join(scanRoot, 'alpha', '.git'), { recursive: true });
|
||
mkdirSync(join(scanRoot, 'beta', '.git'), { recursive: true });
|
||
});
|
||
|
||
it('POST /repos/discover enregistre les repos sous les racines configurées', async () => {
|
||
// configure la racine de scan via l'allow-list settings, désactive le périodique
|
||
const patch = await t.bundle.app.inject({
|
||
method: 'PATCH',
|
||
url: '/api/v1/settings',
|
||
headers: bearer(),
|
||
payload: { scanRoots: [scanRoot], scanIntervalMin: 0 },
|
||
});
|
||
expect(patch.statusCode).toBe(200);
|
||
|
||
const disc = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/repos/discover', headers: bearer() });
|
||
expect(disc.statusCode).toBe(200);
|
||
const body = disc.json() as DiscoverReposResponse;
|
||
expect(body.added).toBe(2);
|
||
expect(body.scanned).toBe(2);
|
||
|
||
const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() });
|
||
const repos = (list.json() as ReposListResponse).repos;
|
||
expect(repos.map((r) => r.label).sort()).toEqual(['alpha', 'beta']);
|
||
expect(repos.every((r) => r.hidden === false)).toBe(true);
|
||
|
||
// re-scan : idempotent (aucun nouveau)
|
||
const disc2 = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/repos/discover', headers: bearer() });
|
||
expect((disc2.json() as DiscoverReposResponse).added).toBe(0);
|
||
});
|
||
|
||
it('PATCH /repos/:id { hidden } masque le repo', async () => {
|
||
const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() });
|
||
const repo = (list.json() as ReposListResponse).repos[0];
|
||
const patch = await t.bundle.app.inject({
|
||
method: 'PATCH',
|
||
url: `/api/v1/repos/${repo.id}`,
|
||
headers: bearer(),
|
||
payload: { hidden: true },
|
||
});
|
||
expect(patch.statusCode).toBe(200);
|
||
expect((patch.json() as RepoResponse).repo.hidden).toBe(true);
|
||
// toujours listé (les masqués restent récupérables) mais avec hidden=true
|
||
const after = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() });
|
||
expect((after.json() as ReposListResponse).repos.find((r) => r.id === repo.id)?.hidden).toBe(true);
|
||
});
|
||
});
|