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:
@@ -18,6 +18,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"dev": "tsc -b --watch & node --watch dist/index.js",
|
||||
"prepack": "node scripts/copy-web.mjs",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
167
packages/server/scripts/acceptance-p1.mjs
Normal file
167
packages/server/scripts/acceptance-p1.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
// Acceptation P1 (sans navigateur, sans quota Claude) : vrai daemon + vrai client WS.
|
||||
// Couvre : login, hello, sub, spawn bash, attach+replay, stdin/output, flood 10 Mo
|
||||
// avec flow control par ACK + RSS borné, déconnexion/reconnexion+replay, kill, drain.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const PORT = 7541;
|
||||
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const results = [];
|
||||
const check = (name, ok, detail = '') => {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-'));
|
||||
const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db')], {
|
||||
env: { ...process.env, ARBORETUM_LOG: 'warn' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let srvOut = '';
|
||||
srv.stdout.on('data', (d) => (srvOut += d));
|
||||
srv.stderr.on('data', (d) => (srvOut += d));
|
||||
|
||||
try {
|
||||
await sleep(1500);
|
||||
const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0];
|
||||
check('boot + token bootstrap', !!token);
|
||||
|
||||
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? '';
|
||||
check('login → cookie', login.status === 200 && cookie.startsWith('arb_session='));
|
||||
|
||||
// Client WS minimal multiplexé
|
||||
function wsClient() {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||
ws.binaryType = 'arraybuffer';
|
||||
const state = { msgs: [], outputs: new Map(), resyncs: new Map(), bytes: new Map() };
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (!isBinary) {
|
||||
state.msgs.push(JSON.parse(String(data)));
|
||||
return;
|
||||
}
|
||||
const buf = Buffer.from(data);
|
||||
const type = buf.readUInt8(0);
|
||||
const channel = buf.readUInt32LE(1);
|
||||
const payload = buf.subarray(5);
|
||||
if (type === 0x02) {
|
||||
state.resyncs.set(channel, (state.resyncs.get(channel) ?? 0) + 1);
|
||||
state.bytes.set(channel, 0); // resync : le compteur ACK repart de zéro
|
||||
state.outputs.set(channel, payload.toString('latin1'));
|
||||
} else {
|
||||
state.outputs.set(channel, ((state.outputs.get(channel) ?? '') + payload.toString('latin1')).slice(-300000));
|
||||
const total = (state.bytes.get(channel) ?? 0) + payload.length;
|
||||
state.bytes.set(channel, total);
|
||||
// ACK tous les 64 Kio traités (invariant : pas d'ACK <= LOW_WATERMARK)
|
||||
const acked = state.lastAck?.get(channel) ?? 0;
|
||||
if (total - acked >= 64 * 1024) {
|
||||
(state.lastAck ??= new Map()).set(channel, total);
|
||||
ws.send(JSON.stringify({ type: 'ack', channel, bytes: total }));
|
||||
}
|
||||
}
|
||||
});
|
||||
const waitMsg = async (pred, timeout = 8000) => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
const m = state.msgs.find(pred);
|
||||
if (m) return m;
|
||||
await sleep(50);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) };
|
||||
}
|
||||
|
||||
const c1 = wsClient();
|
||||
await new Promise((res, rej) => (c1.ws.on('open', res), c1.ws.on('error', rej)));
|
||||
c1.send({ type: 'hello', protocol: 1 });
|
||||
check('hello_ok', !!(await c1.waitMsg((m) => m.type === 'hello_ok')));
|
||||
c1.send({ type: 'sub', topics: ['sessions'] });
|
||||
|
||||
// Spawn bash + attach
|
||||
const created = await fetch(`${ORIGIN}/api/v1/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: ORIGIN, Cookie: cookie },
|
||||
body: JSON.stringify({ cwd: tmp, command: 'bash' }),
|
||||
});
|
||||
const sid = (await created.json()).session.id;
|
||||
check('spawn bash', created.status === 201 && !!sid);
|
||||
|
||||
c1.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 });
|
||||
const att = await c1.waitMsg((m) => m.type === 'attached');
|
||||
check('attach interactif + controlling', att?.controlling === true);
|
||||
const ch = att.channel;
|
||||
|
||||
// stdin → output
|
||||
await sleep(400);
|
||||
c1.send({ type: 'stdin', channel: ch, data: 'echo ACCEPT-$((21*2))\r' });
|
||||
await sleep(800);
|
||||
check('stdin → output', (c1.state.outputs.get(ch) ?? '').includes('ACCEPT-42'));
|
||||
|
||||
// Flood 10 Mo (base64 → ~14 Mo de flux TTY) avec ACK, RSS serveur borné
|
||||
const rssBefore = readFileSync(`/proc/${srv.pid}/status`, 'utf8').match(/VmRSS:\s+(\d+) kB/)?.[1];
|
||||
c1.send({ type: 'stdin', channel: ch, data: 'head -c 10485760 /dev/urandom | base64; echo FLOOD-END-MARKER\r' });
|
||||
const t0 = Date.now();
|
||||
let floodOk = false;
|
||||
let received = 0;
|
||||
while (Date.now() - t0 < 60000) {
|
||||
received = c1.state.bytes.get(ch) ?? 0;
|
||||
if ((c1.state.outputs.get(ch) ?? '').includes('FLOOD-END-MARKER')) {
|
||||
floodOk = true;
|
||||
break;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
const rssAfter = readFileSync(`/proc/${srv.pid}/status`, 'utf8').match(/VmRSS:\s+(\d+) kB/)?.[1];
|
||||
const rssMb = Math.round((Number(rssAfter) - Number(rssBefore)) / 1024);
|
||||
check('flood 10 Mo complet via ACK', floodOk && received > 13_000_000, `${(received / 1048576).toFixed(1)} Mo reçus en ${Date.now() - t0} ms`);
|
||||
check('RSS serveur borné pendant le flood', Number(rssAfter) < 300 * 1024, `Δ ${rssMb} Mo (RSS ${Math.round(Number(rssAfter) / 1024)} Mo)`);
|
||||
|
||||
// Déconnexion brutale → la session survit → reconnexion + replay
|
||||
c1.ws.terminate();
|
||||
await sleep(2000);
|
||||
const c2 = wsClient();
|
||||
await new Promise((res, rej) => (c2.ws.on('open', res), c2.ws.on('error', rej)));
|
||||
c2.send({ type: 'hello', protocol: 1 });
|
||||
await c2.waitMsg((m) => m.type === 'hello_ok');
|
||||
c2.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 100, rows: 30 });
|
||||
const att2 = await c2.waitMsg((m) => m.type === 'attached');
|
||||
await sleep(500);
|
||||
const replay = c2.state.outputs.get(att2.channel) ?? '';
|
||||
check('reconnexion : session vivante + replay', att2?.controlling === true && c2.state.resyncs.get(att2.channel) === 1 && replay.length > 1000, `replay ${(replay.length / 1024).toFixed(0)} Kio`);
|
||||
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo AFTER-RECONNECT\r' });
|
||||
await sleep(800);
|
||||
check('terminal utilisable après reconnexion', (c2.state.outputs.get(att2.channel) ?? '').includes('AFTER-RECONNECT'));
|
||||
|
||||
// Kill + notification session_exit (sub)
|
||||
c2.send({ type: 'sub', topics: ['sessions'] });
|
||||
await fetch(`${ORIGIN}/api/v1/sessions/${sid}`, { method: 'DELETE', headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||
const exited = await c2.waitMsg((m) => m.type === 'session_exit' && m.sessionId === sid, 8000);
|
||||
const detached = await c2.waitMsg((m) => m.type === 'detached' && m.reason === 'session_exit', 8000);
|
||||
check('kill → session_exit + detached broadcast', !!exited && !!detached);
|
||||
c2.ws.close();
|
||||
} catch (err) {
|
||||
check('exception', false, String(err));
|
||||
} finally {
|
||||
srv.kill('SIGTERM');
|
||||
await sleep(1500);
|
||||
check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(failed.length === 0 ? '\nACCEPTANCE P1: ALL GREEN' : `\nACCEPTANCE P1: ${failed.length} FAILURE(S)`);
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
31
packages/server/scripts/copy-web.mjs
Normal file
31
packages/server/scripts/copy-web.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
// Embarque la SPA buildée (packages/web/dist) dans public/ du package server,
|
||||
// pour que le tarball npm soit autonome. Branché sur le hook "prepack".
|
||||
// ARBORETUM_PACK_NO_WEB=1 : mode tolérant (CI/smoke) — placeholder à la place du front.
|
||||
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const webDist = join(serverDir, '..', 'web', 'dist');
|
||||
const publicDir = join(serverDir, 'public');
|
||||
|
||||
if (!existsSync(webDist)) {
|
||||
if (process.env.ARBORETUM_PACK_NO_WEB === '1') {
|
||||
rmSync(publicDir, { recursive: true, force: true });
|
||||
mkdirSync(publicDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(publicDir, 'index.html'),
|
||||
'<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>Arboretum</title></head>\n' +
|
||||
'<body><p>Arboretum server is running, but this package was built without the web UI.</p></body>\n</html>\n',
|
||||
);
|
||||
console.log('copy-web: ARBORETUM_PACK_NO_WEB=1 — wrote placeholder public/index.html');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(`copy-web: ${webDist} not found — run npm run build -w @arboretum/web first`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
rmSync(publicDir, { recursive: true, force: true });
|
||||
cpSync(webDist, publicDir, { recursive: true });
|
||||
console.log(`copy-web: copied ${webDist} -> ${publicDir}`);
|
||||
@@ -73,7 +73,11 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||
registerSessionRoutes(app, manager);
|
||||
registerWsGateway(app, manager, serverVersion);
|
||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
||||
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||
void app.register(async (scoped) => {
|
||||
registerWsGateway(scoped, manager, serverVersion);
|
||||
});
|
||||
|
||||
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
||||
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
|
||||
@@ -69,7 +69,9 @@ export class AuthService {
|
||||
const expected = this.sign(payload);
|
||||
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||
const [tokenId, expiresStr] = payload.split('.');
|
||||
if (!tokenId || !expiresStr || Number(expiresStr) < Date.now()) return null;
|
||||
if (!tokenId || !expiresStr) return null;
|
||||
const expires = Number(expiresStr);
|
||||
if (!Number.isFinite(expires) || expires < Date.now()) return null;
|
||||
const row = this.db
|
||||
.prepare('SELECT id, label FROM auth_tokens WHERE id = ? AND revoked_at IS NULL')
|
||||
.get(tokenId) as { id: string; label: string } | undefined;
|
||||
|
||||
@@ -244,14 +244,13 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
|
||||
s.exited = { exitCode, signal };
|
||||
if (s.killTimer) clearTimeout(s.killTimer);
|
||||
this.db
|
||||
.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?')
|
||||
.run(new Date().toISOString(), exitCode, s.id);
|
||||
const endedAt = new Date().toISOString();
|
||||
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
|
||||
for (const c of s.clients) c.onDetached('session_exit');
|
||||
s.clients.clear();
|
||||
this.live.delete(s.id);
|
||||
this.emit('session_exit', { sessionId: s.id, exitCode, signal });
|
||||
this.emit('session_update', { ...this.summarize(s), live: false, status: 'exited' });
|
||||
this.emit('session_update', { ...this.summarize(s), live: false, status: 'exited', endedAt });
|
||||
}
|
||||
|
||||
private summarize(s: ManagedSession): SessionSummary {
|
||||
|
||||
@@ -22,20 +22,23 @@ export class RingBuffer {
|
||||
}
|
||||
|
||||
write(chunk: Buffer): void {
|
||||
// Chunk plus grand que la fenêtre : on ne stocke que la queue, mais en comptant
|
||||
// les octets sautés dans l'offset pour préserver l'invariant « l'octet k du flux
|
||||
// est à la position k % capacity » (sinon tail/readFrom rendent la fenêtre désordonnée).
|
||||
if (chunk.length >= this.capacity) {
|
||||
chunk.copy(this.buf, 0, chunk.length - this.capacity);
|
||||
this.length = this.capacity;
|
||||
} else {
|
||||
const writePos = this.total % this.capacity;
|
||||
const tailSpace = this.capacity - writePos;
|
||||
if (chunk.length <= tailSpace) {
|
||||
chunk.copy(this.buf, writePos);
|
||||
} else {
|
||||
chunk.copy(this.buf, writePos, 0, tailSpace);
|
||||
chunk.copy(this.buf, 0, tailSpace);
|
||||
}
|
||||
this.length = Math.min(this.capacity, this.length + chunk.length);
|
||||
const skipped = chunk.length - this.capacity;
|
||||
this.total += skipped;
|
||||
chunk = chunk.subarray(skipped);
|
||||
}
|
||||
const writePos = this.total % this.capacity;
|
||||
const tailSpace = this.capacity - writePos;
|
||||
if (chunk.length <= tailSpace) {
|
||||
chunk.copy(this.buf, writePos);
|
||||
} else {
|
||||
chunk.copy(this.buf, writePos, 0, tailSpace);
|
||||
chunk.copy(this.buf, 0, tailSpace);
|
||||
}
|
||||
this.length = Math.min(this.capacity, this.length + chunk.length);
|
||||
this.total += chunk.length;
|
||||
}
|
||||
|
||||
|
||||
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' } });
|
||||
});
|
||||
});
|
||||
208
packages/server/test/auth.test.ts
Normal file
208
packages/server/test/auth.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { AuthService, LoginRateLimiter, type AuthContext } from '../src/auth/service.js';
|
||||
import { getSetting, openDb, type Db } from '../src/db/index.js';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let db: Db;
|
||||
let auth: AuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = openDb(':memory:');
|
||||
auth = new AuthService(db);
|
||||
});
|
||||
|
||||
describe('bootstrap token', () => {
|
||||
it('créé une seule fois : le second appel renvoie null', () => {
|
||||
const token = auth.ensureBootstrapToken();
|
||||
expect(token).toMatch(/^arb_[0-9a-f]{48}$/);
|
||||
expect(auth.ensureBootstrapToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('recréé si tous les tokens sont révoqués', () => {
|
||||
const first = auth.ensureBootstrapToken();
|
||||
expect(first).not.toBeNull();
|
||||
db.prepare('UPDATE auth_tokens SET revoked_at = ?').run(new Date().toISOString());
|
||||
const second = auth.ensureBootstrapToken();
|
||||
expect(second).not.toBeNull();
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyRawToken', () => {
|
||||
it('token valide → contexte avec label', () => {
|
||||
const raw = auth.ensureBootstrapToken();
|
||||
const ctx = auth.verifyRawToken(raw as string);
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.label).toBe('initial');
|
||||
expect(typeof ctx?.tokenId).toBe('string');
|
||||
});
|
||||
|
||||
it('met à jour last_used_at au passage', () => {
|
||||
const raw = auth.ensureBootstrapToken() as string;
|
||||
const ctx = auth.verifyRawToken(raw) as AuthContext;
|
||||
const row = db.prepare('SELECT last_used_at FROM auth_tokens WHERE id = ?').get(ctx.tokenId) as { last_used_at: string | null };
|
||||
expect(row.last_used_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('token inconnu, trop court ou trop long → null', () => {
|
||||
auth.ensureBootstrapToken();
|
||||
expect(auth.verifyRawToken('arb_' + 'f'.repeat(48))).toBeNull(); // bon format, mauvaise valeur
|
||||
expect(auth.verifyRawToken('short')).toBeNull(); // < 8 chars
|
||||
expect(auth.verifyRawToken('a'.repeat(129))).toBeNull(); // > 128 chars
|
||||
expect(auth.verifyRawToken('')).toBeNull();
|
||||
});
|
||||
|
||||
it('token révoqué → null', () => {
|
||||
const raw = auth.ensureBootstrapToken() as string;
|
||||
expect(auth.verifyRawToken(raw)).not.toBeNull();
|
||||
db.prepare('UPDATE auth_tokens SET revoked_at = ?').run(new Date().toISOString());
|
||||
expect(auth.verifyRawToken(raw)).toBeNull();
|
||||
});
|
||||
|
||||
it('createToken : deux tokens coexistent avec leurs labels', () => {
|
||||
auth.ensureBootstrapToken();
|
||||
const raw2 = auth.createToken('laptop');
|
||||
expect(auth.verifyRawToken(raw2)?.label).toBe('laptop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cookies', () => {
|
||||
let ctx: AuthContext;
|
||||
|
||||
beforeEach(() => {
|
||||
const raw = auth.ensureBootstrapToken() as string;
|
||||
ctx = auth.verifyRawToken(raw) as AuthContext;
|
||||
});
|
||||
|
||||
// Signe un payload arbitraire avec le vrai secret du serveur (lu en base),
|
||||
// pour forger des cookies à la structure valide mais au contenu choisi.
|
||||
function forge(payload: string): string {
|
||||
const secretHex = getSetting(db, 'server_secret') as string;
|
||||
const sig = createHmac('sha256', Buffer.from(secretHex, 'hex')).update(payload).digest('hex');
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
it('cookie émis puis vérifié → même tokenId', () => {
|
||||
const cookie = auth.issueCookie(ctx);
|
||||
const back = auth.verifyCookie(cookie);
|
||||
expect(back).toEqual({ tokenId: ctx.tokenId, label: 'initial' });
|
||||
});
|
||||
|
||||
it('le secret persiste : un second AuthService sur la même base vérifie le cookie', () => {
|
||||
const cookie = auth.issueCookie(ctx);
|
||||
const auth2 = new AuthService(db);
|
||||
expect(auth2.verifyCookie(cookie)?.tokenId).toBe(ctx.tokenId);
|
||||
});
|
||||
|
||||
it('cookie altéré (signature ou payload) → null', () => {
|
||||
const cookie = auth.issueCookie(ctx);
|
||||
// un caractère de la signature inversé
|
||||
const last = cookie.slice(-1);
|
||||
const tamperedSig = cookie.slice(0, -1) + (last === 'a' ? 'b' : 'a');
|
||||
expect(auth.verifyCookie(tamperedSig)).toBeNull();
|
||||
// expiration repoussée dans le payload sans re-signer
|
||||
const lastDot = cookie.lastIndexOf('.');
|
||||
const [tokenId, expires] = cookie.slice(0, lastDot).split('.') as [string, string];
|
||||
const inflated = `${tokenId}.${Number(expires) + 1_000_000}.${cookie.slice(lastDot + 1)}`;
|
||||
expect(auth.verifyCookie(inflated)).toBeNull();
|
||||
});
|
||||
|
||||
it('cookie malformé ou absent → null', () => {
|
||||
expect(auth.verifyCookie(undefined)).toBeNull();
|
||||
expect(auth.verifyCookie('')).toBeNull();
|
||||
expect(auth.verifyCookie('garbage')).toBeNull();
|
||||
expect(auth.verifyCookie('.sig')).toBeNull();
|
||||
expect(auth.verifyCookie('a.b.c.d')).toBeNull();
|
||||
});
|
||||
|
||||
it('cookie expiré forgé avec le vrai secret → null (la forge non expirée passe, preuve que seule l’expiration rejette)', () => {
|
||||
const fresh = forge(`${ctx.tokenId}.${Date.now() + 10_000}`);
|
||||
expect(auth.verifyCookie(fresh)?.tokenId).toBe(ctx.tokenId);
|
||||
const expired = forge(`${ctx.tokenId}.${Date.now() - 1}`);
|
||||
expect(auth.verifyCookie(expired)).toBeNull();
|
||||
});
|
||||
|
||||
it('cookie forgé avec tokenId vide → null', () => {
|
||||
expect(auth.verifyCookie(forge(`.${Date.now() + 10_000}`))).toBeNull();
|
||||
});
|
||||
|
||||
it('cookie forgé avec expiration non numérique → null', () => {
|
||||
expect(auth.verifyCookie(forge(`${ctx.tokenId}.notanumber`))).toBeNull();
|
||||
});
|
||||
|
||||
it('cookie valide mais token révoqué entre-temps → null', () => {
|
||||
const cookie = auth.issueCookie(ctx);
|
||||
db.prepare('UPDATE auth_tokens SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), ctx.tokenId);
|
||||
expect(auth.verifyCookie(cookie)).toBeNull();
|
||||
});
|
||||
|
||||
it('cookie signé pour un tokenId inexistant → null', () => {
|
||||
const cookie = forge(`00000000-0000-0000-0000-000000000000.${Date.now() + 10_000}`);
|
||||
expect(auth.verifyCookie(cookie)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoginRateLimiter', () => {
|
||||
it('5 tentatives par minute glissante, puis délai renvoyé', () => {
|
||||
let t = 0;
|
||||
const lim = new LoginRateLimiter(5, () => t);
|
||||
for (let i = 0; i < 5; i++) expect(lim.check()).toBeNull();
|
||||
expect(lim.check()).toBe(60_000); // toutes les tentatives à t=0
|
||||
t = 59_999;
|
||||
expect(lim.check()).toBe(1); // il reste 1 ms avant que la fenêtre glisse
|
||||
t = 60_000;
|
||||
expect(lim.check()).toBeNull(); // fenêtre vidée
|
||||
});
|
||||
|
||||
it('la fenêtre glisse tentative par tentative', () => {
|
||||
let t = 0;
|
||||
const lim = new LoginRateLimiter(2, () => t);
|
||||
expect(lim.check()).toBeNull(); // t=0
|
||||
t = 30_000;
|
||||
expect(lim.check()).toBeNull(); // t=30s
|
||||
t = 40_000;
|
||||
expect(lim.check()).toBe(20_000); // bloqué par la tentative t=0
|
||||
t = 60_000;
|
||||
expect(lim.check()).toBeNull(); // t=0 expirée, t=30s reste
|
||||
});
|
||||
|
||||
it('backoff exponentiel à partir de 3 échecs consécutifs', () => {
|
||||
let t = 0;
|
||||
const lim = new LoginRateLimiter(1000, () => t); // plafond haut pour isoler le backoff
|
||||
lim.recordFailure();
|
||||
lim.recordFailure();
|
||||
expect(lim.check()).toBeNull(); // 2 échecs : pas encore de lock
|
||||
lim.recordFailure(); // 3e échec → 30 s
|
||||
expect(lim.check()).toBe(30_000);
|
||||
t = 29_999;
|
||||
expect(lim.check()).toBe(1);
|
||||
t = 30_000;
|
||||
expect(lim.check()).toBeNull(); // lock levé
|
||||
lim.recordFailure(); // 4e échec → 60 s
|
||||
expect(lim.check()).toBe(60_000);
|
||||
t = 90_000;
|
||||
lim.recordFailure(); // 5e échec → 120 s
|
||||
expect(lim.check()).toBe(120_000);
|
||||
});
|
||||
|
||||
it('le backoff est plafonné à 30 minutes', () => {
|
||||
let t = 0;
|
||||
const lim = new LoginRateLimiter(1000, () => t);
|
||||
for (let i = 0; i < 20; i++) lim.recordFailure();
|
||||
expect(lim.check()).toBe(30 * 60_000);
|
||||
});
|
||||
|
||||
it('reset complet au succès', () => {
|
||||
let t = 0;
|
||||
const lim = new LoginRateLimiter(1000, () => t);
|
||||
for (let i = 0; i < 5; i++) lim.recordFailure();
|
||||
expect(lim.check()).not.toBeNull(); // verrouillé
|
||||
lim.recordSuccess();
|
||||
expect(lim.check()).toBeNull(); // déverrouillé immédiatement
|
||||
lim.recordFailure();
|
||||
lim.recordFailure();
|
||||
expect(lim.check()).toBeNull(); // le compteur d'échecs consécutifs est bien reparti de zéro
|
||||
});
|
||||
});
|
||||
416
packages/server/test/pty-manager.test.ts
Normal file
416
packages/server/test/pty-manager.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { tmpdir } from 'node:os';
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
||||
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
|
||||
import { openDb, type Db } from '../src/db/index.js';
|
||||
|
||||
interface FakePty {
|
||||
pid: number;
|
||||
file: string;
|
||||
args: string[];
|
||||
opts: { cwd: string; cols: number; rows: number };
|
||||
write: Mock;
|
||||
resize: Mock;
|
||||
pause: Mock;
|
||||
resume: Mock;
|
||||
emitData(data: string): void;
|
||||
emitExit(exitCode: number, signal?: number): void;
|
||||
}
|
||||
|
||||
const ptyMock = vi.hoisted(() => ({ instances: [] as unknown[] }));
|
||||
|
||||
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
||||
let nextPid = 100_000;
|
||||
class FakePtyImpl {
|
||||
pid = nextPid++;
|
||||
write = vi.fn();
|
||||
resize = vi.fn();
|
||||
pause = vi.fn();
|
||||
resume = vi.fn();
|
||||
kill = vi.fn();
|
||||
private dataCbs: Array<(d: string) => void> = [];
|
||||
private exitCbs: Array<(e: { exitCode: number; signal?: number }) => void> = [];
|
||||
constructor(
|
||||
readonly file: string,
|
||||
readonly args: string[],
|
||||
readonly opts: unknown,
|
||||
) {}
|
||||
onData(cb: (d: string) => void): { dispose: () => void } {
|
||||
this.dataCbs.push(cb);
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
onExit(cb: (e: { exitCode: number; signal?: number }) => void): { dispose: () => void } {
|
||||
this.exitCbs.push(cb);
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
emitData(d: string): void {
|
||||
for (const cb of this.dataCbs) cb(d);
|
||||
}
|
||||
emitExit(exitCode: number, signal?: number): void {
|
||||
for (const cb of this.exitCbs) cb(signal === undefined ? { exitCode } : { exitCode, signal });
|
||||
}
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
spawn: (file: string, args: string[], opts: unknown): FakePtyImpl => {
|
||||
const p = new FakePtyImpl(file, args, opts);
|
||||
ptyMock.instances.push(p);
|
||||
return p;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type BindingSpies = ClientBinding & {
|
||||
sendOutput: Mock<(payload: Buffer) => void>;
|
||||
sendResync: Mock<(payload: Buffer) => void>;
|
||||
onDetached: Mock<(reason: 'session_exit' | 'replaced') => void>;
|
||||
onControlChanged: Mock<(controlling: boolean) => void>;
|
||||
};
|
||||
|
||||
let channelSeq = 1;
|
||||
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
|
||||
return {
|
||||
channel: channelSeq++,
|
||||
mode,
|
||||
controlling: false,
|
||||
sentBytes: 0,
|
||||
ackedBytes: 0,
|
||||
lagging: false,
|
||||
sendOutput: vi.fn(),
|
||||
sendResync: vi.fn(),
|
||||
onDetached: vi.fn(),
|
||||
onControlChanged: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function lastPty(): FakePty {
|
||||
return ptyMock.instances.at(-1) as FakePty;
|
||||
}
|
||||
|
||||
describe('PtyManager (pty mocké)', () => {
|
||||
let db: Db;
|
||||
let manager: PtyManager;
|
||||
const cwd = tmpdir();
|
||||
|
||||
beforeEach(() => {
|
||||
ptyMock.instances.length = 0;
|
||||
db = openDb(':memory:');
|
||||
manager = new PtyManager(db);
|
||||
});
|
||||
|
||||
function spawnBash(): { summary: SessionSummary; pty: FakePty } {
|
||||
const summary = manager.spawn({ cwd, command: 'bash' });
|
||||
return { summary, pty: lastPty() };
|
||||
}
|
||||
|
||||
describe('spawn', () => {
|
||||
it('émet session_update, insère en base et lance le pty avec le bon spec', () => {
|
||||
const updates: SessionSummary[] = [];
|
||||
manager.on('session_update', (s) => updates.push(s));
|
||||
const { summary, pty } = spawnBash();
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]).toEqual(summary);
|
||||
expect(summary.status).toBe('running');
|
||||
expect(summary.live).toBe(true);
|
||||
expect(summary.clients).toBe(0);
|
||||
expect(summary.command).toBe('bash');
|
||||
|
||||
expect(pty.file).toBe('bash');
|
||||
expect(pty.args).toEqual(['--norc']);
|
||||
expect(pty.opts.cwd).toBe(cwd);
|
||||
expect(pty.opts.cols).toBe(120);
|
||||
expect(pty.opts.rows).toBe(32);
|
||||
|
||||
const row = db.prepare('SELECT cwd, command FROM sessions WHERE id = ?').get(summary.id) as { cwd: string; command: string };
|
||||
expect(row).toEqual({ cwd, command: 'bash' });
|
||||
expect(manager.get(summary.id)?.id).toBe(summary.id);
|
||||
});
|
||||
|
||||
it('cwd inexistant → erreur statusCode 400, rien en base', () => {
|
||||
expect(() => manager.spawn({ cwd: '/definitely/not/a/dir', command: 'bash' })).toThrowError(
|
||||
expect.objectContaining({ statusCode: 400 }),
|
||||
);
|
||||
const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number };
|
||||
expect(row.n).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attach', () => {
|
||||
it('session inconnue → NOT_FOUND', () => {
|
||||
const res = manager.attach('nope', makeBinding('interactive'), 80, 24);
|
||||
expect(res).toEqual({ ok: false, code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('resync à l’attach = queue du ring (REPLAY_TAIL_BYTES max)', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const chunks = ['A', 'B', 'C'].map((c) => c.repeat(100 * 1024));
|
||||
for (const c of chunks) pty.emitData(c);
|
||||
|
||||
const b = makeBinding('interactive');
|
||||
const res = manager.attach(summary.id, b, 80, 24);
|
||||
expect(res).toEqual({ ok: true, controlling: true });
|
||||
|
||||
expect(b.sendResync).toHaveBeenCalledTimes(1);
|
||||
const payload = b.sendResync.mock.calls[0]![0]!;
|
||||
const full = Buffer.from(chunks.join(''), 'ascii');
|
||||
expect(payload.length).toBe(REPLAY_TAIL_BYTES);
|
||||
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
|
||||
// compteurs de flow control remis à zéro après le resync initial
|
||||
expect(b.sentBytes).toBe(0);
|
||||
expect(b.ackedBytes).toBe(0);
|
||||
});
|
||||
|
||||
it('ring vide → resync avec payload vide', () => {
|
||||
const { summary } = spawnBash();
|
||||
const b = makeBinding('interactive');
|
||||
manager.attach(summary.id, b, 80, 24);
|
||||
expect(b.sendResync).toHaveBeenCalledTimes(1);
|
||||
expect(b.sendResync.mock.calls[0]![0]!.length).toBe(0);
|
||||
});
|
||||
|
||||
it('contrôle au premier interactif seulement, les observers ne comptent pas', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const obs = makeBinding('observer');
|
||||
const a = makeBinding('interactive');
|
||||
const b = makeBinding('interactive');
|
||||
|
||||
expect(manager.attach(summary.id, obs, 80, 24)).toEqual({ ok: true, controlling: false });
|
||||
expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas
|
||||
|
||||
expect(manager.attach(summary.id, a, 100, 30)).toEqual({ ok: true, controlling: true });
|
||||
expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille
|
||||
|
||||
expect(manager.attach(summary.id, b, 200, 50)).toEqual({ ok: true, controlling: false });
|
||||
expect(pty.resize).toHaveBeenCalledTimes(1); // pas de resize pour le non-contrôleur
|
||||
expect(manager.get(summary.id)?.clients).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('write / resize', () => {
|
||||
it('observer read-only : write refusé', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const obs = makeBinding('observer');
|
||||
manager.attach(summary.id, obs, 80, 24);
|
||||
expect(manager.write(summary.id, obs, 'ls\r')).toBe('not_controlling');
|
||||
expect(pty.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tout interactif peut écrire (même non contrôleur), session inconnue → gone', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const b = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, b, 80, 24);
|
||||
expect(manager.write(summary.id, a, 'echo a\r')).toBe('ok');
|
||||
expect(manager.write(summary.id, b, 'echo b\r')).toBe('ok');
|
||||
expect(pty.write).toHaveBeenCalledTimes(2);
|
||||
expect(manager.write('nope', a, 'x')).toBe('gone');
|
||||
});
|
||||
|
||||
it('resize ignoré pour les non-contrôleurs', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const b = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, b, 80, 24);
|
||||
pty.resize.mockClear();
|
||||
manager.resize(summary.id, b, 50, 20);
|
||||
expect(pty.resize).not.toHaveBeenCalled();
|
||||
manager.resize(summary.id, a, 90, 28);
|
||||
expect(pty.resize).toHaveBeenCalledWith(90, 28);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flow control', () => {
|
||||
it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const b = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, b, 80, 24);
|
||||
|
||||
const chunk = 'x'.repeat(200 * 1024); // 200 Kio ASCII
|
||||
pty.emitData(chunk); // a=200K, b=200K — sous HIGH (384K)
|
||||
expect(pty.pause).not.toHaveBeenCalled();
|
||||
|
||||
manager.ack(summary.id, b, 200 * 1024); // b rattrape tout
|
||||
pty.emitData(chunk); // a=400K > HIGH, b=200K < HIGH
|
||||
expect(pty.pause).not.toHaveBeenCalled(); // un seul interactif au-dessus ne suffit pas
|
||||
|
||||
pty.emitData(chunk); // a=600K, b=400K → TOUS au-dessus de HIGH
|
||||
expect(a.sentBytes - a.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
|
||||
expect(b.sentBytes - b.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
|
||||
expect(pty.pause).toHaveBeenCalledTimes(1);
|
||||
expect(pty.resume).not.toHaveBeenCalled();
|
||||
|
||||
manager.ack(summary.id, b, 600 * 1024); // outstanding b = 0 < LOW
|
||||
expect(pty.resume).toHaveBeenCalledTimes(1);
|
||||
expect(pty.pause).toHaveBeenCalledTimes(1); // pas de re-pause intempestive
|
||||
});
|
||||
|
||||
it('les observers ne freinent jamais le PTY', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const obs = makeBinding('observer');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, obs, 80, 24);
|
||||
|
||||
const chunk = 'y'.repeat(200 * 1024);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
pty.emitData(chunk);
|
||||
manager.ack(summary.id, a, a.sentBytes); // l'interactif suit le rythme
|
||||
}
|
||||
expect(obs.sentBytes - obs.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
|
||||
expect(pty.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('session sans interactif (observers seuls) : jamais de pause', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const obs = makeBinding('observer');
|
||||
manager.attach(summary.id, obs, 80, 24);
|
||||
pty.emitData('z'.repeat(500 * 1024));
|
||||
expect(pty.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('client lagging', () => {
|
||||
it('> LAGGING_BYTES sans ACK → flux coupé, puis resync au rattrapage', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
|
||||
const mib = 1024 * 1024;
|
||||
const chunks = ['A', 'B', 'C', 'D'].map((c) => c.repeat(mib));
|
||||
|
||||
pty.emitData(chunks[0]!); // outstanding 1 Mio
|
||||
pty.emitData(chunks[1]!); // outstanding 2 Mio — pas strictement > LAGGING_BYTES
|
||||
expect(a.lagging).toBe(false);
|
||||
expect(a.sendOutput).toHaveBeenCalledTimes(2);
|
||||
|
||||
pty.emitData(chunks[2]!); // outstanding 3 Mio > 2 Mio → lagging
|
||||
expect(a.lagging).toBe(true);
|
||||
expect(a.sendOutput).toHaveBeenCalledTimes(3); // ce chunk est parti avant le marquage
|
||||
// le seul interactif est lagging : le PTY ne doit pas rester en pause
|
||||
expect(pty.resume).toHaveBeenCalled();
|
||||
|
||||
pty.emitData(chunks[3]!); // flux coupé pour le client lagging
|
||||
expect(a.sendOutput).toHaveBeenCalledTimes(3);
|
||||
|
||||
manager.ack(summary.id, a, 3 * mib); // rattrapage : outstanding 0 < LOW
|
||||
expect(a.lagging).toBe(false);
|
||||
expect(a.sendResync).toHaveBeenCalledTimes(2); // attach + rattrapage
|
||||
const payload = a.sendResync.mock.calls[1]![0]!;
|
||||
const full = Buffer.from(chunks.join(''), 'ascii');
|
||||
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
|
||||
expect(a.sentBytes).toBe(0);
|
||||
expect(a.ackedBytes).toBe(0);
|
||||
|
||||
// le flux reprend normalement après le resync
|
||||
pty.emitData('tail');
|
||||
expect(a.sendOutput).toHaveBeenCalledTimes(4);
|
||||
expect(a.sendOutput.mock.calls[3]![0]!.toString('ascii')).toBe('tail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detach et transfert de contrôle', () => {
|
||||
it('detach du contrôleur → transfert au prochain interactif (onControlChanged)', () => {
|
||||
const { summary } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const b = makeBinding('interactive');
|
||||
const obs = makeBinding('observer');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, obs, 80, 24);
|
||||
manager.attach(summary.id, b, 80, 24);
|
||||
expect(a.controlling).toBe(true);
|
||||
expect(b.controlling).toBe(false);
|
||||
|
||||
manager.detach(summary.id, a);
|
||||
expect(b.controlling).toBe(true);
|
||||
expect(b.onControlChanged).toHaveBeenCalledExactlyOnceWith(true);
|
||||
expect(obs.onControlChanged).not.toHaveBeenCalled();
|
||||
expect(manager.get(summary.id)?.clients).toBe(2);
|
||||
});
|
||||
|
||||
it('detach d’un non-contrôleur : aucun transfert', () => {
|
||||
const { summary } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const b = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, b, 80, 24);
|
||||
manager.detach(summary.id, b);
|
||||
expect(a.controlling).toBe(true);
|
||||
expect(a.onControlChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('detach du dernier interactif : un observer ne récupère jamais le contrôle', () => {
|
||||
const { summary } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const obs = makeBinding('observer');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, obs, 80, 24);
|
||||
manager.detach(summary.id, a);
|
||||
expect(obs.controlling).toBe(false);
|
||||
expect(obs.onControlChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('un nouvel interactif après detach du contrôleur reprend le contrôle', () => {
|
||||
const { summary } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.detach(summary.id, a);
|
||||
const b = makeBinding('interactive');
|
||||
expect(manager.attach(summary.id, b, 80, 24)).toEqual({ ok: true, controlling: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('exit', () => {
|
||||
it('onDetached(session_exit) pour tous, DB mise à jour, événements émis', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
const obs = makeBinding('observer');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
manager.attach(summary.id, obs, 80, 24);
|
||||
|
||||
const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = [];
|
||||
const updates: SessionSummary[] = [];
|
||||
manager.on('session_exit', (e) => exits.push(e));
|
||||
manager.on('session_update', (s) => updates.push(s));
|
||||
|
||||
pty.emitExit(0);
|
||||
|
||||
expect(a.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit');
|
||||
expect(obs.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit');
|
||||
|
||||
expect(exits).toEqual([{ sessionId: summary.id, exitCode: 0, signal: null }]);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]).toMatchObject({ id: summary.id, status: 'exited', live: false, exitCode: 0 });
|
||||
|
||||
const row = db.prepare('SELECT ended_at, exit_code FROM sessions WHERE id = ?').get(summary.id) as { ended_at: string | null; exit_code: number | null };
|
||||
expect(row.ended_at).not.toBeNull();
|
||||
expect(row.exit_code).toBe(0);
|
||||
|
||||
expect(manager.get(summary.id)).toBeNull();
|
||||
const listed = manager.list().find((s) => s.id === summary.id);
|
||||
expect(listed).toMatchObject({ status: 'exited', live: false, exitCode: 0 });
|
||||
});
|
||||
|
||||
it('exit avec signal : propagé dans session_exit', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = [];
|
||||
manager.on('session_exit', (e) => exits.push(e));
|
||||
pty.emitExit(1, 15);
|
||||
expect(exits).toEqual([{ sessionId: summary.id, exitCode: 1, signal: 15 }]);
|
||||
});
|
||||
|
||||
it('après exit : write → gone, attach → NOT_FOUND', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const a = makeBinding('interactive');
|
||||
manager.attach(summary.id, a, 80, 24);
|
||||
pty.emitExit(0);
|
||||
expect(manager.write(summary.id, a, 'x')).toBe('gone');
|
||||
expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
});
|
||||
247
packages/server/test/ring-buffer.test.ts
Normal file
247
packages/server/test/ring-buffer.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RingBuffer } from '../src/core/ring-buffer.js';
|
||||
|
||||
// Modèle de référence naïf : garde tout le flux et recalcule la fenêtre à la demande
|
||||
class RefBuffer {
|
||||
private data = Buffer.alloc(0);
|
||||
constructor(readonly capacity: number) {}
|
||||
write(chunk: Buffer): void {
|
||||
this.data = Buffer.concat([this.data, chunk]);
|
||||
}
|
||||
get total(): number {
|
||||
return this.data.length;
|
||||
}
|
||||
get windowStart(): number {
|
||||
return Math.max(0, this.data.length - this.capacity);
|
||||
}
|
||||
tail(count: number): Buffer {
|
||||
const window = this.data.subarray(this.windowStart);
|
||||
return Buffer.from(window.subarray(Math.max(0, window.length - count)));
|
||||
}
|
||||
readFrom(offset: number): Buffer | null {
|
||||
if (offset > this.total) return null;
|
||||
if (offset === this.total) return Buffer.alloc(0);
|
||||
if (offset < this.windowStart) return null;
|
||||
return Buffer.from(this.data.subarray(offset));
|
||||
}
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const buf = (s: string): Buffer => Buffer.from(s, 'ascii');
|
||||
|
||||
describe('RingBuffer — écriture simple', () => {
|
||||
it('écrit puis relit sans wrap', () => {
|
||||
const ring = new RingBuffer(16);
|
||||
ring.write(buf('hello'));
|
||||
expect(ring.totalOffset).toBe(5);
|
||||
expect(ring.windowStart).toBe(0);
|
||||
expect(ring.tail(5).toString()).toBe('hello');
|
||||
expect(ring.tail(3).toString()).toBe('llo');
|
||||
expect(ring.tail(100).toString()).toBe('hello'); // borné à la fenêtre
|
||||
expect(ring.tail(0).length).toBe(0);
|
||||
});
|
||||
|
||||
it('écritures successives sans wrap', () => {
|
||||
const ring = new RingBuffer(16);
|
||||
ring.write(buf('abc'));
|
||||
ring.write(buf('def'));
|
||||
expect(ring.totalOffset).toBe(6);
|
||||
expect(ring.tail(6).toString()).toBe('abcdef');
|
||||
});
|
||||
|
||||
it('chunk vide : no-op', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('ab'));
|
||||
ring.write(Buffer.alloc(0));
|
||||
expect(ring.totalOffset).toBe(2);
|
||||
expect(ring.tail(8).toString()).toBe('ab');
|
||||
});
|
||||
|
||||
it('buffer vierge : tail vide, readFrom(0) vide', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
expect(ring.totalOffset).toBe(0);
|
||||
expect(ring.tail(8).length).toBe(0);
|
||||
expect(ring.readFrom(0)?.length).toBe(0);
|
||||
expect(ring.readFrom(1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — wrap-around', () => {
|
||||
it('wrap simple : le chunk chevauche la frontière du buffer', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('01234')); // pos 0..5
|
||||
ring.write(buf('56789')); // 3 octets en queue, 2 au début
|
||||
expect(ring.totalOffset).toBe(10);
|
||||
expect(ring.windowStart).toBe(2);
|
||||
expect(ring.tail(8).toString()).toBe('23456789');
|
||||
expect(ring.tail(4).toString()).toBe('6789');
|
||||
});
|
||||
|
||||
it('wrap-around multiple : la fenêtre reste les N derniers octets', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
let stream = '';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const chunk = String.fromCharCode(97 + i).repeat(3); // 'aaa', 'bbb'…
|
||||
ring.write(buf(chunk));
|
||||
stream += chunk;
|
||||
expect(ring.totalOffset).toBe(stream.length);
|
||||
expect(ring.tail(8).toString()).toBe(stream.slice(Math.max(0, stream.length - 8)));
|
||||
}
|
||||
expect(ring.windowStart).toBe(30 - 8);
|
||||
});
|
||||
|
||||
it('écriture qui finit exactement sur la frontière (tailSpace == chunk.length)', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('abcd'));
|
||||
ring.write(buf('efgh')); // remplit exactement jusqu'à la frontière
|
||||
expect(ring.totalOffset).toBe(8);
|
||||
expect(ring.tail(8).toString()).toBe('abcdefgh');
|
||||
ring.write(buf('ij')); // repart de la position 0
|
||||
expect(ring.tail(8).toString()).toBe('cdefghij');
|
||||
});
|
||||
|
||||
it('total multiple exact de la capacité (end === 0)', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('abcdefgh'));
|
||||
ring.write(buf('ijklmnop'));
|
||||
expect(ring.totalOffset).toBe(16);
|
||||
expect(ring.tail(8).toString()).toBe('ijklmnop');
|
||||
expect(ring.tail(3).toString()).toBe('nop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — chunk >= capacité', () => {
|
||||
it('chunk == capacité sur un ring vierge', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('abcdefgh'));
|
||||
expect(ring.totalOffset).toBe(8);
|
||||
expect(ring.windowStart).toBe(0);
|
||||
expect(ring.tail(8).toString()).toBe('abcdefgh');
|
||||
});
|
||||
|
||||
it('chunk == 2x capacité sur un ring vierge (total reste aligné)', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('0123456789abcdef'));
|
||||
expect(ring.totalOffset).toBe(16);
|
||||
expect(ring.windowStart).toBe(8);
|
||||
expect(ring.tail(8).toString()).toBe('89abcdef');
|
||||
expect(ring.readFrom(8)?.toString()).toBe('89abcdef');
|
||||
expect(ring.readFrom(7)).toBeNull();
|
||||
});
|
||||
|
||||
// Invariant : l'octet k du flux vit à la position k % capacity, même quand un
|
||||
// chunk dépasse la capacité (les octets sautés comptent dans l'offset).
|
||||
it('chunk > capacité non aligné : la fenêtre reste ordonnée', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('0123456789')); // 10 octets, total final 10 % 8 != 0
|
||||
expect(ring.totalOffset).toBe(10);
|
||||
expect(ring.tail(8).toString()).toBe('23456789');
|
||||
});
|
||||
|
||||
it('chunk == capacité après un préfixe non aligné : fenêtre ordonnée', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('abc'));
|
||||
ring.write(buf('ABCDEFGH'));
|
||||
expect(ring.tail(8).toString()).toBe('ABCDEFGH');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — readFrom', () => {
|
||||
function filled(): RingBuffer {
|
||||
// total = 10, capacité 8 → fenêtre = offsets [2, 10)
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('01234'));
|
||||
ring.write(buf('56789'));
|
||||
return ring;
|
||||
}
|
||||
|
||||
it('offset dans la fenêtre', () => {
|
||||
const ring = filled();
|
||||
expect(ring.readFrom(5)?.toString()).toBe('56789');
|
||||
expect(ring.readFrom(9)?.toString()).toBe('9');
|
||||
});
|
||||
|
||||
it('offset == windowStart : toute la fenêtre', () => {
|
||||
const ring = filled();
|
||||
expect(ring.readFrom(ring.windowStart)?.toString()).toBe('23456789');
|
||||
});
|
||||
|
||||
it('offset == total : buffer vide (rien de nouveau)', () => {
|
||||
const ring = filled();
|
||||
const out = ring.readFrom(10);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out?.length).toBe(0);
|
||||
});
|
||||
|
||||
it('offset sorti de fenêtre ou au-delà du total → null', () => {
|
||||
const ring = filled();
|
||||
expect(ring.readFrom(1)).toBeNull(); // windowStart - 1
|
||||
expect(ring.readFrom(0)).toBeNull();
|
||||
expect(ring.readFrom(-1)).toBeNull();
|
||||
expect(ring.readFrom(11)).toBeNull(); // total + 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — totalOffset monotone', () => {
|
||||
it('croît exactement de la taille de chaque chunk, jamais ne décroît', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
const rand = mulberry32(0x5eed);
|
||||
let expected = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const size = Math.floor(rand() * 7); // 0..6 (< capacité)
|
||||
ring.write(Buffer.alloc(size, 65 + (i % 26)));
|
||||
expected += size;
|
||||
expect(ring.totalOffset).toBe(expected);
|
||||
expect(ring.windowStart).toBe(Math.max(0, expected - 8));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — équivalence avec le modèle de référence (property test)', () => {
|
||||
it('tail et readFrom identiques au modèle pour 500 écritures aléatoires (chunks < capacité)', () => {
|
||||
const capacity = 64;
|
||||
const ring = new RingBuffer(capacity);
|
||||
const ref = new RefBuffer(capacity);
|
||||
const rand = mulberry32(0xc0ffee);
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const size = Math.floor(rand() * capacity); // 0..63
|
||||
const chunk = Buffer.alloc(size);
|
||||
for (let j = 0; j < size; j++) chunk[j] = Math.floor(rand() * 256);
|
||||
ring.write(chunk);
|
||||
ref.write(chunk);
|
||||
|
||||
expect(ring.totalOffset).toBe(ref.total);
|
||||
expect(ring.windowStart).toBe(ref.windowStart);
|
||||
for (const n of [0, 1, 7, capacity - 1, capacity, capacity + 1, 500]) {
|
||||
expect(ring.tail(n).equals(ref.tail(n)), `tail(${n}) après ${i + 1} écritures`).toBe(true);
|
||||
}
|
||||
const offsets = [
|
||||
ref.windowStart - 1,
|
||||
ref.windowStart,
|
||||
ref.windowStart + Math.floor((ref.total - ref.windowStart) / 2),
|
||||
ref.total - 1,
|
||||
ref.total,
|
||||
ref.total + 1,
|
||||
];
|
||||
for (const off of offsets) {
|
||||
const a = ring.readFrom(off);
|
||||
const b = ref.readFrom(off);
|
||||
if (b === null) {
|
||||
expect(a, `readFrom(${off}) après ${i + 1} écritures`).toBeNull();
|
||||
} else {
|
||||
expect(a?.equals(b), `readFrom(${off}) après ${i + 1} écritures`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user