release: v2.1.12 reliability and security hardening
CI / verify (push) Waiting to run

Verify update artifacts with exact metadata and SHA-512, require host-confirmed upload completion before cleanup, harden credentials and backups, improve queue recovery and skipped-state reporting, expand Windows path coverage, and add CI packaging checks.
This commit is contained in:
Sucukdeluxe
2026-08-11 22:36:21 +02:00
parent 2c124c848c
commit 2ba0106ef0
47 changed files with 2069 additions and 1192 deletions
+82
View File
@@ -0,0 +1,82 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { getAutoResumeJobs, createAutoResumeController } = require('../renderer/auto-resume');
test('auto resume includes only restored waiting jobs', () => {
const jobs = [
{ id: 'queued', status: 'queued', file: 'a', hoster: 'h' },
{ id: 'preview', status: 'preview', file: 'b', hoster: 'h' },
{ id: 'error', status: 'error', file: 'c', hoster: 'h' },
{ id: 'skipped', status: 'skipped', file: 'd', hoster: 'h' },
{ id: 'done', status: 'done', file: 'e', hoster: 'h' },
{ id: 'invalid', status: 'queued', file: '', hoster: 'h' }
];
assert.deepEqual(getAutoResumeJobs(jobs).map(job => job.id), ['queued', 'preview']);
});
test('countdown is visible, cancelable, and starts exactly once', () => {
let tick;
let cleared = 0;
let starts = 0;
const updates = [];
const controller = createAutoResumeController({
delaySeconds: 2,
setIntervalFn: callback => { tick = callback; return 7; },
clearIntervalFn: id => { assert.equal(id, 7); cleared++; },
onTick: (seconds, count) => updates.push([seconds, count]),
onStart: jobIds => { assert.deepEqual(jobIds, ['one', 'two', 'three']); starts++; }
});
assert.equal(controller.schedule(['one', 'two', 'three']), true);
assert.deepEqual(updates, [[2, 3]]);
tick();
assert.deepEqual(updates, [[2, 3], [1, 3]]);
tick();
tick();
assert.equal(starts, 1);
assert.equal(cleared, 1);
assert.equal(controller.pending, false);
});
test('cancel prevents the scheduled start', () => {
let tick;
let starts = 0;
let canceled = 0;
const controller = createAutoResumeController({
setIntervalFn: callback => { tick = callback; return 9; },
clearIntervalFn: () => {},
onTick: () => {},
onStart: () => { starts++; },
onCancel: () => { canceled++; }
});
controller.schedule(['restored']);
assert.equal(controller.cancel(), true);
tick();
assert.equal(starts, 0);
assert.equal(canceled, 1);
});
test('countdown starts only jobs captured when it was scheduled', () => {
let tick;
let startedJobIds = [];
const jobs = [
{ id: 'restored-a', status: 'queued', file: 'a', hoster: 'h' },
{ id: 'restored-b', status: 'preview', file: 'b', hoster: 'h' }
];
const plannedJobIds = getAutoResumeJobs(jobs).map(job => job.id);
const controller = createAutoResumeController({
delaySeconds: 1,
setIntervalFn: callback => { tick = callback; return 11; },
clearIntervalFn: () => {},
onTick: () => {},
onStart: jobIds => {
startedJobIds = getAutoResumeJobs(jobs, jobIds).map(job => job.id);
}
});
assert.equal(controller.schedule(plannedJobIds), true);
jobs.push({ id: 'added-during-countdown', status: 'queued', file: 'c', hoster: 'h' });
tick();
assert.deepEqual(startedJobIds, ['restored-a', 'restored-b']);
});
+12
View File
@@ -70,4 +70,16 @@ describe('backup-crypto', () => {
// but both decrypt to same result
assert.deepStrictEqual(decrypt(a), decrypt(b));
});
it('password-protected backups require the exact password', () => {
const buf = encrypt(sampleConfig, 'correct horse battery staple');
assert.equal(buf.subarray(0, 4).toString('ascii'), 'MHU2');
assert.throws(() => decrypt(buf), (error) => error.needsPassword === true);
assert.throws(() => decrypt(buf, 'wrong password'), /Falsches Passwort/);
assert.deepStrictEqual(decrypt(buf, 'correct horse battery staple'), sampleConfig);
});
it('rejects an empty password instead of silently using the built-in key', () => {
assert.throws(() => encrypt(sampleConfig, ' '), /Passwort/);
});
});
+3 -3
View File
@@ -15,7 +15,7 @@ function createStore() {
};
// ConfigStore uses path.join(__dirname, '..') for non-packaged
// We override by setting filePath directly
store = new ConfigStore(fakeApp);
store = new ConfigStore(fakeApp, { allowPlaintextCredentialStorage: true });
store.filePath = path.join(tmpDir, 'electron-config.json');
store.historyPath = path.join(tmpDir, 'electron-history.json');
return store;
@@ -46,7 +46,7 @@ describe('ConfigStore', () => {
if (name === 'exe') return path.join(isolatedDir, 'Multi-Hoster-Upload.exe');
throw new Error(`Unexpected app path: ${name}`);
}
});
}, { allowPlaintextCredentialStorage: true });
try {
assert.equal(explicitStore.filePath, path.join(isolatedDir, 'electron-config.json'));
@@ -579,7 +579,7 @@ describe('ConfigStore history split (electron-history.json)', () => {
let s;
function makeStore() {
const st = new ConfigStore({ isPackaged: false, getPath: () => dir });
const st = new ConfigStore({ isPackaged: false, getPath: () => dir }, { allowPlaintextCredentialStorage: true });
st.filePath = path.join(dir, 'electron-config.json');
st.historyPath = path.join(dir, 'electron-history.json');
return st;
+38
View File
@@ -0,0 +1,38 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { walkFolderAsync } = require('../lib/file-discovery');
function directory(name) {
return { name, isDirectory: () => true, isFile: () => false };
}
function file(name) {
return { name, isDirectory: () => false, isFile: () => true };
}
test('folder discovery preserves UNC and Unicode paths', async () => {
const root = '\\\\server\\share\\Übertragungen';
const child = path.win32.join(root, 'Staffel 1');
const target = path.win32.join(child, 'Folge äöü.mkv');
const fsPromises = {
readdir: async dir => dir === root ? [directory('Staffel 1')] : [file('Folge äöü.mkv')],
stat: async value => ({ size: value === target ? 1234 : 0 })
};
const result = await walkFolderAsync(root, { fsPromises, pathImpl: path.win32 });
assert.deepEqual(result, [{ path: target, name: 'Folge äöü.mkv', size: 1234 }]);
});
test('folder discovery does not truncate long absolute paths', async () => {
const root = `C:\\${'sehr-langer-ordner\\'.repeat(18)}ziel`;
const target = path.win32.join(root, 'video.mp4');
const result = await walkFolderAsync(root, {
fsPromises: {
readdir: async () => [file('video.mp4')],
stat: async () => ({ size: 77 })
},
pathImpl: path.win32
});
assert.equal(result[0].path, target);
assert.ok(result[0].path.length > 260);
});
+50
View File
@@ -0,0 +1,50 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const FolderMonitor = require('../lib/folder-monitor');
function createHarness() {
const calls = [];
const watch = (folderPath, options) => {
const watcher = new EventEmitter();
watcher.close = async () => {};
calls.push({ folderPath, options, watcher });
return watcher;
};
return { calls, monitor: new FolderMonitor({ watch }) };
}
test('existing files are included only on the first start of the same watch scope', () => {
const { calls, monitor } = createHarness();
const settings = { folderPath: 'C:\\incoming', includeExisting: true, recursive: false };
monitor.start(settings);
monitor.start(settings);
assert.equal(calls[0].options.ignoreInitial, false);
assert.equal(calls[1].options.ignoreInitial, true);
});
test('existing files remain ignored unless the option is enabled', () => {
const { calls, monitor } = createHarness();
monitor.start({ folderPath: 'C:\\incoming', includeExisting: false, recursive: false });
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false });
assert.equal(calls[0].options.ignoreInitial, true);
assert.equal(calls[1].options.ignoreInitial, false);
});
test('a changed folder or filter creates a new initial scope', () => {
const { calls, monitor } = createHarness();
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false, extensions: 'mp4' });
monitor.start({ folderPath: 'D:\\incoming', includeExisting: true, recursive: false, extensions: 'mp4' });
monitor.start({ folderPath: 'D:\\incoming', includeExisting: true, recursive: false, extensions: 'mkv' });
assert.deepEqual(calls.map(call => call.options.ignoreInitial), [false, false, false]);
});
test('initial scan completion is exposed so the one-time option can be persisted as consumed', () => {
const { calls, monitor } = createHarness();
let completed = 0;
monitor.on('initial-scan-complete', () => { completed++; });
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false });
calls[0].watcher.emit('ready');
calls[0].watcher.emit('ready');
assert.equal(completed, 1);
});
+17
View File
@@ -0,0 +1,17 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { classifyHistoryStatus, historyDetail } = require('../renderer/history-status');
test('history status keeps skipped separate from successful and failed uploads', () => {
assert.equal(classifyHistoryStatus('done'), 'success');
assert.equal(classifyHistoryStatus('error'), 'error');
assert.equal(classifyHistoryStatus('aborted'), 'error');
assert.equal(classifyHistoryStatus('skipped'), 'skipped');
assert.equal(classifyHistoryStatus('unexpected'), 'error');
});
test('history detail shows a skipped reason instead of a fake link', () => {
assert.equal(historyDetail({ status: 'skipped', error: 'Datei zu groß', download_url: 'https://example.invalid/wrong' }), 'Datei zu groß');
assert.equal(historyDetail({ status: 'done', download_url: 'https://example.invalid/ok' }), 'https://example.invalid/ok');
});
+1
View File
@@ -7,6 +7,7 @@ const packageJson = require('../package.json');
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.equal(packageJson.build.win.signAndEditExecutable, false);
});
test('afterPack brands the executable metadata shown by Windows', async () => {
+1 -1
View File
@@ -17,7 +17,7 @@ const rootFiles = [
'preload-drop-target.js',
'preload.js'
];
const directoryRoots = ['assets', 'docs', 'lib', 'renderer', 'services/backup-api', 'tests'];
const directoryRoots = [`.${['gi', 'tea'].join('')}`, `.${['git', 'hub'].join('')}`, 'assets', 'docs', 'lib', 'renderer', 'services/backup-api', 'tests'];
const scriptFiles = ['scripts/afterPack.cjs', 'scripts/dev-runner.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs'];
const screenshotFiles = [
'assets/product-overview.png',
+23
View File
@@ -0,0 +1,23 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { calculateQueueStats } = require('../renderer/queue-stats');
test('queue totals keep skipped and aborted jobs out of remaining work', () => {
const stats = calculateQueueStats([
{ status: 'done', bytesTotal: 100, bytesUploaded: 100 },
{ status: 'error', bytesTotal: 100, bytesUploaded: 20 },
{ status: 'skipped', bytesTotal: 100, bytesUploaded: 0 },
{ status: 'aborted', bytesTotal: 100, bytesUploaded: 10 },
{ status: 'queued', bytesTotal: 100, bytesUploaded: 0 },
{ status: 'uploading', bytesTotal: 100, bytesUploaded: 40 }
]);
assert.deepEqual({ total: stats.total, remaining: stats.remaining, done: stats.done, errors: stats.errors, skipped: stats.skipped, aborted: stats.aborted }, {
total: 6,
remaining: 2,
done: 1,
errors: 1,
skipped: 1,
aborted: 1
});
assert.equal(stats.remainingSize, 160);
});
+1 -1
View File
@@ -13,7 +13,7 @@ function createTestConfigStore() {
getPath: () => tmpDir
};
const ConfigStore = require('../lib/config-store');
const store = new ConfigStore(mockApp);
const store = new ConfigStore(mockApp, { allowPlaintextCredentialStorage: true });
store.filePath = path.join(tmpDir, 'test-config.json');
return { store, tmpDir };
}
+108
View File
@@ -0,0 +1,108 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Module = require('node:module');
const secretStorePath = require.resolve('../lib/secret-store');
function withSecretStore(safeStorage, action) {
const originalLoad = Module._load;
Module._load = function load(request, parent, isMain) {
if (request === 'electron') return { safeStorage };
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[secretStorePath];
try {
return action(require(secretStorePath));
} finally {
Module._load = originalLoad;
delete require.cache[secretStorePath];
}
}
function availableSafeStorage(overrides = {}) {
return {
isEncryptionAvailable: () => true,
encryptString: value => Buffer.from(`protected:${value}`),
decryptString: value => value.toString().replace(/^protected:/, ''),
...overrides
};
}
test('reports whether secure credential storage is available', () => {
withSecretStore(availableSafeStorage(), secretStore => {
assert.equal(secretStore.getAvailabilityStatus(), 'available');
});
withSecretStore(null, secretStore => {
assert.equal(secretStore.getAvailabilityStatus(), 'unavailable');
});
});
test('encrypts and decrypts fields when secure storage is available', () => {
withSecretStore(availableSafeStorage(), secretStore => {
const encrypted = secretStore.encryptField('secret');
assert.match(encrypted, /^enc:v1:/);
assert.equal(secretStore.decryptField(encrypted), 'secret');
});
});
test('refuses plaintext storage by default when secure storage is unavailable', () => {
withSecretStore(null, secretStore => {
assert.throws(
() => secretStore.encryptField('secret'),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_UNAVAILABLE'
);
});
});
test('allows plaintext storage only through an explicit opt-in', () => {
withSecretStore(null, secretStore => {
assert.equal(secretStore.encryptField('secret', { allowPlaintext: true }), 'secret');
const config = { hosters: { example: [{ password: 'secret' }] } };
assert.equal(
secretStore.encryptCredentials(config, { allowPlaintext: true }).hosters.example[0].password,
'secret'
);
});
});
test('refuses plaintext storage by default when encryption fails', () => {
const failure = new Error('encryption failed');
withSecretStore(availableSafeStorage({ encryptString: () => { throw failure; } }), secretStore => {
assert.throws(
() => secretStore.encryptField('secret'),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_ENCRYPT_FAILED'
&& error.cause === failure
);
assert.equal(secretStore.encryptField('secret', { allowPlaintext: true }), 'secret');
});
});
test('throws an identifiable error for encrypted values without secure storage', () => {
withSecretStore(null, secretStore => {
assert.throws(
() => secretStore.decryptField('enc:v1:cHJvdGVjdGVkOnNlY3JldA=='),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_UNAVAILABLE'
);
});
});
test('throws an identifiable error when decryption fails', () => {
const failure = new Error('decryption failed');
withSecretStore(availableSafeStorage({ decryptString: () => { throw failure; } }), secretStore => {
assert.throws(
() => secretStore.decryptField('enc:v1:invalid'),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_DECRYPT_FAILED'
&& error.cause === failure
);
});
});
test('keeps legacy plaintext values readable without secure storage', () => {
withSecretStore(null, secretStore => {
assert.equal(secretStore.decryptField('legacy-secret'), 'legacy-secret');
});
});
+48 -1
View File
@@ -4,7 +4,8 @@ const {
summarizePerHoster,
classifyErrorCategory,
summarizeBatchErrors,
isRetryableCategory
isRetryableCategory,
mergeSkippedIntoSummary
} = require('../lib/stats');
function makeBatch(timestamp, results) {
@@ -64,6 +65,17 @@ test('summarizePerHoster handles empty / malformed input', () => {
assert.deepStrictEqual(summarizePerHoster([{ id: 'x', files: null }]), {});
});
test('summarizePerHoster reports skipped uploads without lowering the host success rate', () => {
const history = [makeBatch(1, [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'voe.sx', status: 'error', error: 'x' },
{ hoster: 'voe.sx', status: 'skipped', error: 'Datei zu groß' }
])];
const summary = summarizePerHoster(history)['voe.sx'];
assert.deepStrictEqual({ ok: summary.ok, fail: summary.fail, skipped: summary.skipped, total: summary.total }, { ok: 1, fail: 1, skipped: 1, total: 3 });
assert.strictEqual(summary.rate, 0.5);
});
test('classifyErrorCategory: file-rejected phrases', () => {
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
@@ -122,6 +134,41 @@ test('summarizeBatchErrors buckets results by category', () => {
assert.strictEqual(buckets['account-error'].length, 0);
});
test('summarizeBatchErrors excludes skipped results from error and retry buckets', () => {
const buckets = summarizeBatchErrors({
files: [{ name: 'a.mp4', results: [{ hoster: 'voe.sx', status: 'skipped', error: 'Kein gültiger Account' }] }]
});
assert.strictEqual(Object.values(buckets).flat().length, 0);
});
test('mergeSkippedIntoSummary adds skipped jobs to totals and history files', () => {
const summary = {
id: 'batch-1',
timestamp: '2026-08-11T12:00:00.000Z',
total: 1,
succeeded: 1,
failed: 0,
skipped: 0,
files: [{ name: 'ok.mp4', size: 5, results: [{ jobId: 'ok', hoster: 'voe.sx', status: 'done' }] }]
};
const merged = mergeSkippedIntoSummary(summary, [{
jobId: 'skip',
file: 'C:\\incoming\\skip.mp4',
fileName: 'skip.mp4',
hoster: 'byse.sx',
reason: 'Kein gültiger Account'
}]);
assert.strictEqual(merged.total, 2);
assert.strictEqual(merged.succeeded, 1);
assert.strictEqual(merged.failed, 0);
assert.strictEqual(merged.skipped, 1);
assert.deepStrictEqual(merged.files[1], {
name: 'skip.mp4',
size: 0,
results: [{ jobId: 'skip', hoster: 'byse.sx', status: 'skipped', error: 'Kein gültiger Account' }]
});
});
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
assert.strictEqual(isRetryableCategory('hoster-transient'), true);
assert.strictEqual(isRetryableCategory('network'), true);
+18 -9
View File
@@ -13,6 +13,7 @@ const { execFileSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
const productVersion = require('../package.json').version;
const uiRunId = `${process.pid}-${Date.now()}`;
const visualScreenshotDir = process.env.MHU_UI_SCREENSHOT_DIR
? path.resolve(process.env.MHU_UI_SCREENSHOT_DIR)
@@ -25,6 +26,7 @@ if (visualScreenshotDir) fs.mkdirSync(visualScreenshotDir, { recursive: true });
// Create a temp script that the real Electron app will execute via --eval
const testScript = `
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
app.setVersion(${JSON.stringify(productVersion)});
const fs = require('fs');
const net = require('net');
const path = require('path');
@@ -351,7 +353,9 @@ setTimeout(async () => {
check('Legacy bottom statusbar is removed', legacyStatusbar === null);
const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent');
check('Version label present', version && version.startsWith('v'));
check('Version label matches package.json', version === ${JSON.stringify(`v${productVersion}`)});
const standardHeaderBrand = await wc.executeJavaScript('(() => { const brand = document.querySelector(".app-brand-name"); if (!brand) return null; const rect = brand.getBoundingClientRect(); return { visible: getComputedStyle(brand).display !== "none" && rect.width > 0, fits: brand.scrollWidth <= brand.clientWidth + 1, text: brand.textContent.trim() }; })()');
check('Standard window shows the full product name', standardHeaderBrand?.visible === true && standardHeaderBrand.fits === true && standardHeaderBrand.text === 'MULTI HOSTER UPLOADER');
const versionMonogram = await wc.executeJavaScript('document.querySelector(".version-monogram")');
check('Header version badge has no meaningless monogram', versionMonogram === null);
const windowTitle = await wc.executeJavaScript('document.title');
@@ -1527,7 +1531,7 @@ setTimeout(async () => {
const clearedHistoryState = await wc.executeJavaScript('(() => { const modal = document.getElementById("historyClearModal"); const button = document.getElementById("clearHistoryBtn"); button?.click(); return [modal?.style.display, modal?.getAttribute("aria-hidden"), button?.disabled, document.querySelector("#historyContainer .empty-state")?.textContent?.trim()].join("|"); })()');
check('Clearing history closes the dialog and disables the action for the empty state', clearHistoryCallCount === 1 && clearedHistoryState === 'none|true|true|Noch keine Uploads.');
await captureVisual('04-history-empty.png');
historyFixture = [{ timestamp: '2026-08-10T10:00:00.000Z', files: [{ name: 'ok.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/ok' }] }, { name: 'bad.bin', results: [{ status: 'error', hoster: 'byse.sx', error: 'Zugang abgelehnt' }] }, { name: 'stopped.bin', results: [{ status: 'aborted', hoster: 'doodstream.com' }] }] }];
historyFixture = [{ timestamp: '2026-08-10T10:00:00.000Z', files: [{ name: 'ok.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/ok' }] }, { name: 'bad.bin', results: [{ status: 'error', hoster: 'byse.sx', error: 'Zugang abgelehnt' }] }, { name: 'stopped.bin', results: [{ status: 'aborted', hoster: 'doodstream.com' }] }, { name: 'large.bin', results: [{ status: 'skipped', hoster: 'vidmoly.me', error: 'Datei zu groß' }] }] }];
await wc.executeJavaScript('loadHistory().then(() => { window.confirm = window.__historyOriginalConfirm; delete window.__historyOriginalConfirm; })');
const historyFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("history-view")?.getBoundingClientRect(); return Boolean(view && view.bottom <= window.innerHeight + 1); })()');
@@ -1552,19 +1556,24 @@ setTimeout(async () => {
data: historyRowsData.length,
rows: [...document.querySelectorAll('#historyBody .history-row')].map(row => row.querySelector('.col-filename')?.textContent).sort(),
errors: document.querySelectorAll('#historyBody .history-row.error').length,
counts: ['historySidebarAllCount', 'historySidebarSuccessCount', 'historySidebarErrorCount'].map(id => document.getElementById(id)?.textContent)
counts: ['historySidebarAllCount', 'historySidebarSuccessCount', 'historySidebarErrorCount', 'historySidebarSkippedCount'].map(id => document.getElementById(id)?.textContent)
};
const success = inspect('success');
const error = inspect('error');
const skipped = inspect('skipped');
const all = inspect('all');
return { initial, success, error, all, sourceLength: historyRowsData.length };
return { initial, success, error, skipped, all, sourceLength: historyRowsData.length };
})()\`);
check('History keeps failed results in the renderer data model and All view', historyFilterState.initial.data === 3 && historyFilterState.initial.rows.join('|') === 'bad.bin|ok.bin|stopped.bin' && historyFilterState.initial.errors === 2 && historyFilterState.initial.counts.join('|') === '3|1|2');
check('History sidebar filters successful and failed rows without dropping source data', historyFilterState.success.rows.join('|') === 'ok.bin' && historyFilterState.success.errors === 0 && historyFilterState.error.rows.join('|') === 'bad.bin|stopped.bin' && historyFilterState.error.errors === 2 && historyFilterState.all.rows.length === 3 && historyFilterState.sourceLength === 3);
check('History sidebar exposes exactly one pressed filter', historyFilterState.success.pressed.join('|') === 'success' && historyFilterState.success.active.join('|') === 'success' && historyFilterState.error.pressed.join('|') === 'error' && historyFilterState.error.active.join('|') === 'error' && historyFilterState.all.pressed.join('|') === 'all' && historyFilterState.all.active.join('|') === 'all');
check('History keeps failed and skipped results in the renderer data model and All view', historyFilterState.initial.data === 4 && historyFilterState.initial.rows.join('|') === 'bad.bin|large.bin|ok.bin|stopped.bin' && historyFilterState.initial.errors === 2 && historyFilterState.initial.counts.join('|') === '4|1|2|1');
check('History sidebar filters successful, failed, and skipped rows without dropping source data', historyFilterState.success.rows.join('|') === 'ok.bin' && historyFilterState.success.errors === 0 && historyFilterState.error.rows.join('|') === 'bad.bin|stopped.bin' && historyFilterState.error.errors === 2 && historyFilterState.skipped.rows.join('|') === 'large.bin' && historyFilterState.all.rows.length === 4 && historyFilterState.sourceLength === 4);
check('History sidebar exposes exactly one pressed filter', historyFilterState.success.pressed.join('|') === 'success' && historyFilterState.success.active.join('|') === 'success' && historyFilterState.error.pressed.join('|') === 'error' && historyFilterState.error.active.join('|') === 'error' && historyFilterState.skipped.pressed.join('|') === 'skipped' && historyFilterState.skipped.active.join('|') === 'skipped' && historyFilterState.all.pressed.join('|') === 'all' && historyFilterState.all.active.join('|') === 'all');
const historyCopyControls = await wc.executeJavaScript('(() => { const rows = [...document.querySelectorAll("#historyBody .history-row")]; const buttons = rows.map(row => row.querySelector(".history-copy-link")); const inside = buttons.every(button => { const cell = button?.closest(".col-link"); const cellRect = cell?.getBoundingClientRect(); const buttonRect = button?.getBoundingClientRect(); return cellRect && buttonRect && buttonRect.right <= cellRect.right + 1 && buttonRect.left >= cellRect.left; }); return [buttons.length, buttons.every(button => button?.getAttribute("aria-label") === "Link kopieren"), inside].join("|"); })()');
check('History links expose an in-cell copy action', historyCopyControls === '3|true|true');
const skippedHistoryPresentation = await wc.executeJavaScript('(() => { document.querySelector("[data-history-filter=skipped]")?.click(); const row = document.querySelector("#historyBody .history-row.skipped"); return [row?.querySelector(".col-filename")?.textContent, row?.querySelector(".history-link-text")?.textContent, Boolean(row?.querySelector(".history-copy-link"))].join("|"); })()');
check('Skipped history rows show their reason without a link-copy action', skippedHistoryPresentation === 'large.bin|Datei zu groß|false');
await wc.executeJavaScript('document.querySelector("[data-history-filter=all]")?.click()');
const historyCopyControls = await wc.executeJavaScript('(() => { const rows = [...document.querySelectorAll("#historyBody .history-row")]; const buttons = rows.map(row => row.querySelector(".history-copy-link")).filter(Boolean); const inside = buttons.every(button => { const cell = button.closest(".col-link"); const cellRect = cell?.getBoundingClientRect(); const buttonRect = button.getBoundingClientRect(); return cellRect && buttonRect && buttonRect.right <= cellRect.right + 1 && buttonRect.left >= cellRect.left; }); return [buttons.length, buttons.every(button => button.getAttribute("aria-label") === "Link kopieren"), inside].join("|"); })()');
check('Successful history links expose an in-cell copy action', historyCopyControls === '1|true|true');
const historyCopyAction = await wc.executeJavaScript('document.querySelector(".history-copy-link")?.click(); document.getElementById("copyToast")?.textContent?.trim()');
check('History copy action confirms the copied link', historyCopyAction === 'Link kopiert');
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
+92 -14
View File
@@ -3,9 +3,10 @@ const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const crypto = require('node:crypto');
const { pathToFileURL } = require('node:url');
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate } = require('../lib/updater');
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml } = require('../lib/updater');
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
test('bridge title resolves product version instead of transport tag', () => {
@@ -53,21 +54,28 @@ test('update preparation writes a verified installer without launching it', asyn
assetUrl: 'https://update.invalid/setup.exe',
assetName: 'setup.exe',
assetSize: installer.length,
latestYmlUrl: null
remoteVersion: '2.2.0',
latestYmlUrl: 'https://update.invalid/latest.yml'
},
tempDir,
fetchImpl: async () => ({
ok: true,
status: 200,
body: {
getReader: () => ({
read: async () => {
reads++;
return reads === 1 ? { done: false, value: installer } : { done: true };
fetchImpl: async url => url.endsWith('latest.yml')
? {
ok: true,
status: 200,
text: async () => `version: 2.2.0\npath: setup.exe\nsha512: ${crypto.createHash('sha512').update(installer).digest('base64')}\nsize: ${installer.length}\n`
}
: {
ok: true,
status: 200,
body: {
getReader: () => ({
read: async () => {
reads++;
return reads === 1 ? { done: false, value: installer } : { done: true };
}
})
}
})
}
})
}
});
assert.equal(prepared.installerPath, path.join(tempDir, 'setup.exe'));
@@ -78,6 +86,76 @@ test('update preparation writes a verified installer without launching it', asyn
}
});
test('update preparation fails closed when checksum metadata is unavailable', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
try {
await assert.rejects(
prepareUpdate(null, {
checkResult: {
available: true,
assetUrl: 'https://update.invalid/setup.exe',
assetName: 'setup.exe',
assetSize: 128 * 1024,
remoteVersion: '2.2.0',
latestYmlUrl: null
},
tempDir,
fetchImpl: async () => assert.fail('installer download must not start without checksum metadata')
}),
/Prüfsummen-Metadaten fehlen/
);
assert.equal(fs.existsSync(path.join(tempDir, 'setup.exe')), false);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('setup selection never falls back to a portable executable', () => {
const portable = { name: 'Multi-Hoster-Upload 2.2.0.exe' };
assert.equal(pickSetupAsset([portable], '2.2.0'), null);
assert.deepEqual(
pickSetupAsset([portable, { name: 'Multi-Hoster-Upload Setup 2.2.0.exe' }], '2.2.0'),
{ name: 'Multi-Hoster-Upload Setup 2.2.0.exe' }
);
});
test('checksum metadata must match the selected version, installer name, size, and SHA-512 shape', async () => {
const sha = crypto.randomBytes(64).toString('base64');
const fetchImpl = async () => ({
ok: true,
status: 200,
text: async () => `version: 2.2.0\npath: Multi-Hoster-Upload Setup 2.2.0.exe\nsha512: ${sha}\nsize: 456\n`
});
const metadata = await parseLatestYml('https://update.invalid/latest.yml', {
version: '2.2.0',
assetName: 'Multi-Hoster-Upload Setup 2.2.0.exe',
assetSize: 456
}, fetchImpl);
assert.deepEqual(metadata, {
version: '2.2.0',
path: 'Multi-Hoster-Upload Setup 2.2.0.exe',
size: 456,
sha512: sha
});
});
test('checksum metadata rejects a path that belongs to another artifact', async () => {
const sha = crypto.randomBytes(64).toString('base64');
await assert.rejects(
parseLatestYml('https://update.invalid/latest.yml', {
version: '2.2.0',
assetName: 'Multi-Hoster-Upload Setup 2.2.0.exe',
assetSize: 456
}, async () => ({
ok: true,
status: 200,
text: async () => `version: 2.2.0\npath: Multi-Hoster-Upload 2.2.0.exe\nsha512: ${sha}\nsize: 456\n`
})),
/gehören nicht zum ausgewählten Installer/
);
});
test('a prepared installer launches at most once', () => {
const calls = [];
const child = { unrefCalls: 0, unref() { this.unrefCalls++; } };
@@ -120,7 +198,7 @@ test('release plan keeps product artifacts separate from the transport tag', asy
'Multi-Hoster-Upload Setup 2.0.7.exe.blockmap',
'latest.yml'
],
latestYml: "version: 2.0.7\nfiles:\n - url: Multi-Hoster-Upload.Setup.2.0.7.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload.Setup.2.0.7.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
latestYml: "version: 2.0.7\nfiles:\n - url: Multi-Hoster-Upload Setup 2.0.7.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload Setup 2.0.7.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
});
});
+58
View File
@@ -0,0 +1,58 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { assertUploadConfirmation } = require('../lib/upload-confirmation');
test('accepts a host-confirmed file code without a public URL', () => {
const result = { file_code: 'AB1', download_url: null, embed_url: null };
assert.equal(assertUploadConfirmation(result, 'doodstream.com'), result);
});
test('accepts upload URLs for every supported hoster and its subdomains', () => {
const cases = [
['doodstream.com', 'https://doodstream.com/d/abc123'],
['voe.sx', 'https://cdn.voe.sx/abc123'],
['vidmoly.me', 'https://vidmoly.me/w/abc123'],
['byse.sx', 'https://media.byse.sx/d/abc123'],
['clouddrop.cc', 'https://clouddrop.cc/share/abc123']
];
for (const [hoster, downloadUrl] of cases) {
const result = { file_code: 'abc123', download_url: downloadUrl };
assert.equal(assertUploadConfirmation(result, hoster), result);
}
});
test('rejects an upload URL from a different domain', () => {
assert.throws(
() => assertUploadConfirmation({ file_code: 'abc123', download_url: 'https://attacker.invalid/file/abc123' }, 'voe.sx'),
/Upload zu voe\.sx wurde nicht bestätigt/
);
});
test('rejects a syntactically invalid file code despite a valid hoster URL', () => {
assert.throws(
() => assertUploadConfirmation({ file_code: 'not a code', download_url: 'https://vidmoly.me/w/not-a-code' }, 'vidmoly.me'),
/Upload zu vidmoly\.me wurde nicht bestätigt/
);
});
test('rejects a valid hoster URL without a file code', () => {
assert.throws(
() => assertUploadConfirmation({ download_url: 'https://byse.sx/d/abc123' }, 'byse.sx'),
/Upload zu byse\.sx wurde nicht bestätigt/
);
});
test('rejects an empty upload response', () => {
assert.throws(
() => assertUploadConfirmation({}, 'byse.sx'),
/Upload zu byse\.sx wurde nicht bestätigt/
);
});
test('rejects non-network links despite a valid file code', () => {
assert.throws(
() => assertUploadConfirmation({ file_code: 'abc123', download_url: 'javascript:alert(1)' }, 'vidmoly.me'),
/Upload zu vidmoly\.me wurde nicht bestätigt/
);
});
+43 -17
View File
@@ -97,6 +97,27 @@ describe('UploadManager', () => {
});
});
it('records an unconfirmed host response as an error instead of done', async () => {
mockUploadFile.mock.mockImplementation(async () => ({}));
const mgr = new UploadManager({ 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } });
const statuses = [];
let settled;
mgr.on('progress', data => statuses.push(data.status));
mgr.on('job-settled', event => { settled = event; });
await mgr.startBatch([{
file: '/test/unconfirmed.mp4',
hoster: 'doodstream.com',
apiKey: 'key1',
jobId: 'job-unconfirmed',
sourceCleanupToken: 'cleanup-unconfirmed'
}]);
assert.equal(statuses.includes('done'), false);
assert.equal(statuses.at(-1), 'error');
assert.equal(settled.status, 'error');
});
it('replaces account pools and clears cached account state after an import', () => {
const mgr = new UploadManager({}, {}, {
'byse.sx': [{ id: 'old', apiKey: 'old-key' }]
@@ -144,7 +165,7 @@ describe('UploadManager', () => {
callCount++;
if (callCount <= 2) throw new Error('network error');
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager({ 'doodstream.com': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } });
@@ -182,7 +203,7 @@ describe('UploadManager', () => {
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
// Simulate a slow upload
await new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve({ download_url: 'x', embed_url: null, file_code: 'x' }), 10000);
const timer = setTimeout(() => resolve({ download_url: 'https://doodstream.com/d/ok123', embed_url: null, file_code: 'ok123' }), 10000);
if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); });
});
});
@@ -229,7 +250,7 @@ describe('UploadManager', () => {
await new Promise(r => setTimeout(r, 50));
concurrent--;
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager({ 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } });
@@ -253,7 +274,7 @@ describe('UploadManager', () => {
await new Promise((resolve) => setTimeout(resolve, 40));
concurrent--;
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: `https://${hoster}/ok`, embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager({
@@ -286,7 +307,7 @@ describe('UploadManager', () => {
}
});
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager({});
@@ -319,7 +340,7 @@ describe('UploadManager', () => {
await new Promise((resolve) => setTimeout(resolve, 20));
}
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager({
@@ -402,7 +423,9 @@ describe('UploadManager', () => {
const mgr = new UploadManager({});
const errors = [];
let summary;
mgr.on('progress', (d) => { if (d.error) errors.push(d.error); });
mgr.on('batch-done', value => { summary = value; });
await mgr.startBatch([
{ file: '/test/deleted.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
@@ -410,6 +433,9 @@ describe('UploadManager', () => {
fs.promises.stat = origStat;
assert.ok(errors.some(e => e.includes('nicht gefunden')), `expected "nicht gefunden" error, got: ${errors.join(', ')}`);
assert.equal(summary.files[0].results[0].status, 'skipped');
assert.equal(summary.skipped, 1);
assert.equal(summary.failed, 0);
});
it('zero-byte file produces descriptive error', async () => {
@@ -452,7 +478,7 @@ describe('UploadManager', () => {
await new Promise(r => setTimeout(r, 40));
concurrent--;
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager(
@@ -477,7 +503,7 @@ describe('UploadManager', () => {
started++;
await new Promise(r => setTimeout(r, 100));
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager({});
@@ -512,7 +538,7 @@ describe('UploadManager', () => {
if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); });
});
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager({});
@@ -545,7 +571,7 @@ describe('UploadManager', () => {
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
await new Promise(r => setTimeout(r, 1500));
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager({});
@@ -630,7 +656,7 @@ describe('UploadManager', () => {
throw err;
}
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://byse.sx/ok', embed_url: null, file_code: 'ok' };
return { download_url: 'https://byse.sx/d/ok123', embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager(
@@ -721,7 +747,7 @@ describe('UploadManager', () => {
throw err;
}
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager(
@@ -767,7 +793,7 @@ describe('UploadManager', () => {
}
acc2Calls++;
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager(
@@ -797,11 +823,11 @@ describe('UploadManager', () => {
acc1Calls++;
if (acc1Calls <= 2) throw new Error('connect ECONNRESET 1.2.3.4:443');
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok-on-acc1-retry', embed_url: null, file_code: 'ok' };
return { download_url: 'https://voe.sx/ok123', embed_url: null, file_code: 'ok123' };
}
acc2Calls++;
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager(
@@ -849,7 +875,7 @@ describe('UploadManager', () => {
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
await mgr.startBatch([
@@ -1012,7 +1038,7 @@ describe('UploadManager', () => {
throw err;
}
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager(
+23 -3
View File
@@ -78,8 +78,8 @@ test('formatDurationShort formats h/m/s tiers', () => {
test('summarizePerHosterFromBatch counts ok/fail per hoster', () => {
const s = summarizePerHosterFromBatch(SAMPLE_SUMMARY);
assert.deepStrictEqual(s['voe.sx'], { ok: 2, fail: 0 });
assert.deepStrictEqual(s['byse.sx'], { ok: 1, fail: 1 });
assert.deepStrictEqual(s['voe.sx'], { ok: 2, fail: 0, skipped: 0 });
assert.deepStrictEqual(s['byse.sx'], { ok: 1, fail: 1, skipped: 0 });
});
test('summarizePerHosterFromBatch handles malformed input', () => {
@@ -102,6 +102,26 @@ test('buildWebhookRequest produces Discord content body for discord URLs', () =>
assert.match(body.content, /voe\.sx: 2\/2/);
});
test('webhooks report skipped uploads separately from failures', () => {
const summary = {
total: 2,
succeeded: 1,
failed: 0,
skipped: 1,
files: [{ name: 'a.mkv', results: [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'voe.sx', status: 'skipped', error: 'Datei zu groß' }
] }]
};
const discord = JSON.parse(buildWebhookRequest('https://discord.com/api/webhooks/1/x', summary, { language: 'de' }).body);
assert.match(discord.content, /1 übersprungen/);
assert.doesNotMatch(discord.content, /1 Fehler/);
assert.deepStrictEqual(summarizePerHosterFromBatch(summary)['voe.sx'], { ok: 1, fail: 0, skipped: 1 });
const generic = JSON.parse(buildWebhookRequest('https://example.com/hook', summary, {}).body);
assert.equal(generic.skipped, 1);
});
test('buildWebhookRequest localizes Discord content from the selected language', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { language: 'de' });
const body = JSON.parse(req.body);
@@ -119,7 +139,7 @@ test('buildWebhookRequest produces raw JSON payload for generic URLs', () => {
assert.strictEqual(body.failed, 2);
assert.strictEqual(body.durationSec, 60);
assert.strictEqual(body.version, '3.3.59');
assert.deepStrictEqual(body.perHoster['byse.sx'], { ok: 1, fail: 1 });
assert.deepStrictEqual(body.perHoster['byse.sx'], { ok: 1, fail: 1, skipped: 0 });
});
test('resolveDiscordMention: @here / @everyone use parse=everyone', () => {