Files
Multi-Debrid-Downloader/tests/support-bundle.test.ts
T
Sucukdeluxe 25ebc55f4f fix: strengthen live recovery and support diagnostics
Apply account and key changes to active queues without a restart and isolate provider attempt cancellation so fallback accounts remain usable. Preserve pause ownership, bound persisted HTTP 416 recovery, reconcile resets with authoritative state, and stabilize package ordering and live update cadence. Correlate rotation, conversion, resume, disk, queue-control, clipboard, and support-export events while redacting sensitive data at every persistent boundary and again in generated bundles. Release as v2.0.31 with updated English documentation and regression coverage.
2026-08-13 11:36:51 +02:00

1211 lines
45 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it } from "vitest";
import {
buildSupportBundle,
createSupportBundleExportRunner,
type SupportBundleExportLifecycleEvent,
writeSupportBundleAtomically
} from "../src/main/support-bundle";
import type { DownloadManager } from "../src/main/download-manager";
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
import { initAccountRotationLog, logAccountRotation, shutdownAccountRotationLog } from "../src/main/account-rotation-log";
import { configureLogger, flushLoggerSync, logger } from "../src/main/logger";
import { ensurePackageLog, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log";
import { ensureItemLog, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
import { initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
import {
primeDebridLinkRuntimeCooldownForTests,
primeMegaDebridInFlightForTests,
primeMegaDebridRuntimeCooldownForTests,
resetDebridLinkRuntimeStateForTests,
resetMegaDebridRuntimeStateForTests
} from "../src/main/debrid";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
const tempDirs: string[] = [];
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
afterEach(() => {
shutdownTraceLog();
shutdownItemLogs();
shutdownPackageLogs();
shutdownSessionLog();
shutdownAccountRotationLog();
resetDebridLinkRuntimeStateForTests();
resetMegaDebridRuntimeStateForTests();
flushLoggerSync();
configureLogger(process.cwd());
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
}
});
function fakeManager(): DownloadManager {
const snapshot = {
stats: {},
session: { packages: {}, items: {}, packageOrder: [] },
speedText: "",
etaText: "",
canStart: false,
canStop: false,
canPause: false
};
return {
getSnapshot: () => snapshot,
getPackageLogPath: () => null,
getItemLogPath: () => null
} as unknown as DownloadManager;
}
function populatedFakeManager(): DownloadManager {
const snapshot = {
stats: {},
session: {
packages: {
"package-1": { id: "package-1", name: "Paket", itemIds: ["item-1"] }
},
items: {
"item-1": { id: "item-1", packageId: "package-1", fileName: "datei.bin" }
},
packageOrder: ["package-1"]
},
speedText: "",
etaText: "",
canStart: false,
canStop: false,
canPause: false
};
return {
getSnapshot: () => snapshot,
getPackageLogPath: () => { throw new Error("package log getter must not run"); },
getItemLogPath: () => { throw new Error("item log getter must not run"); }
} as unknown as DownloadManager;
}
function sensitiveActiveManager(itemCount = 1): DownloadManager {
const packages = {
"package-sensitive": {
id: "package-sensitive",
name: "Private Collection",
outputDir: "C:\\Users\\Alice\\Downloads\\Private Collection",
extractDir: "C:\\Users\\Alice\\Extracted\\Private Collection",
status: "downloading",
itemIds: Array.from({ length: itemCount }, (_, index) => `item-${index}`),
cancelled: false,
enabled: true,
cleanedUrls: ["https://files.example.test/archive?api_key=cleaned-secret#private"],
createdAt: 1,
updatedAt: 2
}
};
const items = Object.fromEntries(Array.from({ length: itemCount }, (_, index) => [
`item-${index}`,
{
id: `item-${index}`,
packageId: "package-sensitive",
url: `https://url-user:url-pass@files.example.test/archive-${index}?token=query-secret-${index}#fragment-secret`,
provider: "realdebrid",
status: "downloading",
retries: 0,
speedBps: 1024,
downloadedBytes: index,
totalBytes: 2048,
progressPercent: 50,
fileName: `private-${index}.bin`,
targetPath: `C:\\Users\\Alice\\Downloads\\Private Collection\\private-${index}.bin`,
resumable: true,
attempts: 1,
lastError: "Authorization: Bearer item-bearer-secret",
fullStatus: "Cookie: session=item-cookie-secret",
createdAt: 1,
updatedAt: index + 2
}
]));
const snapshot = {
stats: {},
session: {
version: 1,
packageOrder: ["package-sensitive"],
packages,
items,
runStartedAt: 1,
totalDownloadedBytes: 10,
summaryText: "password=summary-secret",
reconnectUntil: 0,
reconnectReason: "api_key=reconnect-secret",
paused: false,
running: true,
updatedAt: 2
},
speedText: "Geschwindigkeit: 1 KB/s",
etaText: "ETA: 1m",
canStart: false,
canStop: true,
canPause: true
};
return {
getSnapshot: () => snapshot,
getPackageLogPath: () => { throw new Error("package log getter must not run"); },
getItemLogPath: () => { throw new Error("item log getter must not run"); }
} as unknown as DownloadManager;
}
describe("buildSupportBundle (async, non-blocking)", () => {
it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
fs.writeFileSync(path.join(root, "debug_host.txt"), "host-info-test", "utf8");
fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({ purpose: "support" }), "utf8");
fs.writeFileSync(path.join(root, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
const promise = buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
expect(promise).toBeInstanceOf(Promise);
const buffer = await promise;
expect(Buffer.isBuffer(buffer)).toBe(true);
expect(buffer.length).toBeGreaterThan(0);
const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName);
expect(entries).toContain("overview/meta.json");
expect(entries).toContain("overview/settings.json");
expect(entries).toContain("overview/debug-setup.json");
expect(entries).not.toContain("overview/self-check.json");
expect(entries).toContain("runtime/debug_host.txt");
expect(entries).toContain("runtime/debug_support_manifest.json");
expect(entries).toContain("overview/support-manifest.json");
expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt");
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
const meta = JSON.parse(new AdmZip(buffer).getEntry("overview/meta.json")!.getData().toString("utf8"));
expect(meta.limits).toMatchObject({
directoryLogDiscoveryWindowHours: 8,
currentAndRelevantLogsIgnoreAgeFilter: true
});
expect(meta.limits).not.toHaveProperty("logWindowHours");
});
it("replaces overview clear names with stable bundle-local aliases while retaining extension, size, status and correlation", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-aliases-"));
tempDirs.push(root);
fs.writeFileSync(path.join(root, "rd_history.json"), JSON.stringify([{
id: "history-private-id",
name: "Private Linux Collection.iso",
totalBytes: 8_000,
downloadedBytes: 8_000,
fileCount: 1,
provider: "megadebrid-web",
completedAt: 4,
durationSeconds: 5,
status: "completed",
outputDir: "C:\\Private\\History",
urls: ["https://example.invalid/private"]
}]), "utf8");
const snapshot = {
stats: {},
session: {
version: 1,
packageOrder: ["package-private-id"],
packages: {
"package-private-id": {
id: "package-private-id",
name: "Private Series Collection.zip",
outputDir: "C:\\Private\\Output",
extractDir: "C:\\Private\\Extract",
status: "downloading",
itemIds: ["item-private-id"],
cancelled: false,
enabled: true,
cleanedDownloadedBytes: 500,
cleanedTotalBytes: 500,
createdAt: 1,
updatedAt: 2
}
},
items: {
"item-private-id": {
id: "item-private-id",
packageId: "package-private-id",
url: "https://rapidgator.net/file/example",
provider: "megadebrid-web",
status: "downloading",
retries: 0,
speedBps: 100,
downloadedBytes: 250,
totalBytes: 1_000,
progressPercent: 25,
fileName: "Private.Show.S01E01.part1.rar",
targetPath: "C:\\Private\\Output\\Private.Show.S01E01.part1.rar",
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft",
createdAt: 1,
updatedAt: 2,
onlineStatus: "online"
}
},
runStartedAt: 1,
totalDownloadedBytes: 750,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: true,
updatedAt: 2
},
speedText: "Geschwindigkeit: 100 B/s",
etaText: "ETA: 1m",
canStart: false,
canStop: true,
canPause: true
};
const manager = {
getSnapshot: () => snapshot,
getPackageLogPath: () => null,
getItemLogPath: () => null
} as unknown as DownloadManager;
const buffer = await buildSupportBundle(manager, root, { hostDiagnosticsMode: "none", debugSetupMode: "deferred" });
const zip = new AdmZip(buffer);
const packages = JSON.parse(zip.getEntry("overview/packages.json")!.getData().toString("utf8"));
const items = JSON.parse(zip.getEntry("overview/items.json")!.getData().toString("utf8"));
const history = JSON.parse(zip.getEntry("overview/history.json")!.getData().toString("utf8"));
const overviewText = [packages, items, history].map((value) => JSON.stringify(value)).join("\n");
expect(packages.packages[0]).toMatchObject({
id: "package-private-id",
name: "package-001.zip",
status: "downloading",
downloadedBytes: 750,
totalBytes: 1_500
});
expect(items.items[0]).toMatchObject({
id: "item-private-id",
packageId: "package-private-id",
fileName: "item-001.rar",
status: "downloading",
downloadedBytes: 250,
totalBytes: 1_000
});
expect(history.entries[0]).toMatchObject({
id: "history-private-id",
name: "history-001.iso",
status: "completed",
downloadedBytes: 8_000,
totalBytes: 8_000
});
expect(overviewText).not.toContain("Private Series Collection.zip");
expect(overviewText).not.toContain("Private.Show.S01E01.part1.rar");
expect(overviewText).not.toContain("Private Linux Collection.iso");
});
it("adds provider runtime diagnostics with pool-local aliases and no internal account or key identifiers", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-provider-runtime-"));
tempDirs.push(root);
const disabledApiAccountId = getMegaDebridAccountId("beta-login");
const disabledDebridKeyId = getDebridLinkApiKeyId("debrid-token-two");
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
megaDebridApiCredentials: "alpha-login:alpha-password\nbeta-login:beta-password",
megaDebridWebCredentials: "gamma-login:gamma-password",
megaDebridApiEnabled: true,
megaDebridWebEnabled: false,
megaDebridApiDisabledAccountIds: [disabledApiAccountId],
debridLinkApiKeys: "debrid-token-one,debrid-token-two",
debridLinkDisabledKeyIds: [disabledDebridKeyId]
}), "utf8");
const apiAccountKey = `${getMegaDebridAccountId("alpha-login")}:api`;
const debridKeyId = getDebridLinkApiKeyId("debrid-token-one");
primeMegaDebridRuntimeCooldownForTests(apiAccountKey, 60_000, "private account cooldown detail");
primeMegaDebridInFlightForTests(apiAccountKey, 2);
primeDebridLinkRuntimeCooldownForTests(debridKeyId, 45_000, "private key cooldown detail");
const buffer = await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none", debugSetupMode: "deferred" });
const runtime = JSON.parse(new AdmZip(buffer).getEntry("overview/runtime-diagnostics.json")!.getData().toString("utf8"));
const providerText = JSON.stringify(runtime.providerRuntime);
expect(runtime.providerRuntime).toMatchObject({
megaDebrid: {
rotationCursor: 0,
pools: {
api: {
configuredCount: 2,
activeCount: 1,
disabledCount: 1,
inFlight: 2,
accounts: [{
account: "Account 1/2",
inFlight: 2,
cooldown: {
category: "temporary"
}
}]
},
web: {
configuredCount: 1,
activeCount: 0,
enabled: false,
inFlight: 0
}
}
},
debridLink: {
configuredCount: 2,
activeCount: 1,
disabledCount: 1,
keys: [{
account: "Key 1/2",
cooldown: {
category: "temporary"
}
}]
}
});
expect(runtime.providerRuntime.megaDebrid.pools.api.accounts[0].cooldown.remainingMs).toBeGreaterThan(0);
expect(runtime.providerRuntime.debridLink.keys[0].cooldown.remainingMs).toBeGreaterThan(0);
for (const forbidden of [
"alpha-login",
"beta-login",
"gamma-login",
"debrid-token-one",
"debrid-token-two",
getMegaDebridAccountId("alpha-login"),
debridKeyId
]) {
expect(providerText).not.toContain(forbidden);
}
});
it("includes runtime rotation, disk-wait, export-phase and resume-recovery diagnostics", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
const snapshot = {
stats: {},
rotationEvents: [{
id: "rotation-1",
at: 1_234,
level: "WARN",
provider: "Mega-Debrid Web",
accountLabel: "Account 2/3 (be***ta)",
event: "FAILED",
reason: "timeout",
next: "Account 3/3 (ga***ma)"
}],
diskWaitEvents: [{
phase: "download",
ownerId: "item-resume",
itemId: "item-resume",
packageId: "package-resume",
volumeKey: "C:",
requiredBytes: 2_048,
availableBytes: 1_024,
deficitBytes: 1_024,
retryAt: 2_000
}],
session: {
version: 1,
packageOrder: ["package-resume"],
packages: {
"package-resume": {
id: "package-resume",
name: "Resume",
status: "queued",
itemIds: ["item-resume"],
cancelled: false,
enabled: true,
createdAt: 1,
updatedAt: 2
}
},
items: {
"item-resume": {
id: "item-resume",
packageId: "package-resume",
url: "https://rapidgator.net/file/example",
provider: "megadebrid-web",
status: "queued",
retries: 2,
speedBps: 0,
downloadedBytes: 1_024,
totalBytes: 2_048,
progressPercent: 50,
fileName: "resume.bin",
targetPath: "C:\\Downloads\\resume.bin",
resumable: true,
attempts: 1,
lastError: "range_ignored_on_resume:1024/2048",
fullStatus: "Warte auf Teildatei-Freigabe",
resumeLinkRenewalFailures: 2,
resumeHardResetUsed: false,
resumeResetPending: true,
createdAt: 1,
updatedAt: 2,
onlineStatus: "online"
}
},
runStartedAt: 1,
totalDownloadedBytes: 1_024,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: true,
updatedAt: 2
},
speedText: "Geschwindigkeit: 0 B/s",
etaText: "ETA: --",
canStart: false,
canStop: true,
canPause: true
};
const manager = {
getSnapshot: () => snapshot,
getPackageLogPath: () => null,
getItemLogPath: () => null
} as unknown as DownloadManager;
const buffer = await buildSupportBundle(manager, root, { hostDiagnosticsMode: "none", debugSetupMode: "deferred" });
const zip = new AdmZip(buffer);
const runtimeDiagnostics = JSON.parse(zip.getEntry("overview/runtime-diagnostics.json")!.getData().toString("utf8"));
const itemDiagnostics = JSON.parse(zip.getEntry("overview/items.json")!.getData().toString("utf8"));
expect(runtimeDiagnostics).toMatchObject({
bundleBuild: {
state: "building",
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
},
rotationEvents: [{
provider: "Mega-Debrid Web",
accountLabel: "Account 2/3 (<redacted-account>)",
event: "FAILED",
reason: "timeout"
}],
diskWaitEvents: [{
phase: "download",
itemId: "item-resume",
deficitBytes: 1_024
}]
});
expect(runtimeDiagnostics.bundleBuild.startedAt).toEqual(expect.any(String));
expect(itemDiagnostics.items[0]).toMatchObject({
id: "item-resume",
resumeLinkRenewalFailures: 2,
resumeHardResetUsed: false,
resumeResetPending: true
});
});
it("does not block the event loop while building (a concurrent timer still fires)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
let timerFired = false;
const timer = setTimeout(() => { timerFired = true; }, 0);
await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
clearTimeout(timer);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(timerFired).toBe(true);
});
it("includes only recent directory logs without live duplicates or log getter side effects", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
const recentLog = path.join(itemLogs, "recent.txt");
const oldLog = path.join(itemLogs, "old.txt");
fs.writeFileSync(recentLog, "recent", "utf8");
fs.writeFileSync(oldLog, "old", "utf8");
const oldTimestamp = new Date(Date.now() - 9 * 60 * 60 * 1000);
fs.utimesSync(oldLog, oldTimestamp, oldTimestamp);
const buffer = await buildSupportBundle(populatedFakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
expect(entries).toContain("logs/item-logs/recent.txt");
expect(entries).not.toContain("logs/item-logs/old.txt");
expect(entries.some((entry) => entry.startsWith("logs/live/"))).toBe(false);
});
it("includes each physical session log only once", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
initSessionLog(root);
const sessionLogPath = getSessionLogPath();
expect(sessionLogPath).not.toBeNull();
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
const sessionEntries = entries.filter((entry) => (
entry === "logs/session.log" || entry.startsWith("logs/session-logs/")
));
expect(sessionEntries).toHaveLength(1);
});
it("flushes every pending logger before reading bundle files", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-flush-"));
tempDirs.push(root);
flushLoggerSync();
configureLogger(root);
initSessionLog(root);
initPackageLogs(root);
initItemLogs(root);
initTraceLog(root);
setTraceEnabled(true, "bundle-flush-test", 0);
ensurePackageLog({
packageId: "package-flush",
name: "Flush Package",
outputDir: path.join(root, "output"),
extractDir: path.join(root, "extract")
});
ensureItemLog({
itemId: "item-flush",
packageId: "package-flush",
packageName: "Flush Package",
fileName: "flush.bin",
targetPath: path.join(root, "output", "flush.bin")
});
logger.info("main-buffer-marker");
logPackageEvent("package-flush", "INFO", "package-buffer-marker");
logItemEvent("item-flush", "INFO", "item-buffer-marker");
logTraceEvent("INFO", "support", "trace-buffer-marker");
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const zip = new AdmZip(buffer);
const packageEntry = zip.getEntries().find((entry) => entry.entryName.startsWith("logs/package-logs/"));
const itemEntry = zip.getEntries().find((entry) => entry.entryName.startsWith("logs/item-logs/"));
const entryNames = zip.getEntries().map((entry) => entry.entryName);
expect(zip.getEntry("logs/rd_downloader.log")?.getData().toString("utf8") || "").toContain("main-buffer-marker");
expect(zip.getEntry("logs/session.log")?.getData().toString("utf8") || "").toContain("main-buffer-marker");
expect(zip.getEntry("logs/trace.log")?.getData().toString("utf8") || "").toContain("trace-buffer-marker");
expect(packageEntry, entryNames.join("\n")).toBeDefined();
expect(itemEntry, entryNames.join("\n")).toBeDefined();
expect(packageEntry?.getData().toString("utf8") || "").toContain("package-buffer-marker");
expect(itemEntry?.getData().toString("utf8") || "").toContain("item-buffer-marker");
});
it("bounds recent item logs to the newest diagnostic files", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
const baseTime = Date.now() - 60_000;
for (let index = 0; index < 365; index += 1) {
const filePath = path.join(itemLogs, `item-${String(index).padStart(3, "0")}.txt`);
fs.writeFileSync(filePath, String(index), "utf8");
const timestamp = new Date(baseTime + index);
fs.utimesSync(filePath, timestamp, timestamp);
}
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
const itemEntries = entries.filter((entry) => entry.startsWith("logs/item-logs/"));
expect(itemEntries).toHaveLength(16);
expect(itemEntries).not.toContain("logs/item-logs/item-000.txt");
expect(itemEntries).toContain("logs/item-logs/item-364.txt");
});
it("prioritizes active package and item logs beyond the bounded directory scan", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-priority-"));
tempDirs.push(root);
const packageLogs = path.join(root, "package-logs");
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(packageLogs, { recursive: true });
fs.mkdirSync(itemLogs, { recursive: true });
for (let index = 0; index < 2_048; index += 1) {
const name = `${String(index).padStart(4, "0")}-filler.log`;
fs.writeFileSync(path.join(packageLogs, name), "package filler", "utf8");
fs.writeFileSync(path.join(itemLogs, name), "item filler", "utf8");
}
initPackageLogs(root);
initItemLogs(root);
ensurePackageLog({
packageId: "zzzz-active-package",
name: "Active Package",
outputDir: path.join(root, "output"),
extractDir: path.join(root, "extract")
});
ensureItemLog({
itemId: "zzzz-active-item",
packageId: "zzzz-active-package",
packageName: "Active Package",
fileName: "active.bin",
targetPath: path.join(root, "output", "active.bin")
});
logPackageEvent("zzzz-active-package", "INFO", "active-package-marker");
logItemEvent("zzzz-active-item", "INFO", "active-item-marker");
const snapshot = {
stats: {},
session: {
version: 1,
packageOrder: ["zzzz-active-package"],
packages: {
"zzzz-active-package": {
id: "zzzz-active-package",
name: "Active Package",
status: "downloading",
itemIds: ["zzzz-active-item"],
cancelled: false,
enabled: true,
createdAt: 1,
updatedAt: 2
}
},
items: {
"zzzz-active-item": {
id: "zzzz-active-item",
packageId: "zzzz-active-package",
url: "https://files.example.test/active",
status: "downloading",
retries: 0,
speedBps: 1,
downloadedBytes: 1,
totalBytes: 2,
progressPercent: 50,
fileName: "active.bin",
targetPath: path.join(root, "output", "active.bin"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Lädt",
createdAt: 1,
updatedAt: 2,
onlineStatus: "online"
}
},
runStartedAt: 1,
totalDownloadedBytes: 1,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: true,
updatedAt: 2
},
speedText: "Geschwindigkeit: 1 B/s",
etaText: "ETA: 1s",
canStart: false,
canStop: true,
canPause: true
};
const manager = {
getSnapshot: () => snapshot,
getPackageLogPath: () => { throw new Error("bundle export must not create package logs"); },
getItemLogPath: () => { throw new Error("bundle export must not create item logs"); }
} as unknown as DownloadManager;
const buffer = await buildSupportBundle(manager, root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const zip = new AdmZip(buffer);
const packageEntries = zip.getEntries().filter((entry) => entry.entryName.startsWith("logs/package-logs/"));
const itemEntries = zip.getEntries().filter((entry) => entry.entryName.startsWith("logs/item-logs/"));
const packageText = packageEntries.map((entry) => entry.getData().toString("utf8")).join("\n");
const itemText = itemEntries.map((entry) => entry.getData().toString("utf8")).join("\n");
expect(packageText).toContain("active-package-marker");
expect(itemText).toContain("active-item-marker");
expect(packageEntries.length).toBeLessThanOrEqual(8);
expect(itemEntries.length).toBeLessThanOrEqual(16);
}, 15_000);
it("redacts active DTOs, runtime text and logs at the ZIP boundary", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-"));
tempDirs.push(root);
const escapedSecret = "prefix\"suffix\\trail\tend";
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
megaDebridWebCredentials: `primary-user:primary-password-secret\nsecondary-user:secondary-password-secret\nZ9:Q7!\nAlice:${escapedSecret}`,
debridLinkApiKeys: "abc123456789xyz,def987654321uvw",
megaDebridWebEnabled: true
}), "utf8");
initAccountRotationLog(root);
logAccountRotation("INFO", "Debrid-Link", "Key 1 (abc*********xyz)", "TEST");
logAccountRotation("WARN", "Debrid-Link", "Key 2 (def*********uvw)", "FAILED", { reason: "fixture" });
logAccountRotation("WARN", "Mega-Debrid", "Account 9/9 (Re*******er)", "FAILED", { next: "Account 8/9 (Al*******ce)" });
logAccountRotation("WARN", "Debrid-Link", "Key 9/9 (old***********ken)", "FAILED", { next: "Key 8/9 (ret***********key)" });
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(path.join(itemLogs, "token=filename-secret.log"), [
"Authorization: Bearer log-bearer-secret",
"Cookie: session=log-cookie-secret; auth=second-cookie-secret",
"username=account-secret",
"password=log-password-secret",
"api_key=log-api-key-secret",
"provider rejected secondary-password-secret during rotation",
"rotation rejected account Z9 before fallback Q7!",
"rotation tried Mega account Z*",
"https://log-user:log-pass@files.example.test/archive?token=log-query-secret#log-fragment-secret",
"https://private-support-host.invalid/archive?id=private-resource",
"https://private.example/Alice/after-alice-url-segment?token=x",
"C:\\Users\\Alice\\Downloads\\Private Collection\\private.bin",
"C:/Users/Alice/Downloads/Private Collection/forward-private.bin",
"C:/Users/Alice/after-alice-path-segment/private.bin",
"file:///C:/Users/Alice/Downloads/Private%20Collection/file-private.bin",
JSON.stringify({ password: escapedSecret })
].join("\n"), "utf8");
fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({
authorization: "Bearer manifest-bearer-secret",
supportManifestPath: "C:\\Users\\Alice\\AppData\\debug_support_manifest.json",
endpoint: "https://support.example.test/check?api_key=manifest-query-secret#manifest-fragment"
}), "utf8");
const buffer = await buildSupportBundle(sensitiveActiveManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const zip = new AdmZip(buffer);
const archiveText = zip.getEntries()
.map((entry) => `${entry.entryName}\n${entry.getData().toString("utf8")}`)
.join("\n");
const forbidden = [
root,
"C:\\Users\\Alice",
"url-user",
"url-pass",
"query-secret-0",
"fragment-secret",
"item-bearer-secret",
"item-cookie-secret",
"summary-secret",
"reconnect-secret",
"cleaned-secret",
"filename-secret",
"log-bearer-secret",
"log-cookie-secret",
"second-cookie-secret",
"account-secret",
"log-password-secret",
"log-api-key-secret",
"primary-password-secret",
"secondary-password-secret",
"Z9",
"Q7!",
"Z*",
"abc*********xyz",
"def*********uvw",
"Re*******er",
"Al*******ce",
"old***********ken",
"ret***********key",
escapedSecret,
JSON.stringify(escapedSecret).slice(1, -1),
"log-query-secret",
"log-fragment-secret",
"private-support-host.invalid",
"after-alice-url-segment",
"after-alice-path-segment",
"forward-private.bin",
"file-private.bin",
"manifest-bearer-secret",
"manifest-query-secret",
"manifest-fragment"
];
for (const secret of forbidden) {
expect(archiveText).not.toContain(secret);
}
expect(archiveText).toContain("<redacted>");
expect(archiveText).toContain("<local-path>");
const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as {
items?: Array<Record<string, unknown>>;
};
expect(itemOverview.items?.[0]).toMatchObject({ sourceHost: "files.example.test" });
expect(itemOverview.items?.[0]).not.toHaveProperty("url");
});
it("redacts slash-escaped URLs at the ZIP boundary after credentials change", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-escaped-url-"));
tempDirs.push(root);
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(
path.join(itemLogs, "escaped-url.log"),
String.raw`{"url":"https:\/\/legacy-user:p!7@escaped-private-host.invalid\/secret?value=private"}`,
"utf8"
);
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const text = new AdmZip(buffer).getEntry("logs/item-logs/escaped-url.log")?.getData().toString("utf8") || "";
expect(text).toContain("<redacted-url>");
expect(text).not.toContain("legacy-user");
expect(text).not.toContain("p!7");
expect(text).not.toContain("escaped-private-host.invalid");
});
it("redacts historical comma-style account labels at the ZIP boundary", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-account-label-"));
tempDirs.push(root);
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(path.join(itemLogs, "historical-labels.log"), [
"Mega-Debrid (Account 1/3, Hi*******cal): uebersprungen",
"Debrid-Link (Key 2/4, old********value): fehlgeschlagen"
].join("\n"), "utf8");
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const text = new AdmZip(buffer).getEntry("logs/item-logs/historical-labels.log")?.getData().toString("utf8") || "";
expect(text).toContain("Account 1/3, <redacted-account>");
expect(text).toContain("Key 2/4, <redacted-account>");
expect(text).not.toContain("Hi*******cal");
expect(text).not.toContain("old********value");
});
it("keeps static archive directories stable when a short credential matches their name", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-static-path-"));
tempDirs.push(root);
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
megaDebridWebCredentials: "archive-user:logs"
}), "utf8");
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(path.join(itemLogs, "recent.log"), "diagnostic", "utf8");
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
expect(entries).toContain("logs/item-logs/recent.log");
});
it("keeps separately redacted log filenames distinct", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-archive-names-"));
tempDirs.push(root);
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(path.join(itemLogs, "abcdefghijklmnopqrstuvwxyz1234567890-one.log"), "first-log-marker", "utf8");
fs.writeFileSync(path.join(itemLogs, "abcdefghijklmnopqrstuvwxyz1234567890-two.log"), "second-log-marker", "utf8");
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const entries = new AdmZip(buffer).getEntries().filter((entry) => entry.entryName.startsWith("logs/item-logs/"));
const text = entries.map((entry) => entry.getData().toString("utf8")).join("\n");
expect(entries).toHaveLength(2);
expect(new Set(entries.map((entry) => entry.entryName)).size).toBe(2);
expect(text).toContain("first-log-marker");
expect(text).toContain("second-log-marker");
});
it("bounds active DTOs and recent log tails while keeping the event loop responsive", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-load-"));
tempDirs.push(root);
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
const payload = `${"x".repeat(512 * 1024)}\napi_key=tail-secret`;
for (let index = 0; index < 48; index += 1) {
fs.writeFileSync(path.join(itemLogs, `active-${String(index).padStart(2, "0")}.log`), payload, "utf8");
}
const oldRotatedLog = path.join(itemLogs, "rotated.log.old");
fs.writeFileSync(oldRotatedLog, "api_key=old-secret", "utf8");
const oldTimestamp = new Date(Date.now() - 9 * 60 * 60 * 1000);
fs.utimesSync(oldRotatedLog, oldTimestamp, oldTimestamp);
const timerGaps: number[] = [];
let lastTick = Date.now();
const timer = setInterval(() => {
const now = Date.now();
timerGaps.push(now - lastTick);
lastTick = now;
}, 5);
const buffer = await buildSupportBundle(sensitiveActiveManager(1_800), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
clearInterval(timer);
const zip = new AdmZip(buffer);
const logEntries = zip.getEntries().filter((entry) => entry.entryName.startsWith("logs/item-logs/"));
const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as {
count?: number;
included?: number;
omitted?: number;
items?: unknown[];
};
const totalUncompressedBytes = zip.getEntries().reduce((sum, entry) => sum + entry.getData().length, 0);
expect(logEntries.length).toBeLessThanOrEqual(16);
expect(Math.max(...logEntries.map((entry) => entry.getData().length))).toBeLessThanOrEqual(256 * 1024);
expect(logEntries.some((entry) => entry.entryName.includes("rotated"))).toBe(false);
expect(itemOverview.count).toBe(1_800);
expect(itemOverview.items?.length).toBeLessThanOrEqual(500);
expect(itemOverview.omitted).toBeGreaterThan(0);
expect(totalUncompressedBytes).toBeLessThan(8 * 1024 * 1024);
expect(timerGaps.length).toBeGreaterThan(2);
expect(Math.max(...timerGaps)).toBeLessThan(100);
}, 15_000);
});
describe("support bundle export runner", () => {
it("emits busy, cancel, build, write and success lifecycle phases with deterministic durations", async () => {
let now = 0;
let releaseBuild: (buffer: Buffer) => void = () => undefined;
let signalBuildStarted: () => void = () => undefined;
const buildStarted = new Promise<void>((resolve) => { signalBuildStarted = resolve; });
const buildPending = new Promise<Buffer>((resolve) => { releaseBuild = resolve; });
const lifecycle: SupportBundleExportLifecycleEvent[] = [];
let chooseCount = 0;
const run = createSupportBundleExportRunner({
now: () => now,
chooseFile: async () => {
chooseCount += 1;
now += 5;
return chooseCount === 1 ? "C:\\Private\\support.zip" : null;
},
build: async () => {
signalBuildStarted();
const buffer = await buildPending;
now += 20;
return buffer;
},
write: async () => {
now += 30;
},
onLifecycle: (event) => {
lifecycle.push(event);
}
});
const first = run();
await buildStarted;
await expect(run()).resolves.toMatchObject({ saved: false, busy: true });
releaseBuild(Buffer.from("zip"));
await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: "C:\\Private\\support.zip" });
await expect(run()).resolves.toEqual({ saved: false, busy: false });
expect(lifecycle).toEqual([
{ phase: "busy", durationMs: 0, totalDurationMs: 0 },
{ phase: "build", durationMs: 20, totalDurationMs: 25, bytes: 3 },
{ phase: "write", durationMs: 30, totalDurationMs: 55, bytes: 3 },
{ phase: "success", durationMs: 55, totalDurationMs: 55, bytes: 3 },
{ phase: "cancel", durationMs: 5, totalDurationMs: 5 }
]);
});
it("reports a path-free failure phase and duration when writing fails", async () => {
let now = 100;
const lifecycle: SupportBundleExportLifecycleEvent[] = [];
const failures: unknown[] = [];
const target = "C:\\Users\\Alice\\Desktop\\private-support.zip";
const run = createSupportBundleExportRunner({
now: () => now,
chooseFile: async () => {
now += 4;
return target;
},
build: async () => {
now += 6;
return Buffer.from("zip");
},
write: async () => {
now += 9;
throw Object.assign(new Error(`ENOSPC while writing ${target}`), { code: "ENOSPC" });
},
onLifecycle: (event) => {
lifecycle.push(event);
},
onFailure: (error) => {
failures.push(error);
}
});
await expect(run()).rejects.toMatchObject({
name: "SupportBundleExportError",
phase: "write",
durationMs: 19,
code: "ENOSPC"
});
expect(lifecycle).toEqual([
{ phase: "build", durationMs: 6, totalDurationMs: 10, bytes: 3 },
{
phase: "failure",
failedPhase: "write",
durationMs: 9,
totalDurationMs: 19,
code: "ENOSPC"
}
]);
expect(failures).toHaveLength(1);
expect(String((failures[0] as Error).message)).not.toContain(target);
expect(String((failures[0] as Error).message)).not.toContain("Alice");
});
it("returns a visible busy result for reentry without choosing another target", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
tempDirs.push(root);
const target = path.join(root, "support.zip");
let chooseCount = 0;
let releaseBuild: (buffer: Buffer) => void = () => undefined;
let signalBuildStarted: () => void = () => undefined;
const buildStarted = new Promise<void>((resolve) => { signalBuildStarted = resolve; });
const buildPending = new Promise<Buffer>((resolve) => { releaseBuild = resolve; });
const run = createSupportBundleExportRunner({
chooseFile: async () => {
chooseCount += 1;
return target;
},
build: async () => {
signalBuildStarted();
return buildPending;
},
write: async () => undefined
});
const first = run();
await buildStarted;
const second = await run();
expect(second).toEqual({
saved: false,
busy: true,
message: "Support-Bundle wird bereits erstellt."
});
expect(chooseCount).toBe(1);
releaseBuild(Buffer.from("zip"));
await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: target });
});
it("records the selected export target before bundle construction starts", async () => {
const phases: string[] = [];
const run = createSupportBundleExportRunner({
chooseFile: async () => {
phases.push("choose");
return "C:\\Temp\\support.zip";
},
onStart: ({ filePath }) => {
phases.push(`start:${path.basename(filePath)}`);
},
build: async () => {
phases.push("build");
return Buffer.from("zip");
},
write: async () => {
phases.push("write");
},
onSuccess: () => {
phases.push("success");
}
});
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: "C:\\Temp\\support.zip" });
expect(phases).toEqual(["choose", "start:support.zip", "build", "write", "success"]);
});
it("reports success only after the target write has completed", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
tempDirs.push(root);
const target = path.join(root, "support.zip");
let releaseWrite: () => void = () => undefined;
let signalWriteStarted: () => void = () => undefined;
let successCount = 0;
const writeStarted = new Promise<void>((resolve) => { signalWriteStarted = resolve; });
const writePending = new Promise<void>((resolve) => { releaseWrite = resolve; });
const run = createSupportBundleExportRunner({
chooseFile: async () => target,
build: async () => Buffer.from("zip"),
write: async () => {
signalWriteStarted();
await writePending;
},
onSuccess: async () => {
successCount += 1;
}
});
const result = run();
await writeStarted;
expect(successCount).toBe(0);
releaseWrite();
await expect(result).resolves.toEqual({ saved: true, busy: false, filePath: target });
expect(successCount).toBe(1);
});
it("releases the busy guard after an export failure", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
tempDirs.push(root);
const target = path.join(root, "support.zip");
let writeCount = 0;
const run = createSupportBundleExportRunner({
chooseFile: async () => target,
build: async () => Buffer.from("zip"),
write: async () => {
writeCount += 1;
if (writeCount === 1) {
throw new Error("write failed");
}
}
});
await expect(run()).rejects.toMatchObject({ name: "SupportBundleExportError", phase: "write" });
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target });
});
it("releases the busy guard after the target dialog is canceled", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
tempDirs.push(root);
const target = path.join(root, "support.zip");
let chooseCount = 0;
const run = createSupportBundleExportRunner({
chooseFile: async () => {
chooseCount += 1;
return chooseCount === 1 ? null : target;
},
build: async () => Buffer.from("zip"),
write: async () => undefined
});
await expect(run()).resolves.toEqual({ saved: false, busy: false });
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target });
});
it("replaces the target atomically without leaving temporary files", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
tempDirs.push(root);
const target = path.join(root, "support.zip");
fs.writeFileSync(target, "old", "utf8");
await writeSupportBundleAtomically(target, Buffer.from("new"));
expect(fs.readFileSync(target, "utf8")).toBe("new");
expect(fs.readdirSync(root)).toEqual(["support.zip"]);
});
});