Replace session-long account failure pauses with classified 15, 30, 60, and 120 minute cooldowns for temporary account problems. Keep credential, OTP, banned, and disabled states manual while ignoring unknown, file, network, hoster, and bare WAF errors. Reset escalation after confirmed uploads, deduplicate parallel failures, publish revisioned pause snapshots, and show a stable localized countdown with automatic reactivation.
256 lines
11 KiB
JavaScript
256 lines
11 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const Module = require('node:module');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const { pathToFileURL } = require('node:url');
|
|
const packageJson = require('../package.json');
|
|
|
|
const projectRoot = path.join(__dirname, '..');
|
|
|
|
test('packages every Electron preload referenced by the main process', () => {
|
|
assert.ok(packageJson.build.files.includes('preload.js'));
|
|
assert.ok(packageJson.build.files.includes('preload-drop-target.js'));
|
|
assert.ok(packageJson.build.files.includes('lib/**/*'));
|
|
assert.equal(packageJson.build.win.signAndEditExecutable, false);
|
|
});
|
|
|
|
test('exposes managed online backup operations through narrow IPC boundaries', () => {
|
|
const preloadSource = fs.readFileSync(path.join(projectRoot, 'preload.js'), 'utf8');
|
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
|
|
|
assert.match(preloadSource, /listManagedOnlineBackups:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('online-backup:list-managed'\)/u);
|
|
assert.match(preloadSource, /createManagedOnlineBackup:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('online-backup:create-managed'\)/u);
|
|
assert.match(preloadSource, /copyManagedOnlineBackup:\s*\(id\)\s*=>\s*ipcRenderer\.invoke\('online-backup:copy-managed',\s*id\)/u);
|
|
assert.match(preloadSource, /deleteManagedOnlineBackup:\s*\(id\)\s*=>\s*ipcRenderer\.invoke\('online-backup:delete-managed',\s*id\)/u);
|
|
assert.match(preloadSource, /restoreOnlineBackup:\s*\(key\)\s*=>\s*ipcRenderer\.invoke\('online-backup:restore',\s*key\)/u);
|
|
assert.deepEqual(
|
|
[...preloadSource.matchAll(/^\s{2}(\w*OnlineBackups?):/gmu)].map((match) => match[1]),
|
|
['listManagedOnlineBackups', 'createManagedOnlineBackup', 'copyManagedOnlineBackup', 'deleteManagedOnlineBackup', 'restoreOnlineBackup']
|
|
);
|
|
assert.doesNotMatch(preloadSource, /^\s{2}createOnlineBackup:/mu);
|
|
assert.doesNotMatch(preloadSource, /ipcRenderer\.invoke\('online-backup:create'\)/u);
|
|
assert.match(mainSource, /path\.join\(app\.getPath\('userData'\),\s*'online-backup-keys\.json'\)/u);
|
|
assert.match(mainSource, /Buffer\.from\(id,\s*'base64url'\)/u);
|
|
assert.match(mainSource, /decoded\.toString\('base64url'\)\s*!==\s*id/u);
|
|
assert.doesNotMatch(mainSource, /ipcMain\.handle\('online-backup:create'/u);
|
|
assert.match(mainSource, /ipcMain\.handle\('online-backup:list-managed',[\s\S]*?onlineBackupManager\.listManaged\(\)/u);
|
|
assert.match(mainSource, /ipcMain\.handle\('online-backup:create-managed',[\s\S]*?onlineBackupManager\.createManaged\(\)/u);
|
|
assert.match(mainSource, /ipcMain\.handle\('online-backup:copy-managed',[\s\S]*?onlineBackupManager\.copyManaged\(/u);
|
|
assert.match(mainSource, /ipcMain\.handle\('online-backup:delete-managed',[\s\S]*?onlineBackupManager\.deleteManaged\(/u);
|
|
});
|
|
|
|
test('managed online backup handlers reject every sender outside the local main frame', async () => {
|
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
|
const boundaryStart = mainSource.indexOf('const ONLINE_BACKUP_RENDERER_URL');
|
|
const boundaryEnd = mainSource.indexOf('\nfunction clearCloseFlushTimer', boundaryStart);
|
|
const handlersStart = mainSource.indexOf("ipcMain.handle('online-backup:list-managed'");
|
|
const handlersEnd = mainSource.indexOf("\nipcMain.handle('online-backup:restore'", handlersStart);
|
|
assert.notEqual(boundaryStart, -1, 'central online backup sender boundary is missing');
|
|
assert.notEqual(boundaryEnd, -1, 'central online backup sender boundary is incomplete');
|
|
assert.notEqual(handlersStart, -1, 'managed online backup handlers are missing');
|
|
assert.notEqual(handlersEnd, -1, 'managed online backup handler block is incomplete');
|
|
|
|
const expectedUrl = `${pathToFileURL(path.join(projectRoot, 'renderer', 'index.html')).href}?language=de&version=2.1.31`;
|
|
const state = {
|
|
frameUrl: expectedUrl,
|
|
currentUrl: expectedUrl,
|
|
windowDestroyed: false,
|
|
webContentsDestroyed: false
|
|
};
|
|
const mainFrame = {};
|
|
Object.defineProperty(mainFrame, 'url', { get: () => state.frameUrl });
|
|
const webContents = {
|
|
mainFrame,
|
|
getURL: () => state.currentUrl,
|
|
isDestroyed: () => state.webContentsDestroyed
|
|
};
|
|
const mainWindow = {
|
|
webContents,
|
|
isDestroyed: () => state.windowDestroyed
|
|
};
|
|
const managerCalls = [];
|
|
const onlineBackupManager = {
|
|
listManaged: () => { managerCalls.push('list'); return { ok: true, entries: [] }; },
|
|
createManaged: () => { managerCalls.push('create'); return { ok: true, entry: { id: 'AAAAAAAAAAAAAAAAAAAAAA' } }; },
|
|
copyManaged: (id) => { managerCalls.push(`copy:${id}`); return { ok: true }; },
|
|
deleteManaged: (id) => { managerCalls.push(`delete:${id}`); return { ok: true, removedId: id }; }
|
|
};
|
|
const handlers = new Map();
|
|
const context = {
|
|
Buffer,
|
|
URL,
|
|
__dirname: projectRoot,
|
|
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
|
mainWindow,
|
|
onlineBackupManager,
|
|
path,
|
|
pathToFileURL
|
|
};
|
|
vm.runInNewContext(
|
|
`${mainSource.slice(boundaryStart, boundaryEnd)}\n${mainSource.slice(handlersStart, handlersEnd)}`,
|
|
context
|
|
);
|
|
|
|
const id = 'AAAAAAAAAAAAAAAAAAAAAA';
|
|
const invokeAll = async (event) => Promise.all([
|
|
handlers.get('online-backup:list-managed')(event),
|
|
handlers.get('online-backup:create-managed')(event),
|
|
handlers.get('online-backup:copy-managed')(event, id),
|
|
handlers.get('online-backup:delete-managed')(event, id)
|
|
].map(result => Promise.resolve(result)));
|
|
const legitimateEvent = { sender: webContents, senderFrame: mainFrame };
|
|
const legitimateResults = await invokeAll(legitimateEvent);
|
|
assert.deepEqual(managerCalls, ['list', 'create', `copy:${id}`, `delete:${id}`]);
|
|
assert.equal(legitimateResults.every(result => result.ok === true), true);
|
|
assert.equal(JSON.stringify(legitimateResults).includes('MHU2-'), false);
|
|
|
|
const foreignSender = { mainFrame: { url: expectedUrl }, getURL: () => expectedUrl, isDestroyed: () => false };
|
|
const cases = [
|
|
['foreign sender', { sender: foreignSender, senderFrame: foreignSender.mainFrame }, () => {}],
|
|
['destroyed main window', legitimateEvent, () => { state.windowDestroyed = true; }],
|
|
['destroyed sender', legitimateEvent, () => { state.webContentsDestroyed = true; }],
|
|
['subframe', { sender: webContents, senderFrame: { url: expectedUrl } }, () => {}],
|
|
['remote URL', legitimateEvent, () => { state.frameUrl = 'https://example.invalid/renderer/index.html'; state.currentUrl = state.frameUrl; }],
|
|
['different local file', legitimateEvent, () => { state.frameUrl = pathToFileURL(path.join(projectRoot, 'renderer', 'drop-target.html')).href; state.currentUrl = state.frameUrl; }]
|
|
];
|
|
|
|
for (const [name, event, arrange] of cases) {
|
|
state.frameUrl = expectedUrl;
|
|
state.currentUrl = expectedUrl;
|
|
state.windowDestroyed = false;
|
|
state.webContentsDestroyed = false;
|
|
managerCalls.length = 0;
|
|
arrange();
|
|
const results = await invokeAll(event);
|
|
assert.equal(managerCalls.length, 0, `${name} reached the manager`);
|
|
assert.equal(results.every(result => result.ok === false), true, `${name} was not rejected`);
|
|
assert.equal(JSON.stringify(results).includes('MHU2-'), false, `${name} leaked a complete key`);
|
|
}
|
|
});
|
|
|
|
test('afterPack brands the executable metadata shown by Windows', async () => {
|
|
let editCall = null;
|
|
const originalLoad = Module._load;
|
|
const afterPackPath = require.resolve('../scripts/afterPack.cjs');
|
|
delete require.cache[afterPackPath];
|
|
Module._load = function (request, parent, isMain) {
|
|
if (request === 'rcedit') {
|
|
return async (exePath, options) => { editCall = { exePath, options }; };
|
|
}
|
|
return originalLoad.call(this, request, parent, isMain);
|
|
};
|
|
try {
|
|
const afterPack = require(afterPackPath);
|
|
await afterPack({
|
|
appOutDir: 'C:\\release',
|
|
packager: { appInfo: { productFilename: 'Multi-Hoster-Upload', version: '9.8.7' } }
|
|
});
|
|
} finally {
|
|
Module._load = originalLoad;
|
|
delete require.cache[afterPackPath];
|
|
}
|
|
|
|
assert.equal(editCall.exePath, path.join('C:\\release', 'Multi-Hoster-Upload.exe'));
|
|
assert.equal(editCall.options['file-version'], '9.8.7');
|
|
assert.equal(editCall.options['product-version'], '9.8.7');
|
|
assert.deepEqual(editCall.options['version-string'], {
|
|
CompanyName: 'Sucukdeluxe',
|
|
FileDescription: 'Multi Hoster Uploader',
|
|
InternalName: 'Multi-Hoster-Upload',
|
|
OriginalFilename: 'Multi-Hoster-Upload.exe',
|
|
ProductName: 'Multi Hoster Uploader'
|
|
});
|
|
});
|
|
|
|
test('close readiness is signaled only after the renderer explicitly finishes initialization', () => {
|
|
const listeners = new Map();
|
|
const sent = [];
|
|
let exposedApi = null;
|
|
const electronMock = {
|
|
contextBridge: {
|
|
exposeInMainWorld: (_name, api) => { exposedApi = api; }
|
|
},
|
|
ipcRenderer: {
|
|
invoke: () => Promise.resolve(),
|
|
on: (channel, listener) => { listeners.set(channel, listener); },
|
|
send: (...args) => { sent.push(args); },
|
|
removeAllListeners: () => {}
|
|
},
|
|
webUtils: {
|
|
getPathForFile: () => ''
|
|
}
|
|
};
|
|
const originalLoad = Module._load;
|
|
const preloadPath = require.resolve('../preload');
|
|
delete require.cache[preloadPath];
|
|
Module._load = function (request, parent, isMain) {
|
|
if (request === 'electron') return electronMock;
|
|
return originalLoad.call(this, request, parent, isMain);
|
|
};
|
|
try {
|
|
require(preloadPath);
|
|
} finally {
|
|
Module._load = originalLoad;
|
|
}
|
|
|
|
let closeAttempt = null;
|
|
exposedApi.onPrepareClose(attempt => { closeAttempt = attempt; });
|
|
assert.deepEqual(sent, []);
|
|
|
|
listeners.get('app:prepare-close')({}, 7);
|
|
assert.equal(closeAttempt, 7);
|
|
assert.deepEqual(sent, [['app:close-preparation-started', 7]]);
|
|
|
|
exposedApi.signalCloseHandshakeReady();
|
|
assert.deepEqual(sent, [
|
|
['app:close-preparation-started', 7],
|
|
['app:close-handshake-ready']
|
|
]);
|
|
});
|
|
|
|
test('preload exposes account cooldown snapshots and removes their listener during cleanup', async () => {
|
|
const listeners = new Map();
|
|
const invocations = [];
|
|
const removed = [];
|
|
let exposedApi = null;
|
|
const electronMock = {
|
|
contextBridge: {
|
|
exposeInMainWorld: (_name, api) => { exposedApi = api; }
|
|
},
|
|
ipcRenderer: {
|
|
invoke: (...args) => { invocations.push(args); return Promise.resolve({ version: 2, accounts: [] }); },
|
|
on: (channel, listener) => { listeners.set(channel, listener); },
|
|
send: () => {},
|
|
removeAllListeners: channel => { removed.push(channel); }
|
|
},
|
|
webUtils: {
|
|
getPathForFile: () => ''
|
|
}
|
|
};
|
|
const originalLoad = Module._load;
|
|
const preloadPath = require.resolve('../preload');
|
|
delete require.cache[preloadPath];
|
|
Module._load = function (request, parent, isMain) {
|
|
if (request === 'electron') return electronMock;
|
|
return originalLoad.call(this, request, parent, isMain);
|
|
};
|
|
try {
|
|
require(preloadPath);
|
|
} finally {
|
|
Module._load = originalLoad;
|
|
}
|
|
|
|
const snapshot = await exposedApi.getSessionFailedAccountStates();
|
|
let pushed = null;
|
|
exposedApi.onSessionFailedAccountsChanged(value => { pushed = value; });
|
|
listeners.get('session-failed-accounts-changed')({}, { version: 2, accounts: [{ accountId: 'a1' }] });
|
|
exposedApi.removeAllListeners();
|
|
|
|
assert.deepEqual(snapshot, { version: 2, accounts: [] });
|
|
assert.deepEqual(invocations, [['get-session-failed-account-states']]);
|
|
assert.deepEqual(pushed, { version: 2, accounts: [{ accountId: 'a1' }] });
|
|
assert.equal(removed.includes('session-failed-accounts-changed'), true);
|
|
});
|