P1 complete: web front, test suite, CI — acceptance ALL GREEN

Fan-out integration + fixes found by the test/acceptance pass:
- FIX ring-buffer: chunks >= capacity skipped bytes now count into the
  monotonic offset (invariant: stream byte k lives at k % capacity) —
  window order was corrupted on unaligned big chunks
- FIX auth: non-numeric cookie expiry no longer bypasses expiration
- FIX protocol: safe-integer validation on ack.bytes / hello.protocol
- FIX @fastify/websocket v11: websocket route must be registered in an
  encapsulated context after plugin load (handler got REST signature)
- FIX flow-control deadlock found by e2e acceptance: client only ACKs
  on data receipt, so pausing with an unACKed residue in (LOW,
  ACK_EVERY] stalled both sides at 0.9 MB. ACK_EVERY now 64 KiB (<=
  LOW invariant, tested) + trailing debounced ACK in the web client
- Web: Vue 3 + Vite + Pinia + Tailwind 4 + vue-i18n (EN/FR) + xterm 6
  (fit + webgl fallback), multiplexed ws-client with reconnect/backoff
  and resync epochs
- Tests: 100 vitest (protocol fuzz, ring edges, auth, pty-manager flow
  control with mocked pty, REST e2e) ; CI Node 22/24 + pack-smoke
- scripts/acceptance-p1.mjs: real daemon + real WS client — boot,
  login, attach, stdin, 10 MB flood w/ ACK (13.7 MB/1.9s, RSS bounded),
  brutal disconnect + replay resync, kill broadcast, SIGTERM drain

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-06-11 22:29:58 +02:00
parent f6f73329b9
commit 770f58a640
42 changed files with 4624 additions and 32 deletions

View File

@@ -0,0 +1,270 @@
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' } });
});
});