The strict ConfigStore readers previously propagated native filesystem and JSON parser errors. On Node 24, malformed JSON can be quoted in the parser message, so an opaque value from a corrupt history file could cross the collector and diagnostics-agent boundary unchanged when it was not one of the configured secrets. Map strict config and history read, parse, and shape failures to a closed set of constant content-free errors. Keep separate invalid, read-failed, and history-not-found codes so the pre-migration history fallback remains limited to a genuinely absent dedicated file. Do not retain native errors or raw input as causes, while leaving credential decryption failures and the recovery-oriented UI readers unchanged. Add boundary coverage for corrupt config JSON, invalid shapes, and read failures, plus an end-to-end ConfigStore-to-collectors-to-agent regression proving get_history, list_errors, and server_health never return a planted opaque history value.
1059 lines
46 KiB
JavaScript
1059 lines
46 KiB
JavaScript
const { describe, it, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const Module = require('node:module');
|
|
const originalLoad = Module._load;
|
|
const safeStorage = {
|
|
isEncryptionAvailable: () => true,
|
|
encryptString: value => Buffer.from(`test-protected:${value}`),
|
|
decryptString: value => value.toString().replace(/^test-protected:/, '')
|
|
};
|
|
Module._load = function load(request, parent, isMain) {
|
|
if (request === 'electron') return { safeStorage };
|
|
return originalLoad.call(this, request, parent, isMain);
|
|
};
|
|
const ConfigStore = require('../lib/config-store');
|
|
require('../lib/secret-store').encryptField('test-initialization');
|
|
Module._load = originalLoad;
|
|
const { createCollectors } = require('../lib/diagnostics-collectors');
|
|
const { createAgent } = require('../lib/diagnostics-agent');
|
|
const support = require('../lib/support-bundle');
|
|
const stats = require('../lib/stats');
|
|
|
|
let tmpDir;
|
|
let store;
|
|
|
|
function thrownBy(fn) {
|
|
try {
|
|
fn();
|
|
} catch (error) {
|
|
return error;
|
|
}
|
|
assert.fail('Expected function to throw');
|
|
}
|
|
|
|
function createDiagnosticsAgent(configStore, logDir) {
|
|
return createAgent(createCollectors({
|
|
loadConfig: () => configStore.loadDiagnosticsConfig(),
|
|
loadHistory: () => configStore.loadDiagnosticsHistory(),
|
|
getAllLogPaths: () => ({ logDir }),
|
|
support,
|
|
stats,
|
|
appInfo: () => ({}),
|
|
systemInfo: () => ({}),
|
|
agentInfo: () => ({})
|
|
}));
|
|
}
|
|
|
|
function createStore() {
|
|
const fakeApp = {
|
|
isPackaged: false,
|
|
getPath: () => tmpDir
|
|
};
|
|
// ConfigStore uses path.join(__dirname, '..') for non-packaged
|
|
// We override by setting filePath directly
|
|
store = new ConfigStore(fakeApp);
|
|
store.filePath = path.join(tmpDir, 'electron-config.json');
|
|
store.historyPath = path.join(tmpDir, 'electron-history.json');
|
|
return store;
|
|
}
|
|
|
|
describe('ConfigStore', () => {
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-test-'));
|
|
store = createStore();
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('explicit user-data-dir isolates development config writes from the project', async () => {
|
|
const isolatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-user-data-'));
|
|
const projectConfigPath = path.join(__dirname, '..', 'electron-config.json');
|
|
const projectConfigExisted = fs.existsSync(projectConfigPath);
|
|
const projectConfigBefore = projectConfigExisted ? fs.readFileSync(projectConfigPath) : null;
|
|
const explicitStore = new ConfigStore({
|
|
isPackaged: false,
|
|
commandLine: {
|
|
hasSwitch: (name) => name === 'user-data-dir'
|
|
},
|
|
getPath: (name) => {
|
|
if (name === 'userData') return isolatedDir;
|
|
if (name === 'exe') return path.join(isolatedDir, 'Multi-Hoster-Upload.exe');
|
|
throw new Error(`Unexpected app path: ${name}`);
|
|
}
|
|
});
|
|
|
|
try {
|
|
assert.equal(explicitStore.filePath, path.join(isolatedDir, 'electron-config.json'));
|
|
await explicitStore.save({ globalSettings: { alwaysOnTop: true } });
|
|
assert.equal(fs.existsSync(path.join(isolatedDir, 'electron-config.json')), true);
|
|
assert.equal(fs.existsSync(projectConfigPath), projectConfigExisted);
|
|
if (projectConfigBefore) {
|
|
assert.equal(fs.readFileSync(projectConfigPath).equals(projectConfigBefore), true);
|
|
}
|
|
} finally {
|
|
fs.rmSync(isolatedDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('load returns defaults when file does not exist', () => {
|
|
const config = store.load();
|
|
assert.ok(config.hosters);
|
|
assert.ok(config.hosters['doodstream.com']);
|
|
assert.ok(config.hosters['voe.sx']);
|
|
assert.ok(config.hosters['vidmoly.me']);
|
|
assert.ok(config.hosters['byse.sx']);
|
|
assert.ok(config.hosterSettings);
|
|
assert.equal(config.hosterSettings['doodstream.com'].retries, 3);
|
|
assert.equal(config.hosterSettings['doodstream.com'].parallelCount, 2);
|
|
assert.equal(config.globalSettings.alwaysOnTop, false);
|
|
assert.equal(config.globalSettings.language, 'en');
|
|
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing');
|
|
assert.equal(config.globalSettings.logFilePath, '');
|
|
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
|
|
assert.equal(Object.hasOwn(config.globalSettings, 'allowPlaintextCredentialStorage'), false);
|
|
assert.equal(config.globalSettings.parallelUploadCount, 0);
|
|
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
|
assert.equal(config.globalSettings.lastBrowseDirectory, '');
|
|
assert.equal(config.globalSettings.pendingQueue, null);
|
|
assert.deepEqual(config.history, []);
|
|
});
|
|
|
|
it('drops the retired plaintext credential setting from legacy configurations', () => {
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
hosters: {},
|
|
hosterSettings: {},
|
|
globalSettings: { allowPlaintextCredentialStorage: true }
|
|
}), 'utf-8');
|
|
|
|
const config = store.load();
|
|
|
|
assert.equal(Object.hasOwn(config.globalSettings, 'allowPlaintextCredentialStorage'), false);
|
|
});
|
|
|
|
it('never writes the retired plaintext credential setting back to disk', async () => {
|
|
await store.save({
|
|
globalSettings: { allowPlaintextCredentialStorage: true }
|
|
});
|
|
|
|
const saved = JSON.parse(fs.readFileSync(store.filePath, 'utf-8'));
|
|
|
|
assert.equal(Object.hasOwn(saved.globalSettings, 'allowPlaintextCredentialStorage'), false);
|
|
});
|
|
|
|
it('keeps permanent source deletion disabled by default', () => {
|
|
const config = store.load();
|
|
assert.equal(config.globalSettings.deleteSourceAfterSuccessfulUpload, false);
|
|
});
|
|
|
|
it('save then load round-trips', async () => {
|
|
await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'test-key-123' }] } });
|
|
const config = store.load();
|
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'test-key-123');
|
|
});
|
|
|
|
it('default logMode is "single"', () => {
|
|
const config = store.load();
|
|
assert.equal(config.globalSettings.logMode, 'single');
|
|
});
|
|
|
|
it('persists pendingQueue incl. savedAt (number) and ts-bearing jobs across save/load', async () => {
|
|
// The queue-persistence fix stamps pendingQueue.savedAt and restores it on launch.
|
|
// This proves the persistence layer round-trips the new fields untouched (the
|
|
// ts-gate is worthless if savedAt does not survive serialization).
|
|
const pendingQueue = {
|
|
savedAt: 1750000000123,
|
|
selectedUploadHosters: ['voe.sx', 'byse.sx'],
|
|
selectedFiles: [{ path: 'C:/dl/a.mkv', name: 'a.mkv', size: 4242 }],
|
|
queueJobs: [
|
|
{ id: 'j1', file: 'C:/dl/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'preview', bytesTotal: 4242, maxAttempts: 0 },
|
|
{ id: 'j2', file: 'C:/dl/a.mkv', fileName: 'a.mkv', hoster: 'byse.sx', status: 'error', error: 'boom', maxAttempts: 3 }
|
|
]
|
|
};
|
|
const current = store.load();
|
|
await store.save({ globalSettings: { ...current.globalSettings, pendingQueue } });
|
|
const loaded = store.load();
|
|
const pq = loaded.globalSettings.pendingQueue;
|
|
assert.equal(pq.savedAt, 1750000000123, 'savedAt epoch survives JSON round-trip');
|
|
assert.equal(typeof pq.savedAt, 'number');
|
|
assert.equal(pq.queueJobs.length, 2);
|
|
assert.equal(pq.queueJobs[0].fileName, 'a.mkv');
|
|
assert.equal(pq.queueJobs[0].hoster, 'voe.sx');
|
|
assert.equal(pq.queueJobs[1].status, 'error');
|
|
assert.deepEqual(pq.selectedUploadHosters, ['voe.sx', 'byse.sx']);
|
|
assert.equal(pq.selectedFiles[0].path, 'C:/dl/a.mkv');
|
|
});
|
|
|
|
it('pendingQueue can be cleared back to null (clearPersistedQueueStateSoon path)', async () => {
|
|
const current = store.load();
|
|
await store.save({ globalSettings: { ...current.globalSettings, pendingQueue: { savedAt: 1, queueJobs: [] } } });
|
|
assert.ok(store.load().globalSettings.pendingQueue);
|
|
const c2 = store.load();
|
|
await store.save({ globalSettings: { ...c2.globalSettings, pendingQueue: null } });
|
|
assert.equal(store.load().globalSettings.pendingQueue, null);
|
|
});
|
|
|
|
it('regression: legacy sessionLog:true on disk normalizes to logMode "daily" (NOT "session")', async () => {
|
|
// Write a config with the legacy boolean only (what an existing user has).
|
|
await store.save({ globalSettings: { sessionLog: true } });
|
|
const config = store.load();
|
|
// The misnamed legacy field MUST map to daily — mapping to "session" would
|
|
// silently change every per-day user's behaviour on upgrade.
|
|
assert.equal(config.globalSettings.logMode, 'daily');
|
|
});
|
|
|
|
it('logMode round-trips for all three values', async () => {
|
|
for (const mode of ['single', 'daily', 'session']) {
|
|
await store.save({ globalSettings: { logMode: mode } });
|
|
const config = store.load();
|
|
assert.equal(config.globalSettings.logMode, mode, `mode ${mode}`);
|
|
}
|
|
});
|
|
|
|
it('load merges with defaults for missing hosters', () => {
|
|
// Write partial config in old single-object format (triggers migration)
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
hosters: { 'doodstream.com': { apiKey: 'abc' } }
|
|
}), 'utf-8');
|
|
|
|
const config = store.load();
|
|
// Old format is migrated to array
|
|
assert.ok(Array.isArray(config.hosters['doodstream.com']));
|
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'abc');
|
|
// Other hosters should still have defaults (empty arrays)
|
|
assert.ok(Array.isArray(config.hosters['voe.sx']));
|
|
assert.equal(config.hosters['voe.sx'].length, 0);
|
|
});
|
|
|
|
it('hosterSettings merge fills gaps with defaults', () => {
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
hosterSettings: { 'voe.sx': { retries: 5 } }
|
|
}), 'utf-8');
|
|
|
|
const config = store.load();
|
|
assert.equal(config.hosterSettings['voe.sx'].retries, 5);
|
|
assert.equal(config.hosterSettings['voe.sx'].parallelCount, 2); // default
|
|
assert.equal(config.hosterSettings['voe.sx'].maxSpeedKbs, 0); // default
|
|
assert.equal(config.hosterSettings['voe.sx'].logToFile, true); // default on
|
|
});
|
|
|
|
it('logToFile defaults to true for every hoster', () => {
|
|
const config = store.load();
|
|
for (const name of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']) {
|
|
assert.equal(config.hosterSettings[name].logToFile, true, `${name} should default logToFile=true`);
|
|
}
|
|
});
|
|
|
|
it('sizeMemoEnabled defaults to true for every hoster and persists when disabled', async () => {
|
|
const fresh = store.load();
|
|
for (const name of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']) {
|
|
assert.equal(fresh.hosterSettings[name].sizeMemoEnabled, true, `${name} should default sizeMemoEnabled=true`);
|
|
}
|
|
await store.save({ hosterSettings: { 'byse.sx': { sizeMemoEnabled: false } } });
|
|
const config = store.load();
|
|
assert.equal(config.hosterSettings['byse.sx'].sizeMemoEnabled, false, 'explicit false preserved');
|
|
assert.equal(config.hosterSettings['voe.sx'].sizeMemoEnabled, true, 'other hoster still defaults on');
|
|
});
|
|
|
|
it('logToFile=false persists and survives reload', async () => {
|
|
await store.save({ hosterSettings: { 'voe.sx': { logToFile: false } } });
|
|
const config = store.load();
|
|
assert.equal(config.hosterSettings['voe.sx'].logToFile, false, 'explicit false preserved');
|
|
assert.equal(config.hosterSettings['byse.sx'].logToFile, true, 'other hoster still defaults on');
|
|
});
|
|
|
|
it('save only updates provided sections', async () => {
|
|
// Save hoster settings first
|
|
await store.save({ hosterSettings: { 'doodstream.com': { retries: 10, maxSpeedKbs: 0, parallelCount: 2, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } });
|
|
// Save hosters credentials separately (array format)
|
|
await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'key123' }] } });
|
|
|
|
const config = store.load();
|
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'key123');
|
|
assert.equal(config.hosterSettings['doodstream.com'].retries, 10); // preserved
|
|
});
|
|
|
|
it('appendHistory keeps complete history without truncation', async () => {
|
|
for (let i = 0; i < 105; i++) {
|
|
await store.appendHistory({ id: `batch-${i}`, timestamp: new Date().toISOString(), files: [] });
|
|
}
|
|
const history = store.loadHistory();
|
|
assert.equal(history.length, 105);
|
|
assert.equal(history[0].id, 'batch-0');
|
|
assert.equal(history[104].id, 'batch-104');
|
|
});
|
|
|
|
it('durably syncs migrated history before replacing the live file', async () => {
|
|
store._historyMigrated = true;
|
|
fs.writeFileSync(store.historyPath, '[]', 'utf-8');
|
|
const originalOpen = fs.promises.open;
|
|
const originalRename = fs.promises.rename;
|
|
let synced = false;
|
|
let renamed = false;
|
|
fs.promises.open = async (...args) => {
|
|
const handle = await originalOpen(...args);
|
|
const originalSync = handle.sync.bind(handle);
|
|
handle.sync = async () => {
|
|
await originalSync();
|
|
synced = true;
|
|
};
|
|
return handle;
|
|
};
|
|
fs.promises.rename = async (...args) => {
|
|
assert.equal(synced, true);
|
|
renamed = true;
|
|
return originalRename(...args);
|
|
};
|
|
|
|
try {
|
|
await store.appendHistory({ id: 'durable', files: [] });
|
|
} finally {
|
|
fs.promises.open = originalOpen;
|
|
fs.promises.rename = originalRename;
|
|
}
|
|
|
|
assert.equal(renamed, true);
|
|
assert.deepEqual(store.loadHistory().map(entry => entry.id), ['durable']);
|
|
});
|
|
|
|
it('clearHistory empties the array', async () => {
|
|
await store.appendHistory({ id: 'test', files: [] });
|
|
assert.equal(store.loadHistory().length, 1);
|
|
await store.clearHistory();
|
|
assert.equal(store.loadHistory().length, 0);
|
|
});
|
|
|
|
it('rotationCursors default to an empty object', () => {
|
|
const config = store.load();
|
|
assert.deepEqual(config.rotationCursors, {});
|
|
});
|
|
|
|
it('saveRotationCursors round-trips and survives reload', async () => {
|
|
await store.saveRotationCursors({ 'byse.sx': 7, 'voe.sx': 2 });
|
|
const config = store.load();
|
|
assert.equal(config.rotationCursors['byse.sx'], 7);
|
|
assert.equal(config.rotationCursors['voe.sx'], 2);
|
|
});
|
|
|
|
it('an unrelated save() does not clobber persisted rotationCursors', async () => {
|
|
await store.saveRotationCursors({ 'byse.sx': 3 });
|
|
await store.save({ globalSettings: { alwaysOnTop: true } });
|
|
const config = store.load();
|
|
assert.equal(config.rotationCursors['byse.sx'], 3, 'cursor preserved across a settings save');
|
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
|
});
|
|
|
|
it('saveRotationCursors does not disturb credentials', async () => {
|
|
await store.save({ hosters: { 'byse.sx': [{ id: 'k1', enabled: true, authType: 'api', apiKey: 'secret-key' }] } });
|
|
await store.saveRotationCursors({ 'byse.sx': 1 });
|
|
const config = store.load();
|
|
assert.equal(config.hosters['byse.sx'][0].apiKey, 'secret-key');
|
|
assert.equal(config.rotationCursors['byse.sx'], 1);
|
|
});
|
|
|
|
it('corrupted JSON falls back to defaults', () => {
|
|
fs.writeFileSync(store.filePath, '{invalid json!!!', 'utf-8');
|
|
const config = store.load();
|
|
assert.ok(config.hosters);
|
|
assert.ok(config.hosterSettings);
|
|
assert.deepEqual(config.history, []);
|
|
});
|
|
|
|
it('globalSettings merge preserves partial values', () => {
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
globalSettings: { alwaysOnTop: true }
|
|
}), 'utf-8');
|
|
|
|
const config = store.load();
|
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
|
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing'); // default
|
|
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
|
|
assert.equal(config.globalSettings.parallelUploadCount, 0);
|
|
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
|
assert.equal(config.globalSettings.logFilePath, '');
|
|
});
|
|
|
|
it('concurrent saves preserve both sections', async () => {
|
|
const save1 = store.save({ hosters: { 'doodstream.com': [{ id: 'c1', enabled: true, authType: 'api', apiKey: 'concurrent-key' }] } });
|
|
const save2 = store.save({ globalSettings: { alwaysOnTop: true } });
|
|
await Promise.all([save1, save2]);
|
|
const config = store.load();
|
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'concurrent-key');
|
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
|
});
|
|
|
|
it('drainWrites waits for config and history writes appended while draining', async () => {
|
|
assert.equal(typeof store.drainWrites, 'function');
|
|
await store.save({ globalSettings: { alwaysOnTop: false } });
|
|
store._historyMigrated = true;
|
|
fs.writeFileSync(store.historyPath, '[]', 'utf-8');
|
|
|
|
const originalAtomicWrite = store._atomicWrite.bind(store);
|
|
const originalHistoryWrite = store._writeHistoryFileAtomic.bind(store);
|
|
const configReleases = [];
|
|
const historyReleases = [];
|
|
const block = (releases, operation) => new Promise((resolve, reject) => {
|
|
releases.push(() => Promise.resolve().then(operation).then(resolve, reject));
|
|
});
|
|
store._atomicWrite = (data) => block(configReleases, () => originalAtomicWrite(data));
|
|
store._writeHistoryFileAtomic = (history) => block(historyReleases, () => originalHistoryWrite(history));
|
|
|
|
const configWrites = store.save({ globalSettings: { alwaysOnTop: true } })
|
|
.then(() => store.save({ hosterSettings: { 'byse.sx': { retries: 8 } } }));
|
|
const historyWrites = store.appendHistory({ id: 'first', files: [] })
|
|
.then(() => store.appendHistory({ id: 'second', files: [] }));
|
|
|
|
while (configReleases.length < 1 || historyReleases.length < 1) await new Promise(resolve => setImmediate(resolve));
|
|
let drained = false;
|
|
const draining = store.drainWrites().then(() => { drained = true; });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(drained, false);
|
|
|
|
configReleases.shift()();
|
|
historyReleases.shift()();
|
|
while (configReleases.length < 1 || historyReleases.length < 1) await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(drained, false);
|
|
|
|
configReleases.shift()();
|
|
historyReleases.shift()();
|
|
await Promise.all([configWrites, historyWrites, draining]);
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, true);
|
|
assert.equal(store.load().hosterSettings['byse.sx'].retries, 8);
|
|
assert.deepEqual(store.loadHistory().map(entry => entry.id), ['first', 'second']);
|
|
});
|
|
|
|
it('drainWrites ignores caller-handled failures but propagates a write failing during the drain', async () => {
|
|
const originalAtomicWrite = store._atomicWrite.bind(store);
|
|
store._atomicWrite = () => Promise.reject(new Error('config write failed'));
|
|
await assert.rejects(store.save({ globalSettings: { alwaysOnTop: true } }), /config write failed/);
|
|
await store.drainWrites();
|
|
|
|
store._atomicWrite = originalAtomicWrite;
|
|
store._historyMigrated = true;
|
|
fs.writeFileSync(store.historyPath, '[]', 'utf-8');
|
|
let rejectHistoryWrite;
|
|
store._writeHistoryFileAtomic = () => new Promise((_resolve, reject) => { rejectHistoryWrite = reject; });
|
|
const pendingWrite = store.appendHistory({ id: 'failed', files: [] });
|
|
pendingWrite.catch(() => {});
|
|
while (!rejectHistoryWrite) await new Promise(resolve => setImmediate(resolve));
|
|
const draining = store.drainWrites();
|
|
rejectHistoryWrite(new Error('history write failed'));
|
|
await assert.rejects(draining, /history write failed/);
|
|
await assert.rejects(pendingWrite, /history write failed/);
|
|
await store.drainWrites();
|
|
});
|
|
|
|
it('surfaces a failed queued write and keeps later writes usable', async () => {
|
|
const originalAtomicWrite = store._atomicWrite.bind(store);
|
|
let failNextWrite = true;
|
|
store._atomicWrite = (data) => {
|
|
if (failNextWrite) {
|
|
failNextWrite = false;
|
|
return Promise.reject(new Error('write failed'));
|
|
}
|
|
return originalAtomicWrite(data);
|
|
};
|
|
|
|
await assert.rejects(store.appendHistory({ id: 'failed', files: [] }), /write failed/);
|
|
await store.appendHistory({ id: 'saved', files: [] });
|
|
assert.deepEqual(store.loadHistory().map(entry => entry.id), ['saved']);
|
|
});
|
|
|
|
it('serializes a complete settings replacement with pending saves', async () => {
|
|
const originalAtomicWrite = store._atomicWrite.bind(store);
|
|
let activeWrites = 0;
|
|
let maximumActiveWrites = 0;
|
|
store._atomicWrite = async (data) => {
|
|
activeWrites += 1;
|
|
maximumActiveWrites = Math.max(maximumActiveWrites, activeWrites);
|
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
try {
|
|
await originalAtomicWrite(data);
|
|
} finally {
|
|
activeWrites -= 1;
|
|
}
|
|
};
|
|
|
|
const save = store.save({ globalSettings: { alwaysOnTop: true, pendingQueue: { savedAt: 123, queueJobs: [{ id: 'local' }] } } });
|
|
const replace = store.replaceSettings({
|
|
hosters: { 'byse.sx': [{ id: 'imported', enabled: true, authType: 'api', apiKey: 'imported-key' }] },
|
|
hosterSettings: { 'byse.sx': { retries: 9 } },
|
|
globalSettings: { alwaysOnTop: false, pendingQueue: null },
|
|
history: [],
|
|
rotationCursors: {}
|
|
});
|
|
|
|
await Promise.all([save, replace]);
|
|
const config = store.load();
|
|
assert.equal(maximumActiveWrites, 1);
|
|
assert.equal(config.hosters['byse.sx'][0].apiKey, 'imported-key');
|
|
assert.equal(config.hosterSettings['byse.sx'].retries, 9);
|
|
assert.equal(config.globalSettings.alwaysOnTop, false);
|
|
assert.deepEqual(config.globalSettings.pendingQueue, { savedAt: 123, queueJobs: [{ id: 'local' }] });
|
|
assert.deepEqual(config.rotationCursors, {});
|
|
});
|
|
|
|
it('patches the pending queue after an import without reverting imported settings', async () => {
|
|
await store.save({
|
|
globalSettings: {
|
|
alwaysOnTop: true,
|
|
webhookUrl: 'https://before.invalid',
|
|
pendingQueue: { savedAt: 1, queueJobs: [{ id: 'before' }] }
|
|
}
|
|
});
|
|
|
|
const replace = store.replaceSettings({
|
|
hosters: { 'byse.sx': [{ id: 'imported', enabled: true, authType: 'api', apiKey: 'imported-key' }] },
|
|
hosterSettings: { 'byse.sx': { retries: 9 } },
|
|
globalSettings: {
|
|
alwaysOnTop: false,
|
|
webhookUrl: 'https://imported.invalid',
|
|
pendingQueue: null
|
|
},
|
|
history: [],
|
|
rotationCursors: {}
|
|
});
|
|
const pendingQueue = {
|
|
savedAt: 2,
|
|
queueJobs: [{ id: 'live', status: 'done' }]
|
|
};
|
|
const saveQueue = store.savePendingQueue(pendingQueue);
|
|
|
|
await Promise.all([replace, saveQueue]);
|
|
const config = store.load();
|
|
assert.equal(config.hosters['byse.sx'][0].id, 'imported');
|
|
assert.equal(config.globalSettings.alwaysOnTop, false);
|
|
assert.equal(config.globalSettings.webhookUrl, 'https://imported.invalid');
|
|
assert.deepEqual(config.globalSettings.pendingQueue, pendingQueue);
|
|
});
|
|
|
|
it('preserves main-owned global state when saving a renderer snapshot', async () => {
|
|
await store.save({
|
|
globalSettings: {
|
|
alwaysOnTop: false,
|
|
pendingQueue: { savedAt: 3, queueJobs: [{ id: 'local' }] },
|
|
diagnostics: { enabled: true, port: 7777 },
|
|
historyRetention: '7d',
|
|
remote: { enabled: true, port: 9100, token: 'main-token', allowInput: true }
|
|
}
|
|
});
|
|
|
|
await store.saveRendererGlobalSettings({
|
|
alwaysOnTop: true,
|
|
pendingQueue: null,
|
|
diagnostics: { enabled: false, port: 1 },
|
|
historyRetention: 'all',
|
|
remote: { enabled: true, port: 9200, token: '', allowInput: false }
|
|
});
|
|
|
|
const config = store.load();
|
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
|
assert.deepEqual(config.globalSettings.pendingQueue, { savedAt: 3, queueJobs: [{ id: 'local' }] });
|
|
assert.equal(config.globalSettings.diagnostics.enabled, true);
|
|
assert.equal(config.globalSettings.diagnostics.port, 7777);
|
|
assert.equal(config.globalSettings.historyRetention, '7d');
|
|
assert.deepEqual(config.globalSettings.remote, { enabled: true, port: 9200, token: 'main-token', allowInput: false });
|
|
});
|
|
|
|
it('persists the last browse directory across stale renderer settings saves', async () => {
|
|
const selectedDirectory = path.join(tmpDir, 'selected');
|
|
|
|
await store.saveLastBrowseDirectory(selectedDirectory);
|
|
await store.saveRendererGlobalSettings({
|
|
alwaysOnTop: true,
|
|
lastBrowseDirectory: ''
|
|
});
|
|
|
|
assert.equal(store.load().globalSettings.lastBrowseDirectory, selectedDirectory);
|
|
});
|
|
|
|
it('merges the fallback log path after earlier queued settings without reverting them', async () => {
|
|
assert.equal(typeof store.saveFallbackLogPath, 'function');
|
|
await store.save({
|
|
globalSettings: {
|
|
alwaysOnTop: false,
|
|
webhookUrl: 'https://before.invalid',
|
|
logFilePath: ''
|
|
}
|
|
});
|
|
|
|
const originalAtomicWrite = store._atomicWrite.bind(store);
|
|
let releaseSettingsWrite;
|
|
let signalSettingsWriteStarted;
|
|
const settingsWriteStarted = new Promise(resolve => { signalSettingsWriteStarted = resolve; });
|
|
store._atomicWrite = (data) => {
|
|
const settings = JSON.parse(data).globalSettings;
|
|
if (!releaseSettingsWrite && settings.webhookUrl === 'https://concurrent.invalid') {
|
|
signalSettingsWriteStarted();
|
|
return new Promise((resolve, reject) => {
|
|
releaseSettingsWrite = () => originalAtomicWrite(data).then(resolve, reject);
|
|
});
|
|
}
|
|
return originalAtomicWrite(data);
|
|
};
|
|
|
|
const current = store.load();
|
|
const settingsSave = store.save({
|
|
globalSettings: {
|
|
...current.globalSettings,
|
|
alwaysOnTop: true,
|
|
webhookUrl: 'https://concurrent.invalid'
|
|
}
|
|
});
|
|
await settingsWriteStarted;
|
|
const fallbackPath = path.join(tmpDir, 'fallback', 'fileuploader.log');
|
|
const fallbackSave = store.saveFallbackLogPath(fallbackPath);
|
|
releaseSettingsWrite();
|
|
await Promise.all([settingsSave, fallbackSave]);
|
|
|
|
const saved = store.load().globalSettings;
|
|
assert.equal(saved.alwaysOnTop, true);
|
|
assert.equal(saved.webhookUrl, 'https://concurrent.invalid');
|
|
assert.equal(saved.logFilePath, fallbackPath);
|
|
});
|
|
|
|
it('merges remote settings in the write queue and returns the canonical token', async () => {
|
|
await store.save({
|
|
globalSettings: {
|
|
remote: { enabled: false, port: 9100, token: 'canonical-token', allowInput: true }
|
|
}
|
|
});
|
|
|
|
const rendererSave = store.saveRendererGlobalSettings({
|
|
alwaysOnTop: true,
|
|
remote: { enabled: false, port: 9200, token: '', allowInput: false }
|
|
});
|
|
const remoteSave = store.saveRemoteSettings(
|
|
{ enabled: true, port: 9300, token: '', allowInput: false },
|
|
() => 'generated-token'
|
|
);
|
|
|
|
const [, canonical] = await Promise.all([rendererSave, remoteSave]);
|
|
assert.deepEqual(canonical, { enabled: true, port: 9300, token: 'canonical-token', allowInput: false });
|
|
assert.deepEqual(store.load().globalSettings.remote, canonical);
|
|
});
|
|
|
|
it('rejects ordinary writes while quiesced and permits only the final pending queue snapshot', async () => {
|
|
store.setWritesQuiesced(true);
|
|
|
|
await assert.rejects(store.save({ globalSettings: { alwaysOnTop: true } }), /beendet/);
|
|
await assert.rejects(store.appendHistory({ id: 'late-history', files: [] }), /beendet/);
|
|
await store.savePendingQueue({ savedAt: 4, queueJobs: [{ id: 'final' }] }, { allowDuringQuiesce: true });
|
|
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, false);
|
|
assert.deepEqual(store.load().globalSettings.pendingQueue, { savedAt: 4, queueJobs: [{ id: 'final' }] });
|
|
store.setWritesQuiesced(false);
|
|
await store.save({ globalSettings: { ...store.load().globalSettings, alwaysOnTop: true } });
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, true);
|
|
});
|
|
|
|
it('load() returns independent clones — mutating one result must not leak into the cache', () => {
|
|
store.load(); // warm the cache
|
|
const a = store.load();
|
|
a.globalSettings.alwaysOnTop = true;
|
|
a.hosters['voe.sx'].push({ id: 'mutant' });
|
|
a.history.push({ id: 'ghost' });
|
|
const b = store.load();
|
|
assert.equal(b.globalSettings.alwaysOnTop, false, 'mutating a prior load() result must not corrupt the cache');
|
|
assert.equal(b.hosters['voe.sx'].length, 0);
|
|
assert.equal(b.history.length, 0);
|
|
});
|
|
|
|
it('load() reflects an external file change (mtime/size cache invalidation)', () => {
|
|
store.load(); // warm cache on the no-file defaults
|
|
fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: true } }), 'utf-8');
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, true, 'an external write must invalidate the cache');
|
|
fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: false } }), 'utf-8');
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, false, 'a second external write must be seen too');
|
|
});
|
|
|
|
it('save() invalidates the cache so the next load() sees the new value', async () => {
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, false);
|
|
await store.save({ globalSettings: { alwaysOnTop: true } });
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, true, 'load() after save() must reflect the write');
|
|
});
|
|
|
|
it('backup recovery when main file is corrupted', () => {
|
|
// Write valid config first
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
hosters: { 'doodstream.com': [{ id: 'bak-1', authType: 'api', apiKey: 'from-backup' }] },
|
|
hosterSettings: {}, globalSettings: {}, history: []
|
|
}), 'utf-8');
|
|
// Copy to backup
|
|
fs.copyFileSync(store.filePath, store.filePath + '.bak');
|
|
// Corrupt main file
|
|
fs.writeFileSync(store.filePath, 'CORRUPTED!!!', 'utf-8');
|
|
const config = store.load();
|
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup');
|
|
});
|
|
|
|
it('strict diagnostics config loading rejects primary failures while normal loading keeps recovery', () => {
|
|
assert.equal(typeof store.loadDiagnosticsConfig, 'function');
|
|
|
|
fs.writeFileSync(store.filePath + '.bak', JSON.stringify({
|
|
hosters: { 'doodstream.com': [{ id: 'bak-1', authType: 'api', apiKey: 'from-backup' }] },
|
|
hosterSettings: {},
|
|
globalSettings: {},
|
|
history: []
|
|
}), 'utf-8');
|
|
fs.writeFileSync(store.filePath, '{broken-config', 'utf-8');
|
|
|
|
assert.equal(store.load().hosters['doodstream.com'][0].apiKey, 'from-backup');
|
|
assert.throws(() => store.loadDiagnosticsConfig());
|
|
|
|
fs.rmSync(store.filePath);
|
|
assert.equal(store.load().globalSettings.language, 'en');
|
|
assert.throws(() => store.loadDiagnosticsConfig());
|
|
});
|
|
|
|
it('strict diagnostics config errors never expose malformed JSON or file paths', () => {
|
|
const opaque = 'opaque-config-42';
|
|
fs.writeFileSync(store.filePath, opaque, 'utf-8');
|
|
|
|
let error = thrownBy(() => store.loadDiagnosticsConfig());
|
|
assert.equal(error.code, 'DIAGNOSTIC_CONFIG_INVALID');
|
|
assert.equal(error.message, 'Die Diagnosekonfiguration ist ungültig');
|
|
assert.ok(!error.message.includes(opaque));
|
|
|
|
fs.writeFileSync(store.filePath, '{}', 'utf-8');
|
|
error = thrownBy(() => store.loadDiagnosticsConfig());
|
|
assert.equal(error.code, 'DIAGNOSTIC_CONFIG_INVALID');
|
|
assert.equal(error.message, 'Die Diagnosekonfiguration ist ungültig');
|
|
|
|
fs.rmSync(store.filePath);
|
|
error = thrownBy(() => store.loadDiagnosticsConfig());
|
|
assert.equal(error.code, 'DIAGNOSTIC_CONFIG_READ_FAILED');
|
|
assert.equal(error.message, 'Die Diagnosekonfiguration konnte nicht gelesen werden');
|
|
assert.ok(!error.message.includes(store.filePath));
|
|
});
|
|
|
|
it('strict diagnostics config loading decrypts current credentials and propagates decryption failures', () => {
|
|
assert.equal(typeof store.loadDiagnosticsConfig, 'function');
|
|
|
|
const encrypted = `enc:v1:${Buffer.from('test-protected:diagnostic-secret').toString('base64')}`;
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
hosters: { 'doodstream.com': [{ id: 'diag-1', authType: 'api', apiKey: encrypted }] },
|
|
hosterSettings: {},
|
|
globalSettings: {},
|
|
history: []
|
|
}), 'utf-8');
|
|
|
|
assert.equal(store.loadDiagnosticsConfig().hosters['doodstream.com'][0].apiKey, 'diagnostic-secret');
|
|
|
|
const originalDecryptString = safeStorage.decryptString;
|
|
safeStorage.decryptString = () => { throw new Error('diagnostic decrypt failure'); };
|
|
try {
|
|
assert.throws(() => store.loadDiagnosticsConfig(), /Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden/);
|
|
} finally {
|
|
safeStorage.decryptString = originalDecryptString;
|
|
}
|
|
});
|
|
|
|
it('wipe-guard: a settings-only save recovers accounts from .bak when the live config validly has none', async () => {
|
|
// Post-wipe state: live config parses fine but has empty hosters; a backup still holds the accounts.
|
|
fs.writeFileSync(store.filePath, JSON.stringify({ hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] }), 'utf-8');
|
|
fs.writeFileSync(store.filePath + '.bak', JSON.stringify({
|
|
hosters: { 'voe.sx': [{ id: 'v1', authType: 'api', apiKey: 'survive-key' }] },
|
|
hosterSettings: {}, globalSettings: {}, history: []
|
|
}), 'utf-8');
|
|
await store.save({ globalSettings: { alwaysOnTop: true } });
|
|
const cfg = store.load();
|
|
assert.ok(cfg.hosters['voe.sx'] && cfg.hosters['voe.sx'].length === 1, 'guard must restore accounts from .bak, not persist the wipe');
|
|
assert.equal(cfg.hosters['voe.sx'][0].apiKey, 'survive-key');
|
|
assert.equal(cfg.globalSettings.alwaysOnTop, true);
|
|
});
|
|
|
|
it('wipe-guard: an explicit save({hosters:{}}) (user deleted all) is NOT blocked', async () => {
|
|
await store.save({ hosters: { 'doodstream.com': [{ id: 'd1', authType: 'api', apiKey: 'k' }] } });
|
|
await store.save({ hosters: {} });
|
|
const cfg = store.load();
|
|
assert.equal((cfg.hosters['doodstream.com'] || []).length, 0, 'an intentional hosters write must be allowed to empty them');
|
|
});
|
|
});
|
|
|
|
describe('ConfigStore history split (electron-history.json)', () => {
|
|
let dir;
|
|
let s;
|
|
|
|
function makeStore() {
|
|
const st = new ConfigStore({ isPackaged: false, getPath: () => dir });
|
|
st.filePath = path.join(dir, 'electron-config.json');
|
|
st.historyPath = path.join(dir, 'electron-history.json');
|
|
return st;
|
|
}
|
|
|
|
function writeConfigWithHistory(n) {
|
|
const history = [];
|
|
for (let i = 0; i < n; i++) history.push({ id: `batch-${i}`, timestamp: 1750000000000 + i, total: 3, files: [{ name: `f${i}.mkv` }] });
|
|
fs.writeFileSync(path.join(dir, 'electron-config.json'), JSON.stringify({
|
|
hosters: { 'byse.sx': [{ id: 'a1', authType: 'api', apiKey: 'k' }] },
|
|
hosterSettings: {}, globalSettings: { historyRetention: 'all' }, history
|
|
}), 'utf-8');
|
|
}
|
|
|
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-hist-')); s = makeStore(); });
|
|
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
|
|
it('migration moves history into electron-history.json, preserving every entry', () => {
|
|
writeConfigWithHistory(50);
|
|
s._migrateHistory();
|
|
assert.equal(s._historyMigrated, true);
|
|
assert.ok(fs.existsSync(s.historyPath));
|
|
const hist = JSON.parse(fs.readFileSync(s.historyPath, 'utf-8'));
|
|
assert.equal(hist.length, 50);
|
|
assert.equal(hist[0].id, 'batch-0');
|
|
assert.equal(hist[49].id, 'batch-49');
|
|
assert.ok(fs.existsSync(s.filePath + '.pre-history-split.bak'), 'a permanent pre-split backup is kept');
|
|
});
|
|
|
|
it('after migration load() excludes history (cheap hot path) but loadHistory() returns the real data', () => {
|
|
writeConfigWithHistory(30);
|
|
s._migrateHistory();
|
|
assert.deepEqual(s.load().history, [], 'history is not carried in the always-loaded config');
|
|
assert.equal(s.loadHistory().length, 30);
|
|
});
|
|
|
|
it('strict diagnostics history accepts valid empty history and rejects failed or corrupt dedicated reads', () => {
|
|
assert.equal(typeof s.loadDiagnosticsHistory, 'function');
|
|
writeConfigWithHistory(7);
|
|
s._migrateHistory();
|
|
|
|
fs.writeFileSync(s.historyPath, '[]', 'utf-8');
|
|
assert.deepEqual(s.loadDiagnosticsHistory(), []);
|
|
|
|
for (const invalidHistory of ['null', '{}', '{"history":null}', '{broken-history']) {
|
|
fs.writeFileSync(s.historyPath, invalidHistory, 'utf-8');
|
|
const error = thrownBy(() => s.loadDiagnosticsHistory());
|
|
assert.equal(error.code, 'DIAGNOSTIC_HISTORY_INVALID');
|
|
assert.equal(error.message, 'Die Diagnoseverlaufsdatei ist ungültig');
|
|
assert.ok(!error.message.includes(invalidHistory));
|
|
}
|
|
|
|
fs.rmSync(s.historyPath);
|
|
fs.mkdirSync(s.historyPath);
|
|
const error = thrownBy(() => s.loadDiagnosticsHistory());
|
|
assert.equal(error.code, 'DIAGNOSTIC_HISTORY_READ_FAILED');
|
|
assert.equal(error.message, 'Die Diagnoseverlaufsdatei konnte nicht gelesen werden');
|
|
assert.ok(!error.message.includes(s.historyPath));
|
|
});
|
|
|
|
it('real diagnostics agent never returns opaque corrupt dedicated history content', () => {
|
|
const opaque = 'opaque42';
|
|
writeConfigWithHistory(0);
|
|
fs.writeFileSync(s.historyPath, opaque, 'utf-8');
|
|
const agent = createDiagnosticsAgent(s, dir);
|
|
|
|
for (const operation of ['get_history', 'list_errors', 'server_health']) {
|
|
const response = agent.handle(operation, {});
|
|
assert.deepEqual(response, { ok: false, error: 'Die Diagnoseverlaufsdatei ist ungültig' });
|
|
assert.ok(!JSON.stringify(response).includes(opaque));
|
|
}
|
|
});
|
|
|
|
it('strict diagnostics history never falls back to stale config history when a dedicated file exists', () => {
|
|
assert.equal(typeof s.loadDiagnosticsHistory, 'function');
|
|
writeConfigWithHistory(7);
|
|
fs.writeFileSync(s.historyPath, 'null', 'utf-8');
|
|
|
|
assert.equal(s._historyMigrated, false);
|
|
assert.equal(s.loadHistory().length, 7);
|
|
assert.throws(() => s.loadDiagnosticsHistory());
|
|
});
|
|
|
|
it('strict diagnostics history preserves the valid pre-migration history path', () => {
|
|
assert.equal(typeof s.loadDiagnosticsHistory, 'function');
|
|
writeConfigWithHistory(7);
|
|
|
|
assert.equal(s.loadDiagnosticsHistory().length, 7);
|
|
|
|
const config = JSON.parse(fs.readFileSync(s.filePath, 'utf-8'));
|
|
config.history = null;
|
|
fs.writeFileSync(s.filePath, JSON.stringify(config), 'utf-8');
|
|
assert.throws(() => s.loadDiagnosticsHistory());
|
|
});
|
|
|
|
it('appendHistory writes to history.json; the next config write strips stale history from the config file', async () => {
|
|
writeConfigWithHistory(10);
|
|
s._migrateHistory();
|
|
await s.appendHistory({ id: 'new-batch', timestamp: 1750000099999, total: 1, files: [{ name: 'x.mkv' }] });
|
|
assert.equal(s.loadHistory().length, 11, 'append goes to history.json');
|
|
await s.save({ globalSettings: { alwaysOnTop: true } });
|
|
const onDisk = JSON.parse(fs.readFileSync(s.filePath, 'utf-8'));
|
|
assert.ok(!onDisk.history || onDisk.history.length === 0, 'a config write strips stale history from the config file');
|
|
assert.equal(s.loadHistory().length, 11, 'history.json is unaffected by the config write');
|
|
});
|
|
|
|
it('save({globalSettings}) after migration NEVER loses history (data-loss invariant)', async () => {
|
|
writeConfigWithHistory(40);
|
|
s._migrateHistory();
|
|
await s.save({ globalSettings: { alwaysOnTop: true } });
|
|
assert.equal(s.loadHistory().length, 40, 'a settings write must not touch history');
|
|
assert.equal(s.load().globalSettings.alwaysOnTop, true);
|
|
});
|
|
|
|
it('clearHistory empties history.json only', async () => {
|
|
writeConfigWithHistory(20);
|
|
s._migrateHistory();
|
|
await s.clearHistory();
|
|
assert.equal(s.loadHistory().length, 0);
|
|
});
|
|
|
|
it('migration is idempotent — re-running with history.json present does not re-derive or clobber', () => {
|
|
writeConfigWithHistory(15);
|
|
s._migrateHistory();
|
|
const after = makeStore();
|
|
after._migrateHistory();
|
|
assert.equal(after._historyMigrated, true);
|
|
assert.equal(after.loadHistory().length, 15);
|
|
});
|
|
|
|
it('crash-window fallback: not migrated + no history.json → loadHistory reads config.history', () => {
|
|
writeConfigWithHistory(7);
|
|
assert.equal(s._historyMigrated, false);
|
|
assert.equal(s.loadHistory().length, 7, 'legacy path still serves history if migration never ran');
|
|
});
|
|
|
|
it('migrated prune refuses to overwrite a corrupted history file', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
fs.writeFileSync(s.historyPath, '{broken-history', 'utf-8');
|
|
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
|
|
|
await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /Verlaufsdatei ist beschädigt/);
|
|
|
|
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
|
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
|
|
});
|
|
|
|
it('appendHistory rejects when the migrated history file is corrupted', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
fs.writeFileSync(s.historyPath, '{broken-history', 'utf-8');
|
|
|
|
await assert.rejects(s.appendHistory({ id: 'unsafe-delete-barrier', files: [] }), /Verlaufsdatei ist beschädigt/);
|
|
});
|
|
|
|
it('pruneHistory trims history.json and persists the retention setting', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
const res = await s.pruneHistory('all', { dryRun: false });
|
|
assert.equal(s.loadHistory().length, 12);
|
|
assert.ok(res.keptBatches === 12);
|
|
});
|
|
|
|
it('migrated prune leaves history unchanged when the retention commit fails', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
|
s._atomicWrite = () => Promise.reject(new Error('retention commit failed'));
|
|
|
|
await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /retention commit failed/);
|
|
|
|
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
|
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
|
|
});
|
|
|
|
it('migrated prune restores the previous retention when the history write fails', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
|
const originalAtomicWrite = s._atomicWrite.bind(s);
|
|
const retentionWrites = [];
|
|
s._atomicWrite = (data) => {
|
|
retentionWrites.push(JSON.parse(data).globalSettings.historyRetention);
|
|
return originalAtomicWrite(data);
|
|
};
|
|
s._writeHistoryFileAtomic = () => Promise.reject(new Error('history prune failed'));
|
|
|
|
await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /history prune failed/);
|
|
|
|
assert.deepEqual(retentionWrites, ['7d', 'all']);
|
|
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
|
|
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
|
});
|
|
|
|
it('migrated prune serializes surrounding global settings saves across an internal rollback', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
|
const originalAtomicWrite = s._atomicWrite.bind(s);
|
|
const configWrites = [];
|
|
let priorWriteStarted = false;
|
|
let releasePriorWrite;
|
|
s._atomicWrite = (data) => {
|
|
const settings = JSON.parse(data).globalSettings;
|
|
configWrites.push({ webhookUrl: settings.webhookUrl || '', alwaysOnTop: !!settings.alwaysOnTop, historyRetention: settings.historyRetention });
|
|
if (!priorWriteStarted && settings.webhookUrl === 'https://prune-race.invalid/prior') {
|
|
priorWriteStarted = true;
|
|
return new Promise((resolve, reject) => {
|
|
releasePriorWrite = () => originalAtomicWrite(data).then(resolve, reject);
|
|
});
|
|
}
|
|
return originalAtomicWrite(data);
|
|
};
|
|
|
|
let rejectHistoryWrite;
|
|
s._writeHistoryFileAtomic = () => new Promise((_resolve, reject) => { rejectHistoryWrite = reject; });
|
|
const priorSettings = { ...s.load().globalSettings, alwaysOnTop: true, webhookUrl: 'https://prune-race.invalid/prior', historyRetention: 'all' };
|
|
const priorSave = s.save({ globalSettings: priorSettings });
|
|
while (!releasePriorWrite) await new Promise(resolve => setImmediate(resolve));
|
|
|
|
let pruneError = null;
|
|
const pruning = s.pruneHistory('7d', { dryRun: false }).catch(error => { pruneError = error; });
|
|
releasePriorWrite();
|
|
await priorSave;
|
|
while (!rejectHistoryWrite) await new Promise(resolve => setImmediate(resolve));
|
|
|
|
const laterSettings = { ...priorSettings, alwaysOnTop: false, webhookUrl: 'https://prune-race.invalid/later', historyRetention: 'all' };
|
|
const laterSave = s.save({ globalSettings: laterSettings });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
const laterCommittedBeforeRollback = configWrites.some(write => write.webhookUrl === 'https://prune-race.invalid/later');
|
|
rejectHistoryWrite(new Error('history prune race failed'));
|
|
await pruning;
|
|
await laterSave;
|
|
|
|
assert.match(pruneError?.message || '', /history prune race failed/);
|
|
assert.equal(laterCommittedBeforeRollback, false);
|
|
assert.deepEqual(configWrites.map(write => `${write.webhookUrl}:${write.historyRetention}`), [
|
|
'https://prune-race.invalid/prior:all',
|
|
'https://prune-race.invalid/prior:7d',
|
|
'https://prune-race.invalid/prior:all',
|
|
'https://prune-race.invalid/later:all'
|
|
]);
|
|
const finalSettings = JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings;
|
|
assert.equal(finalSettings.webhookUrl, 'https://prune-race.invalid/later');
|
|
assert.equal(finalSettings.alwaysOnTop, false);
|
|
assert.equal(finalSettings.historyRetention, 'all');
|
|
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
|
});
|
|
|
|
it('renderer snapshots cannot revert retention after a successful prune', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
const originalHistoryWrite = s._writeHistoryFileAtomic.bind(s);
|
|
let releaseHistoryWrite;
|
|
s._writeHistoryFileAtomic = (history) => new Promise((resolve, reject) => {
|
|
releaseHistoryWrite = () => originalHistoryWrite(history).then(resolve, reject);
|
|
});
|
|
|
|
const pruning = s.pruneHistory('7d', { dryRun: false });
|
|
while (!releaseHistoryWrite) await new Promise(resolve => setImmediate(resolve));
|
|
const staleRendererSave = s.saveRendererGlobalSettings({
|
|
...s.load().globalSettings,
|
|
alwaysOnTop: true,
|
|
historyRetention: 'all'
|
|
});
|
|
releaseHistoryWrite();
|
|
await Promise.all([pruning, staleRendererSave]);
|
|
|
|
assert.equal(s.load().globalSettings.historyRetention, '7d');
|
|
assert.equal(s.load().globalSettings.alwaysOnTop, true);
|
|
});
|
|
});
|