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:
270
packages/server/test/app.e2e.test.ts
Normal file
270
packages/server/test/app.e2e.test.ts
Normal 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' } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user