import { mkdtempSync, 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'; // 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, }; 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' } }); }); });