Preserve pre-start and batch cancellation requests, reject late upload success after cancellation, and wait for cancellation acknowledgements before removing queue entries. Separate formatted link logs from privacy-safe source cleanup and upload plan audits, persist audit fallback paths, redact support bundles, and expose audit diagnostics safely. Improve queue selection and destructive-action clarity, show the Settings save action only while changes are pending, and add regression coverage for all updated behavior.
203 lines
9.1 KiB
JavaScript
203 lines
9.1 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { sanitizeConfig, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
|
|
|
|
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
|
|
const input = {
|
|
hosters: {
|
|
'voe.sx': [{ username: 'u', password: 'p1', apiKey: 'k1', enabled: true }],
|
|
'byse.sx': [{ apiKey: 'k2' }, { apiKey: 'k3', token: 't1', label: 'main' }]
|
|
},
|
|
globalSettings: { remote: { token: 'remT' }, scramble: { active: false } }
|
|
};
|
|
const out = sanitizeConfig(input);
|
|
assert.strictEqual(out.hosters['voe.sx'][0].password, REDACTED);
|
|
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, REDACTED);
|
|
assert.strictEqual(out.hosters['voe.sx'][0].username, 'u');
|
|
assert.strictEqual(out.hosters['voe.sx'][0].enabled, true);
|
|
assert.strictEqual(out.hosters['byse.sx'][1].apiKey, REDACTED);
|
|
assert.strictEqual(out.hosters['byse.sx'][1].token, REDACTED);
|
|
assert.strictEqual(out.hosters['byse.sx'][1].label, 'main');
|
|
assert.strictEqual(out.globalSettings.remote.token, REDACTED);
|
|
});
|
|
|
|
test('redactLogText scrubs opaque tokens that are NOT stored config secrets', () => {
|
|
const field = ['to', 'ken'].join('');
|
|
const cases = [
|
|
`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 line of cases) {
|
|
const out = redactLogText(line, []);
|
|
assert.ok(out.includes(REDACTED), `expected redaction in: ${line} -> ${out}`);
|
|
assert.ok(!/qwerty12345|aGVsbG8|abc123DEF456|aaaabbbbcccc|deadbeefcafe/.test(out), `secret survived: ${out}`);
|
|
}
|
|
});
|
|
|
|
test('redactLogText leaves benign "token" prose alone', () => {
|
|
const benign = 'token bucket refill rate is 5 per second';
|
|
assert.equal(redactLogText(benign, []), benign);
|
|
});
|
|
|
|
test('redactLogText scrubs the password from a basic-auth URL but keeps host:port', () => {
|
|
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');
|
|
});
|
|
|
|
test('redactLogText does not touch a host:port URL without userinfo', () => {
|
|
const url = 'connecting to https://cdn.voe.sx:8080/upload now';
|
|
assert.equal(redactLogText(url, []), url);
|
|
});
|
|
|
|
test('redactLogText scrubs Basic auth, JWTs and bare session= values (defense in depth)', () => {
|
|
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 ${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, []);
|
|
assert.ok(!out.includes(c.secret), `must redact: ${c.line} -> ${out}`);
|
|
assert.ok(out.includes(REDACTED), `expected ${REDACTED} in ${out}`);
|
|
}
|
|
});
|
|
|
|
test('redactLogText leaves a normal "session" word in prose alone', () => {
|
|
const benign = 'the session was idle for a while';
|
|
assert.equal(redactLogText(benign, []), benign);
|
|
});
|
|
|
|
test('redactLogText removes complete local paths from structured and free-form log text', () => {
|
|
const profilePath = ['C:', 'Users', 'ProfileFixture', 'Private Folder', 'episode.mkv'].join('\\');
|
|
const drivePath = ['D:', 'Archive', 'Private Folder', 'source.mkv'].join('\\');
|
|
const stagedPath = ['E:', 'Staging', 'source.pending-delete'].join('\\');
|
|
const uncPath = ['', '', 'fileserver', 'private-share', 'secret.bin'].join('\\');
|
|
const input = [
|
|
`source ${profilePath}`,
|
|
`failed at ${drivePath}`,
|
|
JSON.stringify({ stagedFile: stagedPath }),
|
|
`network source ${uncPath}`
|
|
].join('\n');
|
|
const out = redactLogText(input, []);
|
|
for (const value of ['ProfileFixture', 'episode.mkv', 'Private Folder', 'source.mkv', 'source.pending-delete', 'fileserver', 'private-share', 'secret.bin']) {
|
|
assert.ok(!out.includes(value), `private path fragment survived: ${value}`);
|
|
}
|
|
assert.ok((out.match(/<redacted-path>/g) || []).length >= 4);
|
|
});
|
|
|
|
test('sanitizeConfig does not mutate input', () => {
|
|
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
|
|
const clone = JSON.parse(JSON.stringify(input));
|
|
sanitizeConfig(input);
|
|
assert.deepStrictEqual(input, clone);
|
|
});
|
|
|
|
test('sanitizeConfig leaves empty/missing credentials alone', () => {
|
|
const input = { hosters: { 'voe.sx': [{ password: '', apiKey: null }] } };
|
|
const out = sanitizeConfig(input);
|
|
assert.strictEqual(out.hosters['voe.sx'][0].password, '');
|
|
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, null);
|
|
});
|
|
|
|
test('sanitizeConfig handles null/undefined input', () => {
|
|
assert.strictEqual(sanitizeConfig(null), null);
|
|
assert.strictEqual(sanitizeConfig(undefined), undefined);
|
|
});
|
|
|
|
test('collectFile tails when file exceeds maxBytes', () => {
|
|
const tmp = path.join(os.tmpdir(), `mhu-bundle-${Date.now()}.log`);
|
|
const bigLine = 'x'.repeat(1000) + '\n';
|
|
fs.writeFileSync(tmp, bigLine.repeat(100));
|
|
try {
|
|
const section = collectFile(tmp, 'big.log', 5000);
|
|
assert.match(section, /truncated: skipped first \d+ bytes/);
|
|
assert.ok(section.length < bigLine.length * 100, 'section should be truncated');
|
|
} finally {
|
|
fs.unlinkSync(tmp);
|
|
}
|
|
});
|
|
|
|
test('collectFile returns placeholder for missing file', () => {
|
|
const section = collectFile(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.log`), 'missing');
|
|
assert.match(section, /<file does not exist yet>/);
|
|
});
|
|
|
|
test('collectFile returns placeholder for null path', () => {
|
|
const section = collectFile(null, 'no-path');
|
|
assert.match(section, /<no path configured>/);
|
|
});
|
|
|
|
test('buildSupportBundleText produces structured output with header + config + file sections', () => {
|
|
const tmp = path.join(os.tmpdir(), `mhu-bundle-text-${Date.now()}.log`);
|
|
fs.writeFileSync(tmp, 'line one\nline two\n');
|
|
try {
|
|
const text = buildSupportBundleText({
|
|
header: { Version: '3.3.41', Platform: 'win32' },
|
|
sanitizedConfig: { hosters: { 'voe.sx': [{ apiKey: '<redacted>' }] } },
|
|
files: [{ label: 'debug.log', path: tmp }]
|
|
});
|
|
assert.match(text, /^=== Multi-Hoster-Upload Support Bundle ===/);
|
|
assert.match(text, /Version: 3\.3\.41/);
|
|
assert.match(text, /Platform: win32/);
|
|
assert.match(text, /=== Config \(sanitized/);
|
|
assert.match(text, /"apiKey": "<redacted>"/);
|
|
assert.match(text, /=== debug\.log/);
|
|
assert.match(text, /line one\nline two/);
|
|
} finally {
|
|
fs.unlinkSync(tmp);
|
|
}
|
|
});
|
|
|
|
test('buildSupportBundleText handles empty file list and missing header', () => {
|
|
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [] });
|
|
assert.match(text, /=== Multi-Hoster-Upload Support Bundle ===/);
|
|
assert.match(text, /=== Config/);
|
|
});
|
|
|
|
test('buildSupportBundleText redacts configured and pattern-detected secrets from included logs', () => {
|
|
const tmp = path.join(os.tmpdir(), `mhu-bundle-secrets-${Date.now()}.log`);
|
|
const configuredSecret = ['configured', 'Secret', '123456'].join('');
|
|
const bearerSecret = ['opaque', 'Bearer', '987654321'].join('');
|
|
const cookieSecret = ['session', 'Cookie', '1122334455'].join('');
|
|
const querySecret = ['query', 'Secret', '6677889900'].join('');
|
|
const privatePath = ['C:', 'Users', 'ProfileFixture', 'Private', 'episode.mkv'].join('\\');
|
|
const stagedPath = ['D:', 'Private', 'episode.pending-delete'].join('\\');
|
|
fs.writeFileSync(tmp, `# SOURCE-CLEANUP ${JSON.stringify({ file: privatePath, stagedFile: stagedPath })}\ntoken=${configuredSecret}\nAuthorization: Bearer ${bearerSecret}\nCookie: sid=${cookieSecret}\nhttps://example.invalid/upload?api_key=${querySecret}\n`);
|
|
try {
|
|
const text = buildSupportBundleText({
|
|
sanitizedConfig: { globalSettings: { logFilePath: privatePath, pendingQueue: { selectedFiles: [{ path: privatePath }] } } },
|
|
secrets: [configuredSecret],
|
|
files: [{ label: 'upload-audit.log', path: tmp }]
|
|
});
|
|
assert.ok(!text.includes(configuredSecret));
|
|
assert.ok(!text.includes(bearerSecret));
|
|
assert.ok(!text.includes(cookieSecret));
|
|
assert.ok(!text.includes(querySecret));
|
|
assert.ok(!text.includes('ProfileFixture'));
|
|
assert.ok(!text.includes('episode.mkv'));
|
|
assert.ok(!text.includes('episode.pending-delete'));
|
|
assert.ok(!text.includes(tmp));
|
|
assert.match(text, /<redacted>/);
|
|
} finally {
|
|
fs.unlinkSync(tmp);
|
|
}
|
|
});
|