fed3c4f9b8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
372 lines
20 KiB
JavaScript
372 lines
20 KiB
JavaScript
// tools/plan-lock.test.mjs
|
||
import { describe, it, expect } from 'vitest';
|
||
import { freezePlan, verifyFrozenPlan, planId } from './plan-lock.mjs';
|
||
import { actionMatchesStep, nextStep } from './plan-lock.mjs';
|
||
import { saveFrozenPlan, loadFrozenPlan, removeFrozenPlan } from './plan-lock.mjs';
|
||
|
||
describe('freezePlan delivery', () => {
|
||
const KEY = 'k-deliv';
|
||
it('user-result попадает в подписанную печать и верифицируется', () => {
|
||
const p = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'user-result', key: KEY, nowMs: 1 });
|
||
expect(p.delivery).toBe('user-result');
|
||
expect(verifyFrozenPlan(p, KEY)).toBe(true);
|
||
});
|
||
it('internal (по умолчанию) НЕ добавляет поле — старые печати байт-идентичны', () => {
|
||
const a = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], key: KEY, nowMs: 1 });
|
||
const b = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'internal', key: KEY, nowMs: 1 });
|
||
expect('delivery' in a).toBe(false);
|
||
expect(a.sig).toBe(b.sig);
|
||
});
|
||
it('подмена delivery ломает подпись', () => {
|
||
const p = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'user-result', key: KEY, nowMs: 1 });
|
||
expect(verifyFrozenPlan({ ...p, delivery: 'internal' }, KEY)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('removeFrozenPlan (Фаза 5 — чистое завершение: стена снимает печать)', () => {
|
||
const fsWithUnlink = () => {
|
||
const s = new Map();
|
||
return {
|
||
s,
|
||
readFileSync: (p) => { if (!s.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return s.get(String(p)); },
|
||
writeFileSync: (p, d) => s.set(String(p), String(d)),
|
||
renameSync: (a, b) => { s.set(String(b), s.get(String(a))); s.delete(String(a)); },
|
||
unlinkSync: (p) => { if (!s.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } s.delete(String(p)); },
|
||
};
|
||
};
|
||
it('удаляет файл печати → loadFrozenPlan → null', () => {
|
||
const fs = fsWithUnlink();
|
||
saveFrozenPlan({ plan: { plan_id: 'x' }, sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
|
||
expect(loadFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs })).not.toBe(null);
|
||
removeFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
|
||
expect(loadFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs })).toBe(null);
|
||
});
|
||
it('нет файла → no-op (не бросает на ENOENT)', () => {
|
||
const fs = fsWithUnlink();
|
||
expect(() => removeFrozenPlan({ sessionId: 'none', runtimeDir: '/rt', fsImpl: fs })).not.toThrow();
|
||
});
|
||
it('traversal-sessionId бросает (path-guard)', () => {
|
||
expect(() => removeFrozenPlan({ sessionId: '../evil', runtimeDir: '/rt', fsImpl: fsWithUnlink() })).toThrow();
|
||
});
|
||
});
|
||
import { reconcileJournalToPlan } from './plan-lock.mjs';
|
||
import { freezeArtifact, verifyFrozenArtifact, artifactId } from './plan-lock.mjs';
|
||
import { saveFrozenArtifact, loadFrozenArtifact } from './plan-lock.mjs';
|
||
import { refResolves } from './plan-lock.mjs';
|
||
|
||
const KEY = 'plan-lock-test-key';
|
||
const STEPS = [
|
||
{ n: 1, op: 'Write', object: 'tools/foo.mjs', intent: 'создать модуль' },
|
||
{ n: 2, op: 'Bash', object: 'npx vitest run foo', intent: 'прогнать тест' },
|
||
];
|
||
const ART = { sections: { '§1': 'палитра teal', '§2': 'шрифт Inter' }, goal: 'лендинг' };
|
||
|
||
describe('доменное разделение печатей (R-31)', () => {
|
||
it('замороженный план НЕ проходит как артефакт, и наоборот', () => {
|
||
const plan = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
||
const art = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
|
||
expect(verifyFrozenPlan(plan, KEY)).toBe(true);
|
||
expect(verifyFrozenArtifact(art, KEY)).toBe(true);
|
||
expect(verifyFrozenArtifact(plan, KEY)).toBe(false); // печать плана ≠ печать артефакта
|
||
expect(verifyFrozenPlan(art, KEY)).toBe(false);
|
||
});
|
||
});
|
||
|
||
function memFs() {
|
||
const s = new Map();
|
||
return { s,
|
||
readFileSync: (p) => { if (!s.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return s.get(String(p)); },
|
||
writeFileSync: (p, d) => s.set(String(p), String(d)),
|
||
renameSync: (a, b) => { s.set(String(b), s.get(String(a))); s.delete(String(a)); } };
|
||
}
|
||
|
||
describe('freezePlan / verifyFrozenPlan', () => {
|
||
it('freezePlan returns a signed plan with stable plan_id', () => {
|
||
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1000 });
|
||
expect(p.plan_id).toMatch(/^[0-9a-f]{64}$/);
|
||
expect(p.frozen_at).toBe(1000);
|
||
expect(p.sig).toMatch(/^[0-9a-f]{64}$/);
|
||
expect(p.steps).toHaveLength(2);
|
||
});
|
||
it('plan_id is deterministic over steps content', () => {
|
||
expect(planId(STEPS)).toBe(planId(STEPS.map((s) => ({ ...s }))));
|
||
});
|
||
it('verifyFrozenPlan true for an intact signed plan', () => {
|
||
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
||
expect(verifyFrozenPlan(p, KEY)).toBe(true);
|
||
});
|
||
it('verifyFrozenPlan false when a step is tampered (re-seal required)', () => {
|
||
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
||
const tampered = { ...p, steps: [{ ...p.steps[0], object: 'tools/evil.mjs' }, p.steps[1]] };
|
||
expect(verifyFrozenPlan(tampered, KEY)).toBe(false);
|
||
});
|
||
it('verifyFrozenPlan false for unsigned / wrong key (no seal → fiction)', () => {
|
||
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
||
expect(verifyFrozenPlan({ ...p, sig: undefined }, KEY)).toBe(false);
|
||
expect(verifyFrozenPlan(p, 'other-key')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('actionMatchesStep (deterministic, P15-a/e)', () => {
|
||
const step = { n: 1, op: 'Write', object: 'tools/foo.mjs' };
|
||
const norm = (p) => p.replace(/\\/g, '/').toLowerCase(); // stub pathNormalize
|
||
|
||
it('matches when op AND object both equal (after normalize)', () => {
|
||
expect(actionMatchesStep(step, { op: 'Write', object: 'tools/FOO.mjs' }, { normalize: norm })).toBe(true);
|
||
});
|
||
it('does NOT match when op differs (operation axis, P15-e)', () => {
|
||
expect(actionMatchesStep(step, { op: 'Edit', object: 'tools/foo.mjs' }, { normalize: norm })).toBe(false);
|
||
});
|
||
it('does NOT match when object differs', () => {
|
||
expect(actionMatchesStep(step, { op: 'Write', object: 'tools/bar.mjs' }, { normalize: norm })).toBe(false);
|
||
});
|
||
it('Bash matches by normalized command', () => {
|
||
const bstep = { n: 1, op: 'Bash', object: 'npx vitest run foo' };
|
||
expect(actionMatchesStep(bstep, { op: 'Bash', object: 'npx vitest run foo' })).toBe(true);
|
||
});
|
||
// fix: tools/plan-lock.mjs (F5, аудит M1-M4) — пустой object шага не должен быть джокером
|
||
it('шаг с пустым файловым object НЕ матчит действие с пустым object', () => {
|
||
expect(actionMatchesStep({ n: 1, op: 'Write', object: '' }, { op: 'Write', object: '' }, { normalize: norm })).toBe(false);
|
||
});
|
||
it('шаг Bash с пустой командой НЕ матчит пустую команду', () => {
|
||
expect(actionMatchesStep({ n: 1, op: 'Bash', object: '' }, { op: 'Bash', object: '' })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('nextStep (ordered pointer)', () => {
|
||
const steps = [{ n: 1, op: 'Write', object: 'a' }, { n: 2, op: 'Bash', object: 'b' }];
|
||
it('returns the step at the current pointer', () => {
|
||
expect(nextStep(steps, 0)).toEqual(steps[0]);
|
||
expect(nextStep(steps, 1)).toEqual(steps[1]);
|
||
});
|
||
it('returns null past the end', () => {
|
||
expect(nextStep(steps, 2)).toBe(null);
|
||
});
|
||
});
|
||
|
||
describe('frozen plan persistence', () => {
|
||
it('save then load round-trips and stays verifiable', () => {
|
||
const fs = memFs();
|
||
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
||
saveFrozenPlan({ plan: p, sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
|
||
const loaded = loadFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
|
||
expect(verifyFrozenPlan(loaded, KEY)).toBe(true);
|
||
expect(loaded.plan_id).toBe(p.plan_id);
|
||
});
|
||
it('loadFrozenPlan returns null when no plan frozen', () => {
|
||
expect(loadFrozenPlan({ sessionId: 'none', runtimeDir: '/rt', fsImpl: memFs() })).toBe(null);
|
||
});
|
||
});
|
||
|
||
describe('sealed-plan production Task 3 — judge_mode in sig + atomic persist', () => {
|
||
it('freezePlan carries judge_mode in signed base (VA-2)', () => {
|
||
const key = 'k';
|
||
const p = freezePlan({ steps: [{ op: 'Edit', object: 'a', ref: 'r' }], artifactId: 'aid', judgeMode: 'shadow', key });
|
||
expect(p.judge_mode).toBe('shadow');
|
||
expect(verifyFrozenPlan(p, key)).toBe(true);
|
||
const tampered = { ...p, judge_mode: 'live-block' };
|
||
expect(verifyFrozenPlan(tampered, key)).toBe(false); // judge_mode в подписи
|
||
});
|
||
it('saveFrozenPlan writes atomically via temp+rename (SE-4)', () => {
|
||
const calls = [];
|
||
const fsImpl = { writeFileSync: (p, d) => calls.push(['write', p]), renameSync: (a, b) => calls.push(['rename', a, b]) };
|
||
saveFrozenPlan({ plan: { plan_id: 'x' }, sessionId: 's', runtimeDir: '/r', fsImpl });
|
||
expect(calls.some((c) => c[0] === 'rename')).toBe(true); // прошёл через rename
|
||
});
|
||
it('saveFrozenArtifact also writes atomically via temp+rename (SE-4/VA-3)', () => {
|
||
const calls = [];
|
||
const fsImpl = { writeFileSync: (p, d) => calls.push(['write', p]), renameSync: (a, b) => calls.push(['rename', a, b]) };
|
||
saveFrozenArtifact({ artifact: { artifact_id: 'a' }, sessionId: 's', runtimeDir: '/r', fsImpl });
|
||
expect(calls.some((c) => c[0] === 'rename')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('reconcileJournalToPlan (P25-d)', () => {
|
||
const steps = [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }, { n: 2, op: 'Bash', object: 'npx vitest' }];
|
||
it('all journal actions map to a plan step → ok', () => {
|
||
const journal = [{ op: 'Write', object: 'tools/foo.mjs' }, { op: 'Bash', object: 'npx vitest' }];
|
||
expect(reconcileJournalToPlan(journal, steps).ok).toBe(true);
|
||
});
|
||
it('an orphan action (no matching step) is flagged', () => {
|
||
const journal = [{ op: 'Write', object: 'tools/foo.mjs' }, { op: 'Bash', object: 'rm -rf /' }];
|
||
const r = reconcileJournalToPlan(journal, steps);
|
||
expect(r.ok).toBe(false);
|
||
expect(r.orphans).toHaveLength(1);
|
||
expect(r.orphans[0].object).toBe('rm -rf /');
|
||
});
|
||
});
|
||
|
||
describe('freezeArtifact / verifyFrozenArtifact (вторая печать, C-10)', () => {
|
||
it('freezeArtifact returns a signed artifact with stable id', () => {
|
||
const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
|
||
expect(a.artifact_id).toMatch(/^[0-9a-f]{64}$/);
|
||
expect(a.sig).toMatch(/^[0-9a-f]{64}$/);
|
||
});
|
||
it('verify true for intact, false when a section tampered (re-seal required)', () => {
|
||
const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
|
||
expect(verifyFrozenArtifact(a, KEY)).toBe(true);
|
||
const tampered = { ...a, sections: { ...a.sections, '§1': 'evil' } };
|
||
expect(verifyFrozenArtifact(tampered, KEY)).toBe(false);
|
||
});
|
||
it('artifactId deterministic over content', () => {
|
||
expect(artifactId(ART)).toBe(artifactId({ ...ART }));
|
||
});
|
||
it('artifact save/load round-trips and stays verifiable', () => {
|
||
const fs = memFs();
|
||
const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
|
||
saveFrozenArtifact({ artifact: a, sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
|
||
const loaded = loadFrozenArtifact({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
|
||
expect(verifyFrozenArtifact(loaded, KEY)).toBe(true);
|
||
expect(loaded.artifact_id).toBe(a.artifact_id);
|
||
});
|
||
});
|
||
|
||
describe('refResolves (закрытая дверь, C-5)', () => {
|
||
const ARTSEC = { sections: { '§1': 'teal', '§2': 'Inter' } };
|
||
it('ссылка шага резолвится в опечатанном артефакте', () => {
|
||
expect(refResolves({ n: 1, op: 'Write', object: 'x', ref: '§1' }, ARTSEC)).toBe(true);
|
||
});
|
||
it('несуществующая ссылка → не резолвится', () => {
|
||
expect(refResolves({ n: 1, op: 'Write', object: 'x', ref: '§9' }, ARTSEC)).toBe(false);
|
||
});
|
||
it('шаг без ref (простой/legacy) → резолв не требуется (true)', () => {
|
||
expect(refResolves({ n: 1, op: 'Write', object: 'x' }, ARTSEC)).toBe(true);
|
||
});
|
||
});
|
||
|
||
// N3-shared (2026-06-07 аудит M1-M4): planPath/artifactPath строят путь из sessionId
|
||
// (event.session_id, недоверенный источник) — тот же guard формы, что action-journal.
|
||
describe('N3: plan-lock path-injection guard (planPath + artifactPath)', () => {
|
||
it('saveFrozenPlan с traversal-sessionId бросает (ничего не пишет)', () => {
|
||
const fs = memFs();
|
||
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
||
expect(() => saveFrozenPlan({ plan: p, sessionId: '../evil', runtimeDir: '/rt', fsImpl: fs })).toThrow();
|
||
expect(fs.s.size).toBe(0);
|
||
});
|
||
it('loadFrozenPlan/saveFrozenArtifact/loadFrozenArtifact с traversal/слэшем/точкой бросают', () => {
|
||
const fs = memFs();
|
||
const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
|
||
expect(() => loadFrozenPlan({ sessionId: '../../etc/passwd', runtimeDir: '/rt', fsImpl: fs })).toThrow();
|
||
expect(() => saveFrozenArtifact({ artifact: a, sessionId: 'a/b', runtimeDir: '/rt', fsImpl: fs })).toThrow();
|
||
expect(() => loadFrozenArtifact({ sessionId: 'a.b', runtimeDir: '/rt', fsImpl: fs })).toThrow();
|
||
});
|
||
it('нормальный sessionId по-прежнему работает (план + артефакт)', () => {
|
||
const fs = memFs();
|
||
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
||
saveFrozenPlan({ plan: p, sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs });
|
||
expect(loadFrozenPlan({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }).plan_id).toBe(p.plan_id);
|
||
});
|
||
});
|
||
|
||
// 5.1 (Машина 5 Пакет 5, Блок 3) — plan-lock присваивает каждому шагу детерминированный
|
||
// criterion_id ИЗ ЗАПЕЧАТАННОГО содержимого: id воспроизводим (функция содержания шага),
|
||
// запечатан подписью плана (нельзя выдумать на демо-этапе — подмена ломает verifyFrozenPlan).
|
||
import { stepCriterionId, sealedCriterionIds } from './plan-lock.mjs';
|
||
|
||
describe('plan-lock sealed criterion_id (Пакет 5, 5.1)', () => {
|
||
it('freezePlan присваивает каждому шагу criterion_id (64-hex)', () => {
|
||
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
||
for (const s of p.steps) {
|
||
expect(s.criterion_id).toMatch(/^[0-9a-f]{64}$/);
|
||
}
|
||
});
|
||
it('criterion_id детерминирован: две заморозки тех же шагов → те же id', () => {
|
||
const a = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
||
const b = freezePlan({ steps: STEPS, key: KEY, nowMs: 2 });
|
||
expect(a.steps.map((s) => s.criterion_id)).toEqual(b.steps.map((s) => s.criterion_id));
|
||
});
|
||
it('id воспроизводим из содержимого шага (stepCriterionId)', () => {
|
||
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
||
expect(p.steps[0].criterion_id).toBe(stepCriterionId(STEPS[0]));
|
||
});
|
||
it('разное содержимое шага → разный id (подмена смысла при сохранении id невозможна)', () => {
|
||
expect(stepCriterionId({ n: 1, op: 'Write', object: 'a' })).not.toBe(
|
||
stepCriterionId({ n: 1, op: 'Write', object: 'b' }),
|
||
);
|
||
});
|
||
it('stepCriterionId идемпотентен на уже-обогащённом шаге (criterion_id исключён из хеша)', () => {
|
||
const cid = stepCriterionId(STEPS[0]);
|
||
expect(stepCriterionId({ ...STEPS[0], criterion_id: cid })).toBe(cid);
|
||
});
|
||
it('sealedCriterionIds возвращает запечатанный набор id плана', () => {
|
||
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
||
expect(sealedCriterionIds(p).sort()).toEqual(p.steps.map((s) => s.criterion_id).sort());
|
||
});
|
||
it('подмена criterion_id шага после печати → verifyFrozenPlan false (id запечатан подписью)', () => {
|
||
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
||
const tampered = { ...p, steps: [{ ...p.steps[0], criterion_id: 'deadbeef'.repeat(8) }, p.steps[1]] };
|
||
expect(verifyFrozenPlan(tampered, KEY)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ── R-08: дерево листьев / лист по указателю / валидация дерева ──
|
||
import { treeLeaves, treeLeafAt, validatePlanTree } from './plan-lock.mjs';
|
||
|
||
const R08TREE = [
|
||
{ n: 1, op: 'Write', object: 'a' },
|
||
{ n: 2, substeps: [{ n: '2.1', op: 'Edit', object: 'x' }, { n: '2.2', op: 'Bash', object: 'y' }] },
|
||
{ n: 3, op: 'Write', object: 'c' },
|
||
];
|
||
|
||
describe('treeLeaves (depth-first, V1/V2 вход)', () => {
|
||
it('дерево → все листья по порядку', () => {
|
||
expect(treeLeaves(R08TREE).map((s) => s.n)).toEqual([1, '2.1', '2.2', 3]);
|
||
});
|
||
it('плоский план → сами шаги (идентичность)', () => {
|
||
const flat = [{ n: 1, op: 'Write', object: 'a' }, { n: 2, op: 'Bash', object: 'b' }];
|
||
expect(treeLeaves(flat)).toEqual(flat);
|
||
});
|
||
});
|
||
|
||
describe('treeLeafAt (один лист по указателю, SE-2)', () => {
|
||
it('целое 0 → первый лист', () => {
|
||
expect(treeLeafAt(R08TREE, 0).n).toBe(1);
|
||
});
|
||
it('целое 1 (контейнер) → спуск к первому листу, не контейнер', () => {
|
||
const leaf = treeLeafAt(R08TREE, 1);
|
||
expect(leaf.n).toBe('2.1');
|
||
expect(leaf.substeps).toBeUndefined();
|
||
});
|
||
it('массив [1,1] → 2.2', () => {
|
||
expect(treeLeafAt(R08TREE, [1, 1]).n).toBe('2.2');
|
||
});
|
||
it('за концом → null', () => {
|
||
expect(treeLeafAt(R08TREE, 9)).toBe(null);
|
||
});
|
||
it('битый указатель → null', () => {
|
||
expect(treeLeafAt(R08TREE, 'x')).toBe(null);
|
||
});
|
||
});
|
||
|
||
describe('validatePlanTree (SE-2/SE-4 fail-CLOSED)', () => {
|
||
it('валидное дерево → ok', () => {
|
||
expect(validatePlanTree(R08TREE).ok).toBe(true);
|
||
});
|
||
it('пустой контейнер → не ok (SE-4)', () => {
|
||
expect(validatePlanTree([{ n: 1, substeps: [] }]).ok).toBe(false);
|
||
});
|
||
it('контейнер с op/object/ref → не ok (SE-2)', () => {
|
||
expect(validatePlanTree([{ n: 1, op: 'Write', object: 'a', substeps: [{ n: '1.1', op: 'Edit', object: 'x' }] }]).ok).toBe(false);
|
||
});
|
||
it('substeps не массив → не ok', () => {
|
||
expect(validatePlanTree([{ n: 1, substeps: 'x' }]).ok).toBe(false);
|
||
});
|
||
it('превышение глубины → не ok', () => {
|
||
let node = { n: 'leaf', op: 'Write', object: 'z' };
|
||
for (let i = 0; i < 12; i++) node = { substeps: [node] };
|
||
expect(validatePlanTree([node]).ok).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('freezePlan покрывает substeps подписью + criterion_id рекурсия (I9/I10)', () => {
|
||
it('правка под-шага ломает sig', () => {
|
||
const p = freezePlan({ steps: R08TREE, key: KEY, nowMs: 1 });
|
||
const tampered = { ...p, steps: [p.steps[0], { ...p.steps[1], substeps: [{ ...p.steps[1].substeps[0], object: 'evil' }, p.steps[1].substeps[1]] }, p.steps[2]] };
|
||
expect(verifyFrozenPlan(tampered, KEY)).toBe(false);
|
||
});
|
||
it('под-шаги получают criterion_id', () => {
|
||
const p = freezePlan({ steps: R08TREE, key: KEY, nowMs: 1 });
|
||
expect(p.steps[1].substeps[0].criterion_id).toMatch(/^[0-9a-f]{64}$/);
|
||
});
|
||
});
|