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 ()", 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(""); expect(archiveText).toContain(""); const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as { items?: Array>; }; 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(""); 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, "); expect(text).toContain("Key 2/4, "); 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((resolve) => { signalBuildStarted = resolve; }); const buildPending = new Promise((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((resolve) => { signalBuildStarted = resolve; }); const buildPending = new Promise((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((resolve) => { signalWriteStarted = resolve; }); const writePending = new Promise((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"]); }); });