Files
TREK/server/tests/unit/plugins/plugin-backup.test.ts
T
2026-10-06 21:50:59 +02:00

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');
});
});