Files
arboretum/packages/server/test/app.e2e.test.ts
Johan LEROY 65ef616867
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
chore(typo): retire tous les tirets cadratins/demi-cadratins + garde CI
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts).

Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
2026-07-17 16:44:00 +02:00

428 lines
18 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';
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('masquage des sessions : hide-discovered (vide), includeHidden, hide dun id inconnu → 404', async () => {
const headers = { authorization: `Bearer ${t.token}` };
const mass = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/hide-discovered', headers });
expect(mass.statusCode).toBe(200);
expect(mass.json()).toEqual({ hidden: 0 });
const withHidden = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/sessions?includeHidden=true', headers });
expect(withHidden.statusCode).toBe(200);
expect(withHidden.json()).toEqual({ sessions: [] });
const unknown = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/hide', headers });
expect(unknown.statusCode).toBe(404);
});
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);
});
});
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);
});
});