mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
150 lines
7.7 KiB
TypeScript
150 lines
7.7 KiB
TypeScript
/**
|
|
* Backup/restore of the plugin trees (#plugins). Proves the two-step, restart-safe
|
|
* flow: restore STAGES the extracted trees beside the live ones, and a later boot SWAPS
|
|
* them in (so we never overwrite a plugin DB the runtime holds open). Covers the no-op
|
|
* paths (older archive with no plugin trees) and that the pre-restore tree is replaced.
|
|
*/
|
|
import {
|
|
stageExtractedPluginTrees,
|
|
applyStagedPluginTrees,
|
|
setStagedRestoreApplier,
|
|
applyStagedRestoreNow,
|
|
} from '../../../src/nest/plugins/plugin-backup';
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
|
let root: string;
|
|
beforeEach(() => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-pbackup-'));
|
|
process.env.TREK_PLUGINS_DIR = path.join(root, 'plugins');
|
|
process.env.TREK_PLUGINS_DATA_DIR = path.join(root, 'plugins-data');
|
|
});
|
|
afterEach(() => {
|
|
delete process.env.TREK_PLUGINS_DIR;
|
|
delete process.env.TREK_PLUGINS_DATA_DIR;
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
const read = (p: string) => fs.readFileSync(p, 'utf8');
|
|
const write = (p: string, s: string) => {
|
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
fs.writeFileSync(p, s);
|
|
};
|
|
|
|
describe('plugin backup staging + boot reconcile', () => {
|
|
it('stages the extracted trees beside the live ones, then a boot swaps them in', () => {
|
|
// A live install with old plugin data + code.
|
|
write(path.join(root, 'plugins-data', 'notes', 'plugin.db'), 'OLD-DATA');
|
|
write(path.join(root, 'plugins', 'notes', 'server', 'index.js'), 'OLD-CODE');
|
|
|
|
// A restore extracted a newer backup into an extract dir.
|
|
const extract = path.join(root, 'restore-123');
|
|
write(path.join(extract, 'plugins-data', 'notes', 'plugin.db'), 'NEW-DATA');
|
|
write(path.join(extract, 'plugins-code', 'notes', 'server', 'index.js'), 'NEW-CODE');
|
|
|
|
// stage: copies next to the live trees, does not touch the live trees yet
|
|
expect(stageExtractedPluginTrees(extract)).toBe(true);
|
|
expect(fs.existsSync(path.join(root, 'plugins-data.restore', 'notes', 'plugin.db'))).toBe(true);
|
|
expect(read(path.join(root, 'plugins-data', 'notes', 'plugin.db'))).toBe('OLD-DATA'); // live untouched
|
|
|
|
// boot reconcile: swaps staged -> live, drops staging + the old tree
|
|
expect(applyStagedPluginTrees().sort()).toEqual(['plugins-code', 'plugins-data']);
|
|
expect(read(path.join(root, 'plugins-data', 'notes', 'plugin.db'))).toBe('NEW-DATA');
|
|
expect(read(path.join(root, 'plugins', 'notes', 'server', 'index.js'))).toBe('NEW-CODE');
|
|
expect(fs.existsSync(path.join(root, 'plugins-data.restore'))).toBe(false);
|
|
expect(fs.existsSync(path.join(root, 'plugins-data.pre-restore'))).toBe(false);
|
|
});
|
|
|
|
it('stage is a no-op for an archive that carries no plugin trees (older backup)', () => {
|
|
const extract = path.join(root, 'restore-empty');
|
|
write(path.join(extract, 'travel.db'), 'db'); // only core data, no plugin trees
|
|
expect(stageExtractedPluginTrees(extract)).toBe(false);
|
|
expect(fs.existsSync(path.join(root, 'plugins-data.restore'))).toBe(false);
|
|
});
|
|
|
|
it('boot reconcile is a no-op when nothing was staged', () => {
|
|
write(path.join(root, 'plugins-data', 'notes', 'plugin.db'), 'LIVE');
|
|
expect(applyStagedPluginTrees()).toEqual([]);
|
|
expect(read(path.join(root, 'plugins-data', 'notes', 'plugin.db'))).toBe('LIVE'); // untouched
|
|
});
|
|
|
|
it('reconcile creates the live tree even when there was none before (fresh restore)', () => {
|
|
// no live plugins-data at all; only a staged one
|
|
write(path.join(root, 'plugins-data.restore', 'newplug', 'plugin.db'), 'DATA');
|
|
expect(applyStagedPluginTrees()).toEqual(['plugins-data']);
|
|
expect(read(path.join(root, 'plugins-data', 'newplug', 'plugin.db'))).toBe('DATA');
|
|
expect(fs.existsSync(path.join(root, 'plugins-data.restore'))).toBe(false);
|
|
});
|
|
|
|
it('the swap replaces real entries but PRESERVES a dev-linked plugin the backup excluded', () => {
|
|
// a real plugin + a dev-linked plugin (junction to an author source outside the root)
|
|
const authorSrc = path.join(root, 'author-src');
|
|
write(path.join(authorSrc, 'server', 'index.js'), 'DEV');
|
|
write(path.join(root, 'plugins', 'real', 'server', 'index.js'), 'OLD-REAL');
|
|
fs.mkdirSync(path.join(root, 'plugins'), { recursive: true });
|
|
try {
|
|
fs.symlinkSync(authorSrc, path.join(root, 'plugins', 'linked'), 'junction');
|
|
} catch {
|
|
return;
|
|
} // symlink/junction not permitted in this env → skip
|
|
// a restore staged only the real plugin (dev-links are never in a backup)
|
|
const extract = path.join(root, 'restore-dl');
|
|
write(path.join(extract, 'plugins-code', 'real', 'server', 'index.js'), 'NEW-REAL');
|
|
stageExtractedPluginTrees(extract);
|
|
expect(applyStagedPluginTrees()).toContain('plugins-code');
|
|
expect(read(path.join(root, 'plugins', 'real', 'server', 'index.js'))).toBe('NEW-REAL'); // real replaced
|
|
expect(fs.existsSync(path.join(root, 'plugins', 'linked'))).toBe(true); // dev-link kept
|
|
expect(read(path.join(root, 'plugins', 'linked', 'server', 'index.js'))).toBe('DEV'); // still resolves
|
|
});
|
|
|
|
it('the swap removes a live plugin absent from the backup, and re-running is idempotent (crash-safe)', () => {
|
|
// live has an up-to-date plugin + a plugin uninstalled since the backup was taken
|
|
write(path.join(root, 'plugins-data', 'keep', 'plugin.db'), 'OLD');
|
|
write(path.join(root, 'plugins-data', 'removed-since', 'plugin.db'), 'GONE');
|
|
// staged (from the backup) only has 'keep'
|
|
write(path.join(root, 'plugins-data.restore', 'keep', 'plugin.db'), 'NEW');
|
|
applyStagedPluginTrees();
|
|
expect(read(path.join(root, 'plugins-data', 'keep', 'plugin.db'))).toBe('NEW'); // overwritten
|
|
expect(fs.existsSync(path.join(root, 'plugins-data', 'removed-since'))).toBe(false); // not in backup → removed
|
|
expect(fs.existsSync(path.join(root, 'plugins-data.restore'))).toBe(false); // staging consumed last
|
|
|
|
// Simulate a crash BEFORE staging was deleted: the staging dir still exists, live is
|
|
// already (partly) applied. Copy-not-move means re-running restores correctly, never
|
|
// deleting an already-restored entry it can't recreate.
|
|
write(path.join(root, 'plugins-data.restore', 'keep', 'plugin.db'), 'NEW');
|
|
write(path.join(root, 'plugins-data', 'keep', 'plugin.db'), 'NEW'); // already applied
|
|
applyStagedPluginTrees();
|
|
expect(read(path.join(root, 'plugins-data', 'keep', 'plugin.db'))).toBe('NEW'); // intact, not lost
|
|
expect(fs.existsSync(path.join(root, 'plugins-data.restore'))).toBe(false);
|
|
});
|
|
|
|
it('applyStagedRestoreNow runs the registered applier (runtime quiesce), or reports false when none', async () => {
|
|
expect(await applyStagedRestoreNow()).toBe(false); // no applier registered
|
|
let ran = false;
|
|
setStagedRestoreApplier(async () => {
|
|
ran = true;
|
|
});
|
|
expect(await applyStagedRestoreNow()).toBe(true);
|
|
expect(ran).toBe(true);
|
|
// an applier that throws is caught and reported as not-applied (falls back to boot reconcile)
|
|
setStagedRestoreApplier(() => {
|
|
throw new Error('quiesce failed');
|
|
});
|
|
expect(await applyStagedRestoreNow()).toBe(false);
|
|
setStagedRestoreApplier(null);
|
|
});
|
|
|
|
it('a stale staging from an aborted prior restore is overwritten, not merged', () => {
|
|
write(path.join(root, 'plugins-data.restore', 'ghost', 'plugin.db'), 'STALE');
|
|
const extract = path.join(root, 'restore-x');
|
|
write(path.join(extract, 'plugins-data', 'real', 'plugin.db'), 'FRESH');
|
|
stageExtractedPluginTrees(extract);
|
|
// the stale 'ghost' entry is gone; only the fresh staging remains
|
|
expect(fs.existsSync(path.join(root, 'plugins-data.restore', 'ghost'))).toBe(false);
|
|
expect(read(path.join(root, 'plugins-data.restore', 'real', 'plugin.db'))).toBe('FRESH');
|
|
});
|
|
});
|