Harden audit durability and diagnostic redaction
Persist fallback audit targets before use and fail upload starts or active-batch additions closed when the plan cannot be recorded. Keep lifecycle audits out of session and debug logs, expose diagnostics through opaque metadata, localize audit failures, and redact paths plus complete credential values without corrupting benign text.
This commit is contained in:
@@ -79,10 +79,10 @@ function createCollectors(deps) {
|
||||
readableNames.add(path.basename(fp));
|
||||
try {
|
||||
const st = fs.statSync(fp);
|
||||
variants.push({ backup, sizeBytes: st.size, mtime: st.mtime.toISOString() });
|
||||
variants.push({ id: backup === 0 ? name : `${name}:${backup}`, backup, fileName: path.basename(fp), sizeBytes: st.size, mtime: st.mtime.toISOString() });
|
||||
} catch {}
|
||||
}
|
||||
files.push({ name, path: base, readable: true, present: variants.length > 0, variants });
|
||||
files.push({ id: name, name, fileName: path.basename(base), readable: true, present: variants.length > 0, variants });
|
||||
}
|
||||
let siblings = [];
|
||||
try {
|
||||
@@ -95,16 +95,16 @@ function createCollectors(deps) {
|
||||
return { name: f, readable: false, sizeBytes: size, mtime };
|
||||
});
|
||||
} catch {}
|
||||
return { dir, files, otherLogs: siblings };
|
||||
return { files, otherLogs: siblings };
|
||||
}
|
||||
|
||||
function readLog(args) {
|
||||
const a = args || {};
|
||||
const name = a.name;
|
||||
const p = _resolveLogPath(name, a.backup);
|
||||
if (!p) return { ok: false, error: `unknown or non-readable log: ${name}` };
|
||||
if (!p) return { ok: false, error: 'unknown or non-readable log identifier' };
|
||||
const tailKb = Math.min(Math.max(Number(a.tailKb) || 256, 1), 1024);
|
||||
const raw = support.collectFile(p, name, tailKb * 1024);
|
||||
const raw = support.collectFile(p, name, tailKb * 1024, { includePath: false });
|
||||
let content = support.redactLogText(raw, _secrets());
|
||||
let matchedLines;
|
||||
if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) {
|
||||
@@ -120,7 +120,7 @@ function createCollectors(deps) {
|
||||
}
|
||||
let sizeBytes = null;
|
||||
try { sizeBytes = fs.statSync(p).size; } catch {}
|
||||
return { name, path: p, sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content };
|
||||
return { id: name, name, fileName: path.basename(p), sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content };
|
||||
}
|
||||
|
||||
function getAppEvents(args) {
|
||||
|
||||
+60
-24
@@ -26,47 +26,83 @@ function collectSecretValues(config) {
|
||||
if (typeof o !== 'object') return;
|
||||
for (const k of Object.keys(o)) {
|
||||
const v = o[k];
|
||||
if (CRED_KEYS.has(k) && typeof v === 'string' && v.length >= 6) out.add(v);
|
||||
if (CRED_KEYS.has(k) && typeof v === 'string' && v.length > 0) out.add(v);
|
||||
else walk(v);
|
||||
}
|
||||
})(config);
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
function redactConfiguredSecrets(text, secrets) {
|
||||
if (!Array.isArray(secrets)) return text;
|
||||
const values = Array.from(new Set(secrets.filter(value => typeof value === 'string' && value.length > 0)))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
let out = text;
|
||||
for (const value of values) {
|
||||
if (value.length >= 6) {
|
||||
out = out.split(value).join(REDACTED);
|
||||
continue;
|
||||
}
|
||||
let offset = 0;
|
||||
while (offset < out.length) {
|
||||
const index = out.indexOf(value, offset);
|
||||
if (index < 0) break;
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
const before = index > 0 ? out[index - 1] : '';
|
||||
const after = index + value.length < out.length ? out[index + value.length] : '';
|
||||
const identifier = character => /[A-Za-z0-9_]/.test(character);
|
||||
if ((!identifier(first) || !identifier(before)) && (!identifier(last) || !identifier(after))) {
|
||||
out = `${out.slice(0, index)}${REDACTED}${out.slice(index + value.length)}`;
|
||||
offset = index + REDACTED.length;
|
||||
} else {
|
||||
offset = index + value.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function redactHtmlCredentialFields(text) {
|
||||
return text.replace(/<input\b[^>]*>/gi, input => {
|
||||
const sensitive = /\btype\s*=\s*["']?password\b/i.test(input)
|
||||
|| /\b(?:name|id)\s*=\s*["']?(?:password|passwd|api[_-]?(?:key|token)|token|secret|authorization|cookie|session(?:[_-]?id)?)\b/i.test(input);
|
||||
if (!sensitive) return input;
|
||||
return input
|
||||
.replace(/(\bvalue\s*=\s*)(["'])(.*?)\2/gi, `$1$2${REDACTED}$2`)
|
||||
.replace(/(\bvalue\s*=\s*)(?!["'])([^\s>]+)/gi, `$1${REDACTED}`);
|
||||
});
|
||||
}
|
||||
|
||||
function redactLogText(text, secrets) {
|
||||
if (typeof text !== 'string' || !text) return text;
|
||||
let out = text;
|
||||
if (Array.isArray(secrets)) {
|
||||
for (const s of secrets) {
|
||||
if (typeof s === 'string' && s.length >= 6) out = out.split(s).join(REDACTED);
|
||||
}
|
||||
}
|
||||
out = out
|
||||
let out = redactConfiguredSecrets(text, secrets);
|
||||
out = redactHtmlCredentialFields(out)
|
||||
.replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1<redacted-path>$2')
|
||||
.replace(/\b[A-Za-z]:(?:\\+|\/+)[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/gi, '<redacted-path>')
|
||||
.replace(/\\{2,}[A-Za-z0-9._$-]+\\+[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/g, '<redacted-path>')
|
||||
.replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED)
|
||||
.replace(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2')
|
||||
.replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED)
|
||||
.replace(/\bbearer\s+[A-Za-z0-9._\-/+]{16,}/gi, 'bearer ' + REDACTED)
|
||||
.replace(/(\b(?:proxy-)?authorization\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED)
|
||||
.replace(/(\b(?:set-cookie|cookie)\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED)
|
||||
.replace(/(\b(?:bearer|basic)\s+)[A-Za-z0-9._~+\-/=]+/gi, '$1' + REDACTED)
|
||||
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, REDACTED)
|
||||
.replace(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\s&"'`]+/gi, '$1' + REDACTED)
|
||||
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|(?:access|refresh|auth|session)[_-]?token|token|sessionid|session)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED)
|
||||
.replace(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED)
|
||||
.replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/gi, '$1' + REDACTED);
|
||||
.replace(/([?&](?:api[_-]?key|key|token|access[_-]?token|refresh[_-]?token|auth|authorization|password|pass|cookie|session(?:[_-]?id)?)=)[^\s&#"'`]+/gi, '$1' + REDACTED)
|
||||
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|authorization|cookie|(?:access|refresh|auth|session)[_-]?token|token|session[_-]?id|sessionid|session|sess[_-]?id|sessid|sess)"?\s*[:=]\s*)(["'])(.*?)\2/gi, `$1$2${REDACTED}$2`)
|
||||
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|authorization|cookie|(?:access|refresh|auth|session)[_-]?token|token|session[_-]?id|sessionid|session|sess[_-]?id|sessid|sess)"?\s*[:=]\s*)(?!["'])([^\s,;}\]\r\n]+)/gi, '$1' + REDACTED);
|
||||
return out;
|
||||
}
|
||||
|
||||
function valueScrub(value, secrets) {
|
||||
if (value === null || value === undefined) return value;
|
||||
const json = JSON.stringify(value);
|
||||
let scrubbed = json;
|
||||
if (Array.isArray(secrets)) {
|
||||
for (const s of secrets) {
|
||||
if (typeof s === 'string' && s.length >= 6) scrubbed = scrubbed.split(s).join(REDACTED);
|
||||
if (typeof value === 'string') return redactLogText(value, secrets);
|
||||
if (Array.isArray(value)) return value.map(entry => valueScrub(entry, secrets));
|
||||
if (typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [key, entry] of Object.entries(value)) out[key] = valueScrub(entry, secrets);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return JSON.parse(scrubbed);
|
||||
return value;
|
||||
}
|
||||
|
||||
function collectFile(filePath, label, maxBytes, options) {
|
||||
@@ -107,12 +143,12 @@ function buildSupportBundleText({ header, sanitizedConfig, files, secrets }) {
|
||||
}
|
||||
parts.push('\n');
|
||||
parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\n');
|
||||
parts.push(redactLogText(JSON.stringify(sanitizedConfig, null, 2), secrets));
|
||||
parts.push(JSON.stringify(sanitizedConfig, null, 2));
|
||||
parts.push('\n\n');
|
||||
for (const f of (files || [])) {
|
||||
parts.push(redactLogText(collectFile(f.path, f.label || f.path, f.maxBytes, { includePath: false }), secrets));
|
||||
parts.push(collectFile(f.path, f.label || 'log', f.maxBytes, { includePath: false }));
|
||||
}
|
||||
return parts.join('');
|
||||
return redactLogText(parts.join(''), secrets);
|
||||
}
|
||||
|
||||
module.exports = { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED };
|
||||
|
||||
+41
-9
@@ -1,4 +1,5 @@
|
||||
const nodePath = require('path');
|
||||
const { formatUploadPlanLogLine } = require('./upload-log');
|
||||
|
||||
function getUploadAuditLogPath(uploadLogPath, pathApi = nodePath) {
|
||||
if (typeof uploadLogPath !== 'string' || !uploadLogPath.trim()) return null;
|
||||
@@ -12,7 +13,7 @@ function createUploadAuditWriter(options) {
|
||||
const resolveUploadLogTarget = source.resolveUploadLogTarget;
|
||||
const rotateLogFile = typeof source.rotateLogFile === 'function' ? source.rotateLogFile : () => {};
|
||||
const invalidateUploadLogTarget = typeof source.invalidateUploadLogTarget === 'function' ? source.invalidateUploadLogTarget : () => {};
|
||||
const persistFallbackLogPath = typeof source.persistFallbackLogPath === 'function' ? source.persistFallbackLogPath : async () => {};
|
||||
const persistFallbackLogPath = typeof source.persistFallbackLogPath === 'function' ? source.persistFallbackLogPath : async () => false;
|
||||
const reportError = typeof source.reportError === 'function' ? source.reportError : () => {};
|
||||
const retryDelays = Array.isArray(source.retryDelays) && source.retryDelays.length > 0 ? source.retryDelays : [0, 100, 250];
|
||||
const maxBytes = Number.isFinite(source.maxBytes) ? source.maxBytes : 10 * 1024 * 1024;
|
||||
@@ -24,27 +25,35 @@ function createUploadAuditWriter(options) {
|
||||
}
|
||||
|
||||
async function append(line, label) {
|
||||
let excludedPath = null;
|
||||
const excludedPaths = new Set();
|
||||
for (const delay of retryDelays) {
|
||||
if (delay) await new Promise(resolve => setTimeout(resolve, delay));
|
||||
const uploadTarget = resolveUploadLogTarget(excludedPath);
|
||||
const uploadTarget = resolveUploadLogTarget(excludedPaths);
|
||||
if (!uploadTarget || excludedPaths.has(uploadTarget.path)) continue;
|
||||
const targetPath = uploadTarget && getUploadAuditLogPath(uploadTarget.path, path);
|
||||
if (!targetPath) continue;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
rotateLogFile(targetPath, maxBytes, maxBackups);
|
||||
await fs.promises.appendFile(targetPath, line, 'utf-8');
|
||||
activePath = targetPath;
|
||||
if (uploadTarget.isFallback) {
|
||||
let persisted = false;
|
||||
try {
|
||||
await persistFallbackLogPath(uploadTarget.path);
|
||||
persisted = await persistFallbackLogPath(uploadTarget.path);
|
||||
} catch (error) {
|
||||
reportError('audit-fallback-persist', error);
|
||||
}
|
||||
if (persisted !== true) {
|
||||
excludedPaths.add(uploadTarget.path);
|
||||
invalidateUploadLogTarget();
|
||||
reportError('audit-fallback-persist', new Error('Fallback log path could not be persisted'));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rotateLogFile(targetPath, maxBytes, maxBackups);
|
||||
await fs.promises.appendFile(targetPath, line, 'utf-8');
|
||||
activePath = targetPath;
|
||||
return true;
|
||||
} catch (error) {
|
||||
excludedPath = uploadTarget.path;
|
||||
excludedPaths.add(uploadTarget.path);
|
||||
invalidateUploadLogTarget();
|
||||
reportError(label, error);
|
||||
}
|
||||
@@ -55,4 +64,27 @@ function createUploadAuditWriter(options) {
|
||||
return { append, getActivePath: () => activePath };
|
||||
}
|
||||
|
||||
module.exports = { getUploadAuditLogPath, createUploadAuditWriter };
|
||||
function createUploadAuditEvents(writer, now = () => new Date()) {
|
||||
if (!writer || typeof writer.append !== 'function') throw new TypeError('createUploadAuditEvents requires an audit writer');
|
||||
return {
|
||||
appendSourceCleanup: event => writer.append(`# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`, 'source-cleanup'),
|
||||
appendUploadPlan: (plan, mode) => writer.append(formatUploadPlanLogLine(now(), plan, mode), 'upload-plan')
|
||||
};
|
||||
}
|
||||
|
||||
async function runAfterDurableAudit(audit, action) {
|
||||
let persisted = false;
|
||||
try {
|
||||
persisted = await audit();
|
||||
} catch {}
|
||||
if (persisted !== true) return { ok: false };
|
||||
return { ok: true, value: await action() };
|
||||
}
|
||||
|
||||
function getUploadAuditFailureMessage(language) {
|
||||
return language === 'de'
|
||||
? 'Der Uploadplan konnte nicht dauerhaft protokolliert werden. Bitte prüfe den Log-Pfad und versuche es erneut.'
|
||||
: 'The upload plan could not be recorded durably. Check the log path and try again.';
|
||||
}
|
||||
|
||||
module.exports = { getUploadAuditLogPath, createUploadAuditWriter, createUploadAuditEvents, runAfterDurableAudit, getUploadAuditFailureMessage };
|
||||
|
||||
@@ -27,8 +27,8 @@ const { walkFolderAsync } = require('./lib/file-discovery');
|
||||
const RemoteServer = require('./lib/remote-server');
|
||||
const { maybeRotateLogFile } = require('./lib/log-rotation');
|
||||
const { hosterLogToFileEnabled } = require('./lib/log-policy');
|
||||
const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan, formatUploadPlanLogLine } = require('./lib/upload-log');
|
||||
const { getUploadAuditLogPath, createUploadAuditWriter } = require('./lib/upload-audit');
|
||||
const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan } = require('./lib/upload-log');
|
||||
const { getUploadAuditLogPath, createUploadAuditWriter, createUploadAuditEvents, runAfterDurableAudit, getUploadAuditFailureMessage } = require('./lib/upload-audit');
|
||||
const { selectOrphanTmps } = require('./lib/orphan-tmp');
|
||||
const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle');
|
||||
const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify');
|
||||
@@ -778,13 +778,14 @@ function _invalidateUploadLogTargetCache() {
|
||||
_cachedUploadLogKey = '';
|
||||
}
|
||||
|
||||
function _resolveUploadLogTarget(excludedPath) {
|
||||
function _resolveUploadLogTarget(excluded) {
|
||||
const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
|
||||
const primary = getLogFilePath();
|
||||
// The primary path already encodes the mode + date/session, so it changes
|
||||
// when the user toggles mode, daily rolls at midnight, or this is a new
|
||||
// process — cache invalidates naturally on path change.
|
||||
const key = primary;
|
||||
if (_cachedUploadLogKey === key && _cachedUploadLogTarget && _cachedUploadLogTarget.path !== excludedPath) return _cachedUploadLogTarget;
|
||||
if (_cachedUploadLogKey === key && _cachedUploadLogTarget && !excludedPaths.has(_cachedUploadLogTarget.path)) return _cachedUploadLogTarget;
|
||||
|
||||
const commit = (t) => {
|
||||
_cachedUploadLogTarget = t;
|
||||
@@ -793,7 +794,7 @@ function _resolveUploadLogTarget(excludedPath) {
|
||||
};
|
||||
|
||||
// Try primary → desktop → userData, mirror the original fallback ladder.
|
||||
if (primary !== excludedPath) {
|
||||
if (!excludedPaths.has(primary)) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(primary), { recursive: true });
|
||||
return commit({ path: primary, isFallback: false });
|
||||
@@ -805,7 +806,7 @@ function _resolveUploadLogTarget(excludedPath) {
|
||||
if (desktop) {
|
||||
try {
|
||||
const p = buildFallbackLogName(desktop);
|
||||
if (p !== excludedPath) {
|
||||
if (!excludedPaths.has(p)) {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
return commit({ path: p, isFallback: true });
|
||||
}
|
||||
@@ -813,7 +814,7 @@ function _resolveUploadLogTarget(excludedPath) {
|
||||
}
|
||||
try {
|
||||
const p = buildFallbackLogName(app.getPath('userData'));
|
||||
if (p === excludedPath) return null;
|
||||
if (excludedPaths.has(p)) return null;
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
return commit({ path: p, isFallback: true });
|
||||
} catch (err) {
|
||||
@@ -836,6 +837,7 @@ const _uploadAuditWriter = createUploadAuditWriter({
|
||||
persistFallbackLogPath: _persistFallbackLogPath,
|
||||
reportError: (label, error) => debugLog(`${label} audit append failed: ${error.message}`)
|
||||
});
|
||||
const _uploadAuditEvents = createUploadAuditEvents(_uploadAuditWriter);
|
||||
|
||||
function _flushUploadLog() {
|
||||
if (_uploadLogWriting || _uploadLogBuffer.length === 0) return;
|
||||
@@ -936,19 +938,8 @@ function appendUploadLog(hoster, link, fileName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function appendUploadAuditLine(line, label) {
|
||||
return _uploadAuditWriter.append(line, label);
|
||||
}
|
||||
|
||||
async function appendSourceCleanupAudit(event) {
|
||||
debugLog(`source-cleanup: ${event.outcome} ${event.file} trigger=${event.trigger || '-'}`);
|
||||
return appendUploadAuditLine(`# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`, 'source-cleanup');
|
||||
}
|
||||
|
||||
async function appendUploadPlanAudit(plan, mode) {
|
||||
debugLog(`upload-plan: mode=${mode} files=${plan.fileCount} destinations=${plan.destinationCount} uploads=${plan.plannedUploadCount}`);
|
||||
return appendUploadAuditLine(formatUploadPlanLogLine(new Date(), plan, mode), 'upload-plan');
|
||||
}
|
||||
const appendSourceCleanupAudit = _uploadAuditEvents.appendSourceCleanup;
|
||||
const appendUploadPlanAudit = _uploadAuditEvents.appendUploadPlan;
|
||||
|
||||
function flattenHistoryForExport(history) {
|
||||
const rows = [];
|
||||
@@ -2032,7 +2023,6 @@ ipcMain.handle('start-upload', async (_event, payload) => {
|
||||
const tasks = jobs.length > 0
|
||||
? buildUploadTasksFromJobs(config, jobs, pick)
|
||||
: buildUploadTasks(config, files, hosters, pick);
|
||||
persistRotation(pick);
|
||||
|
||||
// Identify jobs that were skipped (no account/credentials)
|
||||
const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
|
||||
@@ -2050,8 +2040,16 @@ ipcMain.handle('start-upload', async (_event, payload) => {
|
||||
|
||||
debugLog(` tasks built: ${tasks.length}`);
|
||||
|
||||
const auditedStart = await runAfterDurableAudit(
|
||||
() => appendUploadPlanAudit(batchPlan, 'start'),
|
||||
() => tasks.length > 0 ? new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)) : null
|
||||
);
|
||||
if (!auditedStart.ok) {
|
||||
return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) };
|
||||
}
|
||||
persistRotation(pick);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
await appendUploadPlanAudit(batchPlan, 'start');
|
||||
const skippedSummary = stats.mergeSkippedIntoSummary({
|
||||
id: `skipped-${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -2068,12 +2066,10 @@ ipcMain.handle('start-upload', async (_event, payload) => {
|
||||
return { started: true, taskCount: 0, skippedJobs };
|
||||
}
|
||||
|
||||
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config));
|
||||
uploadManager = auditedStart.value;
|
||||
globalThis._mhuUploadManagerRef = uploadManager;
|
||||
const _thisManager = uploadManager;
|
||||
|
||||
await appendUploadPlanAudit(batchPlan, 'start');
|
||||
|
||||
const recovery = {
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
startedAt: new Date().toISOString(),
|
||||
@@ -2376,11 +2372,18 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
|
||||
const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : [];
|
||||
const pick = makeAccountPicker(config);
|
||||
const tasks = buildUploadTasksFromJobs(config, jobs, pick);
|
||||
persistRotation(pick);
|
||||
const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
|
||||
const skippedJobs = jobs
|
||||
.filter(j => j && j.id && !taskJobIds.has(j.id))
|
||||
.map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' }));
|
||||
if (jobs.length > 0) {
|
||||
const auditedAdd = await runAfterDurableAudit(
|
||||
() => appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'),
|
||||
() => true
|
||||
);
|
||||
if (!auditedAdd.ok) return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) };
|
||||
}
|
||||
persistRotation(pick);
|
||||
const sourceCleanupFingerprints = batchManager.sourceFileCleanup
|
||||
? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups)
|
||||
: {};
|
||||
@@ -2393,7 +2396,6 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
|
||||
|
||||
if (tasks.length === 0) {
|
||||
debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`);
|
||||
if (jobs.length > 0) await appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add');
|
||||
return { added: 0, skippedJobs, alreadyInBatchJobIds: [], sourceCleanupFingerprints };
|
||||
}
|
||||
|
||||
@@ -2406,7 +2408,6 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
|
||||
debugLog(
|
||||
`add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${alreadyInBatchJobIds.length} already in batch, ${skippedJobs.length} skipped)`
|
||||
);
|
||||
if (jobs.length > 0) await appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add');
|
||||
return { added, skippedJobs, alreadyInBatchJobIds, sourceCleanupFingerprints };
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ function makeFixture() {
|
||||
crashLog: path.join(dir, 'crash.log'),
|
||||
logDir: dir
|
||||
};
|
||||
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
|
||||
fs.writeFileSync(paths.debug, `boot ok\nsource ${path.join(dir, 'private-source.mkv')}\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');
|
||||
@@ -88,7 +88,7 @@ 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 { collectors, dir, paths } = 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');
|
||||
@@ -97,6 +97,10 @@ test('readLog redacts a planted token and a Bearer line; doodstream is NOT reada
|
||||
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.ok(!JSON.stringify(dbg).includes(dir), 'read log content and metadata must not expose its absolute directory');
|
||||
const rejectedAbsolutePath = collectors.readLog({ name: paths.debug });
|
||||
assert.ok(!rejectedAbsolutePath.error.includes(paths.debug), 'rejected log identifiers must not be echoed as absolute paths');
|
||||
assert.ok(!rejectedAbsolutePath.error.includes(path.basename(paths.debug)), 'rejected log identifiers must not echo path components');
|
||||
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
|
||||
});
|
||||
|
||||
@@ -104,11 +108,21 @@ test('rotated audit backups are listed and readable with the rotation naming con
|
||||
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');
|
||||
const logList = collectors.listLogs();
|
||||
const listed = logList.files.find(file => file.name === 'uploadAudit');
|
||||
assert.equal(logList.dir, undefined);
|
||||
assert.equal(listed.id, 'uploadAudit');
|
||||
assert.equal(listed.fileName, 'upload-audit.log');
|
||||
assert.equal(listed.path, undefined);
|
||||
assert.ok(listed.variants.some(variant => variant.backup === 1));
|
||||
assert.ok(listed.variants.every(variant => variant.fileName && !Object.hasOwn(variant, 'path')));
|
||||
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.equal(backup.id, 'uploadAudit');
|
||||
assert.equal(backup.name, 'uploadAudit');
|
||||
assert.equal(backup.fileName, 'upload-audit.1.log');
|
||||
assert.equal(backup.path, undefined);
|
||||
assert.ok(!JSON.stringify({ logList, backup }).includes(path.dirname(paths.uploadAudit)));
|
||||
assert.ok(!backup.content.includes(fixtureAlpha));
|
||||
});
|
||||
|
||||
@@ -171,9 +185,10 @@ test('listErrors classifies via stats.classifyErrorCategory and redacts error te
|
||||
});
|
||||
|
||||
test('serverHealth assembles the one-shot hub without leaking secrets', () => {
|
||||
const { collectors } = makeFixture();
|
||||
const { collectors, dir, paths } = 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');
|
||||
assert.ok(!json.includes(dir) && !json.includes(paths.debug), 'server_health must not expose absolute log paths');
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ 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');
|
||||
const { sanitizeConfig, collectSecretValues, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
|
||||
|
||||
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
|
||||
const input = {
|
||||
@@ -85,6 +85,38 @@ test('redactLogText leaves a normal "session" word in prose alone', () => {
|
||||
assert.equal(redactLogText(benign, []), benign);
|
||||
});
|
||||
|
||||
test('redactLogText removes complete authorization, cookie, session, HTML credential, and query values', () => {
|
||||
const authorization = 'Digest username="private-user", realm="private-realm", response="private-response"';
|
||||
const cookie = 'sid=private-cookie; preferences=private-preferences';
|
||||
const sessionId = 's3';
|
||||
const htmlPassword = 'private-html-password';
|
||||
const htmlToken = 'private-html-token';
|
||||
const queryToken = 'q1';
|
||||
const input = [
|
||||
`Authorization: ${authorization}`,
|
||||
`Cookie: ${cookie}`,
|
||||
`session_id=${sessionId}`,
|
||||
`<input type="password" name="password" value="${htmlPassword}">`,
|
||||
`<input value="${htmlToken}" name="api_token" type="text">`,
|
||||
`https://example.invalid/upload?token=${queryToken}&next=ok`
|
||||
].join('\n');
|
||||
const out = redactLogText(input, []);
|
||||
for (const value of [authorization, 'private-user', 'private-realm', 'private-response', cookie, 'private-cookie', 'private-preferences', sessionId, htmlPassword, htmlToken, queryToken]) {
|
||||
assert.ok(!out.includes(value), `sensitive value survived: ${value}`);
|
||||
}
|
||||
assert.ok((out.match(/<redacted>/g) || []).length >= 6);
|
||||
});
|
||||
|
||||
test('redactLogText masks a one-character configured secret only as a complete sensitive value', () => {
|
||||
const out = redactLogText('status=diagnostics available\npassword=x\nfile=xylophone.mkv\nmarker=x', ['x']);
|
||||
assert.ok(out.includes('status=diagnostics available'));
|
||||
assert.ok(out.includes('file=xylophone.mkv'));
|
||||
assert.ok(!out.includes('password=x'));
|
||||
assert.ok(!out.includes('marker=x'));
|
||||
assert.ok(out.includes(`password=${REDACTED}`));
|
||||
assert.ok(out.includes(`marker=${REDACTED}`));
|
||||
});
|
||||
|
||||
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('\\');
|
||||
@@ -172,6 +204,18 @@ test('buildSupportBundleText handles empty file list and missing header', () =>
|
||||
assert.match(text, /=== Config/);
|
||||
});
|
||||
|
||||
test('buildSupportBundleText never uses an absolute source path as a section label', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-unlabeled-${Date.now()}.log`);
|
||||
fs.writeFileSync(tmp, 'safe content\n');
|
||||
try {
|
||||
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [{ path: tmp }], secrets: [] });
|
||||
assert.ok(!text.includes(tmp));
|
||||
assert.ok(text.includes('=== log (size='));
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
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('');
|
||||
@@ -200,3 +244,44 @@ test('buildSupportBundleText redacts configured and pattern-detected secrets fro
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSupportBundleText removes configured secrets of every non-empty length', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-short-secrets-${Date.now()}.log`);
|
||||
const config = {
|
||||
hosters: { 'voe.sx': [{ password: 'p1', apiKey: 'k2' }] },
|
||||
globalSettings: { diagnostics: { token: 't3' }, cookie: '', sessionId: null }
|
||||
};
|
||||
fs.writeFileSync(tmp, 'password=p1\napiKey=k2\ntoken=t3\n');
|
||||
try {
|
||||
const secrets = collectSecretValues(config);
|
||||
assert.deepEqual(new Set(secrets), new Set(['p1', 'k2', 't3']));
|
||||
const text = buildSupportBundleText({
|
||||
header: { Marker: 'p1-k2-t3' },
|
||||
sanitizedConfig: sanitizeConfig(config),
|
||||
secrets,
|
||||
files: [{ label: 'short-secrets.log', path: tmp }]
|
||||
});
|
||||
for (const secret of ['p1', 'k2', 't3']) assert.ok(!text.includes(secret), `configured secret survived: ${secret}`);
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSupportBundleText removes a one-character configured secret', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-one-character-secret-${Date.now()}.log`);
|
||||
const config = { hosters: { 'voe.sx': [{ password: 'x' }] } };
|
||||
fs.writeFileSync(tmp, 'password=x\n');
|
||||
try {
|
||||
const text = buildSupportBundleText({
|
||||
header: { Marker: 'secret:x' },
|
||||
sanitizedConfig: sanitizeConfig(config),
|
||||
secrets: collectSecretValues(config),
|
||||
files: [{ label: 'one-character.log', path: tmp }]
|
||||
});
|
||||
assert.ok(!text.includes('secret:x'));
|
||||
assert.ok(!text.includes('password=x'));
|
||||
assert.ok(text.includes('one-character.log'));
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
+131
-5
@@ -6,13 +6,17 @@ const path = require('path');
|
||||
|
||||
test('internal audit records never contaminate the MDU session link log', async () => {
|
||||
let createUploadAuditWriter;
|
||||
let createUploadAuditEvents;
|
||||
try {
|
||||
({ createUploadAuditWriter } = require('../lib/upload-audit'));
|
||||
({ createUploadAuditWriter, createUploadAuditEvents } = require('../lib/upload-audit'));
|
||||
} catch {}
|
||||
assert.equal(typeof createUploadAuditWriter, 'function');
|
||||
assert.equal(typeof createUploadAuditEvents, '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 debugLog = path.join(directory, 'upload-debug.log');
|
||||
fs.writeFileSync(debugLog, 'debug-before\r\n');
|
||||
const writer = createUploadAuditWriter({
|
||||
fs,
|
||||
path,
|
||||
@@ -22,13 +26,15 @@ test('internal audit records never contaminate the MDU session link log', async
|
||||
reportError: () => {},
|
||||
retryDelays: [0]
|
||||
});
|
||||
const events = createUploadAuditEvents(writer, () => new Date('2026-08-13T12:00:00.000Z'));
|
||||
|
||||
await writer.append('# SOURCE-CLEANUP {"outcome":"deleted"}\r\n', 'source-cleanup');
|
||||
await writer.append('# UPLOAD-PLAN {"plannedUploadCount":4}\r\n', 'upload-plan');
|
||||
await events.appendSourceCleanup({ outcome: 'deleted' });
|
||||
await events.appendUploadPlan({ fileCount: 2, destinationCount: 2, plannedUploadCount: 4 }, 'start');
|
||||
|
||||
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');
|
||||
assert.equal(fs.readFileSync(debugLog, 'utf8'), 'debug-before\r\n');
|
||||
assert.equal(fs.readFileSync(auditLog, 'utf8'), '# SOURCE-CLEANUP {"outcome":"deleted"}\r\n# UPLOAD-PLAN {"timestamp":"2026-08-13T12:00:00.000Z","mode":"start","fileCount":2,"destinationCount":2,"plannedUploadCount":4}\r\n');
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -51,7 +57,7 @@ test('audit writer reports the actual fallback file after a failed primary write
|
||||
}),
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); },
|
||||
persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); return true; },
|
||||
reportError: () => {},
|
||||
retryDelays: [0, 0]
|
||||
});
|
||||
@@ -62,3 +68,123 @@ test('audit writer reports the actual fallback file after a failed primary write
|
||||
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n');
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('audit writer rejects false and thrown fallback persistence before trying the next safe target', async (t) => {
|
||||
const { createUploadAuditWriter } = require('../lib/upload-audit');
|
||||
for (const rejection of ['false', 'throw']) {
|
||||
await t.test(rejection, async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `mhu-upload-audit-${rejection}-`));
|
||||
const first = path.join(directory, 'first', 'fileuploader.log');
|
||||
const second = path.join(directory, 'second', 'fileuploader.log');
|
||||
const targets = [first, second].map(targetPath => ({ path: targetPath, isFallback: true }));
|
||||
const persistedFallbacks = [];
|
||||
const writer = createUploadAuditWriter({
|
||||
fs,
|
||||
path,
|
||||
resolveUploadLogTarget: excluded => {
|
||||
const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
|
||||
return targets.find(target => !excludedPaths.has(target.path)) || null;
|
||||
},
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async targetPath => {
|
||||
persistedFallbacks.push(targetPath);
|
||||
if (targetPath === first) {
|
||||
if (rejection === 'throw') throw new Error('settings write failed');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
reportError: () => {},
|
||||
retryDelays: [0, 0, 0]
|
||||
});
|
||||
|
||||
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), true);
|
||||
assert.equal(writer.getActivePath(), path.join(directory, 'second', 'upload-audit.log'));
|
||||
assert.deepEqual(persistedFallbacks, [first, second]);
|
||||
assert.equal(fs.existsSync(path.join(directory, 'first', 'upload-audit.log')), false);
|
||||
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n');
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('audit writer returns false only after every allowed fallback target is rejected', async () => {
|
||||
const { createUploadAuditWriter } = require('../lib/upload-audit');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-exhausted-'));
|
||||
const targets = ['first', 'second'].map(name => ({ path: path.join(directory, name, 'fileuploader.log'), isFallback: true }));
|
||||
const persistedFallbacks = [];
|
||||
const writer = createUploadAuditWriter({
|
||||
fs,
|
||||
path,
|
||||
resolveUploadLogTarget: excluded => {
|
||||
const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
|
||||
return targets.find(target => !excludedPaths.has(target.path)) || null;
|
||||
},
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async targetPath => {
|
||||
persistedFallbacks.push(targetPath);
|
||||
if (persistedFallbacks.length === 2) throw new Error('settings write failed');
|
||||
return false;
|
||||
},
|
||||
reportError: () => {},
|
||||
retryDelays: [0, 0, 0]
|
||||
});
|
||||
|
||||
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), false);
|
||||
assert.deepEqual(persistedFallbacks, targets.map(target => target.path));
|
||||
assert.equal(writer.getActivePath(), null);
|
||||
assert.equal(fs.existsSync(path.join(directory, 'first', 'upload-audit.log')), false);
|
||||
assert.equal(fs.existsSync(path.join(directory, 'second', 'upload-audit.log')), false);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('durable audit gate never creates a manager or adds jobs after a false or thrown audit', async () => {
|
||||
const { runAfterDurableAudit } = require('../lib/upload-audit');
|
||||
assert.equal(typeof runAfterDurableAudit, 'function');
|
||||
for (const actionName of ['manager', 'addJobs']) {
|
||||
for (const audit of [async () => false, async () => { throw new Error('audit failed'); }]) {
|
||||
let actions = 0;
|
||||
const result = await runAfterDurableAudit(audit, () => { actions += 1; return actionName; });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(actions, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let actions = 0;
|
||||
const result = await runAfterDurableAudit(async () => true, () => { actions += 1; return 'started'; });
|
||||
assert.deepEqual(result, { ok: true, value: 'started' });
|
||||
assert.equal(actions, 1);
|
||||
});
|
||||
|
||||
test('audit failure message is localized and tells the user how to retry', () => {
|
||||
const { getUploadAuditFailureMessage } = require('../lib/upload-audit');
|
||||
const german = getUploadAuditFailureMessage('de');
|
||||
const english = getUploadAuditFailureMessage('en');
|
||||
assert.match(german, /Log-Pfad/);
|
||||
assert.match(german, /erneut/);
|
||||
assert.match(english, /log path/i);
|
||||
assert.match(english, /try again/i);
|
||||
assert.notEqual(german, english);
|
||||
});
|
||||
|
||||
test('main process audits batch plans before creating or mutating upload work', () => {
|
||||
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const startHandler = mainSource.slice(
|
||||
mainSource.indexOf("ipcMain.handle('start-upload'"),
|
||||
mainSource.indexOf("ipcMain.handle('cancel-upload'")
|
||||
);
|
||||
const addHandler = mainSource.slice(
|
||||
mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"),
|
||||
mainSource.indexOf("ipcMain.handle('finish-after-active'")
|
||||
);
|
||||
|
||||
assert.ok(startHandler.indexOf("appendUploadPlanAudit(batchPlan, 'start')") < startHandler.indexOf('new UploadManager('));
|
||||
assert.ok(startHandler.indexOf("appendUploadPlanAudit(batchPlan, 'start')") < startHandler.indexOf('persistRotation(pick)'));
|
||||
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('persistRotation(pick)'));
|
||||
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('registerGroups(sourceCleanupGroups)'));
|
||||
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('batchManager.addJobs(tasks)'));
|
||||
assert.doesNotMatch(mainSource, /debugLog\(`source-cleanup:/);
|
||||
assert.doesNotMatch(mainSource, /debugLog\(`upload-plan:/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user