Enregistrement de repos et gestion de leurs worktrees git, source de vérité = git (worktrees dérivés + cache court par repo), corrélation worktree ↔ sessions par cwd, mutations sérialisées par repo. - shared: RepoSummary, PostCreateHook, WorktreeSummary, WorktreeGitStatus ; messages WS repo_update/worktree_update/*_removed ; topic sub 'worktrees' (+ parseClientMessage) ; DTOs REST repos/worktrees. - db: migration id:3 (table repos). - core/git.ts: couche git sûre (execFile, jamais de shell, -- avant chemins, GIT_OPTIONAL_LOCKS=0), parseWorktreePorcelain, list/add/remove/prune/status/ ahead-behind, validation branche + chemin. - core/claude-trust.ts: pré-trust atomique de ~/.claude.json (spike S3). - core/worktree-manager.ts: repos CRUD, create (worktree add + pré-trust + hooks post-create + startSession optionnel), adopt, delete (garde-fous 409 dirty / 400 main / 409 session live), prune ; events. - routes/repos.ts + routes/worktrees.ts, câblage app.ts, gateway topic worktrees. - tests: git (repos tmp réels), claude-trust, worktree-manager (146 verts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
243 lines
10 KiB
TypeScript
243 lines
10 KiB
TypeScript
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/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, 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 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]);
|
||
});
|
||
});
|