five-views-publish.test.js 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
  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 { publish, rollback, FILES, RELEASE } from '../scripts/publish-five-views.mjs';
  8. const sha = (b) => crypto.createHash('sha256').update(b).digest('hex');
  9. function setup(t) {
  10. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-demo-publish-'));
  11. t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
  12. const sourceDir = path.join(dir, 'release'), targetDir = path.join(dir, 'site');
  13. const put = (file, text) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text); };
  14. const protectedFiles = ['src/services/board-service.js', 'src/components/kf-opportunity-card.js', 'src/pages/view-pool.js', 'src/core/report.js', 'src/core/evidence/chain.js'];
  15. put(path.join(targetDir, 'index.html'), 'homepage');
  16. for (const f of protectedFiles) put(path.join(targetDir, f), 'published-repair');
  17. const files = FILES.map((f, i) => {
  18. put(path.join(sourceDir, 'payload', f), 'new-' + i);
  19. if (i < 4) put(path.join(targetDir, f), 'old-' + i);
  20. return { path: f, sha256: sha('new-' + i), allowMissing: i >= 4, priorNormalizedSha256: i < 4 ? [sha('old-' + i)] : [] };
  21. });
  22. const manifestPath = path.join(sourceDir, 'manifest.json');
  23. fs.writeFileSync(manifestPath, JSON.stringify({ version: 'ksf.five-views.publish.v1', release: RELEASE, homepageSha256: sha('homepage'), files,
  24. preserved: protectedFiles.map((f) => ({ path: f, normalizedSha256: sha('published-repair') })) }));
  25. return { sourceDir, targetDir, manifestPath, put };
  26. }
  27. test('release defaults to a read-only plan, applies once and can restore all prior files', (t) => {
  28. const s = setup(t);
  29. assert.equal(publish(s).changes.length, FILES.length);
  30. assert.equal(fs.readFileSync(path.join(s.targetDir, FILES[0]), 'utf8'), 'old-0');
  31. const result = publish({ ...s, apply: true });
  32. assert.equal(result.filesystemVerified, true);
  33. assert.equal(publish({ ...s, apply: true }).changes.length, 0);
  34. const args = { targetDir: s.targetDir, backupDir: result.backup };
  35. assert.equal(rollback(args).readOnly, true);
  36. assert.equal(rollback({ ...args, apply: true }).filesystemVerified, true);
  37. assert.equal(fs.readFileSync(path.join(s.targetDir, FILES[0]), 'utf8'), 'old-0');
  38. assert.equal(fs.existsSync(path.join(s.targetDir, FILES[5])), false);
  39. assert.equal(fs.readFileSync(path.join(s.targetDir, 'index.html'), 'utf8'), 'homepage');
  40. });
  41. test('unknown versions, homepage changes, protected repair changes and overlapping directories stop before writes', (t) => {
  42. const s = setup(t);
  43. s.put(path.join(s.targetDir, FILES[0]), 'third-party');
  44. assert.throws(() => publish({ ...s, apply: true }), { code: 'UNKNOWN_TARGET_CONTENT' });
  45. s.put(path.join(s.targetDir, FILES[0]), 'old-0');
  46. s.put(path.join(s.targetDir, 'index.html'), 'other');
  47. assert.throws(() => publish(s), { code: 'HOMEPAGE_MISMATCH' });
  48. s.put(path.join(s.targetDir, 'index.html'), 'homepage');
  49. s.put(path.join(s.targetDir, 'src/services/board-service.js'), 'other');
  50. assert.throws(() => publish(s), { code: 'PUBLISHED_REPAIR_MISMATCH' });
  51. assert.throws(() => publish({ ...s, sourceDir: s.targetDir }), { code: 'SOURCE_TARGET_OVERLAP' });
  52. });
  53. test('an interrupted publication rolls back completed files without overwriting concurrent edits', (t) => {
  54. const s = setup(t); let caught;
  55. try { publish({ ...s, apply: true }, { beforeWrite(name) { if (name === FILES[2]) { s.put(path.join(s.targetDir, name), 'concurrent'); } } }); }
  56. catch (error) { caught = error; }
  57. assert.equal(caught.code, 'CONCURRENT_CHANGE'); assert.equal(caught.rollbackComplete, true);
  58. assert.equal(fs.readFileSync(path.join(s.targetDir, FILES[0]), 'utf8'), 'old-0');
  59. assert.equal(fs.readFileSync(path.join(s.targetDir, FILES[2]), 'utf8'), 'concurrent');
  60. assert.equal(JSON.parse(fs.readFileSync(path.join(caught.backup, 'journal.json'))).status, 'stopped');
  61. });
  62. test('rollback refuses a modified target or corrupted backup before restoring any file', (t) => {
  63. const s = setup(t), result = publish({ ...s, apply: true });
  64. const args = { targetDir: s.targetDir, backupDir: result.backup, apply: true };
  65. s.put(path.join(s.targetDir, FILES[1]), 'someone-else');
  66. assert.throws(() => rollback(args), { code: 'CONCURRENT_CHANGE' });
  67. assert.equal(fs.readFileSync(path.join(s.targetDir, FILES[0]), 'utf8'), 'new-0');
  68. s.put(path.join(s.targetDir, FILES[1]), 'new-1');
  69. s.put(path.join(result.backup, FILES[0]), 'broken-backup');
  70. assert.throws(() => rollback(args), { code: 'BACKUP_HASH_MISMATCH' });
  71. });