fix: harden upload cancellation and audit logging
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.
This commit is contained in:
@@ -18,6 +18,7 @@ function makeFixture() {
|
||||
const fixtureZeta = ['WBHOOK', 'SECRET', 'TOKEN'].join('');
|
||||
const paths = {
|
||||
fileuploader: path.join(dir, 'fileuploader.log'),
|
||||
uploadAudit: path.join(dir, 'upload-audit.log'),
|
||||
debug: path.join(dir, 'debug.log'),
|
||||
accountRotation: path.join(dir, 'account-rotation.log'),
|
||||
doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
|
||||
@@ -25,6 +26,7 @@ function makeFixture() {
|
||||
logDir: dir
|
||||
};
|
||||
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
|
||||
fs.writeFileSync(paths.uploadAudit, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
|
||||
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`);
|
||||
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
|
||||
const config = {
|
||||
@@ -88,13 +90,28 @@ 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 });
|
||||
const audit = collectors.readLog({ name: 'uploadAudit', 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(audit.name, 'uploadAudit');
|
||||
assert.ok(!audit.content.includes('SECRETTOKEN123456'), 'source cleanup audit is readable only through the redacted diagnostics path');
|
||||
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('rotated audit backups are listed and readable with the rotation naming convention', () => {
|
||||
const { collectors, paths, fixtureAlpha } = makeFixture();
|
||||
const backupPath = path.join(path.dirname(paths.uploadAudit), 'upload-audit.1.log');
|
||||
fs.writeFileSync(backupPath, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
|
||||
const listed = collectors.listLogs().files.find(file => file.name === 'uploadAudit');
|
||||
assert.ok(listed.variants.some(variant => variant.backup === 1));
|
||||
assert.ok(!collectors.listLogs().otherLogs.some(file => file.name === 'upload-audit.1.log'));
|
||||
const backup = collectors.readLog({ name: 'uploadAudit', backup: 1, tailKb: 64 });
|
||||
assert.equal(backup.path, backupPath);
|
||||
assert.ok(!backup.content.includes(fixtureAlpha));
|
||||
});
|
||||
|
||||
test('readLog grep is case-insensitive substring with | alternation, and is ReDoS-safe', () => {
|
||||
const { paths } = makeFixture();
|
||||
const fs2 = require('fs');
|
||||
|
||||
@@ -85,6 +85,24 @@ test('redactLogText leaves a normal "session" word in prose alone', () => {
|
||||
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));
|
||||
@@ -153,3 +171,32 @@ test('buildSupportBundleText handles empty file list and missing header', () =>
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
+70
-9
@@ -203,8 +203,8 @@ setTimeout(async () => {
|
||||
await captureVisual('00-language-picker.png');
|
||||
await wc.executeJavaScript('document.getElementById("upload-tab").click()');
|
||||
const unchangedValues = await wc.executeJavaScript('(() => { setUiLanguage("de"); const nodes = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { if (node.nodeValue.trim()) nodes.push({ node, source: node.nodeValue.trim() }); node = walker.nextNode(); } const attributes = [...document.querySelectorAll("[title],[aria-label],[placeholder],[data-tooltip]")].flatMap(element => ["title", "aria-label", "placeholder", "data-tooltip"].filter(name => element.hasAttribute(name)).map(name => ({ element, name, source: element.getAttribute(name).trim() }))); setUiLanguage("en"); const unchanged = nodes.filter(entry => entry.source === entry.node.nodeValue.trim()).map(entry => entry.source); unchanged.push(...attributes.filter(entry => entry.source === entry.element.getAttribute(entry.name).trim()).map(entry => entry.source)); return [...new Set(unchanged.filter(value => /[A-Za-zÄÖÜäöüß]{2}/.test(value)))].sort(); })()');
|
||||
const neutralUiValues = new Set(['0 kB/s', 'Accounts', 'BBCode', 'CSV', 'Changelog', 'ETA', 'ETA --:--', 'FileUploader Log', 'HTML', 'JSON', 'Label (optional)', 'Link', 'Log', 'Logs & Support', 'MB/s', 'MHU2-…', 'MULTI HOSTER UPLOADER', 'Markdown', 'Multi Hoster Uploader', 'OK', 'Plaintext', 'Port', 'Server', 'Status', 'Update', 'Upload', 'Uploads', 'Verbose Logging', 'Webhook', 'account-rotation.log', 'debug.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-debug.log', 'mp4,mkv,avi']);
|
||||
const neutralUiPathBasenames = new Set(['account-rotation.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-debug.log']);
|
||||
const neutralUiValues = new Set(['0 kB/s', 'Accounts', 'BBCode', 'CSV', 'Changelog', 'ETA', 'ETA --:--', 'FileUploader Log', 'HTML', 'JSON', 'Label (optional)', 'Link', 'Log', 'Logs & Support', 'MB/s', 'MHU2-…', 'MULTI HOSTER UPLOADER', 'Markdown', 'Multi Hoster Uploader', 'OK', 'Plaintext', 'Port', 'Server', 'Status', 'Update', 'Upload', 'Uploads', 'Verbose Logging', 'Webhook', 'account-rotation.log', 'debug.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-audit.log', 'upload-debug.log', 'mp4,mkv,avi']);
|
||||
const neutralUiPathBasenames = new Set(['account-rotation.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-audit.log', 'upload-debug.log']);
|
||||
const unexpectedUnchangedValues = unchangedValues.filter(value => !neutralUiValues.has(value) && !neutralUiPathBasenames.has(path.basename(value)) && !value.includes('Multi-Hoster-Uploader'));
|
||||
if (process.env.AUDIT_I18N_UNCHANGED === '1' || unexpectedUnchangedValues.length) console.log('Unchanged i18n values: ' + JSON.stringify(unchangedValues, null, 2));
|
||||
check('Every mounted human-facing value is translated or explicitly language-neutral', unexpectedUnchangedValues.length === 0);
|
||||
@@ -243,8 +243,8 @@ setTimeout(async () => {
|
||||
check('Language changes redraw stable telemetry values with the active locale', localizedStableMetric.german.join('|') === '1.234|1.234' && localizedStableMetric.english.join('|') === '1,234|1,234');
|
||||
const germanSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))');
|
||||
check('German sidebar hierarchy uses distinct localized kickers', germanSidebarHeadings.join('::') === 'Arbeitsbereich|Uploads::Accounts verwalten|Accounts::Archiv|Verlauf');
|
||||
const saveAfterLanguageChange = await wc.executeJavaScript('(() => { const button = document.getElementById("saveSettingsBtn"); return [button.disabled, button.classList.contains("btn-success")].join("|"); })()');
|
||||
check('Changing language enables the green save action', saveAfterLanguageChange === 'false|true');
|
||||
const saveAfterLanguageChange = await wc.executeJavaScript('(() => { const button = document.getElementById("saveSettingsBtn"); const channels = getComputedStyle(button).backgroundColor.match(/[0-9.]+/g)?.map(Number) || []; return { disabled: button.disabled, success: button.classList.contains("btn-success"), green: channels.length >= 3 && channels[1] > channels[0] * 1.25 && channels[1] > channels[2] * 1.2 }; })()');
|
||||
check('Changing language enables a visibly green save action', saveAfterLanguageChange.disabled === false && saveAfterLanguageChange.success && saveAfterLanguageChange.green);
|
||||
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("saveSettingsBtn").disabled'));
|
||||
const saveAfterCommit = await wc.executeJavaScript('(() => { const button = document.getElementById("saveSettingsBtn"); return [button.disabled, button.classList.contains("btn-secondary")].join("|"); })()');
|
||||
@@ -722,6 +722,68 @@ setTimeout(async () => {
|
||||
check('Status changes drop selections that leave the upload filter', uploadSelectionScope.statusChangeSelected.length === 0 && uploadSelectionScope.statusChangeVisible.join('|') === 'scope-active-z');
|
||||
check('Selected upload actions ignore hidden stale selections', uploadSelectionScope.hiddenAction.selected.length === 0 && uploadSelectionScope.hiddenAction.retryDisabled && uploadSelectionScope.hiddenAction.moveDisabled);
|
||||
|
||||
const queueSelectionAnchor = await wc.executeJavaScript(\`(() => {
|
||||
queueJobs = ['a', 'b', 'c', 'd'].map(id => ({ id: 'anchor-' + id, file: 'C:/ui/anchor-' + id + '.bin', fileName: 'anchor-' + id + '.bin', hoster: 'byse.sx', status: 'queued', bytesUploaded: 0, bytesTotal: 100, progress: 0 }));
|
||||
selectedJobIds.clear();
|
||||
rebuildJobIndex();
|
||||
renderQueueTable();
|
||||
const row = id => document.querySelector('[data-job-id="anchor-' + id + '"]');
|
||||
handleRowClick({ ctrlKey: false, metaKey: false, shiftKey: false }, row('a'));
|
||||
handleRowClick({ ctrlKey: true, metaKey: false, shiftKey: false }, row('c'));
|
||||
handleRowClick({ ctrlKey: false, metaKey: false, shiftKey: true }, row('d'));
|
||||
const selected = [...selectedJobIds].sort();
|
||||
const aria = Object.fromEntries(['a', 'b', 'c', 'd'].map(id => [id, row(id).getAttribute('aria-selected')]));
|
||||
queueJobs = [];
|
||||
selectedJobIds.clear();
|
||||
rebuildJobIndex();
|
||||
renderQueueTable();
|
||||
return { selected, aria };
|
||||
})()\`);
|
||||
check('Shift selection starts from the last clicked row and keeps ARIA state synchronized', queueSelectionAnchor.selected.join('|') === 'anchor-a|anchor-c|anchor-d' && queueSelectionAnchor.aria.a === 'true' && queueSelectionAnchor.aria.b === 'false' && queueSelectionAnchor.aria.c === 'true' && queueSelectionAnchor.aria.d === 'true');
|
||||
|
||||
const queueSelectionVisual = await wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-selection-visual", file: "C:/ui/selection.bin", fileName: "selection.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 }]; selectedJobIds.clear(); selectedJobIds.add("ui-selection-visual"); rebuildJobIndex(); renderQueueTable(); const row = document.querySelector(".queue-row.selected"); const style = getComputedStyle(row); const channels = style.backgroundColor.match(/[0-9.]+/g)?.map(Number) || []; const result = { userSelect: getComputedStyle(row.querySelector(".col-filename")).userSelect, alpha: channels[3] ?? 1, marker: style.boxShadow !== "none" }; queueJobs = []; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); return result; })()');
|
||||
check('Upload rows prevent accidental text selection and expose a strong selected state', queueSelectionVisual.userSelect === 'none' && queueSelectionVisual.alpha >= 0.16 && queueSelectionVisual.marker);
|
||||
|
||||
const removedAnchorState = await wc.executeJavaScript('(() => { queueJobs = ["a", "b"].map(id => ({ id: "ui-anchor-remove-" + id, file: "C:/ui/anchor-remove-" + id + ".bin", fileName: "anchor-remove-" + id + ".bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 })); selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); const first = document.querySelector("[data-job-id=ui-anchor-remove-a]"); handleRowClick({ ctrlKey: false, metaKey: false, shiftKey: false }, first); const removed = queueJobs.shift(); removeJobFromIndex(removed, true); selectedJobIds.delete(removed.id); renderQueueTable(); const second = document.querySelector("[data-job-id=ui-anchor-remove-b]"); handleRowClick({ ctrlKey: false, metaKey: false, shiftKey: true }, second); const result = { anchor: selectionAnchorJobId, selected: [...selectedJobIds] }; queueJobs = []; selectedJobIds.clear(); selectionAnchorJobId = null; rebuildJobIndex(); renderQueueTable(); return result; })()');
|
||||
check('Removing the selected anchor leaves the next Shift click usable', removedAnchorState.anchor === 'ui-anchor-remove-b' && removedAnchorState.selected.join('|') === 'ui-anchor-remove-b');
|
||||
|
||||
const removeAllDanger = await wc.executeJavaScript('(() => { const item = document.querySelector("#contextMenu [data-action=delete-all]"); const channels = getComputedStyle(item).color.match(/[0-9.]+/g)?.map(Number) || []; return Boolean(item && channels.length >= 3 && channels[0] > channels[1] * 1.2 && channels[0] > channels[2] * 1.15); })()');
|
||||
check('Remove all is visually marked as a destructive queue action', removeAllDanger === true);
|
||||
|
||||
let releaseSelectedQueueCancel = null;
|
||||
ipcMain.removeHandler('cancel-selected-jobs');
|
||||
ipcMain.handle('cancel-selected-jobs', () => new Promise(resolve => { releaseSelectedQueueCancel = () => resolve(true); }));
|
||||
await wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-delete-selected", file: "C:/ui/delete-selected.bin", fileName: "delete-selected.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 }]; selectedJobIds.clear(); selectedJobIds.add("ui-delete-selected"); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteSelectedPromise = handleContextAction("delete-selected"); return true; })()');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
|
||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
|
||||
await waitUntil(() => releaseSelectedQueueCancel);
|
||||
const selectedQueueStillPresent = await wc.executeJavaScript('queueJobs.length');
|
||||
releaseSelectedQueueCancel();
|
||||
const selectedQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteSelectedPromise.then(() => { delete window.__uiDeleteSelectedPromise; return queueJobs.length; })');
|
||||
check('Removing selected uploads waits for the main-process cancellation acknowledgement', selectedQueueStillPresent === 1 && selectedQueueAfterCancel === 0);
|
||||
restoreInitialIpcHandler('cancel-selected-jobs');
|
||||
|
||||
let releaseFullQueueCancel = null;
|
||||
let fullQueueCancelCalls = 0;
|
||||
let selectedQueueCancelCalls = 0;
|
||||
ipcMain.removeHandler('cancel-upload');
|
||||
ipcMain.handle('cancel-upload', () => {
|
||||
fullQueueCancelCalls++;
|
||||
return new Promise(resolve => { releaseFullQueueCancel = () => resolve(true); });
|
||||
});
|
||||
ipcMain.removeHandler('cancel-selected-jobs');
|
||||
ipcMain.handle('cancel-selected-jobs', () => { selectedQueueCancelCalls++; return true; });
|
||||
await wc.executeJavaScript('(() => { uploading = true; queueJobs = ["a", "b", "c"].map(id => ({ id: "ui-delete-all-" + id, file: "C:/ui/delete-all-" + id + ".bin", fileName: "delete-all-" + id + ".bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 })); selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllPromise = handleContextAction("delete-all"); return true; })()');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
|
||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
|
||||
await waitUntil(() => releaseFullQueueCancel);
|
||||
const fullQueueStillPresent = await wc.executeJavaScript('queueJobs.length');
|
||||
releaseFullQueueCancel();
|
||||
const fullQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteAllPromise.then(() => { delete window.__uiDeleteAllPromise; return { length: queueJobs.length, uploading }; })');
|
||||
check('Remove all awaits one batch cancellation instead of issuing one cancellation per queued job', fullQueueStillPresent === 3 && fullQueueAfterCancel.length === 0 && fullQueueAfterCancel.uploading === false && fullQueueCancelCalls === 1 && selectedQueueCancelCalls === 0);
|
||||
restoreInitialIpcHandler('cancel-upload');
|
||||
restoreInitialIpcHandler('cancel-selected-jobs');
|
||||
|
||||
const keyboardTab = await wc.executeJavaScript('document.getElementById("upload-tab").focus(); document.getElementById("upload-tab").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); document.querySelector(".tab.active")?.textContent?.trim() + "|" + document.activeElement?.id');
|
||||
check('Arrow keys move and activate main tabs', keyboardTab === 'Accounts|accounts-tab');
|
||||
|
||||
@@ -1195,7 +1257,7 @@ setTimeout(async () => {
|
||||
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'logs\\']")?.click()');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const logPathLayout = await wc.executeJavaScript('(() => { const block = document.getElementById("logPathsBlock")?.getBoundingClientRect(); const rows = [...document.querySelectorAll("#logPathsList > div")]; const visible = rows.length > 0 && rows.every(row => { const rect = row.getBoundingClientRect(); const code = row.querySelector("code")?.getBoundingClientRect(); const button = row.querySelector("button")?.getBoundingClientRect(); return block && rect.right <= block.right + 1 && code && button && code.right <= button.left - 6 && button.right <= block.right + 1; }); return [rows.length, visible].join("|"); })()');
|
||||
check('Log file rows keep paths and buttons inside the Diagnose panel', logPathLayout === '4|true');
|
||||
check('Log file rows keep paths and buttons inside the Diagnose panel', logPathLayout === '5|true');
|
||||
|
||||
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'remote\\']")?.click()');
|
||||
const remoteSettingsSpacing = await wc.executeJavaScript('(() => { const grid = document.querySelector("[data-subpage=remote] .settings-grid-mini")?.getBoundingClientRect(); const port = document.getElementById("remotePortInput")?.closest(".settings-row")?.getBoundingClientRect(); return grid && port ? Math.round(port.top - grid.bottom) : -1; })()');
|
||||
@@ -1438,14 +1500,13 @@ setTimeout(async () => {
|
||||
releaseBlockedWrite?.();
|
||||
for (let attempt = 0; attempt < 100 && !finalImportQueueStarted; attempt++) await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const importStateDuringFinalQueuePersist = await wc.executeJavaScript('({ gateClosed: configImportInProgress, webhookUrl: config.globalSettings.webhookUrl, accountId: config.hosters["byse.sx"]?.[0]?.id })');
|
||||
const staleImportSettings = wc.executeJavaScript('saveGlobalSettingsTracked({ ...(config.globalSettings || {}), webhookUrl: "https://import-epoch.invalid/stale-after-commit" }).then(() => ({ ok: true }), error => ({ ok: false, code: error.code }))');
|
||||
const staleImportAccounts = wc.executeJavaScript('saveConfigTracked({ hosters: { ...(config.hosters || {}), "byse.sx": [{ id: "ui-stale-account", enabled: true, authType: "api", apiKey: "stale" }] } }).then(() => ({ ok: true }), error => ({ ok: false, code: error.code }))');
|
||||
const staleImportWriteStart = await wc.executeJavaScript('(() => { const settings = saveGlobalSettingsTracked({ ...(config.globalSettings || {}), webhookUrl: "https://import-epoch.invalid/stale-after-commit" }).then(() => ({ ok: true }), error => ({ ok: false, code: error.code })); const accounts = saveConfigTracked({ hosters: { ...(config.hosters || {}), "byse.sx": [{ id: "ui-stale-account", enabled: true, authType: "api", apiKey: "stale" }] } }).then(() => ({ ok: true }), error => ({ ok: false, code: error.code })); window.__uiStaleImportWrites = Promise.all([settings, accounts]); return { gateClosed: configImportInProgress }; })()');
|
||||
releaseFinalImportQueue?.();
|
||||
const [staleImportSettingsResult, staleImportAccountsResult] = await Promise.all([staleImportSettings, staleImportAccounts]);
|
||||
const [staleImportSettingsResult, staleImportAccountsResult] = await wc.executeJavaScript('window.__uiStaleImportWrites.then(results => { delete window.__uiStaleImportWrites; return results; })');
|
||||
await pendingImportEpoch;
|
||||
const configAfterImportEpoch = await wc.executeJavaScript('window.api.getConfig()');
|
||||
check('Import keeps its gate closed through apply and final queue persistence', importStateDuringFinalQueuePersist.gateClosed === true && importStateDuringFinalQueuePersist.webhookUrl === 'https://import-epoch.invalid/imported' && importStateDuringFinalQueuePersist.accountId === 'ui-import-epoch-account');
|
||||
check('Import rejects stale settings and account writes until the full transition finishes', staleImportSettingsResult.code === 'CONFIG_WRITE_SUPERSEDED' && staleImportAccountsResult.code === 'CONFIG_WRITE_SUPERSEDED' && configAfterImportEpoch.hosters['byse.sx']?.[0]?.id === 'ui-import-epoch-account' && configAfterImportEpoch.globalSettings.webhookUrl === 'https://import-epoch.invalid/imported');
|
||||
check('Import rejects stale settings and account writes until the full transition finishes', staleImportWriteStart.gateClosed === true && staleImportSettingsResult.code === 'CONFIG_WRITE_SUPERSEDED' && staleImportAccountsResult.code === 'CONFIG_WRITE_SUPERSEDED' && configAfterImportEpoch.hosters['byse.sx']?.[0]?.id === 'ui-import-epoch-account' && configAfterImportEpoch.globalSettings.webhookUrl === 'https://import-epoch.invalid/imported');
|
||||
|
||||
restoreInitialIpcHandler('save-pending-queue');
|
||||
const importPersistFailureConfig = structuredClone(configAfterImportEpoch);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
test('internal audit records never contaminate the MDU session link log', async () => {
|
||||
let createUploadAuditWriter;
|
||||
try {
|
||||
({ createUploadAuditWriter } = require('../lib/upload-audit'));
|
||||
} catch {}
|
||||
assert.equal(typeof createUploadAuditWriter, 'function');
|
||||
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-'));
|
||||
const sessionLog = path.join(directory, '13-08-2026-mdu-session-14-20-123456.log');
|
||||
const writer = createUploadAuditWriter({
|
||||
fs,
|
||||
path,
|
||||
resolveUploadLogTarget: () => ({ path: sessionLog, isFallback: false }),
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
reportError: () => {},
|
||||
retryDelays: [0]
|
||||
});
|
||||
|
||||
await writer.append('# SOURCE-CLEANUP {"outcome":"deleted"}\r\n', 'source-cleanup');
|
||||
await writer.append('# UPLOAD-PLAN {"plannedUploadCount":4}\r\n', 'upload-plan');
|
||||
|
||||
const auditLog = path.join(directory, 'upload-audit.log');
|
||||
assert.equal(fs.existsSync(sessionLog), false);
|
||||
assert.equal(fs.readFileSync(auditLog, 'utf8'), '# SOURCE-CLEANUP {"outcome":"deleted"}\r\n# UPLOAD-PLAN {"plannedUploadCount":4}\r\n');
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('audit writer reports the actual fallback file after a failed primary write', async () => {
|
||||
const { createUploadAuditWriter } = require('../lib/upload-audit');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-fallback-'));
|
||||
const blockedParent = path.join(directory, 'blocked');
|
||||
const fallbackDirectory = path.join(directory, 'fallback');
|
||||
fs.writeFileSync(blockedParent, 'not a directory');
|
||||
let attempts = 0;
|
||||
const persistedFallbacks = [];
|
||||
const writer = createUploadAuditWriter({
|
||||
fs,
|
||||
path,
|
||||
resolveUploadLogTarget: () => ({
|
||||
path: attempts++ === 0
|
||||
? path.join(blockedParent, 'fileuploader.log')
|
||||
: path.join(fallbackDirectory, 'fileuploader.log'),
|
||||
isFallback: attempts > 1
|
||||
}),
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); },
|
||||
reportError: () => {},
|
||||
retryDelays: [0, 0]
|
||||
});
|
||||
|
||||
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), true);
|
||||
assert.equal(writer.getActivePath(), path.join(fallbackDirectory, 'upload-audit.log'));
|
||||
assert.deepEqual(persistedFallbacks, [path.join(fallbackDirectory, 'fileuploader.log')]);
|
||||
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n');
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { formatUploadLogLine, parseUploadLogLine } = require('../lib/upload-log');
|
||||
const {
|
||||
formatUploadLogLine,
|
||||
parseUploadLogLine,
|
||||
summarizeBatchPlan,
|
||||
formatUploadPlanLogLine
|
||||
} = require('../lib/upload-log');
|
||||
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
|
||||
|
||||
function previewJob(fileName, hoster) {
|
||||
@@ -16,6 +21,59 @@ test('writer -> reader round trip: parsed ts is the same epoch frame as the sour
|
||||
assert.equal(parsed.ts, d.getTime(), 'parser ts must equal the writer Date epoch (no tz shift)');
|
||||
});
|
||||
|
||||
test('batch plan records unique sources, destinations, and requested upload count without file paths', () => {
|
||||
const jobs = [];
|
||||
for (const file of ['C:/private/a.mkv', 'C:/private/b.mkv', 'C:/private/c.mkv']) {
|
||||
for (const hoster of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']) {
|
||||
jobs.push({ file, hoster });
|
||||
}
|
||||
}
|
||||
|
||||
const plan = summarizeBatchPlan({ jobs });
|
||||
const line = formatUploadPlanLogLine(new Date('2026-08-13T12:00:00.000Z'), plan, 'start');
|
||||
|
||||
assert.deepEqual(plan, {
|
||||
fileCount: 3,
|
||||
destinationCount: 4,
|
||||
plannedUploadCount: 12
|
||||
});
|
||||
assert.equal(line.startsWith('# UPLOAD-PLAN '), true);
|
||||
assert.equal(line.includes('C:/private'), false);
|
||||
assert.equal(line.includes('a.mkv'), false);
|
||||
assert.equal(line.includes('doodstream.com'), false);
|
||||
assert.deepEqual(JSON.parse(line.slice('# UPLOAD-PLAN '.length)), {
|
||||
timestamp: '2026-08-13T12:00:00.000Z',
|
||||
mode: 'start',
|
||||
fileCount: 3,
|
||||
destinationCount: 4,
|
||||
plannedUploadCount: 12
|
||||
});
|
||||
assert.equal(parseUploadLogLine(line), null);
|
||||
});
|
||||
|
||||
test('batch plan supports the legacy files and hosters payload', () => {
|
||||
assert.deepEqual(summarizeBatchPlan({
|
||||
files: ['C:/private/a.mkv', 'C:/private/b.mkv'],
|
||||
hosters: ['voe.sx', 'doodstream.com']
|
||||
}), {
|
||||
fileCount: 2,
|
||||
destinationCount: 2,
|
||||
plannedUploadCount: 4
|
||||
});
|
||||
});
|
||||
|
||||
test('batch plan preserves a sparse requested job count instead of multiplying dimensions', () => {
|
||||
assert.deepEqual(summarizeBatchPlan({ jobs: [
|
||||
{ file: 'C:/private/a.mkv', hoster: 'voe.sx' },
|
||||
{ file: 'C:/private/a.mkv', hoster: 'byse.sx' },
|
||||
{ file: 'C:/private/b.mkv', hoster: 'voe.sx' }
|
||||
] }), {
|
||||
fileCount: 2,
|
||||
destinationCount: 2,
|
||||
plannedUploadCount: 3
|
||||
});
|
||||
});
|
||||
|
||||
test('SEAM: a real appendUploadLog-format line drops a preview ghost vs a savedAt taken BEFORE completion', () => {
|
||||
const completion = new Date(2026, 5, 19, 12, 0, 30);
|
||||
const line = formatUploadLogLine(completion, 'voe.sx', 'link', 'a.mkv');
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
const { describe, it, mock, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('path');
|
||||
const { EventEmitter } = require('events');
|
||||
const { createSourceFileCleanup } = require('../lib/source-file-cleanup');
|
||||
|
||||
// We need to mock fs.statSync and the hoster upload functions before requiring upload-manager
|
||||
// Use node:test mock.module (available in Node 22+)
|
||||
@@ -399,6 +402,158 @@ describe('UploadManager', () => {
|
||||
assert.ok(statuses.some((entry) => entry.jobId === 'selected-job' && entry.status === 'aborted'));
|
||||
});
|
||||
|
||||
it('cancelJobs prevents a not-yet-spawned job from starting', async () => {
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
const settled = new Map();
|
||||
mgr.on('job-settled', (event) => settled.set(event.jobId, event.status));
|
||||
const tasks = Array.from({ length: 101 }, (_, index) => ({
|
||||
jobId: index === 100 ? 'late-cancelled-job' : `early-job-${index}`,
|
||||
file: `/test/chunk-${index}.mp4`,
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1',
|
||||
sourceCleanupToken: `cleanup-${index}`
|
||||
}));
|
||||
|
||||
const batchPromise = mgr.startBatch(tasks);
|
||||
mgr.cancelJobs(['late-cancelled-job']);
|
||||
await batchPromise;
|
||||
|
||||
const lateCalls = mockUploadFile.mock.calls.filter((call) => call.arguments[1] === '/test/chunk-100.mp4');
|
||||
assert.equal(lateCalls.length, 0);
|
||||
assert.equal(settled.get('late-cancelled-job'), 'aborted');
|
||||
});
|
||||
|
||||
it('cancel before startBatch prevents the reserved batch from uploading', async () => {
|
||||
const mgr = new UploadManager({});
|
||||
let summary = null;
|
||||
mgr.on('batch-done', (value) => { summary = value; });
|
||||
|
||||
mgr.cancel();
|
||||
await mgr.startBatch([{
|
||||
jobId: 'prestart-cancelled-job',
|
||||
file: '/test/prestart-cancelled.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1',
|
||||
sourceCleanupToken: 'cleanup-prestart'
|
||||
}]);
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
assert.equal(summary.succeeded, 0);
|
||||
});
|
||||
|
||||
it('cancelJobs before startBatch prevents the reserved job from uploading', async () => {
|
||||
const mgr = new UploadManager({});
|
||||
let summary = null;
|
||||
const settled = [];
|
||||
mgr.on('batch-done', (value) => { summary = value; });
|
||||
mgr.on('job-settled', (value) => settled.push(value));
|
||||
|
||||
mgr.cancelJobs(['prestart-selected-job']);
|
||||
await mgr.startBatch([{
|
||||
jobId: 'prestart-selected-job',
|
||||
file: '/test/prestart-selected.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1',
|
||||
sourceCleanupToken: 'cleanup-prestart-selected'
|
||||
}]);
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
assert.equal(summary.succeeded, 0);
|
||||
assert.equal(settled.at(-1).status, 'aborted');
|
||||
});
|
||||
|
||||
it('cancelJobs rejects a late success from an uploader that ignores abort', async () => {
|
||||
let releaseUpload;
|
||||
mockUploadFile.mock.mockImplementation(async () => new Promise((resolve) => {
|
||||
releaseUpload = () => resolve({ download_url: 'https://doodstream.com/d/late', embed_url: null, file_code: 'late' });
|
||||
}));
|
||||
const mgr = new UploadManager({});
|
||||
const settled = [];
|
||||
const progress = [];
|
||||
let summary = null;
|
||||
mgr.on('job-settled', (event) => settled.push(event));
|
||||
mgr.on('progress', (event) => progress.push(event));
|
||||
mgr.on('batch-done', (value) => { summary = value; });
|
||||
const batchPromise = mgr.startBatch([{
|
||||
jobId: 'late-success-job',
|
||||
file: '/test/late-success.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1',
|
||||
sourceCleanupToken: 'cleanup-late-success'
|
||||
}]);
|
||||
|
||||
for (let index = 0; index < 50 && !releaseUpload; index++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
mgr.cancelJobs(['late-success-job']);
|
||||
releaseUpload();
|
||||
await batchPromise;
|
||||
|
||||
assert.equal(settled.at(-1).status, 'aborted');
|
||||
assert.equal(progress.some(event => event.status === 'done'), false);
|
||||
assert.equal(summary.succeeded, 0);
|
||||
});
|
||||
|
||||
it('late success after cancellation stays blocked by the real source cleanup gate', async (t) => {
|
||||
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-manager-cleanup-'));
|
||||
const file = path.join(directory, 'source.bin');
|
||||
await fs.promises.writeFile(file, Buffer.from('source-data'));
|
||||
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
|
||||
|
||||
let releaseUpload;
|
||||
mockUploadFile.mock.mockImplementation(async () => new Promise((resolve) => {
|
||||
releaseUpload = () => resolve({ download_url: 'https://doodstream.com/d/late-cleanup', embed_url: null, file_code: 'late-cleanup' });
|
||||
}));
|
||||
|
||||
const audits = [];
|
||||
const cleanup = createSourceFileCleanup({
|
||||
fs,
|
||||
path,
|
||||
platform: process.platform,
|
||||
isEnabled: () => true,
|
||||
audit: (event) => audits.push(event),
|
||||
journal: { plan: async () => {}, clear: async () => {} }
|
||||
});
|
||||
await cleanup.registerGroups([{
|
||||
token: 'cleanup-late-seam',
|
||||
file,
|
||||
requiredHosters: ['doodstream.com'],
|
||||
completedHosters: [],
|
||||
jobs: [{ jobId: 'late-cleanup-job', file, hoster: 'doodstream.com', status: 'pending' }]
|
||||
}]);
|
||||
|
||||
const mgr = new UploadManager({});
|
||||
let settleChain = Promise.resolve();
|
||||
let summary = null;
|
||||
mgr.on('job-settled', (event) => {
|
||||
settleChain = settleChain.then(() => cleanup.settle(event));
|
||||
});
|
||||
mgr.on('batch-done', (value) => { summary = value; });
|
||||
|
||||
const batchPromise = mgr.startBatch([{
|
||||
jobId: 'late-cleanup-job',
|
||||
file,
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1',
|
||||
sourceCleanupToken: 'cleanup-late-seam'
|
||||
}]);
|
||||
for (let index = 0; index < 50 && !releaseUpload; index++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
mgr.cancelJobs(['late-cleanup-job']);
|
||||
releaseUpload();
|
||||
await batchPromise;
|
||||
await settleChain;
|
||||
const outcomes = await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
|
||||
|
||||
assert.equal(summary.succeeded, 0);
|
||||
assert.deepEqual(outcomes, ['blocked']);
|
||||
assert.equal(audits.at(-1).outcome, 'blocked');
|
||||
await fs.promises.access(file);
|
||||
});
|
||||
|
||||
it('addJobs returns duplicate info and still runs newly queued jobs', async () => {
|
||||
let releaseFirst = null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user