import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import {setup} from './helpers/deployment-fixture.mjs'; import {rollbackOnHost,inspectRollback} from '../platform/rollback.mjs'; import {verifySnapshot} from '../platform/backups.mjs'; import {verifyRelease} from '../platform/runtime.mjs'; import {parseArgs} from '../platform/commands.mjs'; async function seeded(t){ const f=setup(t);await f.deploy(); fs.mkdirSync(path.join(f.host,'runtime/world'));fs.writeFileSync(path.join(f.host,'runtime/world/progress'),'before'); fs.mkdirSync(path.join(f.host,'runtime/mod-state'));fs.writeFileSync(path.join(f.host,'runtime/mod-state/progress'),'before'); f.second=f.next();const update=await f.deploy(f.second);f.snapshot=update.backup_snapshot;f.backupId=path.basename(f.snapshot); fs.writeFileSync(path.join(f.host,'runtime/world/progress'),'after');fs.writeFileSync(path.join(f.host,'runtime/mod-state/progress'),'after'); fs.writeFileSync(path.join(f.host,'runtime/new-file'),'new'); f.rollback=options=>rollbackOnHost({repository:f.root,root:f.host,env:f.env,execute:f.execute,timeout:30,...options}); f.rollbackState=()=>JSON.parse(fs.readFileSync(path.join(f.host,'rollback-state.json'))); return f; } test('no-mode plan is read-only and explicit modes require separate confirmations',async t=>{ const f=await seeded(t),files=fs.readdirSync(f.host),events=f.events().length; const plan=inspectRollback(f.host); assert.equal(plan.action,'plan-only');assert.equal(plan.active,'fixture-2.0.0');assert.equal(plan.previous,'fixture-1.0.0');assert.equal(plan.snapshots[0].id,f.backupId); assert.deepEqual(fs.readdirSync(f.host),files);assert.equal(f.events().length,events); assert.throws(()=>f.rollback({mode:'pack',version:'1.0.0'}),/confirm-compatible/); assert.throws(()=>f.rollback({mode:'snapshot',backupId:f.backupId}),/accept-progress-loss/); assert(!fs.existsSync(path.join(f.host,'rollback-state.json'))); }); test('snapshot rollback replaces full runtime, retains displaced progress/current backup and env',async t=>{ const f=await seeded(t),settings=fs.readFileSync(path.join(f.host,'.env'),'utf8'); const state=f.rollback({mode:'snapshot',backupId:f.backupId,acceptProgressLoss:true}); assert.equal(state.phase,'complete');assert.equal(state.runtime_replaced,true); assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'before');assert.equal(fs.readFileSync(path.join(f.host,'runtime/mod-state/progress'),'utf8'),'before'); assert(!fs.existsSync(path.join(f.host,'runtime/new-file'))); assert.equal(fs.readFileSync(path.join(state.retired_runtime,'world/progress'),'utf8'),'after');assert.equal(fs.readFileSync(path.join(state.retired_runtime,'new-file'),'utf8'),'new'); assert.equal(verifySnapshot(state.backup_snapshot).source_release.name,'fixture-2.0.0'); assert.equal(fs.readFileSync(path.join(f.host,'.env'),'utf8'),settings); assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');assert.equal(fs.readFileSync(path.join(f.host,'previous-release'),'utf8').trim(),'fixture-2.0.0'); assert.equal(JSON.parse(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'))).release_manifest_sha256,verifyRelease(f.release).identity); assert(!fs.existsSync(path.join(f.host,'.mcpack-runtime.lock'))); }); test('pack-only uses retained release without restore/build and preserves current world',async t=>{ const f=await seeded(t); const state=f.rollback({mode:'pack',version:'1.0.0',confirmCompatible:true}); assert.equal(state.mode,'pack');assert.equal(state.runtime_replaced,false);assert(state.backup_snapshot); assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'after');assert.equal(fs.readFileSync(path.join(f.host,'runtime/mod-state/progress'),'utf8'),'after'); assert(fs.existsSync(path.join(f.host,'runtime/new-file')));assert.equal(state.target_release,'fixture-1.0.0'); }); test('corrupt snapshot or release is rejected before stopping/current backup',async t=>{ const f=await seeded(t),events=f.events().length; fs.appendFileSync(path.join(f.snapshot,'runtime.tar.zst'),'corrupt'); assert.throws(()=>f.rollback({mode:'snapshot',backupId:f.backupId,acceptProgressLoss:true}),/checksum mismatch/); assert.equal(f.events().length,events);assert(!fs.existsSync(path.join(f.host,'rollback-state.json'))); assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'after'); }); test('current-state backup failure blocks runtime swap and pointer/binding changes',async t=>{ const f=await seeded(t),binding=fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8'); fs.writeFileSync(path.join(f.root,'.tools/tar'),'#!/bin/sh\nexit 42\n',{mode:0o755}); // Pack-only avoids snapshot validation's tar and reaches backup failure. assert.throws(()=>f.rollback({mode:'pack',version:'1.0.0',confirmCompatible:true}),/No snapshot published/); assert.equal(f.rollbackState().applied,false);assert.equal(f.rollbackState().phase,'failed'); assert.equal(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8'),binding);assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-2.0.0'); assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'after'); }); test('failed snapshot startup retains both worlds and source pointers, never auto starts previous pack',async t=>{ const f=await seeded(t);fs.writeFileSync(path.join(f.root,'.tools/fail-start'),'fail'); assert.throws(()=>f.rollback({mode:'snapshot',backupId:f.backupId,acceptProgressLoss:true}),/Rollback startup\/health failed/); const state=f.rollbackState();assert.equal(state.phase,'failed');assert.equal(state.stopped_confirmed,true);assert(state.backup_snapshot); assert.equal(fs.readFileSync(path.join(state.retired_runtime,'world/progress'),'utf8'),'after');assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'before'); assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-2.0.0'); assert.equal(JSON.parse(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'))).release_manifest_sha256,verifyRelease(f.release).identity); const up=f.events().filter(e=>e.command==='up');assert.equal(up.length,3); assert.throws(()=>f.rollback({mode:'pack',version:'2.0.0',confirmCompatible:true}),/Unresolved rollback state/); await assert.rejects(f.deploy(f.second),/Unresolved rollback state/); }); test('explicit snapshot recovery resolves a failed deployment using actual applied binding',async t=>{ const f=await seeded(t),third=f.next('3.0.0');fs.writeFileSync(path.join(f.root,'.tools/fail-start'),'fail'); await assert.rejects(f.deploy(third),/Target startup\/health failed/); const failure=f.state();assert.equal(failure.phase,'failed'); assert.throws(()=>f.rollback({mode:'pack',version:'1.0.0',confirmCompatible:true}),/snapshot recovery/); fs.unlinkSync(path.join(f.root,'.tools/fail-start')); const state=f.rollback({mode:'snapshot',backupId:f.backupId,acceptProgressLoss:true}); assert.equal(state.source_release,'fixture-3.0.0');assert.equal(state.target_release,'fixture-1.0.0');assert.equal(state.phase,'complete'); assert.equal(JSON.parse(fs.readFileSync(state.prior_deployment_state)).phase,'failed');assert.equal(f.state().phase,'complete'); assert.equal(f.state().resolved_deployment,failure.id); }); test('lock and snapshot ID/path validation precede mutation',async t=>{ const f=await seeded(t);fs.mkdirSync(path.join(f.host,'.mcpack-runtime.lock')); assert.throws(()=>f.rollback({mode:'pack',version:'1.0.0',confirmCompatible:true}),/runtime operation/); fs.rmdirSync(path.join(f.host,'.mcpack-runtime.lock')); assert.throws(()=>f.rollback({mode:'snapshot',backupId:'../escape',acceptProgressLoss:true}),/Snapshot rollback requires/); assert.throws(()=>inspectRollback(f.host,{backupId:'../escape'}),/snapshot ID/); }); test('CLI confirmation flags cannot substitute for each other or leak to other commands',()=>{ assert.equal(parseArgs(['rollback']).acceptProgressLoss,false); assert.equal(parseArgs(['rollback','--backup','id','--accept-progress-loss']).acceptProgressLoss,true); assert.equal(parseArgs(['rollback-pack','0.0.4','--confirm-compatible']).confirmCompatible,true); for(const args of [['rollback','--yes'],['rollback','--accept-progress-loss'],['rollback','--confirm-compatible'],['rollback-pack','0.0.4','--accept-progress-loss'],['rollback-pack'],['deploy','--confirm-compatible'],['restore','--backup','id','--destination','/new','--accept-progress-loss']])assert.throws(()=>parseArgs(args)); });