Files
Multi-Hoster-Upload/tests/config-store.test.js
T
Sucukdeluxe d3bac698c1 fix(security): remove plaintext credential storage fallback
Credential writes now require Electron safeStorage encryption. Legacy plaintext values remain readable for migration and are encrypted during the next successful save. The UI override and status IPC are removed.
2026-08-12 00:25:19 +02:00

834 lines
37 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;
let tmpDir;
let store;
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('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 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('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('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);
});
});