108 lines
5.1 KiB
JavaScript
108 lines
5.1 KiB
JavaScript
// tools/action-journal.test.mjs
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
GENESIS_HASH, computeEntryHash, appendEntry, verifyChain,
|
|
journalAppend, loadJournal,
|
|
} from './action-journal.mjs';
|
|
|
|
const KEY = 'journal-test-key';
|
|
|
|
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);
|
|
});
|
|
});
|