workshop-report-update.test.mjs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import os from 'node:os';
  5. import path from 'node:path';
  6. import crypto from 'node:crypto';
  7. import {fileURLToPath, pathToFileURL} from 'node:url';
  8. const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
  9. const releaseDir=path.join(root,'docs/workshop-skill-release-20261109-r7');
  10. const packageSource=fs.realpathSync(process.env.KSF_REPORT_PACKAGE_DIR||path.join(releaseDir,'package'));
  11. const {planUpdate,publicPlan,applyUpdate,verifyUpdate,rollbackUpdate}=await import(pathToFileURL(path.join(packageSource,'update-skill.mjs')));
  12. const hash=b=>crypto.createHash('sha256').update(b).digest('hex');
  13. const digest=p=>hash(fs.readFileSync(p));
  14. function fileMap(dir){
  15. const out={};for(const e of fs.readdirSync(dir,{withFileTypes:true})){
  16. const p=path.join(dir,e.name);
  17. if(e.isDirectory())for(const[n,h]of Object.entries(fileMap(p)))out[e.name+'/'+n]=h;
  18. else out[e.name]=digest(p);
  19. }return out;
  20. }
  21. function fixture(t){
  22. const temp=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-report-update-test-'));
  23. t.after(()=>{
  24. const resolved=fs.realpathSync(temp);
  25. assert.ok(resolved.startsWith(fs.realpathSync(os.tmpdir())+path.sep)&&path.basename(resolved).startsWith('ksf-report-update-test-'));
  26. fs.rmSync(resolved,{recursive:true});
  27. });
  28. const dataRoot=path.join(temp,'data'),targetDir=path.join(dataRoot,'skills/ksf-workshop-runtime');
  29. const libraryDir=path.join(dataRoot,'skills/ksf-voc-library'),packageDir=path.join(temp,'package');
  30. fs.mkdirSync(path.dirname(targetDir),{recursive:true});
  31. fs.cpSync(path.join(releaseDir,'rehearsal-baseline-r6'),targetDir,{recursive:true});
  32. fs.cpSync(packageSource,packageDir,{recursive:true});
  33. const release=JSON.parse(fs.readFileSync(path.join(packageDir,'release.json')));
  34. const declared=[];
  35. for(const n of release.researchEntryNames){
  36. const source=path.join(root,'skills/ksf-voc-library',n),dest=path.join(libraryDir,n);
  37. fs.mkdirSync(path.dirname(dest),{recursive:true});fs.copyFileSync(source,dest);
  38. declared.push(digest(dest)+' '+n+'\n');
  39. }
  40. fs.writeFileSync(path.join(libraryDir,'FILES.sha256'),declared.join(''));
  41. fs.writeFileSync(path.join(targetDir,'credential-binding.json'),'{"fixtureOnly":true}',{mode:0o600});
  42. fs.writeFileSync(path.join(targetDir,'fixture-private-state.json'),'{"syntheticOnly":true}',{mode:0o600});
  43. return{temp,dataRoot,targetDir,libraryDir,packageDir,release,before:fileMap(dataRoot)};
  44. }
  45. function restored(f){
  46. const after=fileMap(f.dataRoot);
  47. for(const[n,h]of Object.entries(f.before))assert.equal(after[n],h,n);
  48. for(const n of Object.keys(after).filter(n=>!n.startsWith('ksf-workshop-research-backup-')))assert.ok(Object.hasOwn(f.before,n),n);
  49. }
  50. test('exact r6 dry is read-only; 23 planned changes contain 22 updates and one creation',t=>{
  51. const f=fixture(t),dry=publicPlan(planUpdate(f));
  52. assert.equal(dry.baselineId,'r6');assert.equal(dry.changedFiles,23);assert.equal(dry.readOnly,true);
  53. assert.equal(dry.changes.filter(x=>x.action==='update').length,22);
  54. assert.equal(dry.changes.filter(x=>x.action==='create').length,1);
  55. assert.ok(dry.changes.find(x=>x.path.endsWith(path.join('references','workshop-report-design.md'))));
  56. assert.deepEqual(fileMap(f.dataRoot),f.before);
  57. });
  58. test('apply and verify cover 43 public files, preserve research/runtime/private files and retain 22 backups',t=>{
  59. const f=fixture(t),applied=applyUpdate(f),verified=verifyUpdate({backup:applied.backup});
  60. assert.equal(verified.installedFilesVerified,43);assert.equal(verified.changedFilesVerified,23);
  61. assert.equal(verified.runtimeAndBindingPreserved,true);assert.equal(verified.researchFilesUnchanged,true);
  62. assert.equal(verified.agentLoaded,false);
  63. for(const[n,h]of Object.entries(f.release.candidate))assert.equal(digest(path.join(f.targetDir,n)),h,n);
  64. const after=fileMap(f.dataRoot),owned=new Set(Object.keys(f.release.candidate).map(n=>'skills/ksf-workshop-runtime/'+n));
  65. for(const[n,h]of Object.entries(f.before))if(!owned.has(n))assert.equal(after[n],h,n);
  66. assert.equal(fs.readdirSync(applied.backup).filter(n=>n.endsWith('.before')).length,22);
  67. assert.ok(!fs.readFileSync(path.join(applied.backup,'journal.json'),'utf8').includes('fixtureOnly'));
  68. assert.equal(fs.existsSync(path.join(f.libraryDir,'SKILL.md')),false);
  69. });
  70. test('repeat install is a no-op; rollback restores r6 and removes only the new reference',t=>{
  71. const f=fixture(t),applied=applyUpdate(f),after=fileMap(f.dataRoot),repeated=applyUpdate(f);
  72. assert.equal(repeated.alreadyApplied,true);assert.equal(repeated.readOnly,true);assert.equal(repeated.changedFiles,0);
  73. assert.deepEqual(fileMap(f.dataRoot),after);
  74. rollbackUpdate({backup:applied.backup});restored(f);
  75. assert.equal(fs.existsSync(path.join(f.targetDir,'references/workshop-report-design.md')),false);
  76. assert.equal(rollbackUpdate({backup:applied.backup}).alreadyRolledBack,true);
  77. });
  78. test('unknown entry, mixed files, stale manifest and runtime edits stop before any installation',t=>{
  79. for(const [name,code]of [['SKILL.md','UNKNOWN_TARGET_SKILL'],['references/workshop-feedback.md','TARGET_BASELINE_MISMATCH'],['FILES.sha256','TARGET_BASELINE_MISMATCH'],['scripts/ksf-secure.mjs','RUNTIME_BASELINE_MISMATCH']]){
  80. const f=fixture(t);fs.appendFileSync(path.join(f.targetDir,name),'unknown');const before=fileMap(f.dataRoot);
  81. assert.throws(()=>applyUpdate(f),{code});assert.deepEqual(fileMap(f.dataRoot),before);
  82. }
  83. });
  84. test('existing new reference and altered payload are never overwritten or accepted',t=>{
  85. for(const scenario of ['new-file','payload']){
  86. const f=fixture(t);
  87. const name=scenario==='new-file'?path.join(f.targetDir,'references/workshop-report-design.md'):path.join(f.packageDir,'payload/SKILL.md');
  88. fs.writeFileSync(name,'unexpected');const before=fileMap(f.dataRoot);
  89. assert.throws(()=>applyUpdate(f),{code:scenario==='new-file'?'UNEXPECTED_EXISTING_FILE':'PACKAGE_HASH_MISMATCH'});
  90. assert.deepEqual(fileMap(f.dataRoot),before);
  91. }
  92. });
  93. test('research modifications or a restored legacy entry block installation',t=>{
  94. for(const scenario of ['entry','reference']){
  95. const f=fixture(t);
  96. if(scenario==='entry')fs.writeFileSync(path.join(f.libraryDir,'SKILL.md'),'not allowed');
  97. else fs.appendFileSync(path.join(f.libraryDir,'references/query-rules.md'),'unknown');
  98. const before=fileMap(f.dataRoot);
  99. assert.throws(()=>applyUpdate(f),{code:scenario==='entry'?'LEGACY_ENTRY_REACTIVATED':'RESEARCH_MANIFEST_MISMATCH'});
  100. assert.deepEqual(fileMap(f.dataRoot),before);
  101. }
  102. });
  103. test('partial failure after the new file is installed restores r6 bytes',t=>{
  104. const f=fixture(t);let error;
  105. try{applyUpdate(f,{afterMutation:i=>{if(i===f.release.plans.r6.findIndex(e=>e.action==='create'))throw Object.assign(Error('injected'),{code:'INJECTED_FAILURE'});}});}catch(e){error=e;}
  106. assert.equal(error.code,'INJECTED_FAILURE');assert.equal(error.recoveryStatus,'FAILED_RESTORED');restored(f);
  107. assert.equal(fs.existsSync(path.join(f.targetDir,'references/workshop-report-design.md')),false);
  108. });
  109. test('concurrent binding change is detected and preserved for review',t=>{
  110. const f=fixture(t);let error;
  111. try{applyUpdate(f,{afterMutation:i=>{if(i===0)fs.appendFileSync(path.join(f.targetDir,'credential-binding.json'),'concurrent');}});}catch(e){error=e;}
  112. assert.equal(error.code,'BINDING_CHANGED');assert.equal(error.recoveryStatus,'FAILED_REQUIRES_REVIEW');
  113. assert.ok(fs.readFileSync(path.join(f.targetDir,'credential-binding.json'),'utf8').endsWith('concurrent'));
  114. assert.equal(digest(path.join(f.targetDir,'SKILL.md')),f.release.baselines.r6['SKILL.md']);
  115. });
  116. test('verify and rollback refuse later edits without modifying them',t=>{
  117. const f=fixture(t),applied=applyUpdate(f);fs.appendFileSync(path.join(f.targetDir,'references/workshop-report-design.md'),'later edit');
  118. const before=fileMap(f.dataRoot);
  119. assert.throws(()=>verifyUpdate({backup:applied.backup}),{code:'CONCURRENT_FILE_CHANGE'});
  120. assert.throws(()=>rollbackUpdate({backup:applied.backup}),{code:'CONCURRENT_FILE_CHANGE'});
  121. assert.deepEqual(fileMap(f.dataRoot),before);
  122. });