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 'answer': expect(isU32(msg.channel)).toBe(true); expect(['select', 'confirm', 'deny']).toContain(msg.action); if (msg.action === 'select') { expect(typeof msg.optionN).toBe('number'); expect(msg.optionN).toBeGreaterThanOrEqual(1); expect(msg.optionN).toBeLessThanOrEqual(99); } 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('answer select / confirm / deny', () => { expect(parseClientMessage('{"type":"answer","channel":2,"action":"select","optionN":3}')) .toEqual({ type: 'answer', channel: 2, action: 'select', optionN: 3 }); expect(parseClientMessage('{"type":"answer","channel":2,"action":"confirm"}')) .toEqual({ type: 'answer', channel: 2, action: 'confirm' }); expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny"}')) .toEqual({ type: 'answer', channel: 2, action: 'deny' }); // optionN n'est porté que par 'select' (ignoré pour confirm/deny) expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny","optionN":3}')) .toEqual({ type: 'answer', channel: 2, action: 'deny' }); }); 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/worktrees (et tableau vide accepté)', () => { expect(parseClientMessage('{"type":"sub","topics":["sessions"]}')).toEqual({ type: 'sub', topics: ['sessions'] }); expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees"]}')).toEqual({ type: 'sub', topics: ['sessions', 'worktrees'] }); 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 => 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('answer : action inconnue, optionN manquant/hors bornes, channel invalide', () => { expect(parseClientMessage('{"type":"answer","channel":1,"action":"select"}')).toBeNull(); // optionN requis expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":0}')).toBeNull(); expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":100}')).toBeNull(); expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":1.5}')).toBeNull(); expect(parseClientMessage('{"type":"answer","channel":1,"action":"nope"}')).toBeNull(); expect(parseClientMessage('{"type":"answer","channel":-1,"action":"deny"}')).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', 'answer', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null]; const values: unknown[] = [ undefined, null, true, false, 0, -1, 1, 1.5, 2, 99, 100, 999, 1000, 1001, 0xffffffff, 0x100000000, -0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', 'select', 'confirm', 'deny', {}, [], ['sessions'], ['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000), ]; const pick = (arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T; for (let i = 0; i < 1000; i++) { const obj: Record = { type: pick(types) }; for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics', 'action', 'optionN']) { 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 d’un 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]); }); });