test(action-journal): pin M1 fail-closed on torn append (entry without head update)

This commit is contained in:
Дмитрий
2026-06-09 05:23:51 +03:00
parent 2b0c28e59f
commit 5782ede3eb
+33
View File
@@ -158,6 +158,39 @@ describe('N2: verifyChain fail-closed на битой записи (не бро
});
});
// 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).