397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
219 lines
13 KiB
JavaScript
219 lines
13 KiB
JavaScript
// 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);
|
||
});
|
||
});
|