const { test } = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const support = require('../lib/support-bundle'); const stats = require('../lib/stats'); const { createCollectors } = require('../lib/diagnostics-collectors'); const { createAgent } = require('../lib/diagnostics-agent'); function makeFixture() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-')); const paths = { fileuploader: path.join(dir, 'fileuploader.log'), debug: path.join(dir, 'debug.log'), accountRotation: path.join(dir, 'account-rotation.log'), doodstreamDebug: path.join(dir, 'doodstream-debug.log'), crashLog: path.join(dir, 'crash.log'), logDir: dir }; fs.writeFileSync(paths.debug, 'boot ok\nuploading file with token SECRETTOKEN123456 inline\nAuthorization: Bearer abcdef123456\n'); fs.writeFileSync(paths.doodstreamDebug, 'api_key=LIVEKEY99999 sess=abc\n'); fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n'); const config = { hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: 'HUNTER2SECRET' }], 'byse.sx': [{ id: 'b1', apiKey: 'BYSEKEY1234567' }] }, hosterSettings: {}, globalSettings: { webhookUrl: 'https://discord.com/api/webhooks/12345/WBHOOKSECRETTOKEN', diagnostics: { enabled: true, port: 9110, token: 'SECRETTOKEN123456', 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' }] }] }], rotationCursors: { 'voe.sx': 1 } }; const collectors = createCollectors({ loadConfig: () => JSON.parse(JSON.stringify(config)), getAllLogPaths: () => paths, support, stats, appInfo: () => ({ name: 'mhu', version: '9.9.9' }), systemInfo: () => ({ platform: 'win32', hostname: 'srv' }), agentInfo: () => ({ version: '9.9.9', port: 9110, clientCount: 0, lastAccess: null }) }); return { dir, paths, config, collectors }; } test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs the token mid-string', () => { const { collectors } = makeFixture(); const out = collectors.getConfigRedacted({ section: 'all' }); const json = JSON.stringify(out); assert.ok(!json.includes('HUNTER2SECRET'), 'password must be redacted'); assert.ok(!json.includes('BYSEKEY1234567'), 'apiKey must be redacted'); assert.ok(!json.includes('SECRETTOKEN123456'), 'diag token must be redacted'); assert.ok(!json.includes('WBHOOKSECRETTOKEN'), 'webhook secret must be redacted'); }); 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('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'); }); test('getQueueState flags stale=true for the persisted snapshot and counts by status', () => { const { collectors } = makeFixture(); const q = collectors.getQueueState({}); assert.equal(q.source, 'persisted'); assert.equal(q.stale, true); assert.equal(q.counts.error, 1); }); test('getQueueState (includeJobs default) pattern-scrubs an opaque token in a job error that is NOT a config secret', () => { 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=OPAQUE_NONconfig_TOKEN_9988' } ] } }, history: [], rotationCursors: {} }; const collectors = createCollectors({ loadConfig: () => JSON.parse(JSON.stringify(config)), getAllLogPaths: () => ({ logDir: os.tmpdir() }), support, stats, appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({}) }); const q = collectors.getQueueState({}); const json = JSON.stringify(q); 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', () => { const { collectors } = makeFixture(); const e = collectors.listErrors({}); assert.equal(e.total, 1, 'only the non-done result is an error'); assert.equal(e.byCategory['file-rejected'], 1, '"Not video file format" -> file-rejected'); }); test('serverHealth assembles the one-shot hub without leaking secrets', () => { const { collectors } = makeFixture(); 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('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health'); });