Files
arboretum/packages/server/test/app.e2e.test.ts
Johan LEROY c177eeea07 P2: découverte & reprise des sessions Claude
Arboretum découvre désormais toutes les sessions Claude de la machine
(scan ~/.claude/projects + registre ~/.claude/sessions), distingue
vivantes/mortes par pid+procStart, et permet de reprendre une morte
(--resume dans son cwd d'origine) ou forker une vivante sans la corrompre.

- shared: SessionSummary enrichi (source, claudeSessionId, pid, resumable,
  attachable, registryStatus) — additif, PROTOCOL_VERSION inchangé ;
  types REST resume/fork.
- db: migration id:2 (claude_session_id, resumed_from).
- core: jsonl-discovery (parseur tolérant, scan asynchrone non bloquant),
  session-registry (vivacité pid+procStart), discovery-service (cache +
  refresh périodique + diff/broadcast), pty-manager (resume/fork + capture
  du claudeSessionId via le registre).
- routes: /sessions/:id/resume (garde-fou 409 anti-corruption sur session
  vivante) et /fork ; GET fusionné managées + découvertes ; relais WS.
- web: badges managed/discovered + busy/idle/waiting, actions conditionnelles
  (Open/Observe/Kill vs Fork/View vs Resume/Fork), vue read-only des sessions
  externes, i18n EN/FR.
- tests: jsonl-discovery, session-registry, discovery-service + resume/fork
  (130 verts) ; acceptation E2E acceptance-p2.mjs (sans quota) ALL GREEN.

Conforme aux verdicts S1 (resume dans cwd d'origine, vivacité pid+procStart)
et S4 (munge cwd, parseur tête+queue, priorité de titre).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:20:21 +02:00

340 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
// 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');
cookieValue = cookie!.value;
});
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()).toEqual({ ok: true, tokenLabel: 'initial', serverVersion: '0.0.0-test' });
});
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 dun 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);
});
});