release: Multi-Hoster-Upload v2.0.3

This commit is contained in:
Sucukdeluxe
2026-08-09 14:54:44 +02:00
parent 6cdccf71f2
commit 1c4d096cb9
31 changed files with 2304 additions and 131 deletions
+34
View File
@@ -254,6 +254,40 @@ describe('ConfigStore', () => {
assert.equal(config.globalSettings.alwaysOnTop, true);
});
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('load() returns independent clones — mutating one result must not leak into the cache', () => {
store.load(); // warm the cache
const a = store.load();
+22 -26
View File
@@ -8,17 +8,14 @@ const stats = require('../lib/stats');
const { createCollectors } = require('../lib/diagnostics-collectors');
const { createAgent } = require('../lib/diagnostics-agent');
const fixtureSecrets = {
diagnosticToken: ['fixture', 'diagnostic', 'token', '123456'].join('-'),
bearerToken: ['fixture', 'bearer', 'token', '123456'].join('-'),
doodstreamKey: ['fixture', 'doodstream', 'key', '99999'].join('-'),
password: ['fixture', 'password', 'not', 'real'].join('-'),
apiKey: ['fixture', 'api', 'key', '1234567'].join('-'),
webhookToken: ['fixture', 'webhook', 'token', '123456'].join('-')
};
function makeFixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-'));
const fixtureAlpha = ['SECRET', 'TOKEN', '123456'].join('');
const fixtureBeta = ['abcdef', '123456'].join('');
const fixtureGamma = ['LIVE', 'KEY', '99999'].join('');
const fixtureDelta = ['HUNTER', '2', 'SECRET'].join('');
const fixtureEpsilon = ['BYSE', 'KEY', '1234567'].join('');
const fixtureZeta = ['WBHOOK', 'SECRET', 'TOKEN'].join('');
const paths = {
fileuploader: path.join(dir, 'fileuploader.log'),
debug: path.join(dir, 'debug.log'),
@@ -27,15 +24,15 @@ function makeFixture() {
crashLog: path.join(dir, 'crash.log'),
logDir: dir
};
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureSecrets.diagnosticToken} inline\nAuthorization: Bearer ${fixtureSecrets.bearerToken}\n`);
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureSecrets.doodstreamKey} sess=abc\n`);
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`);
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
const config = {
hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: fixtureSecrets.password }], 'byse.sx': [{ id: 'b1', apiKey: fixtureSecrets.apiKey }] },
hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: fixtureDelta }], 'byse.sx': [{ id: 'b1', apiKey: fixtureEpsilon }] },
hosterSettings: {},
globalSettings: {
webhookUrl: `https://discord.com/api/webhooks/12345/${fixtureSecrets.webhookToken}`,
diagnostics: { enabled: true, port: 9110, token: fixtureSecrets.diagnosticToken, bindAddress: '127.0.0.1' },
webhookUrl: ['https://discord.com/api/webhooks/', '12345', fixtureZeta].join('/'),
diagnostics: { enabled: true, port: 9110, token: fixtureAlpha, bindAddress: '127.0.0.1' },
pendingQueue: { savedAt: 1, selectedUploadHosters: ['voe.sx'], selectedFiles: [{ path: 'C:/a.mkv' }], queueJobs: [{ file: 'C:/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'error', error: 'timeout' }] }
},
history: [{ timestamp: new Date(2026, 0, 1).toISOString(), files: [{ name: 'x.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }, { hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/x' }] }] }],
@@ -49,17 +46,17 @@ function makeFixture() {
systemInfo: () => ({ platform: 'win32', hostname: 'srv' }),
agentInfo: () => ({ version: '9.9.9', port: 9110, clientCount: 0, lastAccess: null })
});
return { dir, paths, config, collectors };
return { dir, paths, config, collectors, fixtureAlpha, fixtureDelta, fixtureEpsilon, fixtureZeta };
}
test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs the token mid-string', () => {
const { collectors } = makeFixture();
const { collectors, fixtureAlpha, fixtureDelta, fixtureEpsilon, fixtureZeta } = makeFixture();
const out = collectors.getConfigRedacted({ section: 'all' });
const json = JSON.stringify(out);
assert.ok(!json.includes(fixtureSecrets.password), 'password must be redacted');
assert.ok(!json.includes(fixtureSecrets.apiKey), 'apiKey must be redacted');
assert.ok(!json.includes(fixtureSecrets.diagnosticToken), 'diag token must be redacted');
assert.ok(!json.includes(fixtureSecrets.webhookToken), 'webhook secret must be redacted');
assert.ok(!json.includes(fixtureDelta), 'password must be redacted');
assert.ok(!json.includes(fixtureEpsilon), 'apiKey must be redacted');
assert.ok(!json.includes(fixtureAlpha), 'diag token must be redacted');
assert.ok(!json.includes(fixtureZeta), 'webhook secret must be redacted');
});
test('getHistory reads loadHistory (migrated mode: loadConfig().history is empty)', () => {
@@ -91,8 +88,8 @@ test('getHistory falls back to loadConfig().history when loadHistory is absent (
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
const { collectors } = makeFixture();
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
assert.ok(!dbg.content.includes(fixtureSecrets.diagnosticToken), 'value-scrub removes the live diag token from logs');
assert.ok(!dbg.content.includes(fixtureSecrets.bearerToken), 'pattern-scrub removes Authorization Bearer');
assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs');
assert.ok(!/Bearer abcdef123456/.test(dbg.content), 'pattern-scrub removes Authorization Bearer');
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
@@ -131,11 +128,10 @@ test('getQueueState flags stale=true for the persisted snapshot and counts by st
});
test('getQueueState (includeJobs default) pattern-scrubs an opaque token in a job error that is NOT a config secret', () => {
const opaqueToken = ['fixture', 'opaque', 'token', '9988'].join('_');
const config = {
hosters: {}, hosterSettings: {},
globalSettings: { pendingQueue: { savedAt: 1, selectedUploadHosters: [], selectedFiles: [], queueJobs: [
{ file: 'C:/b.mkv', fileName: 'b.mkv', hoster: 'streamtape', status: 'error', error: `upload rejected: token=${opaqueToken}` }
{ file: 'C:/b.mkv', fileName: 'b.mkv', hoster: 'streamtape', status: 'error', error: 'upload rejected: token=OPAQUE_NONconfig_TOKEN_9988' }
] } },
history: [], rotationCursors: {}
};
@@ -147,7 +143,7 @@ test('getQueueState (includeJobs default) pattern-scrubs an opaque token in a jo
});
const q = collectors.getQueueState({});
const json = JSON.stringify(q);
assert.ok(!json.includes(opaqueToken), 'opaque token in a job error must be pattern-scrubbed even on the default includeJobs path');
assert.ok(!json.includes('OPAQUE_NONconfig_TOKEN_9988'), 'opaque token in a job error must be pattern-scrubbed even on the default includeJobs path');
});
test('listErrors classifies via stats.classifyErrorCategory and redacts error text', () => {
@@ -162,5 +158,5 @@ test('serverHealth assembles the one-shot hub without leaking secrets', () => {
const h = collectors.serverHealth({});
const json = JSON.stringify(h);
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
assert.ok(!json.includes(fixtureSecrets.password) && !json.includes(fixtureSecrets.diagnosticToken) && !json.includes(fixtureSecrets.webhookToken), 'no secret leaks in server_health');
assert.ok(!json.includes('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health');
});
+13 -20
View File
@@ -92,44 +92,37 @@ test('_parseUploadFormFields returns {} for markup without a form', () => {
// --- deriveApiKey: pull + validate the account API key from the web session ---
test('_extractApiKeyCandidates finds the key in an input value and ranks api-context first', () => {
const up = new DoodstreamUploader();
const csrfCandidate = ['fixture', 'csrf', 'candidate', '00000001'].join('');
const apiCandidate = ['fixture', 'api', 'candidate', '0000000001'].join('');
const html = `
<input type="text" name="csrf" value="${csrfCandidate}">
<div class="panel">API Key <input readonly value="${apiCandidate}"></div>
<input type="text" name="csrf" value="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa">
<div class="panel">API Key <input readonly value="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"></div>
`;
const cands = up._extractApiKeyCandidates(html);
// The token whose preceding context mentions "API" must rank first.
assert.equal(cands[0], apiCandidate);
assert.ok(cands.includes(csrfCandidate));
assert.equal(cands[0], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
assert.ok(cands.includes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'));
});
test('_extractApiKeyCandidates handles textarea + api_key: "x" shapes and empty input', () => {
const up = new DoodstreamUploader();
assert.deepEqual(up._extractApiKeyCandidates(''), []);
const textareaCandidate = ['fixture', 'textarea', 'candidate', '000001'].join('');
const objectCandidate = ['fixture', 'object', 'candidate', '00000001'].join('');
const ta = up._extractApiKeyCandidates(`<textarea id="k">${textareaCandidate}</textarea>`);
assert.ok(ta.includes(textareaCandidate));
const js = up._extractApiKeyCandidates(`var x = {"api_key":"${objectCandidate}"};`);
assert.ok(js.includes(objectCandidate));
const ta = up._extractApiKeyCandidates('<textarea id="k">cccccccccccccccccccccccccccccccc</textarea>');
assert.ok(ta.includes('cccccccccccccccccccccccccccccccc'));
const js = up._extractApiKeyCandidates('var x = {"api_key":"dddddddddddddddddddddddddddddddd"};');
assert.ok(js.includes('dddddddddddddddddddddddddddddddd'));
});
test('deriveApiKey returns the candidate that validates against the API', async () => {
const up = new DoodstreamUploader();
const acceptedCandidate = ['fixture', 'accepted', 'candidate', '1234567890'].join('');
const rejectedCandidate = ['fixture', 'rejected', 'candidate', '0987654321'].join('');
up._fetch = async () => ({ text: async () => `<div>API Key <input value="${acceptedCandidate}"></div><input value="${rejectedCandidate}">` });
up._validateApiKey = async (key) => key === acceptedCandidate;
up._fetch = async () => ({ text: async () => '<div>API Key <input value="REALKEY1234567890abcdefGHIJK"></div><input value="notthekey000000000000000000">' });
up._validateApiKey = async (key) => key === 'REALKEY1234567890abcdefGHIJK';
const key = await up.deriveApiKey();
assert.equal(key, acceptedCandidate);
assert.equal(up.apiKey, acceptedCandidate); // cached on the instance
assert.equal(key, 'REALKEY1234567890abcdefGHIJK');
assert.equal(up.apiKey, 'REALKEY1234567890abcdefGHIJK'); // cached on the instance
});
test('deriveApiKey returns null when no candidate validates (→ caller uses web fallback)', async () => {
const up = new DoodstreamUploader();
const rejectedCandidate = ['fixture', 'rejected', 'candidate', '0000000000'].join('');
up._fetch = async () => ({ text: async () => `<input value="${rejectedCandidate}">` });
up._fetch = async () => ({ text: async () => '<input value="bogustoken0000000000000000000">' });
up._validateApiKey = async () => false;
assert.equal(await up.deriveApiKey(), null);
assert.equal(up.apiKey, '');
+71
View File
@@ -0,0 +1,71 @@
const { once } = require('node:events');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { after, before, describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
createOnlineBackup,
deleteOnlineBackup,
downloadOnlineBackup,
uploadOnlineBackup
} = require('../lib/online-backup');
let rootDir;
let server;
let baseUrl;
before(async () => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-backup-contract-'));
const moduleUrl = pathToFileURL(path.join(__dirname, '..', 'services', 'backup-api', 'src', 'server.mjs')).href;
const { createBackupServer } = await import(moduleUrl);
server = createBackupServer({ rootDir });
server.listen(0, '127.0.0.1');
await once(server, 'listening');
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
if (server) await new Promise((resolve) => server.close(resolve));
if (rootDir) fs.rmSync(rootDir, { recursive: true, force: true });
});
describe('online backup client and service contract', () => {
it('keeps older keys valid and stores ciphertext only', async () => {
const firstSettings = {
hosters: { 'byse.sx': [{ id: 'first', apiKey: 'first-secret' }] },
hosterSettings: { 'byse.sx': { retries: 3 } },
globalSettings: { alwaysOnTop: false },
history: []
};
const secondSettings = {
hosters: { 'byse.sx': [{ id: 'second', apiKey: 'second-secret' }] },
hosterSettings: { 'byse.sx': { retries: 7 } },
globalSettings: { alwaysOnTop: true },
history: []
};
const first = createOnlineBackup(firstSettings, '2.0.3');
const second = createOnlineBackup(secondSettings, '2.0.3');
await uploadOnlineBackup(first.record, baseUrl);
await uploadOnlineBackup(second.record, baseUrl);
assert.deepEqual((await downloadOnlineBackup(first.key, baseUrl)).settings, firstSettings);
assert.deepEqual((await downloadOnlineBackup(second.key, baseUrl)).settings, secondSettings);
const stored = fs.readdirSync(rootDir)
.filter((name) => name.endsWith('.json'))
.map((name) => fs.readFileSync(path.join(rootDir, name), 'utf8'))
.join('\n');
assert.equal(stored.includes('first-secret'), false);
assert.equal(stored.includes('second-secret'), false);
assert.equal(stored.includes(first.key), false);
assert.equal(stored.includes(second.key), false);
await deleteOnlineBackup(first.key, baseUrl);
await assert.rejects(downloadOnlineBackup(first.key, baseUrl), /nicht gefunden/i);
assert.deepEqual((await downloadOnlineBackup(second.key, baseUrl)).settings, secondSettings);
await deleteOnlineBackup(second.key, baseUrl);
});
});
+165
View File
@@ -0,0 +1,165 @@
const http = require('node:http');
const { once } = require('node:events');
const { afterEach, describe, it } = require('node:test');
const assert = require('node:assert/strict');
const servers = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))));
});
function settings() {
return {
hosters: {
'doodstream.com': [{ id: 'account-1', authType: 'api', apiKey: 'secret-api-key', enabled: true }]
},
hosterSettings: {
'doodstream.com': { retries: 3, parallelCount: 5 }
},
globalSettings: {
alwaysOnTop: true,
webhookUrl: 'https://example.invalid/private-webhook'
},
history: []
};
}
describe('online backup key', () => {
it('creates a unique 75-character MHU key and restores every snapshot independently', () => {
const { createOnlineBackup, restoreOnlineBackup } = require('../lib/online-backup');
const first = createOnlineBackup(settings(), '2.0.3', '2026-08-09T00:00:00.000Z');
const secondSettings = settings();
secondSettings.globalSettings.alwaysOnTop = false;
const second = createOnlineBackup(secondSettings, '2.0.3', '2026-08-09T00:01:00.000Z');
assert.match(first.key, /^MHU2-[A-Za-z0-9_-]{70}$/);
assert.equal(first.key.length, 75);
assert.notEqual(second.key, first.key);
assert.deepEqual(restoreOnlineBackup(first.key, first.record.blob).settings, settings());
assert.equal(restoreOnlineBackup(second.key, second.record.blob).settings.globalSettings.alwaysOnTop, false);
});
it('never places credentials or the decryption secret in the server record', () => {
const { createOnlineBackup, parseOnlineBackupKey } = require('../lib/online-backup');
const created = createOnlineBackup(settings(), '2.0.3');
const serialized = JSON.stringify(created.record);
const parsed = parseOnlineBackupKey(created.key);
assert.equal(serialized.includes('secret-api-key'), false);
assert.equal(serialized.includes('private-webhook'), false);
assert.equal(serialized.includes(parsed.masterKey.toString('base64url')), false);
assert.deepEqual(Object.keys(created.record).sort(), ['blob', 'deleteVerifier', 'id']);
});
it('rejects corrupted keys, ciphertext and oversized settings', () => {
const { createOnlineBackup, parseOnlineBackupKey, restoreOnlineBackup } = require('../lib/online-backup');
const created = createOnlineBackup(settings(), '2.0.3');
const keyTail = created.key.endsWith('A') ? 'B' : 'A';
const blobTail = created.record.blob.endsWith('A') ? 'B' : 'A';
assert.throws(() => parseOnlineBackupKey(`${created.key.slice(0, -1)}${keyTail}`), /Schlüssel/i);
assert.throws(() => restoreOnlineBackup(created.key, `${created.record.blob.slice(0, -1)}${blobTail}`), /entschlüsselt|beschädigt/i);
assert.throws(() => createOnlineBackup({ huge: 'x'.repeat(600_000) }, '2.0.3'), /zu groß/i);
});
});
describe('online backup transport', () => {
it('uses only POST bodies and never sends the master key or record id in URLs', async () => {
const {
createOnlineBackup,
deleteOnlineBackup,
downloadOnlineBackup,
parseOnlineBackupKey,
uploadOnlineBackup
} = require('../lib/online-backup');
let stored = null;
let deleteRequest = null;
const requestedUrls = [];
const server = http.createServer(async (request, response) => {
requestedUrls.push(String(request.url || ''));
const chunks = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {};
if (request.method === 'POST' && request.url === '/v1/backups') {
stored = body;
response.writeHead(201, { 'content-type': 'application/json' });
response.end('{"created":true}');
return;
}
if (request.method === 'POST' && request.url === '/v1/backups/restore' && stored) {
assert.equal(body.id, stored.id);
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ blob: stored.blob }));
return;
}
if (request.method === 'POST' && request.url === '/v1/backups/delete' && stored) {
deleteRequest = body;
response.writeHead(204);
response.end();
return;
}
response.writeHead(404, { 'content-type': 'application/json' });
response.end('{"error":"not_found"}');
});
servers.push(server);
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const created = createOnlineBackup(settings(), '2.0.3');
await uploadOnlineBackup(created.record, baseUrl);
const restored = await downloadOnlineBackup(created.key, baseUrl);
await deleteOnlineBackup(created.key, baseUrl);
assert.deepEqual(restored.settings, settings());
assert.equal(JSON.stringify(stored).includes(parseOnlineBackupKey(created.key).masterKey.toString('base64url')), false);
assert.match(deleteRequest.deleteSecret, /^[A-Za-z0-9_-]{43}$/);
assert.deepEqual(requestedUrls, ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete']);
assert.equal(requestedUrls.join(' ').includes(stored.id), false);
});
it('does not reflect server response bodies into client errors', async () => {
const { createOnlineBackup, uploadOnlineBackup } = require('../lib/online-backup');
const server = http.createServer((_request, response) => {
response.writeHead(500, { 'content-type': 'application/json' });
response.end('{"leaked":"server-secret-value"}');
});
servers.push(server);
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const created = createOnlineBackup(settings(), '2.0.3');
await assert.rejects(
uploadOnlineBackup(created.record, `http://127.0.0.1:${server.address().port}`),
(error) => !String(error.message).includes('server-secret-value')
);
});
it('keeps the timeout active until the response body is fully read', async () => {
const { createOnlineBackup, downloadOnlineBackup } = require('../lib/online-backup');
const created = createOnlineBackup(settings(), '2.0.3');
const fetchImpl = async (_url, options) => ({
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
body: {
getReader: () => ({
read: () => new Promise((_resolve, reject) => {
options.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
}),
cancel: async () => {}
})
}
});
const outcome = await Promise.race([
assert.rejects(
downloadOnlineBackup(created.key, 'http://127.0.0.1:8788', { fetchImpl, timeoutMs: 20 }),
/antwortet nicht/i
).then(() => 'timed-out'),
new Promise((resolve) => setTimeout(() => resolve('hung'), 120))
]);
assert.equal(outcome, 'timed-out');
});
});
+8
View File
@@ -0,0 +1,8 @@
const test = require('node:test');
const assert = require('node:assert/strict');
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'));
});
+32
View File
@@ -0,0 +1,32 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
describe('serialized runner', () => {
it('flush waits for an already running save and later work stays ordered', async () => {
const { createSerializedRunner } = require('../lib/serialized-runner');
let releaseFirst;
const calls = [];
const runner = createSerializedRunner(async (value) => {
calls.push(`start:${value}`);
if (value === 'first') await new Promise((resolve) => { releaseFirst = resolve; });
calls.push(`end:${value}`);
return value;
});
const first = runner.run('first');
await new Promise((resolve) => setImmediate(resolve));
const second = runner.run('second');
let flushed = false;
const flush = runner.flush().then(() => { flushed = true; });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(flushed, false);
assert.deepEqual(calls, ['start:first']);
releaseFirst();
assert.equal(await first, 'first');
assert.equal(await second, 'second');
await flush;
assert.equal(flushed, true);
assert.deepEqual(calls, ['start:first', 'end:first', 'start:second', 'end:second']);
});
});
+47
View File
@@ -0,0 +1,47 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
describe('settings backup snapshot', () => {
it('copies accounts and settings while excluding history, queue and rotation state', () => {
const { createPortableSettingsSnapshot } = require('../lib/settings-backup');
const input = {
hosters: { 'voe.sx': [{ id: 'v1', username: 'user', password: 'secret', enabled: true }] },
hosterSettings: { 'voe.sx': { retries: 7 } },
globalSettings: { alwaysOnTop: true, pendingQueue: [{ file: 'private.mkv' }] },
history: [{ file: 'done.mkv' }],
rotationCursors: { 'voe.sx': 4 }
};
const snapshot = createPortableSettingsSnapshot(input);
assert.deepEqual(snapshot, {
hosters: input.hosters,
hosterSettings: input.hosterSettings,
globalSettings: { alwaysOnTop: true, pendingQueue: null },
history: []
});
assert.notEqual(snapshot.hosters, input.hosters);
});
it('validates imports and clears only source-machine paths that do not exist locally', () => {
const { prepareImportedSettings } = require('../lib/settings-backup');
const snapshot = {
hosters: { 'byse.sx': [{ id: 'b1', apiKey: 'secret', enabled: true }] },
hosterSettings: { 'byse.sx': { parallelCount: 6 } },
globalSettings: {
alwaysOnTop: true,
logFilePath: 'Z:\\missing\\upload.log',
folderMonitor: { enabled: true, folderPath: 'Z:\\missing\\watch' },
pendingQueue: [{ file: 'do-not-restore.mkv' }]
}
};
const imported = prepareImportedSettings(snapshot, { pathExists: () => false, pathDirname: (value) => value });
assert.equal(imported.globalSettings.logFilePath, '');
assert.deepEqual(imported.globalSettings.folderMonitor, { enabled: false, folderPath: '' });
assert.equal(imported.globalSettings.pendingQueue, null);
assert.deepEqual(imported.history, []);
assert.throws(() => prepareImportedSettings({ hosters: {} }), /ungültige Struktur/i);
});
});
+19
View File
@@ -0,0 +1,19 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
describe('settings import gate', () => {
it('blocks upload starts for the complete import transition', () => {
const { createSettingsImportGate } = require('../lib/settings-import-gate');
let uploadRunning = false;
const gate = createSettingsImportGate(() => uploadRunning);
gate.begin();
assert.equal(gate.canStartUpload(), false);
assert.throws(() => gate.begin(), /bereits importiert/i);
gate.end();
assert.equal(gate.canStartUpload(), true);
uploadRunning = true;
assert.throws(() => gate.begin(), /laufender Uploads/i);
});
});
+23 -28
View File
@@ -5,8 +5,6 @@ const os = require('os');
const path = require('path');
const { sanitizeConfig, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
const artificialSecret = (...fragments) => fragments.join('');
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
const input = {
hosters: {
@@ -27,24 +25,18 @@ test('sanitizeConfig redacts known credential keys at any nesting depth', () =>
});
test('redactLogText scrubs opaque tokens that are NOT stored config secrets', () => {
const secrets = [
artificialSecret('fixture_token_', 'qwerty', '12345'),
artificialSecret('fixture_auth_', 'value', '123456'),
artificialSecret('fixture_refresh_', 'value', '123456'),
artificialSecret('fixture_bearer_', 'value', '123456'),
artificialSecret('fixture_authorization_', 'value', '123456')
];
const field = ['to', 'ken'].join('');
const cases = [
`boom token=${secrets[0]}`,
`response auth_token: ${secrets[1]}`,
`refresh_token = ${secrets[2]}`,
`using Bearer ${secrets[3]}`,
`Authorization: Bearer ${secrets[4]}`
`boom ${field}=${['bearer', 'tok', 'qwerty12345'].join('_')}`,
`response auth_${field}: ${['aGVsbG8t', 'd29ybGQt', 'MTIz'].join('')}`,
`refresh_${field} = ${['abc123', 'DEF456', 'ghi789'].join('')}`,
`using Bearer ${['aaaa', 'bbbb', 'cccc', 'dddd', 'eeee', 'ffff'].join('')}`,
`Authorization: Bearer ${['deadbeef', 'cafef00d', 'ba5e'].join('')}`
];
for (const [index, line] of cases.entries()) {
for (const line of cases) {
const out = redactLogText(line, []);
assert.ok(out.includes(REDACTED), `expected redaction in: ${line} -> ${out}`);
assert.ok(!out.includes(secrets[index]), `secret survived: ${out}`);
assert.ok(!/qwerty12345|aGVsbG8|abc123DEF456|aaaabbbbcccc|deadbeefcafe/.test(out), `secret survived: ${out}`);
}
});
@@ -54,9 +46,9 @@ test('redactLogText leaves benign "token" prose alone', () => {
});
test('redactLogText scrubs the password from a basic-auth URL but keeps host:port', () => {
const password = artificialSecret('fixture', 'Proxy', 'Password');
const out = redactLogText(`proxy https://admin:${password}@proxy.internal:8080/path`, []);
assert.ok(!out.includes(password), 'basic-auth password must be redacted');
const credential = ['Sup3r', 'Proxy', 'Pass'].join('');
const out = redactLogText(`proxy https://admin:${credential}@proxy.internal:8080/path`, []);
assert.ok(!out.includes(credential), 'basic-auth password must be redacted');
assert.ok(out.includes('proxy.internal:8080'), 'host:port preserved');
assert.ok(out.includes('admin:'), 'username preserved');
});
@@ -67,16 +59,19 @@ test('redactLogText does not touch a host:port URL without userinfo', () => {
});
test('redactLogText scrubs Basic auth, JWTs and bare session= values (defense in depth)', () => {
const basicValue = artificialSecret('dXNlcjpw', 'YXNzd29y', 'ZDEyMw');
const jwtValue = artificialSecret('eyJhbGciOiJIUzI1NiJ9', '.', 'eyJzdWIiOiIxMjM0NTY3ODkwIn0', '.', 'dozjgNryP4J3jVmNHl0w5N');
const jwtSecret = artificialSecret('eyJhbGciOiJIUzI1NiJ9', '.', 'eyJzdWIiOiIxMjM0NTY3ODkwIn0');
const sessionValue = artificialSecret('fixture', 'Session', 'Value', '99887766');
const jsonSessionValue = artificialSecret('fixture', 'Json', 'Session', '123456');
const basic = ['dXNlcjpw', 'YXNzd29y', 'ZDEyMw=='].join('');
const jwt = [
['eyJhbGci', 'OiJIUzI1NiJ9'].join(''),
['eyJzdWIi', 'OiIxMjM0', 'NTY3ODkwIn0'].join(''),
['dozjgNry', 'P4J3jVmN', 'Hl0w5N'].join('')
].join('.');
const sessionA = ['SESSION', 'secret', 'value', '99887766'].join('');
const sessionB = ['json', 'Session', 'Secret', '123456'].join('');
const cases = [
{ line: `Authorization: Basic ${basicValue}==`, secret: basicValue },
{ line: `jwt ${jwtValue}`, secret: jwtSecret },
{ line: `session=${sessionValue}`, secret: sessionValue },
{ line: `"session":"${jsonSessionValue}"`, secret: jsonSessionValue },
{ line: `Authorization: Basic ${basic}`, secret: basic.replace(/==$/, '') },
{ line: `jwt ${jwt}`, secret: jwt.split('.').slice(0, 2).join('.') },
{ line: `session=${sessionA}`, secret: sessionA },
{ line: `"session":"${sessionB}"`, secret: sessionB },
];
for (const c of cases) {
const out = redactLogText(c.line, []);
+21 -1
View File
@@ -9,7 +9,7 @@ if (!process.env.RUN_UI_SMOKE) {
return;
}
const { execSync } = require('child_process');
const { execFileSync, execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
@@ -178,6 +178,25 @@ setTimeout(async () => {
const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value');
check('Global parallel uploads default 0', parallel === '0');
await wc.executeJavaScript('document.querySelector("[data-subtab=\\'backup\\']").click()');
const onlineBackupControls = await wc.executeJavaScript('["createOnlineBackupBtn", "onlineBackupKeyOutput", "copyOnlineBackupKeyBtn", "onlineBackupKeyInput", "restoreOnlineBackupBtn", "onlineBackupStatus"].every(id => Boolean(document.getElementById(id)))');
check('Online backup controls exist', onlineBackupControls);
const onlineBackupKeyContract = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput")?.maxLength + "|" + document.getElementById("onlineBackupKeyInput")?.getAttribute("pattern")');
check('Online backup input enforces the 75-character MHU key format', onlineBackupKeyContract === '75|MHU2-[A-Za-z0-9_-]{70}');
const onlineBackupBridge = await wc.executeJavaScript('typeof window.api.createOnlineBackup + "|" + typeof window.api.restoreOnlineBackup');
check('Online backup uses a narrow preload bridge', onlineBackupBridge === 'function|function');
const invalidOnlineBackup = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput").value = "MHU2-short"; document.getElementById("onlineBackupKeyInput").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("restoreOnlineBackupBtn").disabled + "|" + document.getElementById("onlineBackupStatus").textContent');
check('Invalid online backup keys stay blocked with visible guidance', invalidOnlineBackup === 'true|Der Schlüssel muss exakt 75 Zeichen lang sein.');
const validOnlineBackup = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput").value = "MHU2-" + "A".repeat(70); document.getElementById("onlineBackupKeyInput").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("restoreOnlineBackupBtn").disabled');
check('Valid 75-character online backup keys enable restore', validOnlineBackup === false);
const onlineRestoreNavigation = await wc.executeJavaScript('_handleMenuAction("online-backup-restore"); document.activeElement?.id + "|" + document.querySelector(".settings-subtab.active")?.dataset.subtab');
check('Online restore menu opens the backup page and focuses the key', onlineRestoreNavigation === 'onlineBackupKeyInput|backup');
// Test save
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
await new Promise(r => setTimeout(r, 500));
@@ -222,6 +241,7 @@ setTimeout(async () => {
// Write the injection script
const injectPath = path.join(__dirname, '_ui-inject.tmp.js');
fs.writeFileSync(injectPath, testScript, 'utf-8');
execFileSync(process.execPath, ['--check', injectPath], { cwd: path.join(__dirname, '..'), stdio: 'pipe' });
// Run the real app with the injection
try {
+24
View File
@@ -67,6 +67,30 @@ describe('UploadManager', () => {
assert.ok(events.length > 0, 'should emit at least one progress event');
});
it('replaces account pools and clears cached account state after an import', () => {
const mgr = new UploadManager({}, {}, {
'byse.sx': [{ id: 'old', apiKey: 'old-key' }]
});
mgr.switchAccount('byse.sx', { id: 'fallback', apiKey: 'fallback-key' });
mgr._failedAccounts.set('byse.sx:old', true);
mgr._suspectSizeMemo.set('byse.sx:old', { size: 1, count: 2 });
mgr._suspectGoodAccounts.set('byse.sx', 'old');
mgr._doodApiKeyCache.set('old', 'cached-key');
mgr._baselineCache.set('byse.sx:old-key', Promise.resolve(new Set()));
mgr.replaceAccountPools({
'byse.sx': [{ id: 'new', apiKey: 'new-key' }]
});
assert.deepEqual(mgr.accountPools['byse.sx'], [{ id: 'new', apiKey: 'new-key' }]);
assert.equal(mgr.getFailedAccountKeys().length, 0);
assert.equal(mgr.getOverride('byse.sx'), null);
assert.equal(mgr._suspectSizeMemo.size, 0);
assert.equal(mgr._suspectGoodAccounts.size, 0);
assert.equal(mgr._doodApiKeyCache.size, 0);
assert.equal(mgr._baselineCache.size, 0);
});
it('emits batch-done with correct summary', async () => {
const mgr = new UploadManager({});
let summary = null;