Template tests / tests (pull_request) Failing after 33s
Phase 1/2 of the improvement plan (PR 6 of the sequence). Recovery and
resource-limit hardening for the storage-adjacent modules.
ZIP resource limits (core/zip.js):
- unzipSync now enforces entry-count, total compressed, total inflated, and
per-entry inflated budgets, and caps inflation with inflateRawSync
maxOutputLength so a deflate bomb can't exhaust memory. Exact inflated-size
match (not "at least") and CRC verification are kept. Import uses the
default limits.
Transactional archive import (core/archive.js):
- The import validates the guide AND every step before writing anything, then
stages the whole guide in a temp directory and publishes it with a single
atomic rename. A corrupt step no longer leaves a partial guide in the
library; a failure cleans up the staging directory.
Atomic snapshot restore (core/snapshots.js):
- Restore extracts and validates into a temp directory first; only then does
it swap content in, moving live content aside so a mid-swap failure rolls
back. A corrupt/truncated snapshot can no longer destroy the live guide
(the old restore deleted live content before extracting).
- Fixed snapshot filename collisions: names kept milliseconds so two backups
in the same second no longer overwrite each other.
Automatic backups (core/snapshots.js):
- Implemented the previously-dead backups.automatic/everyNSaves/keepLast
settings: autoSnapshotIfDue snapshots every N saves and prunes to keepLast,
wired into the save choke point in main.js. Never throws — a backup failure
cannot break the save that triggered it.
Exclusive locks (core/locks.js):
- acquireLock uses O_CREAT|O_EXCL (flag 'wx') so only one writer wins the
race; the old read-then-write left a window where two writers both believed
they held the lock. Added a per-acquisition token so release only removes
the exact lock it took (never one a force-steal replaced). Same-process
re-acquire still succeeds; cross-process fresh locks conflict.
Search reconciliation (core/search.js):
- New reconcile(store) rebuilds/repairs the index against the library at
startup using per-guide fingerprints (updatedAt+revision): reindexes new/
changed guides, drops entries for deleted ones, and exposes a recovery
status ('ok'|'reset'|'reconciled'). A missing/corrupt/version-mismatched
index recovers instead of silently returning nothing. Wired into startup.
Recovery surface:
- New recovery:status IPC + preload method returns quarantined files (this
session) and the search index status so the UI can surface data issues.
Tests: ZIP bomb/limits, transactional import abort with no partial guide,
atomic snapshot restore preserving the live guide on corruption, exclusive
lock conflict/steal/release-by-token, same-process re-acquire, search
reconcile (rebuild/drop/reindex/corrupt-reset), and automatic backup
cadence/pruning. 255 unit tests pass; startup smoke and workflow E2E pass.
Co-Authored-By: Claude Fable 5 <[email protected]>
300 lines
13 KiB
JavaScript
300 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const zlib = require('node:zlib');
|
|
|
|
const { GuideStore } = require('../../core/store');
|
|
const { SearchIndex } = require('../../core/search');
|
|
const { unzipSync, zipSync, crc32 } = require('../../core/zip');
|
|
const { exportGuideArchive, importGuideArchive } = require('../../core/archive');
|
|
const { createSnapshot, restoreSnapshot, autoSnapshotIfDue } = require('../../core/snapshots');
|
|
const { acquireLock, releaseLock } = require('../../core/locks');
|
|
const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers');
|
|
|
|
// ---- ZIP bomb / resource limits ---------------------------------------------
|
|
|
|
test('unzip rejects an archive that declares too many entries', () => {
|
|
const many = [];
|
|
for (let i = 0; i < 20; i += 1) many.push({ name: `f${i}.txt`, data: 'x' });
|
|
const buf = zipSync(many);
|
|
assert.throws(() => unzipSync(buf, { limits: { maxEntries: 5 } }), /too many entries/);
|
|
});
|
|
|
|
test('unzip rejects an entry whose declared size exceeds the per-entry limit', () => {
|
|
const buf = zipSync([{ name: 'big.txt', data: Buffer.alloc(1000, 65) }]);
|
|
assert.throws(() => unzipSync(buf, { limits: { maxEntryUncompressed: 100 } }), /entry too large/);
|
|
});
|
|
|
|
test('unzip caps inflation so a deflate bomb cannot exhaust memory', () => {
|
|
// A hand-built entry whose deflate stream expands far past the cap. The
|
|
// maxOutputLength guard must abort inflation rather than allocating it all.
|
|
const bomb = Buffer.alloc(10 * 1024 * 1024, 0); // 10 MiB of zeros -> tiny deflate
|
|
const raw = zlib.deflateRawSync(bomb, { level: 9 });
|
|
const name = 'bomb';
|
|
const nameBuf = Buffer.from(name);
|
|
const local = Buffer.alloc(30);
|
|
local.writeUInt32LE(0x04034b50, 0);
|
|
local.writeUInt16LE(20, 4);
|
|
local.writeUInt16LE(0x0800, 6);
|
|
local.writeUInt16LE(8, 8); // deflate
|
|
local.writeUInt32LE(crc32(bomb), 14);
|
|
local.writeUInt32LE(raw.length, 18);
|
|
local.writeUInt32LE(bomb.length, 22);
|
|
local.writeUInt16LE(nameBuf.length, 26);
|
|
const central = Buffer.alloc(46);
|
|
central.writeUInt32LE(0x02014b50, 0);
|
|
central.writeUInt16LE(20, 4);
|
|
central.writeUInt16LE(20, 6);
|
|
central.writeUInt16LE(0x0800, 8);
|
|
central.writeUInt16LE(8, 10);
|
|
central.writeUInt32LE(crc32(bomb), 16);
|
|
central.writeUInt32LE(raw.length, 20);
|
|
central.writeUInt32LE(bomb.length, 24);
|
|
central.writeUInt16LE(nameBuf.length, 28);
|
|
central.writeUInt32LE(0, 42);
|
|
const localBlock = Buffer.concat([local, nameBuf, raw]);
|
|
const centralBlock = Buffer.concat([central, nameBuf]);
|
|
const eocd = Buffer.alloc(22);
|
|
eocd.writeUInt32LE(0x06054b50, 0);
|
|
eocd.writeUInt16LE(1, 8);
|
|
eocd.writeUInt16LE(1, 10);
|
|
eocd.writeUInt32LE(centralBlock.length, 12);
|
|
eocd.writeUInt32LE(localBlock.length, 16);
|
|
const buf = Buffer.concat([localBlock, centralBlock, eocd]);
|
|
|
|
assert.throws(() => unzipSync(buf, { limits: { maxEntryUncompressed: 64 * 1024 } }));
|
|
});
|
|
|
|
// ---- transactional archive import -------------------------------------------
|
|
|
|
test('a corrupt step aborts the import leaving no partial guide', (t) => {
|
|
const root = makeTmpDir('import-atomic');
|
|
t.after(() => rmrf(root));
|
|
const store = new GuideStore(root);
|
|
|
|
const guide = store.createGuide({ title: 'Src' });
|
|
store.addStep(guide.guideId, { title: 'S1' }, TINY_PNG, { width: 1, height: 1 });
|
|
const archiveFile = path.join(root, 'out.sfgz');
|
|
exportGuideArchive(store, guide.guideId, archiveFile);
|
|
|
|
// Corrupt the exported step so validation fails during import.
|
|
const { unzipSync: uz } = require('../../core/zip');
|
|
const entries = uz(fs.readFileSync(archiveFile));
|
|
const tampered = entries.map((e) => {
|
|
if (e.name.endsWith('step.json')) {
|
|
const obj = JSON.parse(e.data.toString('utf8'));
|
|
// Corrupt the image size to non-finite values — validateStep rejects
|
|
// an image step with an invalid size.
|
|
obj.image = { originalPath: 'original.png', workingPath: 'working.png', size: { width: 'x', height: null } };
|
|
return { name: e.name, data: Buffer.from(JSON.stringify(obj)) };
|
|
}
|
|
return { name: e.name, data: e.data };
|
|
});
|
|
fs.writeFileSync(archiveFile, zipSync(tampered));
|
|
|
|
const before = store.listGuides().length;
|
|
assert.throws(() => importGuideArchive(store, archiveFile, { mode: 'copy' }));
|
|
// No partial guide was left behind, and no staging dir remains.
|
|
assert.equal(store.listGuides().length, before);
|
|
const leftover = fs.readdirSync(store.guidesDir).filter((n) => n.includes('.importing'));
|
|
assert.deepEqual(leftover, []);
|
|
});
|
|
|
|
test('a valid archive imports cleanly', (t) => {
|
|
const root = makeTmpDir('import-ok');
|
|
t.after(() => rmrf(root));
|
|
const store = new GuideStore(root);
|
|
const guide = store.createGuide({ title: 'Src' });
|
|
store.addStep(guide.guideId, { title: 'S1' }, TINY_PNG, { width: 1, height: 1 });
|
|
const archiveFile = path.join(root, 'out.sfgz');
|
|
exportGuideArchive(store, guide.guideId, archiveFile);
|
|
|
|
const imported = importGuideArchive(store, archiveFile, { mode: 'copy' });
|
|
assert.equal(imported.title, 'Src');
|
|
assert.equal(imported.stepsOrder.length, 1);
|
|
});
|
|
|
|
// ---- atomic snapshot restore ------------------------------------------------
|
|
|
|
test('restoring a corrupt snapshot never destroys the live guide', (t) => {
|
|
const root = makeTmpDir('snap-atomic');
|
|
t.after(() => rmrf(root));
|
|
const store = new GuideStore(root);
|
|
const guide = store.createGuide({ title: 'Live' });
|
|
store.addStep(guide.guideId, { title: 'keep me' }, TINY_PNG, { width: 1, height: 1 });
|
|
const snap = createSnapshot(store, guide.guideId, { label: 'good' });
|
|
|
|
// Corrupt the snapshot zip so restore must abort.
|
|
const snapFile = path.join(store.guideDir(guide.guideId), 'history', 'snapshots', snap);
|
|
fs.writeFileSync(snapFile, Buffer.from('not a zip at all'));
|
|
|
|
assert.throws(() => restoreSnapshot(store, guide.guideId, snap), /restore aborted|invalid|zip/i);
|
|
// The live guide and its step are intact.
|
|
const after = store.getGuide(guide.guideId);
|
|
assert.equal(after.title, 'Live');
|
|
assert.equal(after.stepsOrder.length, 1);
|
|
});
|
|
|
|
test('restoring a valid snapshot swaps content and keeps history', (t) => {
|
|
const root = makeTmpDir('snap-ok');
|
|
t.after(() => rmrf(root));
|
|
const store = new GuideStore(root);
|
|
const guide = store.createGuide({ title: 'V1' });
|
|
const s1 = store.addStep(guide.guideId, { title: 'first' }, TINY_PNG, { width: 1, height: 1 });
|
|
const snap = createSnapshot(store, guide.guideId, { label: 'v1' });
|
|
|
|
// Change the guide, then restore.
|
|
store.saveGuide({ ...store.getGuide(guide.guideId), title: 'V2' });
|
|
store.deleteStep(guide.guideId, s1.stepId);
|
|
assert.equal(store.getGuide(guide.guideId).title, 'V2');
|
|
|
|
const restored = restoreSnapshot(store, guide.guideId, snap);
|
|
assert.equal(restored.title, 'V1');
|
|
assert.equal(restored.stepsOrder.length, 1);
|
|
// history/ survived the restore (pre-restore snapshot exists too).
|
|
assert.ok(fs.existsSync(path.join(store.guideDir(guide.guideId), 'history')));
|
|
});
|
|
|
|
// ---- atomic locks -----------------------------------------------------------
|
|
|
|
test('another process holding a fresh lock is a conflict; release-by-token frees ours', (t) => {
|
|
const root = makeTmpDir('lock');
|
|
t.after(() => rmrf(root));
|
|
const { lockPathFor } = require('../../core/locks');
|
|
const target = path.join(root, 'shared.sfgz');
|
|
fs.writeFileSync(target, 'x');
|
|
|
|
// Simulate a different process already holding a fresh lock.
|
|
fs.writeFileSync(lockPathFor(target), JSON.stringify({
|
|
host: 'other-host', user: 'someone-else', pid: 999999,
|
|
token: 'their-token', acquiredAt: new Date().toISOString(),
|
|
}));
|
|
|
|
const attempt = acquireLock(target);
|
|
assert.equal(attempt.acquired, false);
|
|
assert.ok(attempt.conflict);
|
|
// We must not be able to release their lock with a guessed/absent token.
|
|
assert.equal(releaseLock(target, { token: 'wrong' }), false);
|
|
|
|
// Force-steal (user confirmed), then release by our own acquisition token.
|
|
const stolen = acquireLock(target, { force: true });
|
|
assert.equal(stolen.acquired, true);
|
|
assert.equal(releaseLock(target, { lock: stolen.lock }), true);
|
|
assert.equal(acquireLock(target).acquired, true);
|
|
});
|
|
|
|
test('the same process can re-acquire its own lock', (t) => {
|
|
const root = makeTmpDir('lock-reacquire');
|
|
t.after(() => rmrf(root));
|
|
const target = path.join(root, 'shared.sfgz');
|
|
fs.writeFileSync(target, 'x');
|
|
assert.equal(acquireLock(target).acquired, true);
|
|
// Same process, second acquire: not a conflict.
|
|
assert.equal(acquireLock(target).acquired, true);
|
|
});
|
|
|
|
test('force steal takes over a held lock', (t) => {
|
|
const root = makeTmpDir('lock-force');
|
|
t.after(() => rmrf(root));
|
|
const target = path.join(root, 'shared.sfgz');
|
|
fs.writeFileSync(target, 'x');
|
|
acquireLock(target);
|
|
const stolen = acquireLock(target, { force: true });
|
|
assert.equal(stolen.acquired, true);
|
|
});
|
|
|
|
// ---- search reconcile -------------------------------------------------------
|
|
|
|
test('reconcile rebuilds a missing index from the store', (t) => {
|
|
const root = makeTmpDir('search-rebuild');
|
|
t.after(() => rmrf(root));
|
|
const store = new GuideStore(root);
|
|
const guide = store.createGuide({ title: 'Password reset guide' });
|
|
store.addStep(guide.guideId, { title: 'Open admin portal' }, TINY_PNG, { width: 1, height: 1 });
|
|
|
|
// A brand-new index (nothing persisted) must recover by reconciling.
|
|
const index = new SearchIndex(store.indexDir);
|
|
const summary = index.reconcile(store);
|
|
assert.equal(summary.reindexed, 1);
|
|
assert.ok(index.search('password').length > 0);
|
|
});
|
|
|
|
test('reconcile drops entries for deleted guides and reindexes changed ones', (t) => {
|
|
const root = makeTmpDir('search-reconcile');
|
|
t.after(() => rmrf(root));
|
|
const store = new GuideStore(root);
|
|
const g1 = store.createGuide({ title: 'alpha guide' });
|
|
const g2 = store.createGuide({ title: 'beta guide' });
|
|
const index = new SearchIndex(store.indexDir);
|
|
index.reconcile(store);
|
|
assert.ok(index.search('alpha').length > 0);
|
|
|
|
// Delete g1 out from under the index and change g2's title.
|
|
store.deleteGuide(g1.guideId);
|
|
store.saveGuide({ ...store.getGuide(g2.guideId), title: 'beta renamed gamma' });
|
|
|
|
const summary = index.reconcile(store);
|
|
assert.equal(index.search('alpha').length, 0, 'deleted guide is gone from search');
|
|
assert.ok(index.search('gamma').length > 0, 'changed guide is reindexed');
|
|
assert.equal(summary.removed, 1);
|
|
});
|
|
|
|
test('a corrupt index file resets to a recoverable status', (t) => {
|
|
const root = makeTmpDir('search-corrupt');
|
|
t.after(() => rmrf(root));
|
|
const store = new GuideStore(root);
|
|
store.createGuide({ title: 'recoverable' });
|
|
fs.mkdirSync(store.indexDir, { recursive: true });
|
|
fs.writeFileSync(path.join(store.indexDir, 'search-index.json'), '{ corrupt json');
|
|
|
|
const index = new SearchIndex(store.indexDir);
|
|
const summary = index.reconcile(store);
|
|
assert.equal(summary.status, 'reset');
|
|
assert.ok(index.search('recoverable').length > 0);
|
|
});
|
|
|
|
// ---- automatic backups ------------------------------------------------------
|
|
|
|
test('autoSnapshotIfDue takes a snapshot every N saves and prunes', (t) => {
|
|
const root = makeTmpDir('auto-backup');
|
|
t.after(() => rmrf(root));
|
|
const store = new GuideStore(root);
|
|
const guide = store.createGuide({ title: 'G' });
|
|
const settings = {
|
|
get: (k) => ({ automatic: true, everyNSaves: 3, keepLast: 2 }[k.replace('backups.', '')] ?? ({ backups: { automatic: true, everyNSaves: 3, keepLast: 2 } }[k])),
|
|
};
|
|
// The helper reads settings.get('backups'):
|
|
const s = { get: (k) => (k === 'backups' ? { automatic: true, everyNSaves: 3, keepLast: 2 } : null) };
|
|
|
|
const dir = path.join(store.guideDir(guide.guideId), 'history', 'snapshots');
|
|
const count = () => (fs.existsSync(dir) ? fs.readdirSync(dir).filter((n) => n.endsWith('.zip')).length : 0);
|
|
|
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null); // 1
|
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null); // 2
|
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), true); // 3 -> snapshot
|
|
assert.equal(count(), 1);
|
|
autoSnapshotIfDue(store, guide.guideId, s); // 1
|
|
autoSnapshotIfDue(store, guide.guideId, s); // 2
|
|
autoSnapshotIfDue(store, guide.guideId, s); // 3 -> snapshot
|
|
assert.equal(count(), 2);
|
|
autoSnapshotIfDue(store, guide.guideId, s);
|
|
autoSnapshotIfDue(store, guide.guideId, s);
|
|
autoSnapshotIfDue(store, guide.guideId, s); // 3rd snapshot, pruned to keepLast=2
|
|
assert.equal(count(), 2, 'pruned to keepLast');
|
|
});
|
|
|
|
test('autoSnapshotIfDue is a no-op when automatic backups are off', (t) => {
|
|
const root = makeTmpDir('auto-backup-off');
|
|
t.after(() => rmrf(root));
|
|
const store = new GuideStore(root);
|
|
const guide = store.createGuide({ title: 'G' });
|
|
const s = { get: () => ({ automatic: false, everyNSaves: 1 }) };
|
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null);
|
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null);
|
|
const dir = path.join(store.guideDir(guide.guideId), 'history', 'snapshots');
|
|
assert.equal(fs.existsSync(dir) ? fs.readdirSync(dir).length : 0, 0);
|
|
});
|