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,241 @@
import { describe, expect, it } from 'vitest';
import {
BINARY_FRAME,
decodeBinaryFrame,
encodeBinaryFrame,
parseClientMessage,
type ClientMessage,
} from '../src/protocol.js';
// PRNG déterministe pour le fuzz (reproductible d'un run à l'autre)
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 isU32 = (v: number): boolean => Number.isInteger(v) && v >= 0 && v <= 0xffffffff;
const isDim = (v: number): boolean => Number.isInteger(v) && v >= 2 && v <= 1000;
// Invariants que tout message accepté DOIT respecter, quel que soit l'input
function assertInvariants(msg: ClientMessage): void {
switch (msg.type) {
case 'hello':
expect(typeof msg.protocol).toBe('number');
break;
case 'attach':
expect(typeof msg.sessionId).toBe('string');
expect(['interactive', 'observer']).toContain(msg.mode);
expect(isDim(msg.cols)).toBe(true);
expect(isDim(msg.rows)).toBe(true);
break;
case 'detach':
expect(isU32(msg.channel)).toBe(true);
break;
case 'stdin':
expect(isU32(msg.channel)).toBe(true);
expect(typeof msg.data).toBe('string');
expect(msg.data.length).toBeLessThanOrEqual(65536);
break;
case 'resize':
expect(isU32(msg.channel)).toBe(true);
expect(isDim(msg.cols)).toBe(true);
expect(isDim(msg.rows)).toBe(true);
break;
case 'ack':
expect(isU32(msg.channel)).toBe(true);
expect(typeof msg.bytes).toBe('number');
expect(msg.bytes).toBeGreaterThanOrEqual(0);
break;
case 'sub':
expect(msg.topics.every((t) => t === 'sessions')).toBe(true);
break;
case 'ping':
break;
}
}
describe('parseClientMessage — cas valides', () => {
it('hello', () => {
expect(parseClientMessage('{"type":"hello","protocol":1}')).toEqual({ type: 'hello', protocol: 1 });
});
it('attach avec dimensions aux bornes', () => {
const make = (cols: number, rows: number): string =>
JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols, rows });
expect(parseClientMessage(make(2, 2))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 2, rows: 2 });
expect(parseClientMessage(make(1000, 1000))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 1000, rows: 1000 });
});
it('attach en mode observer', () => {
expect(parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 })))
.toEqual({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 });
});
it('detach avec channel aux bornes u32', () => {
expect(parseClientMessage('{"type":"detach","channel":0}')).toEqual({ type: 'detach', channel: 0 });
expect(parseClientMessage(`{"type":"detach","channel":${0xffffffff}}`)).toEqual({ type: 'detach', channel: 0xffffffff });
});
it('stdin avec data à la taille max', () => {
const data = 'x'.repeat(65536);
expect(parseClientMessage(JSON.stringify({ type: 'stdin', channel: 1, data }))).toEqual({ type: 'stdin', channel: 1, data });
});
it('resize', () => {
expect(parseClientMessage('{"type":"resize","channel":3,"cols":120,"rows":32}'))
.toEqual({ type: 'resize', channel: 3, cols: 120, rows: 32 });
});
it('ack à zéro octet', () => {
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
});
it('sub avec topics sessions (et tableau vide accepté)', () => {
expect(parseClientMessage('{"type":"sub","topics":["sessions"]}')).toEqual({ type: 'sub', topics: ['sessions'] });
expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] });
});
it('ping (champs superflus ignorés)', () => {
expect(parseClientMessage('{"type":"ping","extra":42}')).toEqual({ type: 'ping' });
});
});
describe('parseClientMessage — cas malformés', () => {
it('JSON invalide ou non-objet', () => {
for (const raw of ['', 'not json', '{', '42', '"str"', 'null', 'true', '[]', '[1,2]']) {
expect(parseClientMessage(raw)).toBeNull();
}
});
it('type absent, non-string ou inconnu', () => {
expect(parseClientMessage('{}')).toBeNull();
expect(parseClientMessage('{"type":42}')).toBeNull();
expect(parseClientMessage('{"type":null}')).toBeNull();
expect(parseClientMessage('{"type":"unknown"}')).toBeNull();
});
it('hello sans protocol ou protocol non numérique', () => {
expect(parseClientMessage('{"type":"hello"}')).toBeNull();
expect(parseClientMessage('{"type":"hello","protocol":"1"}')).toBeNull();
});
it('attach : dimensions hors bornes ou non entières', () => {
const make = (over: Record<string, unknown>): string =>
JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 80, rows: 24, ...over });
expect(parseClientMessage(make({ cols: 1 }))).toBeNull();
expect(parseClientMessage(make({ cols: 1001 }))).toBeNull();
expect(parseClientMessage(make({ rows: 0 }))).toBeNull();
expect(parseClientMessage(make({ rows: 1001 }))).toBeNull();
expect(parseClientMessage(make({ cols: 80.5 }))).toBeNull();
expect(parseClientMessage(make({ cols: -80 }))).toBeNull();
expect(parseClientMessage(make({ mode: 'admin' }))).toBeNull();
expect(parseClientMessage(make({ sessionId: 42 }))).toBeNull();
});
it('channel négatif, flottant ou > u32', () => {
expect(parseClientMessage('{"type":"detach","channel":-1}')).toBeNull();
expect(parseClientMessage('{"type":"detach","channel":1.5}')).toBeNull();
expect(parseClientMessage(`{"type":"detach","channel":${0x100000000}}`)).toBeNull();
expect(parseClientMessage('{"type":"stdin","channel":-1,"data":"x"}')).toBeNull();
expect(parseClientMessage('{"type":"ack","channel":-1,"bytes":0}')).toBeNull();
});
it('stdin : data trop longue ou non-string', () => {
expect(parseClientMessage(JSON.stringify({ type: 'stdin', channel: 1, data: 'x'.repeat(65537) }))).toBeNull();
expect(parseClientMessage('{"type":"stdin","channel":1,"data":42}')).toBeNull();
expect(parseClientMessage('{"type":"stdin","channel":1}')).toBeNull();
});
it('ack : bytes négatif ou non numérique', () => {
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":-1}')).toBeNull();
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":"0"}')).toBeNull();
});
it('sub : topics inconnu ou non-tableau', () => {
expect(parseClientMessage('{"type":"sub","topics":["other"]}')).toBeNull();
expect(parseClientMessage('{"type":"sub","topics":["sessions","other"]}')).toBeNull();
expect(parseClientMessage('{"type":"sub","topics":"sessions"}')).toBeNull();
});
});
describe('parseClientMessage — fuzz rapide', () => {
it('ne lève jamais et tout message accepté respecte les invariants', () => {
const rand = mulberry32(0xa5b0e7);
const types = ['hello', 'attach', 'detach', 'stdin', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
const values: unknown[] = [
undefined, null, true, false, 0, -1, 1, 1.5, 2, 999, 1000, 1001, 0xffffffff, 0x100000000,
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', {}, [], ['sessions'],
['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000),
];
const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T;
for (let i = 0; i < 1000; i++) {
const obj: Record<string, unknown> = { type: pick(types) };
for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics']) {
if (rand() < 0.7) obj[key] = pick(values);
}
const raw = JSON.stringify(obj);
let msg: ClientMessage | null = null;
expect(() => {
msg = parseClientMessage(raw);
}).not.toThrow();
if (msg !== null) assertInvariants(msg);
}
});
});
describe('encode/decodeBinaryFrame', () => {
it('round-trip payload vide', () => {
const frame = encodeBinaryFrame(BINARY_FRAME.OUTPUT, 0, new Uint8Array(0));
expect(frame.byteLength).toBe(BINARY_FRAME.HEADER_BYTES);
const decoded = decodeBinaryFrame(frame);
expect(decoded.type).toBe(BINARY_FRAME.OUTPUT);
expect(decoded.channel).toBe(0);
expect(decoded.payload.byteLength).toBe(0);
});
it('round-trip gros payload (1 Mio) et channel max u32', () => {
const rand = mulberry32(0xdeadbe);
const payload = new Uint8Array(1024 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = Math.floor(rand() * 256);
const frame = encodeBinaryFrame(BINARY_FRAME.RESYNC, 0xffffffff, payload);
const decoded = decodeBinaryFrame(frame);
expect(decoded.type).toBe(BINARY_FRAME.RESYNC);
expect(decoded.channel).toBe(0xffffffff);
expect(Buffer.from(decoded.payload).equals(Buffer.from(payload))).toBe(true);
});
it('layout : type u8 puis channel u32 little-endian', () => {
const frame = encodeBinaryFrame(BINARY_FRAME.OUTPUT, 0x01020304, new Uint8Array([0xff]));
expect([...frame]).toEqual([0x01, 0x04, 0x03, 0x02, 0x01, 0xff]);
});
it('octets UTF-8 coupés en frontière de frame : le transport préserve les octets bruts', () => {
const text = Buffer.from('héllo 😀 wörld', 'utf8');
const cut = 8; // coupe au milieu d'une séquence multi-octets
const part1 = new Uint8Array(text.subarray(0, cut));
const part2 = new Uint8Array(text.subarray(cut));
const d1 = decodeBinaryFrame(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 7, part1));
const d2 = decodeBinaryFrame(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 7, part2));
const reassembled = Buffer.concat([d1.payload, d2.payload]);
expect(reassembled.equals(text)).toBe(true);
expect(reassembled.toString('utf8')).toBe('héllo 😀 wörld');
});
it('decode sur une vue avec byteOffset non nul (subarray dun buffer plus large)', () => {
const payload = new Uint8Array([1, 2, 3]);
const frame = encodeBinaryFrame(BINARY_FRAME.OUTPUT, 42, payload);
const padded = new Uint8Array(frame.byteLength + 11);
padded.set(frame, 11);
const view = padded.subarray(11);
const decoded = decodeBinaryFrame(view);
expect(decoded.type).toBe(BINARY_FRAME.OUTPUT);
expect(decoded.channel).toBe(42);
expect([...decoded.payload]).toEqual([1, 2, 3]);
});
});