// tools/action-journal.test.mjs import { describe, it, expect } from 'vitest'; import { GENESIS_HASH, computeEntryHash, appendEntry, verifyChain, journalAppend, loadJournal, assertSafeSessionId, } from './action-journal.mjs'; import { verifyReceipt } from './receipt-sign.mjs'; const KEY = 'journal-test-key'; // N3-shared (2026-06-07 аудит M1-M4): единый экспортируемый guard формы sessionId, // общий для всех sibling-строителей пути машин (action-journal/judge-subrun-journal/ // plan-lock/enforce-supreme-gate). Та же форма /^[A-Za-z0-9_-]+$/ + throw (fail-closed). describe('assertSafeSessionId (общий guard формы sessionId)', () => { it('нормальный UUID-подобный id проходит и возвращается', () => { expect(assertSafeSessionId('253c4625-762f-4f3e-9e15-ae5a25e498ea')).toBe('253c4625-762f-4f3e-9e15-ae5a25e498ea'); expect(assertSafeSessionId('unknown')).toBe('unknown'); }); it('traversal/слэш/точка/обратный слэш/пусто/не-строка → throw', () => { expect(() => assertSafeSessionId('../evil')).toThrow(); expect(() => assertSafeSessionId('a/b')).toThrow(); expect(() => assertSafeSessionId('a.b')).toThrow(); expect(() => assertSafeSessionId('a\\b')).toThrow(); expect(() => assertSafeSessionId('')).toThrow(); expect(() => assertSafeSessionId(null)).toThrow(); expect(() => assertSafeSessionId(42)).toThrow(); }); }); describe('R-31: голова журнала подписана в своём домене (journal-head)', () => { it('домен-агностичная проверка отвергает, правильный домен принимает', () => { const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 }); const head = { seq: a.entries[0].seq, head_hash: a.entries[0].chain_hash, sig: a.headSig }; expect(verifyReceipt(head, KEY)).toBe(false); expect(verifyReceipt(head, KEY, 'journal-head')).toBe(true); }); }); describe('action-journal core', () => { it('GENESIS_HASH is 64 zeros', () => { expect(GENESIS_HASH).toBe('0'.repeat(64)); }); it('computeEntryHash is deterministic and 64-char hex', () => { const h = computeEntryHash(GENESIS_HASH, { action: 'Edit', file: 'a.mjs' }); expect(h).toMatch(/^[0-9a-f]{64}$/); expect(computeEntryHash(GENESIS_HASH, { action: 'Edit', file: 'a.mjs' })).toBe(h); }); it('appendEntry links to previous head and signs the head', () => { const e1 = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1000 }); expect(e1.entries).toHaveLength(1); expect(e1.entries[0].seq).toBe(1); expect(e1.entries[0].prev_hash).toBe(GENESIS_HASH); expect(e1.headSig).toMatch(/^[0-9a-f]{64}$/); const e2 = appendEntry(e1.entries, { action: 'B' }, { key: KEY, nowMs: 2000 }); expect(e2.entries).toHaveLength(2); expect(e2.entries[1].prev_hash).toBe(e1.entries[0].chain_hash); expect(e2.entries[1].seq).toBe(2); }); it('verifyChain ok for an intact, signed chain', () => { const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 }); const b = appendEntry(a.entries, { action: 'B' }, { key: KEY, nowMs: 2 }); expect(verifyChain(b.entries, b.headSig, { key: KEY })).toEqual({ ok: true, brokenAt: null }); }); it('фикс-B2: подмена ts или seq промежуточной записи ломает цепь (раньше проскакивала)', () => { const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 }); const b = appendEntry(a.entries, { action: 'B' }, { key: KEY, nowMs: 2 }); const tamperedTs = b.entries.map((e, i) => i === 0 ? { ...e, ts: 999999 } : e); expect(verifyChain(tamperedTs, b.headSig, { key: KEY }).ok).toBe(false); const tamperedSeq = b.entries.map((e, i) => i === 0 ? { ...e, seq: 99 } : e); expect(verifyChain(tamperedSeq, b.headSig, { key: KEY }).ok).toBe(false); }); it('verifyChain detects a tampered entry (rewrite history)', () => { const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 }); const b = appendEntry(a.entries, { action: 'B' }, { key: KEY, nowMs: 2 }); const tampered = b.entries.map((e, i) => i === 0 ? { ...e, payload: { action: 'EVIL' } } : e); const r = verifyChain(tampered, b.headSig, { key: KEY }); expect(r.ok).toBe(false); expect(r.brokenAt).toBe(1); }); it('verifyChain detects wholesale rewrite (recomputed chain, forged head) without key', () => { const a = appendEntry([], { action: 'EVIL' }, { key: 'attacker-key', nowMs: 1 }); const r = verifyChain(a.entries, a.headSig, { key: KEY }); expect(r.ok).toBe(false); }); it('verifyChain false when head signature missing', () => { const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 }); expect(verifyChain(a.entries, null, { key: KEY }).ok).toBe(false); }); }); function memFs() { const store = new Map(); return { store, readFileSync: (p) => { if (!store.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return store.get(String(p)); }, writeFileSync: (p, data) => { store.set(String(p), String(data)); }, appendFileSync: (p, data) => { store.set(String(p), (store.has(String(p)) ? store.get(String(p)) : '') + String(data)); }, existsSync: (p) => store.has(String(p)), }; } describe('journalAppend persistence', () => { const opts = (fs) => ({ key: KEY, sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }); it('writes a JSONL line and a head-sig file', () => { const fs = memFs(); const r = journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) }); expect(r.entry.seq).toBe(1); expect(fs.store.get('/rt/action-journal-S1.jsonl')).toContain('"action":"A"'); expect(fs.store.get('/rt/action-journal-S1.head')).toBe(r.headSig); }); it('appends a second entry linked to the first', () => { const fs = memFs(); journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) }); const r2 = journalAppend({ payload: { action: 'B' }, nowMs: 2, ...opts(fs) }); expect(r2.entry.seq).toBe(2); const loaded = loadJournal({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }); expect(loaded.entries).toHaveLength(2); expect(loaded.entries[1].prev_hash).toBe(loaded.entries[0].chain_hash); }); it('loadJournal returns empty for a missing file', () => { const fs = memFs(); expect(loadJournal({ sessionId: 'none', runtimeDir: '/rt', fsImpl: fs })).toEqual({ entries: [], headSig: null }); }); it('round-trip verifyChain on persisted-then-loaded journal', () => { const fs = memFs(); journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) }); const r2 = journalAppend({ payload: { action: 'B' }, nowMs: 2, ...opts(fs) }); const loaded = loadJournal({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }); expect(verifyChain(loaded.entries, r2.headSig, { key: KEY }).ok).toBe(true); }); }); // N2 (2026-06-07 аудит M1): verifyChain должен вырождаться в {ok:false}, а не бросать, // на структурно-битой записи (null / не-объект / массив). Сценарий: внешняя порча // .jsonl строкой `null` → loadJournal возвращает [null] БЕЗ исключения → verifyChain // раньше падал TypeError на e.prev_hash. Fail-closed value вместо краша. describe('N2: verifyChain fail-closed на битой записи (не бросает)', () => { it('одиночная null-запись → {ok:false}, без исключения', () => { expect(() => verifyChain([null], '0'.repeat(64), { key: KEY })).not.toThrow(); expect(verifyChain([null], '0'.repeat(64), { key: KEY }).ok).toBe(false); }); it('не-объектные записи (число / строка / массив) → {ok:false}, без исключения', () => { expect(verifyChain([123], '0'.repeat(64), { key: KEY }).ok).toBe(false); expect(verifyChain(['str'], '0'.repeat(64), { key: KEY }).ok).toBe(false); expect(verifyChain([[1, 2]], '0'.repeat(64), { key: KEY }).ok).toBe(false); }); it('null в середине валидной цепи → {ok:false}, без исключения', () => { const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 }); const b = appendEntry(a.entries, { action: 'B' }, { key: KEY, nowMs: 2 }); const withNull = [b.entries[0], null]; expect(() => verifyChain(withNull, b.headSig, { key: KEY })).not.toThrow(); expect(verifyChain(withNull, b.headSig, { key: KEY }).ok).toBe(false); }); }); // M1 (loose-ends C): journalAppend делает ДВА fs-вызова — appendFileSync(jsonl) + writeFileSync(head). // Они не атомарны: процесс может «умереть» между ними → jsonl получит новую запись, а head останется // на предыдущей. Реестр хвостов: «атомарность дозаписи журнала оставлена (fail-closed по конструкции, // чистого теста нет)». Здесь — характеризационный (pinning) тест этого fail-closed-инварианта. describe('M1: оборванная дозапись (entry в jsonl, head не обновлён) → verifyChain fail-closed', () => { const opts = (fs) => ({ key: KEY, sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }); it('jsonl получил entry3, а head остался на entry2 → {ok:false}, brokenAt=3', () => { const fs = memFs(); journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) }); journalAppend({ payload: { action: 'B' }, nowMs: 2, ...opts(fs) }); const headAfter2 = fs.store.get('/rt/action-journal-S1.head'); // Симуляция обрыва: дозаписываем entry3 в jsonl, но head НЕ трогаем (как если бы процесс // упал между appendFileSync и writeFileSync внутри journalAppend). const loaded2 = loadJournal({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }); const e3 = appendEntry(loaded2.entries, { action: 'C' }, { key: KEY, nowMs: 3 }); fs.store.set('/rt/action-journal-S1.jsonl', e3.entries.map((e) => JSON.stringify(e)).join('\n') + '\n'); const loaded3 = loadJournal({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }); expect(loaded3.entries).toHaveLength(3); expect(loaded3.headSig).toBe(headAfter2); // head всё ещё подписывает entry2 const v = verifyChain(loaded3.entries, loaded3.headSig, { key: KEY }); expect(v.ok).toBe(false); // голова цепи (entry3) не сходится с подписью head(entry2) expect(v.brokenAt).toBe(3); }); it('контроль: завершённая дозапись (entry3 + head3) → {ok:true}', () => { const fs = memFs(); journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) }); journalAppend({ payload: { action: 'B' }, nowMs: 2, ...opts(fs) }); journalAppend({ payload: { action: 'C' }, nowMs: 3, ...opts(fs) }); const loaded = loadJournal({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }); expect(verifyChain(loaded.entries, loaded.headSig, { key: KEY }).ok).toBe(true); }); }); // N3 (2026-06-07 аудит M1): paths() вклеивал sessionId в путь файла без проверки формы. // `..`/`/` → path traversal (журнал пишется/читается вне каталога). Guard /^[A-Za-z0-9_-]+$/ // → throw (fail-closed; в supreme-gate ловится внешним try/catch → block). describe('N3: sessionId path-injection guard', () => { it('journalAppend с traversal-sessionId бросает (не пишет вне каталога)', () => { const fs = memFs(); expect(() => journalAppend({ payload: { a: 1 }, key: KEY, sessionId: '../evil', runtimeDir: '/rt', nowMs: 1, fsImpl: fs })).toThrow(); expect(fs.store.size).toBe(0); }); it('loadJournal с traversal-sessionId бросает', () => { const fs = memFs(); expect(() => loadJournal({ sessionId: '../../etc/passwd', runtimeDir: '/rt', fsImpl: fs })).toThrow(); }); it('sessionId со слэшем/точкой/обратным слэшем бросает', () => { const fs = memFs(); expect(() => loadJournal({ sessionId: 'a/b', runtimeDir: '/rt', fsImpl: fs })).toThrow(); expect(() => loadJournal({ sessionId: 'a.b', runtimeDir: '/rt', fsImpl: fs })).toThrow(); expect(() => loadJournal({ sessionId: 'a\\b', runtimeDir: '/rt', fsImpl: fs })).toThrow(); }); it('нормальный UUID-подобный sessionId работает', () => { const fs = memFs(); const r = journalAppend({ payload: { a: 1 }, key: KEY, sessionId: '253c4625-762f-4f3e-9e15-ae5a25e498ea', runtimeDir: '/rt', nowMs: 1, fsImpl: fs }); expect(r.entry.seq).toBe(1); }); });