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, 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('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, //); }); test('collectFile returns placeholder for null path', () => { const section = collectFile(null, 'no-path'); assert.match(section, //); }); 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: '' }] } }, 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": ""/); 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/); });