release: Multi-Hoster-Upload v2.0.3
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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, '');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user