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'); // 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 => ({ 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); }); });