Files
brain/tools/action-journal.test.mjs
T

219 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
});