test(action-journal): pin M1 fail-closed on torn append (entry without head update)
This commit is contained in:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user