// tests/integration/schema-rollback.test.mjs // SC5b: post-write validator failure rolls back byte-identical pre-revision state. // // Exercises lib/util/revision-guard.mjs revisionGuard(): // - Apply a deliberately-corrupting mutator that produces an artifact // the validator will reject (missing required section / wrong type). // - Assert outcome === 'rolled-back'. // - Assert sha256_after === sha256_before (byte-identical recovery). // - Assert .local.bak is deleted on the rollback path. // // Cases: // 1. brief-rollback — strip a required body section // 2. plan-rollback — break plan structure (rename Implementation Plan) // 3. review-rollback — flip type to non-trekreview // 4. sha256-invariance-cross-target — across all three targets, verify // the byte-invariance holds for at least one common corrupting class // (frontmatter `type:` flip). import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { revisionGuard } from '../../lib/util/revision-guard.mjs'; import { validateBrief } from '../../lib/validators/brief-validator.mjs'; import { validatePlan } from '../../lib/validators/plan-validator.mjs'; import { validateReview } from '../../lib/validators/review-validator.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..'); const FIX_DIR = join(ROOT, 'tests/fixtures/annotation'); function sha256(p) { return createHash('sha256').update(readFileSync(p)).digest('hex'); } function tmpCopy(name) { const dir = mkdtempSync(join(tmpdir(), 'voyage-rollback-')); const dst = join(dir, name); copyFileSync(join(FIX_DIR, name), dst); return { dir, path: dst }; } test('brief-rollback: strip Goal section -> validator FAIL -> byte-identical restore', () => { const { dir, path } = tmpCopy('annotation-brief.md'); try { const sha_before = sha256(path); const result = revisionGuard( path, ({ frontmatter, body }) => ({ frontmatter, body: body.replace(/## Goal[\s\S]*?(?=\n## Success Criteria)/, ''), // strip Goal section }), validateBrief, ); assert.strictEqual(result.outcome, 'rolled-back', `expected rolled-back, got ${result.outcome}`); const sha_after = sha256(path); assert.strictEqual(sha_after, sha_before, 'sha256 must be byte-identical after rollback'); assert.ok(!existsSync(path + '.local.bak'), '.local.bak must be deleted after rollback'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('plan-rollback: rename Implementation Plan heading -> validator FAIL -> byte-identical restore', () => { const { dir, path } = tmpCopy('annotation-plan.md'); try { const sha_before = sha256(path); const result = revisionGuard( path, ({ frontmatter, body }) => ({ frontmatter, // Inject a forbidden phase-style heading the plan-schema rejects (PLAN_FORBIDDEN_HEADING) body: body + '\n\n### Fase 99: This forbidden heading triggers PLAN_FORBIDDEN_HEADING\n', }), validatePlan, ); assert.strictEqual(result.outcome, 'rolled-back', `expected rolled-back, got ${result.outcome}`); const sha_after = sha256(path); assert.strictEqual(sha_after, sha_before, 'sha256 must be byte-identical after rollback'); assert.ok(!existsSync(path + '.local.bak'), '.local.bak must be deleted after rollback'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('review-rollback: flip type to non-trekreview -> validator FAIL -> byte-identical restore', () => { const { dir, path } = tmpCopy('annotation-review.md'); try { const sha_before = sha256(path); const result = revisionGuard( path, ({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, type: 'not-a-real-type' }, body, }), validateReview, ); assert.strictEqual(result.outcome, 'rolled-back', `expected rolled-back, got ${result.outcome}`); const sha_after = sha256(path); assert.strictEqual(sha_after, sha_before, 'sha256 must be byte-identical after rollback'); assert.ok(!existsSync(path + '.local.bak'), '.local.bak must be deleted after rollback'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('sha256-invariance-cross-target: byte-identical rollback for all three targets', () => { const cases = [ { fixture: 'annotation-brief.md', validator: validateBrief, frontmatterCorruption: { type: 'wrong-type' } }, { fixture: 'annotation-plan.md', validator: validatePlan, bodyCorruption: '\n\n### Fase 1: forbidden\n' }, { fixture: 'annotation-review.md', validator: validateReview, frontmatterCorruption: { findings: 'not-an-array' } }, ]; for (const c of cases) { const { dir, path } = tmpCopy(c.fixture); try { const sha_before = sha256(path); const result = revisionGuard( path, ({ frontmatter, body }) => ({ frontmatter: c.frontmatterCorruption ? { ...frontmatter, ...c.frontmatterCorruption } : frontmatter, body: c.bodyCorruption ? body + c.bodyCorruption : body, }), c.validator, ); assert.strictEqual(result.outcome, 'rolled-back', `${c.fixture}: expected rolled-back, got ${result.outcome}`); assert.strictEqual(sha256(path), sha_before, `${c.fixture}: sha256 must be byte-identical after rollback`); assert.ok(!existsSync(path + '.local.bak'), `${c.fixture}: .local.bak must be deleted after rollback`); } finally { rmSync(dir, { recursive: true, force: true }); } } });