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.
This commit is contained in:
@@ -1,8 +1,28 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
|
||||
import type { RotationEvent } from "../src/shared/types";
|
||||
|
||||
describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, it, expect } from "vitest";
|
||||
import {
|
||||
getAccountRotationLogPath,
|
||||
getRecentRotationEvents,
|
||||
initAccountRotationLog,
|
||||
logAccountRotation,
|
||||
runWithRotationItemSink,
|
||||
shutdownAccountRotationLog,
|
||||
type CorrelatedRotationEvent
|
||||
} from "../src/main/account-rotation-log";
|
||||
import type { RotationEvent } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
shutdownAccountRotationLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
|
||||
const captured: RotationEvent[] = [];
|
||||
await runWithRotationItemSink((ev) => captured.push(ev), async () => {
|
||||
@@ -15,10 +35,110 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
|
||||
const events = captured.map((e) => e.event);
|
||||
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
|
||||
const failed = captured.find((e) => e.event === "FAILED");
|
||||
expect(failed?.reason).toBe("Timeout");
|
||||
expect(failed?.next).toBe("Account 2/3 (cd**zw)");
|
||||
});
|
||||
const failed = captured.find((e) => e.event === "FAILED");
|
||||
expect(failed?.reason).toBe("Timeout");
|
||||
expect(failed?.accountLabel).toBe("Account 1/3");
|
||||
expect(failed?.next).toBe("Account 2/3");
|
||||
});
|
||||
|
||||
it("removes account identities, credentials and source URLs before events reach a sink or ring", async () => {
|
||||
const captured: RotationEvent[] = [];
|
||||
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
|
||||
await runWithRotationItemSink((event) => captured.push(event), async () => {
|
||||
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (al***ce)", "FAILED", {
|
||||
reason: `Incorrect password for alice@example.test password=provider-secret masked=al***ce@identity.invalid source=${sourceUrl}`,
|
||||
category: "invalid",
|
||||
cooldownSec: 30,
|
||||
next: "Account 2/3 (bo***ob)",
|
||||
token: "direct-token-secret",
|
||||
authorization: "Bearer direct-bearer-secret",
|
||||
credentials: {
|
||||
username: "nested-user",
|
||||
password: "nested-password-secret",
|
||||
apiKey: "nested-api-key-secret",
|
||||
sourceUrl
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const event = captured[0];
|
||||
const serialized = JSON.stringify(event);
|
||||
expect(event).toMatchObject({
|
||||
accountLabel: "Account 1/3",
|
||||
category: "invalid",
|
||||
cooldownSec: 30,
|
||||
next: "Account 2/3"
|
||||
});
|
||||
expect(event.reason).toContain("Incorrect password");
|
||||
expect(event.reason).toMatch(/files\.example\.test#[a-f0-9]{10}/);
|
||||
expect(serialized).not.toContain("alice@example.test");
|
||||
expect(serialized).not.toContain("provider-secret");
|
||||
expect(serialized).not.toContain("source-user");
|
||||
expect(serialized).not.toContain("source-pass");
|
||||
expect(serialized).not.toContain("query-secret");
|
||||
expect(serialized).not.toContain("al***ce");
|
||||
expect(serialized).not.toContain("bo***ob");
|
||||
for (const sensitive of ["identity.invalid", "direct-token-secret", "direct-bearer-secret", "nested-user", "nested-password-secret", "nested-api-key-secret"]) {
|
||||
expect(serialized).not.toContain(sensitive);
|
||||
}
|
||||
expect(JSON.stringify(getRecentRotationEvents(10))).not.toContain("provider-secret");
|
||||
});
|
||||
|
||||
it("writes only anonymous accounts and fingerprinted links to the account rotation log", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rotation-safety-"));
|
||||
tempDirs.push(root);
|
||||
const sourceUrl = "https://log-user:log-pass@rapidgator.net/file/private?token=rotation-secret";
|
||||
initAccountRotationLog(root);
|
||||
|
||||
logAccountRotation("WARN", "Mega-Debrid API", "Account 1/2 (lo***in)", "FAILED", {
|
||||
reason: `Unauthorized login=private-login password=private-password at ${sourceUrl}`,
|
||||
category: "invalid",
|
||||
link: sourceUrl,
|
||||
next: "Account 2/2 (ne***xt)",
|
||||
token: "direct-log-token-secret",
|
||||
authorization: "Bearer direct-log-bearer-secret",
|
||||
credentials: {
|
||||
username: "nested-log-user",
|
||||
password: "nested-log-password-secret",
|
||||
apiKey: "nested-log-api-key-secret",
|
||||
sourceUrl
|
||||
}
|
||||
});
|
||||
|
||||
const logPath = getAccountRotationLogPath();
|
||||
expect(logPath).not.toBeNull();
|
||||
shutdownAccountRotationLog();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("Account 1/2 | FAILED");
|
||||
expect(content).toContain("category=invalid");
|
||||
expect(content).toMatch(/link=rapidgator\.net#[a-f0-9]{10}/);
|
||||
expect(content).toContain("next=Account 2/2");
|
||||
for (const sensitive of ["log-user", "log-pass", "rotation-secret", "private-login", "private-password", "lo***in", "ne***xt", "direct-log-token-secret", "direct-log-bearer-secret", "nested-log-user", "nested-log-password-secret", "nested-log-api-key-secret", sourceUrl]) {
|
||||
expect(content).not.toContain(sensitive);
|
||||
}
|
||||
});
|
||||
|
||||
it("writes the active correlation IDs into the account rotation log", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rotation-correlation-"));
|
||||
tempDirs.push(root);
|
||||
initAccountRotationLog(root);
|
||||
|
||||
await runWithRotationItemSink(
|
||||
() => undefined,
|
||||
async () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 1/1", "OK");
|
||||
},
|
||||
{ attemptId: "attempt-log", itemId: "item-log", packageId: "package-log" }
|
||||
);
|
||||
|
||||
const logPath = getAccountRotationLogPath();
|
||||
expect(logPath).not.toBeNull();
|
||||
shutdownAccountRotationLog();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("attemptId=attempt-log");
|
||||
expect(content).toContain("itemId=item-log");
|
||||
expect(content).toContain("packageId=package-log");
|
||||
});
|
||||
|
||||
it("does not leak events to the sink outside the run() scope", () => {
|
||||
const captured: RotationEvent[] = [];
|
||||
@@ -26,7 +146,7 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
expect(captured).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("isolates two parallel item sinks (no cross-attribution)", async () => {
|
||||
it("isolates two parallel item sinks (no cross-attribution)", async () => {
|
||||
const a: RotationEvent[] = [];
|
||||
const b: RotationEvent[] = [];
|
||||
await Promise.all([
|
||||
@@ -44,15 +164,51 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
|
||||
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
|
||||
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
|
||||
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
|
||||
});
|
||||
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
|
||||
});
|
||||
|
||||
it("attaches and isolates optional attempt, item and package IDs across parallel rotations", async () => {
|
||||
const first: CorrelatedRotationEvent[] = [];
|
||||
const second: CorrelatedRotationEvent[] = [];
|
||||
|
||||
await Promise.all([
|
||||
runWithRotationItemSink(
|
||||
(event) => first.push(event),
|
||||
async () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/2", "TEST");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/2", "OK");
|
||||
},
|
||||
{ attemptId: "attempt-a", itemId: "item-a", packageId: "package-a" }
|
||||
),
|
||||
runWithRotationItemSink(
|
||||
(event) => second.push(event),
|
||||
async () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/2", "TEST");
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
logAccountRotation("WARN", "Mega-Debrid Web", "Account 2/2", "FAILED");
|
||||
},
|
||||
{ attemptId: "attempt-b", itemId: "item-b", packageId: "package-b" }
|
||||
)
|
||||
]);
|
||||
|
||||
expect(first).toHaveLength(2);
|
||||
expect(first.every((event) => event.attemptId === "attempt-a" && event.itemId === "item-a" && event.packageId === "package-a")).toBe(true);
|
||||
expect(second).toHaveLength(2);
|
||||
expect(second.every((event) => event.attemptId === "attempt-b" && event.itemId === "item-b" && event.packageId === "package-b")).toBe(true);
|
||||
|
||||
const correlatedRing = getRecentRotationEvents(10).filter((event) => event.attemptId === "attempt-a" || event.attemptId === "attempt-b");
|
||||
expect(correlatedRing).toHaveLength(4);
|
||||
expect(correlatedRing.filter((event) => event.attemptId === "attempt-a").every((event) => event.itemId === "item-a" && event.packageId === "package-a")).toBe(true);
|
||||
expect(correlatedRing.filter((event) => event.attemptId === "attempt-b").every((event) => event.itemId === "item-b" && event.packageId === "package-b")).toBe(true);
|
||||
});
|
||||
|
||||
it("feeds the global UI ring with TEST and outcome events", () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
||||
const ring = getRecentRotationEvents(10);
|
||||
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
||||
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
||||
const ring = getRecentRotationEvents(10);
|
||||
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9")).toBe(true);
|
||||
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9")).toBe(true);
|
||||
});
|
||||
|
||||
it("marks TIMEOUT_COOLDOWN as a failed attempt in the global UI ring without changing its event type", () => {
|
||||
@@ -62,7 +218,7 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
next: "Account 11 (yz)"
|
||||
});
|
||||
|
||||
const event = getRecentRotationEvents(10).find((entry) => entry.accountLabel === "Account 10 (xy)");
|
||||
const event = getRecentRotationEvents(10).find((entry) => entry.accountLabel === "Account 10");
|
||||
|
||||
expect(event).toMatchObject({
|
||||
event: "TIMEOUT_COOLDOWN",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAccountToggleSettingsUpdate,
|
||||
SerialTaskQueue,
|
||||
setAccountTargetEnabled,
|
||||
type AccountToggleTarget
|
||||
@@ -47,6 +48,8 @@ describe("account toggle queue", () => {
|
||||
let settings = {
|
||||
disabledProviders: [],
|
||||
debridLinkDisabledKeyIds: [],
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridApiDisabledAccountIds: [],
|
||||
megaDebridWebDisabledAccountIds: [...accountIds],
|
||||
megaDebridDisabledAccountIds: [...accountIds]
|
||||
@@ -60,4 +63,25 @@ describe("account toggle queue", () => {
|
||||
expect(settings.megaDebridWebDisabledAccountIds).toEqual([]);
|
||||
expect(settings.megaDebridDisabledAccountIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("reactivates a disabled Mega-Debrid mode when one of its accounts is enabled", () => {
|
||||
const settings = {
|
||||
disabledProviders: [],
|
||||
debridLinkDisabledKeyIds: [],
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridApiDisabledAccountIds: ["api-1"],
|
||||
megaDebridWebDisabledAccountIds: ["web-1"],
|
||||
megaDebridDisabledAccountIds: ["api-1", "web-1"]
|
||||
};
|
||||
|
||||
const next = setAccountTargetEnabled(settings, { kind: "mega-web", accountId: "web-1" }, true);
|
||||
const update = buildAccountToggleSettingsUpdate(next);
|
||||
|
||||
expect(next.megaDebridWebEnabled).toBe(true);
|
||||
expect(next.megaDebridApiEnabled).toBe(false);
|
||||
expect(update.megaDebridWebEnabled).toBe(true);
|
||||
expect(update.megaDebridApiEnabled).toBe(false);
|
||||
expect(update.megaDebridWebDisabledAccountIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/A
|
||||
import { AppHeader } from "../src/renderer/shell/AppHeader";
|
||||
import { AppShell } from "../src/renderer/shell/AppShell";
|
||||
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
||||
import { getSnapshotRenderDelay, runSupportBundleExportUi, SupportBundleToast } from "../src/renderer/App";
|
||||
import { getSnapshotRenderDelay, runResetUiAction, runSupportBundleExportUi, SupportBundleToast } from "../src/renderer/App";
|
||||
|
||||
describe("desktop shell", () => {
|
||||
it("uses keyboard-focusable controls for every copy target", () => {
|
||||
@@ -35,9 +35,78 @@ describe("desktop shell", () => {
|
||||
expect(removal).toContain('title: "Ausgewählte Links löschen"');
|
||||
});
|
||||
|
||||
it("renders active download telemetry at a stable half-second cadence", () => {
|
||||
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(500);
|
||||
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(500);
|
||||
it("does not add a second renderer debounce after main-process telemetry throttling", () => {
|
||||
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0);
|
||||
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(0);
|
||||
});
|
||||
|
||||
it("guards a pending reset, reconciles the snapshot, and releases the busy state", async () => {
|
||||
let finishReset: () => void = () => undefined;
|
||||
const pendingReset = new Promise<void>((resolve) => { finishReset = resolve; });
|
||||
const gate = { busy: false };
|
||||
const busyStates: boolean[] = [];
|
||||
const events: string[] = [];
|
||||
let resetCalls = 0;
|
||||
|
||||
const first = runResetUiAction({
|
||||
gate,
|
||||
reset: async () => {
|
||||
resetCalls += 1;
|
||||
await pendingReset;
|
||||
events.push("reset");
|
||||
},
|
||||
reconcile: async () => { events.push("reconcile"); },
|
||||
setBusy: (busy) => { busyStates.push(busy); },
|
||||
onError: () => { events.push("error"); }
|
||||
});
|
||||
const duplicate = await runResetUiAction({
|
||||
gate,
|
||||
reset: async () => { resetCalls += 1; },
|
||||
reconcile: async () => { events.push("duplicate-reconcile"); },
|
||||
setBusy: (busy) => { busyStates.push(busy); },
|
||||
onError: () => { events.push("duplicate-error"); }
|
||||
});
|
||||
|
||||
expect(duplicate).toBe("busy");
|
||||
expect(gate.busy).toBe(true);
|
||||
expect(resetCalls).toBe(1);
|
||||
expect(busyStates).toEqual([true]);
|
||||
|
||||
finishReset();
|
||||
await expect(first).resolves.toBe("completed");
|
||||
expect(events).toEqual(["reset", "reconcile"]);
|
||||
expect(busyStates).toEqual([true, false]);
|
||||
expect(gate.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("reports reset failures and still reconciles the authoritative snapshot", async () => {
|
||||
const gate = { busy: false };
|
||||
const busyStates: boolean[] = [];
|
||||
const errors: unknown[] = [];
|
||||
const events: string[] = [];
|
||||
const failure = new Error("Teildatei ist gesperrt");
|
||||
|
||||
await expect(runResetUiAction({
|
||||
gate,
|
||||
reset: async () => { throw failure; },
|
||||
reconcile: async () => { events.push("reconcile"); },
|
||||
setBusy: (busy) => { busyStates.push(busy); },
|
||||
onError: (error) => { errors.push(error); }
|
||||
})).resolves.toBe("failed");
|
||||
|
||||
expect(errors).toEqual([failure]);
|
||||
expect(events).toEqual(["reconcile"]);
|
||||
expect(busyStates).toEqual([true, false]);
|
||||
expect(gate.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("routes every renderer reset through the guarded authoritative workflow", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(source).not.toMatch(/window\.rd\.reset(?:Package|Items)\([^\n]*\.catch\(\(\) => \{\}\)/);
|
||||
expect(source.match(/performReset\(/g)).toHaveLength(3);
|
||||
expect(source).toContain('disabled={resetBusy}');
|
||||
expect(source).toContain('actionBusy: actionBusy || resetBusy');
|
||||
});
|
||||
|
||||
it("keeps support bundle progress tied to the unresolved export", async () => {
|
||||
|
||||
+42
-5
@@ -30,7 +30,7 @@ describe("audit-log", () => {
|
||||
expect(content).toContain("changedKeys");
|
||||
});
|
||||
|
||||
it("rotates oversized audit logs on startup", () => {
|
||||
it("rotates oversized audit logs on startup", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-rotate-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
@@ -42,7 +42,44 @@ describe("audit-log", () => {
|
||||
|
||||
expect(fs.existsSync(oversizedPath)).toBe(true);
|
||||
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
|
||||
const content = fs.readFileSync(oversizedPath, "utf8");
|
||||
expect(content).toContain("Audit-Log Start");
|
||||
});
|
||||
});
|
||||
const content = fs.readFileSync(oversizedPath, "utf8");
|
||||
expect(content).toContain("Audit-Log Start");
|
||||
});
|
||||
|
||||
it("redacts secrets, identities, direct links and local paths from audit logs", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-sensitive-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initAuditLog(baseDir);
|
||||
logAuditEvent(
|
||||
"ERROR",
|
||||
"Abruf https://rapidgator.net/file/private-id/archive.rar?token=url-secret für audit@example.com in C:\\Users\\Administrator\\Downloads\\archive.rar fehlgeschlagen",
|
||||
{
|
||||
directUrl: "https://rapidgator.net/file/private-id/archive.rar?token=url-secret",
|
||||
authorization: "Bearer authorization-secret-value",
|
||||
details: {
|
||||
password: "password-secret-value",
|
||||
cookie: "sid=cookie-secret-value",
|
||||
email: "audit@example.com",
|
||||
extractPath: "/var/lib/downloader/archive.rar"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const logPath = getAuditLogPath();
|
||||
expect(logPath).not.toBeNull();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toMatch(/rapidgator\.net#[a-f0-9]{10}/);
|
||||
expect(content).toContain("<redacted>");
|
||||
expect(content).toContain("<redacted-account>");
|
||||
expect(content).toContain("<redacted-path>");
|
||||
expect(content).not.toContain("private-id");
|
||||
expect(content).not.toContain("url-secret");
|
||||
expect(content).not.toContain("authorization-secret-value");
|
||||
expect(content).not.toContain("password-secret-value");
|
||||
expect(content).not.toContain("cookie-secret-value");
|
||||
expect(content).not.toContain("audit@example.com");
|
||||
expect(content).not.toContain("C:\\Users\\Administrator");
|
||||
expect(content).not.toContain("/var/lib/downloader");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { writeClipboardTextFromIpc } from "../src/main/clipboard-ipc";
|
||||
import type { TrustedIpcOptions } from "../src/main/ipc-security";
|
||||
|
||||
const electronMocks = vi.hoisted(() => ({
|
||||
writeText: vi.fn()
|
||||
}));
|
||||
const loggerMocks = vi.hoisted(() => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
clipboard: {
|
||||
writeText: electronMocks.writeText
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../src/main/logger", () => ({
|
||||
logger: loggerMocks
|
||||
}));
|
||||
|
||||
const trustedOptions: TrustedIpcOptions = {
|
||||
isPackaged: false,
|
||||
devServerUrl: "http://localhost:5180",
|
||||
appPath: "C:\\Program Files\\MDD"
|
||||
};
|
||||
|
||||
function eventFor(url: string) {
|
||||
return {
|
||||
senderFrame: { url },
|
||||
sender: {
|
||||
getURL: () => url
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
electronMocks.writeText.mockReset();
|
||||
loggerMocks.info.mockReset();
|
||||
loggerMocks.warn.mockReset();
|
||||
});
|
||||
|
||||
describe("clipboard IPC", () => {
|
||||
it("writes trusted renderer text through Electron clipboard", () => {
|
||||
const result = writeClipboardTextFromIpc(
|
||||
eventFor("http://localhost:5180/downloads"),
|
||||
"https://rapidgator.net/file/example",
|
||||
trustedOptions
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(electronMocks.writeText).toHaveBeenCalledOnce();
|
||||
expect(electronMocks.writeText).toHaveBeenCalledWith("https://rapidgator.net/file/example");
|
||||
expect(loggerMocks.info).toHaveBeenCalledWith("Zwischenablage geschrieben: bytes=35");
|
||||
expect(JSON.stringify(loggerMocks.info.mock.calls)).not.toContain("rapidgator.net");
|
||||
});
|
||||
|
||||
it("rejects untrusted renderer calls before touching the clipboard", () => {
|
||||
expect(() => writeClipboardTextFromIpc(
|
||||
eventFor("https://attacker.example/downloads"),
|
||||
"private value",
|
||||
trustedOptions
|
||||
)).toThrow("IPC-Absender ist nicht vertrauenswürdig");
|
||||
|
||||
expect(electronMocks.writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs native clipboard failures without the copied value", () => {
|
||||
electronMocks.writeText.mockImplementationOnce(() => {
|
||||
throw new Error("Clipboard busy");
|
||||
});
|
||||
|
||||
expect(() => writeClipboardTextFromIpc(
|
||||
eventFor("http://localhost:5180/downloads"),
|
||||
"private-link-value",
|
||||
trustedOptions
|
||||
)).toThrow("Clipboard busy");
|
||||
|
||||
expect(loggerMocks.warn).toHaveBeenCalledWith("Zwischenablage-Schreiben fehlgeschlagen: Error: Clipboard busy");
|
||||
expect(JSON.stringify(loggerMocks.warn.mock.calls)).not.toContain("private-link-value");
|
||||
});
|
||||
|
||||
it("rejects UTF-8 payloads larger than 16 MiB before touching the clipboard", () => {
|
||||
const oversizedText = "ä".repeat((8 * 1024 * 1024) + 1);
|
||||
|
||||
expect(Buffer.byteLength(oversizedText, "utf8")).toBe((16 * 1024 * 1024) + 2);
|
||||
expect(() => writeClipboardTextFromIpc(
|
||||
eventFor("http://localhost:5180/downloads"),
|
||||
oversizedText,
|
||||
trustedOptions
|
||||
)).toThrow("Ungültiger Zwischenablageinhalt");
|
||||
|
||||
expect(electronMocks.writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+130
-20
@@ -1,11 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatConversionBlock,
|
||||
hasActiveConversionTrace,
|
||||
runWithConversionTrace,
|
||||
traceConversionPhase,
|
||||
type ConversionTrace
|
||||
} from "../src/main/conversion-trace";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatConversionBlock,
|
||||
getConversionLogPath,
|
||||
hasActiveConversionTrace,
|
||||
initConversionLog,
|
||||
runWithConversionTrace,
|
||||
shutdownConversionLog,
|
||||
traceConversionPhase,
|
||||
type ConversionTrace
|
||||
} from "../src/main/conversion-trace";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
shutdownConversionLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("formatConversionBlock", () => {
|
||||
it("renders a header with verdict + total and one indented line per phase", () => {
|
||||
@@ -26,26 +41,89 @@ describe("formatConversionBlock", () => {
|
||||
|
||||
const lines = block.split("\n");
|
||||
expect(lines[0]).toContain("[CONV]");
|
||||
expect(lines[0]).toContain("item=tvs-foo.part5.rar");
|
||||
expect(lines[0]).toContain("itemId=id1");
|
||||
expect(lines[0]).not.toContain("tvs-foo.part5.rar");
|
||||
expect(lines[0]).not.toContain("rapidgator.net");
|
||||
expect(lines[0]).toContain("result=OK");
|
||||
expect(lines[0]).toContain("total=1450ms");
|
||||
expect(lines[0]).toContain("slots=conv2/dl6/max8");
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines[2]).toContain("+5ms token");
|
||||
expect(lines[2]).toContain("token=fresh");
|
||||
expect(lines[2]).toContain("+5ms token");
|
||||
expect(lines[2]).toContain("account=Account 2/2");
|
||||
expect(lines[2]).toContain("token=fresh");
|
||||
expect(lines[2]).toContain("workMs=812");
|
||||
});
|
||||
|
||||
it("includes the failure detail in the header verdict", () => {
|
||||
it("includes the failure detail in the header verdict", () => {
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: 0, itemId: "i", itemName: "x", link: "l", providerOrder: "megadebrid-web", notes: {},
|
||||
phases: [{ atMs: 60000, phase: "caller-timeout", provider: "megadebrid-web", outcome: "timeout", detail: "Unrestrict Timeout nach 60s" }]
|
||||
};
|
||||
const block = formatConversionBlock(trace, "FAIL", "Unrestrict Timeout nach 60s", 60003);
|
||||
expect(block.split("\n")[0]).toContain("result=FAIL (Unrestrict Timeout nach 60s)");
|
||||
expect(block).toContain("caller-timeout");
|
||||
});
|
||||
});
|
||||
expect(block).toContain("caller-timeout");
|
||||
});
|
||||
|
||||
it("keeps diagnostic value while removing identities, credentials and source URLs", () => {
|
||||
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: 0,
|
||||
itemId: "item-safe",
|
||||
itemName: "file.rar",
|
||||
link: sourceUrl,
|
||||
providerOrder: "megadebrid-web",
|
||||
notes: {
|
||||
retry: 1,
|
||||
auth: "login=trace-user password=trace-password",
|
||||
token: "note-token-secret",
|
||||
credentials: JSON.stringify({ login: "nested-trace-user", password: "nested-trace-password" })
|
||||
},
|
||||
phases: [{
|
||||
atMs: 25,
|
||||
phase: "mega-account",
|
||||
provider: "megadebrid-web",
|
||||
account: "Account 1/3 (tr***ce)",
|
||||
outcome: "failed",
|
||||
detail: `Incorrect password for trace@example.test token=provider-token masked=tr***ce@identity.invalid source=${sourceUrl}`
|
||||
}]
|
||||
};
|
||||
|
||||
const block = formatConversionBlock(trace, "FAIL", `Provider rejected password=header-secret at ${sourceUrl}`, 30);
|
||||
expect(block).toContain("result=FAIL");
|
||||
expect(block).toContain("Incorrect password");
|
||||
expect(block).toContain("account=Account 1/3");
|
||||
expect(block).toContain("itemId=item-safe");
|
||||
expect(block).not.toContain("files.example.test");
|
||||
for (const sensitive of ["source-user", "source-pass", "query-secret", "trace-user", "trace-password", "trace@example.test", "provider-token", "header-secret", "tr***ce", "identity.invalid", "note-token-secret", "nested-trace-user", "nested-trace-password", sourceUrl]) {
|
||||
expect(block).not.toContain(sensitive);
|
||||
}
|
||||
expect(block).not.toContain("https://");
|
||||
});
|
||||
|
||||
it("renders shared opaque correlation IDs without exposing item names or source links", () => {
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: 0,
|
||||
attemptId: "attempt-42",
|
||||
itemId: "item-42",
|
||||
packageId: "package-42",
|
||||
itemName: "Private.Release.Name.part1.rar",
|
||||
link: "https://private-user:private-password@rapidgator.net/file/private-token/Private.Release.Name.part1.rar",
|
||||
providerOrder: "megadebrid-web",
|
||||
notes: {},
|
||||
phases: []
|
||||
};
|
||||
|
||||
const block = formatConversionBlock(trace, "OK", "", 25);
|
||||
|
||||
expect(block).toContain("attemptId=attempt-42");
|
||||
expect(block).toContain("itemId=item-42");
|
||||
expect(block).toContain("packageId=package-42");
|
||||
expect(block).not.toContain("Private.Release.Name");
|
||||
expect(block).not.toContain("rapidgator.net");
|
||||
expect(block).not.toContain("private-token");
|
||||
expect(block).not.toContain("link=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversion trace context", () => {
|
||||
it("traceConversionPhase is a no-op outside an active trace and does not throw", () => {
|
||||
@@ -53,7 +131,7 @@ describe("conversion trace context", () => {
|
||||
expect(() => traceConversionPhase({ phase: "orphan" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("activates an ambient trace across awaits inside runWithConversionTrace", async () => {
|
||||
it("activates an ambient trace across awaits inside runWithConversionTrace", async () => {
|
||||
expect(hasActiveConversionTrace()).toBe(false);
|
||||
const seen = await runWithConversionTrace(
|
||||
{ itemId: "i", itemName: "n", link: "l", providerOrder: "megadebrid-api" },
|
||||
@@ -65,7 +143,39 @@ describe("conversion trace context", () => {
|
||||
return before && afterAwait;
|
||||
}
|
||||
);
|
||||
expect(seen).toBe(true);
|
||||
expect(hasActiveConversionTrace()).toBe(false);
|
||||
});
|
||||
});
|
||||
expect(seen).toBe(true);
|
||||
expect(hasActiveConversionTrace()).toBe(false);
|
||||
});
|
||||
|
||||
it("carries optional correlation IDs through the async trace into the written block", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-conversion-correlation-"));
|
||||
tempDirs.push(root);
|
||||
initConversionLog(root);
|
||||
|
||||
await runWithConversionTrace(
|
||||
{
|
||||
attemptId: "attempt-written",
|
||||
itemId: "item-written",
|
||||
packageId: "package-written",
|
||||
itemName: "Clear.Release.Name.rar",
|
||||
link: "https://rapidgator.net/file/clear-link-token/Clear.Release.Name.rar",
|
||||
providerOrder: "megadebrid-api"
|
||||
},
|
||||
async () => {
|
||||
traceConversionPhase({ phase: "chain-try", provider: "megadebrid-api" });
|
||||
await Promise.resolve();
|
||||
}
|
||||
);
|
||||
|
||||
const logPath = getConversionLogPath();
|
||||
expect(logPath).not.toBeNull();
|
||||
shutdownConversionLog();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("attemptId=attempt-written");
|
||||
expect(content).toContain("itemId=item-written");
|
||||
expect(content).toContain("packageId=package-written");
|
||||
expect(content).not.toContain("Clear.Release.Name");
|
||||
expect(content).not.toContain("rapidgator.net");
|
||||
expect(content).not.toContain("clear-link-token");
|
||||
});
|
||||
});
|
||||
|
||||
+421
-14
@@ -4,7 +4,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
||||
import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||
import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeDebridLinkRuntimeCooldownForTests, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -315,7 +315,7 @@ describe("debrid service", () => {
|
||||
expect(calledUrls.some((url) => url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rotates to the next Debrid-Link key when the first key is invalid", async () => {
|
||||
it("rotates to the next Debrid-Link key when the first key is invalid", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
||||
@@ -368,8 +368,23 @@ describe("debrid service", () => {
|
||||
expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]);
|
||||
expect(result.provider).toBe("debridlink");
|
||||
expect(result.providerLabel).toContain("Key 2");
|
||||
expect(result.directUrl).toBe("https://debrid-link.example/valid.bin");
|
||||
});
|
||||
expect(result.directUrl).toBe("https://debrid-link.example/valid.bin");
|
||||
});
|
||||
|
||||
it("clears Debrid-Link runtime cooldown when a key is reactivated live", () => {
|
||||
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
||||
debridLinkDisabledKeyIds: [keys[0].id]
|
||||
};
|
||||
primeDebridLinkRuntimeCooldownForTests(keys[0].id, 60_000, "stale cooldown");
|
||||
const service = new DebridService(settings);
|
||||
|
||||
service.setSettings({ ...settings, debridLinkDisabledKeyIds: [] });
|
||||
|
||||
expect(getDebridLinkKeyCooldownStateForTests(keys[0].id)).toBeNull();
|
||||
});
|
||||
|
||||
it("looks up limits and rotates keys when Debrid-Link host quota is reached", async () => {
|
||||
const settings = {
|
||||
@@ -1351,7 +1366,7 @@ describe("debrid service", () => {
|
||||
expect(realDebridWeb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats MegaDebrid as not configured when no credentials are set", async () => {
|
||||
it("treats MegaDebrid as not configured when no credentials are set", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaLogin: "",
|
||||
@@ -1363,10 +1378,125 @@ describe("debrid service", () => {
|
||||
};
|
||||
|
||||
const service = new DebridService(settings);
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-mega-web")).rejects.toThrow(/nicht konfiguriert/i);
|
||||
});
|
||||
|
||||
it("uses Mega web fallback when API fails", async () => {
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-mega-web")).rejects.toThrow(/nicht konfiguriert/i);
|
||||
});
|
||||
|
||||
it("keeps dedicated Mega-Debrid pools disabled when both explicit mode flags are false", async () => {
|
||||
const fetchSpy = vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("action=connectUser")) {
|
||||
return new Response(JSON.stringify({ response_code: "ok", token: "disabled-api-token" }), { status: 200 });
|
||||
}
|
||||
if (url.includes("action=getLink")) {
|
||||
return new Response(JSON.stringify({
|
||||
response_code: "ok",
|
||||
debridLink: "https://mega-cdn.example/disabled-api.rar",
|
||||
filename: "disabled-api.rar"
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchSpy as typeof fetch;
|
||||
const megaWeb = vi.fn(async () => ({
|
||||
fileName: "disabled-web.rar",
|
||||
directUrl: "https://mega-web.example/disabled-web.rar",
|
||||
fileSize: null,
|
||||
retriesUsed: 0
|
||||
}));
|
||||
|
||||
for (const preferApi of [true, false]) {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaLogin: "legacy-user",
|
||||
megaPassword: "legacy-pass",
|
||||
megaCredentials: "legacy-user:legacy-pass\napi-user:api-pass\nweb-user:web-pass",
|
||||
megaDebridApiCredentials: "api-user:api-pass",
|
||||
megaDebridWebCredentials: "web-user:web-pass",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridPreferApi: preferApi,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
await expect(service.unrestrictLink(`https://rapidgator.net/file/dedicated-disabled-${preferApi}`)).rejects.toThrow(/nicht konfiguriert/i);
|
||||
}
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(megaWeb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the preferred API fallback for legacy Mega-Debrid settings without dedicated pool fields", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaLogin: "legacy-api-user",
|
||||
megaPassword: "legacy-api-pass",
|
||||
megaCredentials: "legacy-api-user:legacy-api-pass",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridPreferApi: true,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
delete (settings as Partial<typeof settings>).megaDebridApiCredentials;
|
||||
delete (settings as Partial<typeof settings>).megaDebridWebCredentials;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("action=connectUser")) {
|
||||
return new Response(JSON.stringify({ response_code: "ok", token: "legacy-api-token" }), { status: 200 });
|
||||
}
|
||||
if (url.includes("action=getLink")) {
|
||||
return new Response(JSON.stringify({
|
||||
response_code: "ok",
|
||||
debridLink: "https://mega-cdn.example/legacy-api.rar",
|
||||
filename: "legacy-api.rar"
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
const result = await service.unrestrictLink("https://rapidgator.net/file/legacy-api");
|
||||
expect(result.directUrl).toBe("https://mega-cdn.example/legacy-api.rar");
|
||||
});
|
||||
|
||||
it("keeps the preferred Web fallback for legacy Mega-Debrid settings without dedicated pool fields", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaLogin: "legacy-web-user",
|
||||
megaPassword: "legacy-web-pass",
|
||||
megaCredentials: "legacy-web-user:legacy-web-pass",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridPreferApi: false,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
delete (settings as Partial<typeof settings>).megaDebridApiCredentials;
|
||||
delete (settings as Partial<typeof settings>).megaDebridWebCredentials;
|
||||
const megaWeb = vi.fn(async () => ({
|
||||
fileName: "legacy-web.rar",
|
||||
directUrl: "https://mega-web.example/legacy-web.rar",
|
||||
fileSize: null,
|
||||
retriesUsed: 0
|
||||
}));
|
||||
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
const result = await service.unrestrictLink("https://rapidgator.net/file/legacy-web");
|
||||
expect(result.directUrl).toBe("https://mega-web.example/legacy-web.rar");
|
||||
expect(megaWeb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses Mega web fallback when API fails", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
@@ -1524,6 +1654,201 @@ describe("debrid service", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("releases Mega-Debrid Web in-flight state when the provider ignores caller abort", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaCredentials: "ignored-web-user:ignored-web-pass",
|
||||
megaDebridApiCredentials: "",
|
||||
megaDebridWebCredentials: "ignored-web-user:ignored-web-pass",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: true,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid-web" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
let markProviderStarted: () => void = () => {};
|
||||
const providerStarted = new Promise<void>((resolve) => {
|
||||
markProviderStarted = resolve;
|
||||
});
|
||||
let rejectProvider: (reason?: unknown) => void = () => {};
|
||||
const ignoredProviderPromise = new Promise<never>((_resolve, reject) => {
|
||||
rejectProvider = reject;
|
||||
});
|
||||
const megaWeb = vi.fn(() => {
|
||||
markProviderStarted();
|
||||
return ignoredProviderPromise;
|
||||
});
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
const controller = new AbortController();
|
||||
const request = service.unrestrictLink("https://rapidgator.net/file/ignored-web-abort", controller.signal);
|
||||
const outcome = request.then(
|
||||
() => ({ status: "fulfilled" as const, error: null }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
await providerStarted;
|
||||
expect(getMegaDebridInFlightCountForMode("web")).toBe(1);
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
controller.abort("settings_refresh");
|
||||
const settled = await Promise.race([
|
||||
outcome,
|
||||
new Promise<null>((resolve) => {
|
||||
timeout = setTimeout(() => resolve(null), 100);
|
||||
})
|
||||
]);
|
||||
expect(settled).not.toBeNull();
|
||||
expect(settled?.status).toBe("rejected");
|
||||
expect(String(settled?.error)).toMatch(/aborted/i);
|
||||
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
rejectProvider(new Error("late Mega-Web provider failure"));
|
||||
await outcome;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
|
||||
});
|
||||
|
||||
it("releases Mega-Debrid Web in-flight state when the provider ignores the account timeout", async () => {
|
||||
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20";
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaCredentials: "ignored-timeout-user:ignored-timeout-pass",
|
||||
megaDebridApiCredentials: "",
|
||||
megaDebridWebCredentials: "ignored-timeout-user:ignored-timeout-pass",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: true,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid-web" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
let markProviderStarted: () => void = () => {};
|
||||
const providerStarted = new Promise<void>((resolve) => {
|
||||
markProviderStarted = resolve;
|
||||
});
|
||||
let rejectProvider: (reason?: unknown) => void = () => {};
|
||||
const ignoredProviderPromise = new Promise<never>((_resolve, reject) => {
|
||||
rejectProvider = reject;
|
||||
});
|
||||
const megaWeb = vi.fn(() => {
|
||||
markProviderStarted();
|
||||
return ignoredProviderPromise;
|
||||
});
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
const request = service.unrestrictLink("https://rapidgator.net/file/ignored-web-timeout");
|
||||
const outcome = request.then(
|
||||
() => ({ status: "fulfilled" as const, error: null }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
await providerStarted;
|
||||
expect(getMegaDebridInFlightCountForMode("web")).toBe(1);
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const settled = await Promise.race([
|
||||
outcome,
|
||||
new Promise<null>((resolve) => {
|
||||
timeout = setTimeout(() => resolve(null), 200);
|
||||
})
|
||||
]);
|
||||
expect(settled).not.toBeNull();
|
||||
expect(settled?.status).toBe("rejected");
|
||||
expect(String(settled?.error)).toMatch(/mega_debrid_slow_link|aborted/i);
|
||||
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
rejectProvider(new Error("late Mega-Web timeout failure"));
|
||||
await outcome;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
|
||||
});
|
||||
|
||||
it("releases Mega-Debrid API in-flight state when getLink ignores caller abort", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaCredentials: "ignored-api-user:ignored-api-pass",
|
||||
megaDebridApiCredentials: "ignored-api-user:ignored-api-pass",
|
||||
megaDebridWebCredentials: "",
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridWebEnabled: false,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid-api" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
let markGetLinkStarted: () => void = () => {};
|
||||
const getLinkStarted = new Promise<void>((resolve) => {
|
||||
markGetLinkStarted = resolve;
|
||||
});
|
||||
let rejectGetLink: (reason?: unknown) => void = () => {};
|
||||
const ignoredGetLinkPromise = new Promise<Response>((_resolve, reject) => {
|
||||
rejectGetLink = reject;
|
||||
});
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("action=connectUser")) {
|
||||
return new Response(JSON.stringify({ response_code: "ok", token: "ignored-api-token" }), { status: 200 });
|
||||
}
|
||||
if (url.includes("action=getLink")) {
|
||||
markGetLinkStarted();
|
||||
return ignoredGetLinkPromise;
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
const service = new DebridService(settings);
|
||||
const controller = new AbortController();
|
||||
const request = service.unrestrictLink("https://rapidgator.net/file/ignored-api-abort", controller.signal);
|
||||
const outcome = request.then(
|
||||
() => ({ status: "fulfilled" as const, error: null }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
await getLinkStarted;
|
||||
expect(getMegaDebridInFlightCountForMode("api")).toBe(1);
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
controller.abort("reset");
|
||||
const settled = await Promise.race([
|
||||
outcome,
|
||||
new Promise<null>((resolve) => {
|
||||
timeout = setTimeout(() => resolve(null), 100);
|
||||
})
|
||||
]);
|
||||
expect(settled).not.toBeNull();
|
||||
expect(settled?.status).toBe("rejected");
|
||||
expect(String(settled?.error)).toMatch(/aborted/i);
|
||||
expect(getMegaDebridInFlightCountForMode("api")).toBe(0);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
rejectGetLink(new Error("late Mega-Debrid API provider failure"));
|
||||
await outcome;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(getMegaDebridInFlightCountForMode("api")).toBe(0);
|
||||
});
|
||||
|
||||
it("does not cache a stale Mega-Debrid API token after credentials change during connect", async () => {
|
||||
const oldSettings = {
|
||||
...defaultSettings(),
|
||||
@@ -2341,17 +2666,99 @@ describe("debrid service", () => {
|
||||
expect(getMegaDebridAccountCooldownState(key, afterReset)).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT treat a per-hoster 'no server' failure as an account daily-limit signal (no until-restart park)", () => {
|
||||
it("does NOT treat a per-hoster 'no server' failure as an account daily-limit signal (no until-restart park)", () => {
|
||||
const noServer = classifyMegaDebridAccountFailureForTests(new Error("no server available for this host"));
|
||||
expect(noServer.limitSignal).toBeFalsy();
|
||||
expect(noServer.category).toBe("quota");
|
||||
expect(noServer.cooldownMs).toBeGreaterThan(0);
|
||||
|
||||
const genuineEmpty = classifyMegaDebridAccountFailureForTests(new Error("Antwort leer"));
|
||||
expect(genuineEmpty.limitSignal).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies an empty Mega-Debrid API result ('Linkgenerierung lieferte kein Ergebnis') as a fast transient, not a 30s cooldown", () => {
|
||||
expect(genuineEmpty.limitSignal).toBe(true);
|
||||
});
|
||||
|
||||
it("sanitizes provider-supplied account failures before they leave Mega-Debrid rotation", async () => {
|
||||
const login = "private-user@example.test";
|
||||
const password = "provider-password-secret";
|
||||
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaLogin: login,
|
||||
megaPassword: password,
|
||||
megaCredentials: `${login}:${password}`,
|
||||
megaDebridApiCredentials: "",
|
||||
megaDebridWebCredentials: `${login}:${password}`,
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridPreferApi: false,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid-web" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
const megaWeb = vi.fn(async () => {
|
||||
throw new Error(`Incorrect password for ${login} login=${login} password=${password} source=${sourceUrl}`);
|
||||
});
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
|
||||
const error = await service.unrestrictLink("https://rapidgator.net/file/provider-error").then(() => null, (caught: unknown) => caught as Error);
|
||||
const message = String(error?.message || error || "");
|
||||
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId(login)}:web`);
|
||||
expect(message).toContain("ungueltiger Account");
|
||||
expect(message).toContain("Account 1/1");
|
||||
expect(message).toMatch(/files\.example\.test#[a-f0-9]{10}/);
|
||||
expect(cooldown?.category).toBe("invalid");
|
||||
for (const sensitive of [login, password, "source-user", "source-pass", "query-secret", sourceUrl]) {
|
||||
expect(message).not.toContain(sensitive);
|
||||
expect(cooldown?.message || "").not.toContain(sensitive);
|
||||
}
|
||||
expect(message).not.toContain("*");
|
||||
});
|
||||
|
||||
it("sanitizes provider-supplied API key failures before they leave Debrid-Link rotation", async () => {
|
||||
const apiKey = "provider-debrid-link-secret";
|
||||
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
debridLinkApiKeys: apiKey,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "debridlink" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: "badToken",
|
||||
error_description: `Rejected api_key=${apiKey} source=${sourceUrl}`
|
||||
}), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
})) as typeof fetch;
|
||||
const service = new DebridService(settings);
|
||||
|
||||
const error = await service.unrestrictLink("https://rapidgator.net/file/provider-key-error").then(() => null, (caught: unknown) => caught as Error);
|
||||
const message = String(error?.message || error || "");
|
||||
const keyId = parseDebridLinkApiKeys(apiKey)[0].id;
|
||||
const cooldown = getDebridLinkKeyCooldownStateForTests(keyId);
|
||||
expect(message).toContain("ungueltiger oder deaktivierter API-Key");
|
||||
expect(message).toContain("Key 1/1");
|
||||
expect(message).toMatch(/files\.example\.test#[a-f0-9]{10}/);
|
||||
expect(getDebridLinkKeyRuntimeStateForTests(keyId)).toBe("invalid");
|
||||
for (const sensitive of [apiKey, "source-user", "source-pass", "query-secret", sourceUrl]) {
|
||||
expect(message).not.toContain(sensitive);
|
||||
expect(cooldown?.message || "").not.toContain(sensitive);
|
||||
}
|
||||
expect(message).not.toContain("*");
|
||||
});
|
||||
|
||||
it("classifies an empty Mega-Debrid API result ('Linkgenerierung lieferte kein Ergebnis') as a fast transient, not a 30s cooldown", () => {
|
||||
const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis"));
|
||||
expect(result.fatal).toBe(false);
|
||||
expect(result.cooldownMs).toBe(0);
|
||||
|
||||
@@ -583,7 +583,7 @@ describe("debug-server", () => {
|
||||
expect(entries).toContain("overview/settings.json");
|
||||
expect(entries).toContain("overview/accounts.json");
|
||||
expect(entries).toContain("overview/debug-setup.json");
|
||||
expect(entries).toContain("overview/self-check.json");
|
||||
expect(entries).not.toContain("overview/self-check.json");
|
||||
expect(entries).toContain("overview/trace-config.json");
|
||||
expect(entries).toContain("logs/audit.log");
|
||||
expect(entries).toContain("logs/rename.log");
|
||||
|
||||
+892
-47
File diff suppressed because it is too large
Load Diff
@@ -79,35 +79,42 @@ function createSnapshot(running: boolean, paused: boolean): UiSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function findStartAction(node: ReactNode): (() => void) | null {
|
||||
interface DownloadActions {
|
||||
onStartDownloads?: () => void;
|
||||
onPauseDownloads?: () => void;
|
||||
onStopDownloads?: () => void;
|
||||
}
|
||||
|
||||
function findDownloadActions(node: ReactNode): DownloadActions | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const action = findStartAction(child);
|
||||
if (action) return action;
|
||||
const actions = findDownloadActions(child);
|
||||
if (actions) return actions;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!isValidElement(node)) return null;
|
||||
const props = node.props as {
|
||||
actions?: { onStartDownloads?: () => void };
|
||||
actions?: DownloadActions;
|
||||
children?: ReactNode;
|
||||
toolbar?: ReactNode;
|
||||
};
|
||||
if (typeof props.actions?.onStartDownloads === "function") {
|
||||
return props.actions.onStartDownloads;
|
||||
if (props.actions && typeof props.actions.onStartDownloads === "function") {
|
||||
return props.actions;
|
||||
}
|
||||
return findStartAction(props.toolbar) ?? findStartAction(props.children);
|
||||
return findDownloadActions(props.toolbar) ?? findDownloadActions(props.children);
|
||||
}
|
||||
|
||||
async function flushAsyncAction(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function renderPausedStartAction(
|
||||
function renderDownloadActions(
|
||||
initialSnapshot: UiSnapshot,
|
||||
togglePause: () => Promise<boolean>,
|
||||
getSnapshot: () => Promise<UiSnapshot>
|
||||
): () => void {
|
||||
getSnapshot: () => Promise<UiSnapshot>,
|
||||
stop: () => Promise<void> = async () => undefined
|
||||
): DownloadActions {
|
||||
hookState.capturedSnapshot = false;
|
||||
hookState.initialSnapshot = initialSnapshot;
|
||||
hookState.currentSnapshot = initialSnapshot;
|
||||
@@ -125,14 +132,14 @@ function renderPausedStartAction(
|
||||
devicePixelRatio: 1,
|
||||
matchMedia: () => ({ matches: false }),
|
||||
prompt: () => null,
|
||||
rd: { getSnapshot, togglePause },
|
||||
rd: { getSnapshot, togglePause, stop },
|
||||
removeEventListener: () => {},
|
||||
setInterval,
|
||||
setTimeout
|
||||
});
|
||||
const action = findStartAction(App() as ReactElement);
|
||||
if (!action) throw new Error("Download-Startaktion nicht gefunden");
|
||||
return action;
|
||||
const actions = findDownloadActions(App() as ReactElement);
|
||||
if (!actions) throw new Error("Download-Aktionen nicht gefunden");
|
||||
return actions;
|
||||
}
|
||||
|
||||
describe("paused download resume reconciliation", () => {
|
||||
@@ -149,13 +156,13 @@ describe("paused download resume reconciliation", () => {
|
||||
it("restores the authoritative paused state when togglePause rejects without a state event", async () => {
|
||||
const initial = createSnapshot(true, true);
|
||||
const authoritative = createSnapshot(true, true);
|
||||
const action = renderPausedStartAction(
|
||||
const actions = renderDownloadActions(
|
||||
initial,
|
||||
async () => { throw new Error("Kein aktiver Download-Account verfügbar"); },
|
||||
async () => authoritative
|
||||
);
|
||||
|
||||
action();
|
||||
actions.onStartDownloads?.();
|
||||
await flushAsyncAction();
|
||||
|
||||
expect(hookState.currentSnapshot).toEqual(authoritative);
|
||||
@@ -164,13 +171,51 @@ describe("paused download resume reconciliation", () => {
|
||||
it("replaces stale running state when togglePause returns false without a state event", async () => {
|
||||
const initial = createSnapshot(true, true);
|
||||
const authoritative = createSnapshot(false, false);
|
||||
const action = renderPausedStartAction(
|
||||
const actions = renderDownloadActions(
|
||||
initial,
|
||||
async () => false,
|
||||
async () => authoritative
|
||||
);
|
||||
|
||||
action();
|
||||
actions.onStartDownloads?.();
|
||||
await flushAsyncAction();
|
||||
|
||||
expect(hookState.currentSnapshot).toEqual(authoritative);
|
||||
});
|
||||
|
||||
it("replaces stale running state when pausing returns false without a state event", async () => {
|
||||
const initial = createSnapshot(true, false);
|
||||
const authoritative = createSnapshot(false, false);
|
||||
const actions = renderDownloadActions(
|
||||
initial,
|
||||
async () => false,
|
||||
async () => authoritative
|
||||
);
|
||||
|
||||
actions.onPauseDownloads?.();
|
||||
await flushAsyncAction();
|
||||
|
||||
expect(hookState.currentSnapshot).toEqual(authoritative);
|
||||
});
|
||||
|
||||
it("loads the complete authoritative snapshot after stop succeeds without a state event", async () => {
|
||||
const initial = createSnapshot(true, false);
|
||||
const authoritative = {
|
||||
...createSnapshot(false, false),
|
||||
canStart: true,
|
||||
stats: {
|
||||
...createSnapshot(false, false).stats,
|
||||
totalDownloaded: 4096
|
||||
}
|
||||
};
|
||||
const actions = renderDownloadActions(
|
||||
initial,
|
||||
async () => false,
|
||||
async () => authoritative,
|
||||
async () => undefined
|
||||
);
|
||||
|
||||
actions.onStopDownloads?.();
|
||||
await flushAsyncAction();
|
||||
|
||||
expect(hookState.currentSnapshot).toEqual(authoritative);
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
formatHosterLabel,
|
||||
normalizeDownloadServiceLabel
|
||||
} from "../src/renderer/download-format";
|
||||
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
|
||||
import { getRollingMetricDirection, shouldAnimateRollingMetric } from "../src/renderer/ui/RollingMetricValue";
|
||||
|
||||
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||
|
||||
@@ -98,6 +98,13 @@ describe("rollende Downloadkennzahlen", () => {
|
||||
expect(getRollingMetricDirection(300, 300)).toBe("none");
|
||||
});
|
||||
|
||||
it("skips rolling animations when reduced motion is requested", () => {
|
||||
expect(shouldAnimateRollingMetric("up", true)).toBe(false);
|
||||
expect(shouldAnimateRollingMetric("down", true)).toBe(false);
|
||||
expect(shouldAnimateRollingMetric("up", false)).toBe(true);
|
||||
expect(shouldAnimateRollingMetric("none", false)).toBe(false);
|
||||
});
|
||||
|
||||
it("animates exactly the five stable sidebar metrics", () => {
|
||||
const html = renderToStaticMarkup(<DownloadsSidebarStatus model={withRuntime(createInput())} />);
|
||||
expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(5);
|
||||
|
||||
+51
-4
@@ -35,7 +35,7 @@ describe("item-log", () => {
|
||||
expect(content).toContain("episode.part2.rar");
|
||||
});
|
||||
|
||||
it("writes detail events into the item log", async () => {
|
||||
it("writes detail events into the item log", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
@@ -60,9 +60,56 @@ describe("item-log", () => {
|
||||
expect(logPath).not.toBeNull();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("Entpack-Fehler");
|
||||
expect(content).toContain("archive=episode.part2.rar");
|
||||
expect(content).toContain("code=missing_parts");
|
||||
});
|
||||
expect(content).toContain("archive=episode.part2.rar");
|
||||
expect(content).toContain("code=missing_parts");
|
||||
});
|
||||
|
||||
it("redacts secrets, identities, direct links and local paths from item logs", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initItemLogs(baseDir);
|
||||
ensureItemLog({
|
||||
itemId: "item-sensitive",
|
||||
packageId: "pkg-sensitive",
|
||||
packageName: "Sensitive Paket",
|
||||
fileName: "episode.part2.rar",
|
||||
targetPath: "C:\\Users\\Administrator\\Downloads\\Sensitive Paket\\episode.part2.rar"
|
||||
});
|
||||
|
||||
logItemEvent(
|
||||
"item-sensitive",
|
||||
"ERROR",
|
||||
"Download https://ddownload.com/private/file.rar?auth=url-secret für user@example.net nach /mnt/downloads/file.rar fehlgeschlagen",
|
||||
{
|
||||
downloadUrl: "https://ddownload.com/private/file.rar?auth=url-secret",
|
||||
password: "password-secret-value",
|
||||
metadata: {
|
||||
cookies: "sid=cookie-secret-value",
|
||||
username: "user@example.net",
|
||||
localPath: "\\\\server\\share\\file.rar"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
const logPath = getItemLogPath("item-sensitive");
|
||||
expect(logPath).not.toBeNull();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toMatch(/ddownload\.com#[a-f0-9]{10}/);
|
||||
expect(content).toContain("<redacted>");
|
||||
expect(content).toContain("<redacted-account>");
|
||||
expect(content).toContain("<redacted-path>");
|
||||
expect(content).not.toContain("/private/file.rar");
|
||||
expect(content).not.toContain("url-secret");
|
||||
expect(content).not.toContain("password-secret-value");
|
||||
expect(content).not.toContain("cookie-secret-value");
|
||||
expect(content).not.toContain("user@example.net");
|
||||
expect(content).not.toContain("C:\\Users\\Administrator");
|
||||
expect(content).not.toContain("/mnt/downloads");
|
||||
expect(content).not.toContain("\\\\server\\share");
|
||||
});
|
||||
|
||||
it("keeps traversal-like item ids inside the item log directory", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { configureLogger, flushLogger, getLogFilePath, logger } from "../src/main/logger";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await flushLogger();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("logger", () => {
|
||||
it("redacts secrets, accounts, URLs and local paths before every log sink", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-logger-redaction-"));
|
||||
tempDirs.push(baseDir);
|
||||
configureLogger(baseDir);
|
||||
|
||||
logger.warn("URL=https://rapidgator.net/file/private?token=secret | target=C:\\Users\\Admin\\Desktop\\private.rar | email=user@example.com | password=hunter2 | Authorization: Bearer abc.def");
|
||||
await flushLogger();
|
||||
|
||||
const content = fs.readFileSync(getLogFilePath(), "utf8");
|
||||
expect(content).toContain("rapidgator.net#");
|
||||
expect(content).toContain("<redacted-path>");
|
||||
expect(content).toContain("<redacted-account>");
|
||||
expect(content).toContain("password=<redacted>");
|
||||
expect(content).not.toContain("/file/private");
|
||||
expect(content).not.toContain("C:\\Users\\Admin");
|
||||
expect(content).not.toContain("user@example.com");
|
||||
expect(content).not.toContain("hunter2");
|
||||
expect(content).not.toContain("abc.def");
|
||||
});
|
||||
});
|
||||
@@ -56,11 +56,58 @@ describe("package-log", () => {
|
||||
|
||||
const logPath = getPackageLogPath("pkg-2");
|
||||
expect(logPath).not.toBeNull();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("Passwort-Versuch");
|
||||
expect(content).toContain("archive=episode.part1.rar");
|
||||
expect(content).toContain("password=\"secret\"");
|
||||
});
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("Passwort-Versuch");
|
||||
expect(content).toContain("archive=episode.part1.rar");
|
||||
expect(content).toContain("password=<redacted>");
|
||||
expect(content).not.toContain("\"secret\"");
|
||||
});
|
||||
|
||||
it("redacts secrets, identities, direct links and local paths from package logs", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initPackageLogs(baseDir);
|
||||
ensurePackageLog({
|
||||
packageId: "pkg-sensitive",
|
||||
name: "Sensitive Paket",
|
||||
outputDir: "C:\\Users\\Administrator\\Downloads\\Sensitive Paket",
|
||||
extractDir: "/srv/downloads/Sensitive Paket"
|
||||
});
|
||||
|
||||
logPackageEvent(
|
||||
"pkg-sensitive",
|
||||
"ERROR",
|
||||
"Abruf https://rapidgator.net/file/private-id/archive.rar?token=query-secret für owner@example.org unter C:\\Users\\Administrator\\Downloads\\archive.rar fehlgeschlagen",
|
||||
{
|
||||
directUrl: "https://rapidgator.net/file/private-id/archive.rar?token=query-secret",
|
||||
apiToken: "token-secret-value",
|
||||
nested: {
|
||||
cookie: "session=cookie-secret-value",
|
||||
email: "owner@example.org",
|
||||
outputPath: "/home/downloader/archive.rar"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
const logPath = getPackageLogPath("pkg-sensitive");
|
||||
expect(logPath).not.toBeNull();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toMatch(/rapidgator\.net#[a-f0-9]{10}/);
|
||||
expect(content).toContain("<redacted>");
|
||||
expect(content).toContain("<redacted-account>");
|
||||
expect(content).toContain("<redacted-path>");
|
||||
expect(content).not.toContain("private-id");
|
||||
expect(content).not.toContain("query-secret");
|
||||
expect(content).not.toContain("token-secret-value");
|
||||
expect(content).not.toContain("cookie-secret-value");
|
||||
expect(content).not.toContain("owner@example.org");
|
||||
expect(content).not.toContain("C:\\Users\\Administrator");
|
||||
expect(content).not.toContain("/home/downloader");
|
||||
expect(content).not.toContain("/srv/downloads");
|
||||
});
|
||||
|
||||
it("keeps traversal-like package ids inside the package log directory", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PackageEntry } from "../src/shared/types";
|
||||
import { preservePackageOrderForDisplay } from "../src/renderer/package-order";
|
||||
import {
|
||||
preservePackageOrderForDisplay,
|
||||
reconcileCollapsedPackageState,
|
||||
reconcileOptimisticPackageOrder
|
||||
} from "../src/renderer/package-order";
|
||||
|
||||
function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry {
|
||||
const now = Date.now();
|
||||
@@ -39,3 +43,81 @@ describe("preservePackageOrderForDisplay", () => {
|
||||
expect(preservePackageOrderForDisplay(packages).map((pkg) => pkg.id)).toEqual(["pkg-first", "pkg-second", "pkg-third"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileCollapsedPackageState", () => {
|
||||
it("keeps user collapse choices stable while package metadata and order change", () => {
|
||||
const previous = { "pkg-first": true, "pkg-second": false };
|
||||
const packages = {
|
||||
"pkg-first": { ...createPackage("pkg-first", ["first-item"], 300), status: "downloading" as const },
|
||||
"pkg-second": { ...createPackage("pkg-second", ["second-item"], 100), status: "completed" as const }
|
||||
};
|
||||
|
||||
const next = reconcileCollapsedPackageState(previous, ["pkg-second", "pkg-first"], packages, true);
|
||||
|
||||
expect(next).toBe(previous);
|
||||
expect(next).toEqual({ "pkg-first": true, "pkg-second": false });
|
||||
});
|
||||
|
||||
it("defaults only new packages and removes packages that disappeared", () => {
|
||||
const previous = { "pkg-old": false, "pkg-stale": true };
|
||||
const packages = {
|
||||
"pkg-old": createPackage("pkg-old", ["old-item"]),
|
||||
"pkg-new": createPackage("pkg-new", ["new-item"])
|
||||
};
|
||||
|
||||
expect(reconcileCollapsedPackageState(previous, ["pkg-old", "pkg-new"], packages, true)).toEqual({
|
||||
"pkg-old": false,
|
||||
"pkg-new": true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileOptimisticPackageOrder", () => {
|
||||
it("keeps the optimistic order visible while an older state event arrives", () => {
|
||||
const pending = ["pkg-a", "pkg-c", "pkg-b"];
|
||||
|
||||
expect(reconcileOptimisticPackageOrder(
|
||||
["pkg-a", "pkg-b", "pkg-c"],
|
||||
pending,
|
||||
1_000,
|
||||
1_500
|
||||
)).toEqual({
|
||||
displayOrder: pending,
|
||||
pendingOrder: pending,
|
||||
pendingAt: 1_000,
|
||||
status: "pending"
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the authoritative order once it acknowledges the optimistic change", () => {
|
||||
const pending = ["pkg-a", "pkg-c", "pkg-b"];
|
||||
|
||||
expect(reconcileOptimisticPackageOrder(
|
||||
pending,
|
||||
pending,
|
||||
1_000,
|
||||
1_500
|
||||
)).toEqual({
|
||||
displayOrder: pending,
|
||||
pendingOrder: null,
|
||||
pendingAt: 0,
|
||||
status: "acknowledged"
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to the authoritative order after the optimistic hold times out", () => {
|
||||
const authoritative = ["pkg-a", "pkg-b", "pkg-c"];
|
||||
|
||||
expect(reconcileOptimisticPackageOrder(
|
||||
authoritative,
|
||||
["pkg-a", "pkg-c", "pkg-b"],
|
||||
1_000,
|
||||
2_500
|
||||
)).toEqual({
|
||||
displayOrder: authoritative,
|
||||
pendingOrder: null,
|
||||
pendingAt: 0,
|
||||
status: "timed-out"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AppController } from "../src/main/app-controller";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: { getPath: () => "C:\\MDD\\Test" },
|
||||
BrowserWindow: class {},
|
||||
clipboard: {},
|
||||
dialog: {},
|
||||
ipcMain: { handle: vi.fn(), on: vi.fn() },
|
||||
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
|
||||
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
|
||||
shell: {},
|
||||
Tray: class {}
|
||||
}));
|
||||
|
||||
describe("reset controller boundary", () => {
|
||||
it("audits reset completion only after the manager operation succeeds", async () => {
|
||||
const packagePromise = Promise.resolve();
|
||||
const itemPromise = Promise.resolve();
|
||||
const controller = Object.create(AppController.prototype) as {
|
||||
manager: {
|
||||
resetPackage: ReturnType<typeof vi.fn>;
|
||||
resetItems: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
audit: ReturnType<typeof vi.fn>;
|
||||
resetPackage: (packageId: string) => Promise<void>;
|
||||
resetItems: (itemIds: string[]) => Promise<void>;
|
||||
};
|
||||
controller.manager = {
|
||||
resetPackage: vi.fn(() => packagePromise),
|
||||
resetItems: vi.fn(() => itemPromise)
|
||||
};
|
||||
controller.audit = vi.fn();
|
||||
|
||||
await controller.resetPackage("package-1");
|
||||
await controller.resetItems(["item-1"]);
|
||||
|
||||
expect(controller.audit.mock.calls.map((call) => call[1])).toEqual([
|
||||
"Paket-Reset angefordert",
|
||||
"Paket-Reset abgeschlossen",
|
||||
"Item-Reset angefordert",
|
||||
"Item-Reset abgeschlossen"
|
||||
]);
|
||||
});
|
||||
|
||||
it("audits reset failures instead of reporting a false success", async () => {
|
||||
const controller = Object.create(AppController.prototype) as {
|
||||
manager: {
|
||||
resetPackage: ReturnType<typeof vi.fn>;
|
||||
resetItems: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
audit: ReturnType<typeof vi.fn>;
|
||||
resetPackage: (packageId: string) => Promise<void>;
|
||||
resetItems: (itemIds: string[]) => Promise<void>;
|
||||
};
|
||||
controller.manager = {
|
||||
resetPackage: vi.fn(async () => { throw new Error("C:\\private\\locked.part"); }),
|
||||
resetItems: vi.fn(async () => { throw new Error("item locked"); })
|
||||
};
|
||||
controller.audit = vi.fn();
|
||||
|
||||
await expect(controller.resetPackage("package-1")).rejects.toThrow();
|
||||
await expect(controller.resetItems(["item-1"])).rejects.toThrow();
|
||||
|
||||
expect(controller.audit.mock.calls.map((call) => call[1])).toEqual([
|
||||
"Paket-Reset angefordert",
|
||||
"Paket-Reset fehlgeschlagen",
|
||||
"Item-Reset angefordert",
|
||||
"Item-Reset fehlgeschlagen"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session control diagnostics", () => {
|
||||
it("records requested and applied phases for stop and pause", () => {
|
||||
const snapshot = {
|
||||
session: {
|
||||
running: true,
|
||||
paused: false,
|
||||
packages: { "package-1": {} },
|
||||
items: {
|
||||
"item-1": { status: "downloading" },
|
||||
"item-2": { status: "queued" }
|
||||
}
|
||||
}
|
||||
};
|
||||
const controller = Object.create(AppController.prototype) as {
|
||||
manager: {
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
togglePause: ReturnType<typeof vi.fn>;
|
||||
getSnapshot: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
audit: ReturnType<typeof vi.fn>;
|
||||
stop: () => void;
|
||||
togglePause: () => boolean;
|
||||
};
|
||||
controller.manager = {
|
||||
stop: vi.fn(() => { snapshot.session.running = false; }),
|
||||
togglePause: vi.fn(() => {
|
||||
snapshot.session.running = true;
|
||||
snapshot.session.paused = true;
|
||||
return true;
|
||||
}),
|
||||
getSnapshot: vi.fn(() => snapshot)
|
||||
};
|
||||
controller.audit = vi.fn();
|
||||
|
||||
controller.stop();
|
||||
controller.togglePause();
|
||||
|
||||
expect(controller.audit.mock.calls.map((call) => call[1])).toEqual([
|
||||
"Session-Stopp angefordert",
|
||||
"Session-Stopp angewendet",
|
||||
"Pause angefordert",
|
||||
"Pause angewendet"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("support bundle diagnostics", () => {
|
||||
it("records selection and export phases without target paths", () => {
|
||||
const controller = Object.create(AppController.prototype) as {
|
||||
manager: {
|
||||
getSnapshot: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
audit: ReturnType<typeof vi.fn>;
|
||||
recordSupportBundleExportSelected: () => void;
|
||||
recordSupportBundleExportLifecycle: (event: {
|
||||
phase: "write" | "failure";
|
||||
durationMs: number;
|
||||
totalDurationMs: number;
|
||||
bytes?: number;
|
||||
failedPhase?: "write";
|
||||
code?: string;
|
||||
}) => void;
|
||||
};
|
||||
controller.manager = {
|
||||
getSnapshot: vi.fn(() => ({
|
||||
session: {
|
||||
running: true,
|
||||
paused: false,
|
||||
packages: { "package-1": {} },
|
||||
items: {
|
||||
"item-1": { status: "downloading" },
|
||||
"item-2": { status: "queued" }
|
||||
}
|
||||
}
|
||||
}))
|
||||
};
|
||||
controller.audit = vi.fn();
|
||||
|
||||
controller.recordSupportBundleExportSelected();
|
||||
controller.recordSupportBundleExportLifecycle({
|
||||
phase: "write",
|
||||
durationMs: 250,
|
||||
totalDurationMs: 700,
|
||||
bytes: 4096
|
||||
});
|
||||
controller.recordSupportBundleExportLifecycle({
|
||||
phase: "failure",
|
||||
durationMs: 300,
|
||||
totalDurationMs: 1000,
|
||||
failedPhase: "write",
|
||||
code: "ENOSPC"
|
||||
});
|
||||
|
||||
expect(controller.audit.mock.calls).toEqual([
|
||||
["INFO", "Support-Bundle-Ziel ausgewählt", {
|
||||
phase: "selected",
|
||||
running: true,
|
||||
paused: false,
|
||||
packageCount: 1,
|
||||
itemCount: 2,
|
||||
activeItemCount: 1
|
||||
}],
|
||||
["INFO", "Support-Bundle geschrieben", {
|
||||
phase: "write",
|
||||
durationMs: 250,
|
||||
totalDurationMs: 700,
|
||||
bytes: 4096
|
||||
}],
|
||||
["ERROR", "Support-Bundle-Export fehlgeschlagen", {
|
||||
phase: "failure",
|
||||
durationMs: 300,
|
||||
totalDurationMs: 1000,
|
||||
failedPhase: "write",
|
||||
code: "ENOSPC"
|
||||
}]
|
||||
]);
|
||||
expect(JSON.stringify(controller.audit.mock.calls)).not.toContain("C:\\");
|
||||
});
|
||||
});
|
||||
+64
-5
@@ -954,7 +954,7 @@ describe("settings storage", () => {
|
||||
expect(loaded.packageOrder).toEqual(empty.packageOrder);
|
||||
});
|
||||
|
||||
it("loads backup session when primary session is corrupted", () => {
|
||||
it("loads backup session when primary session is corrupted", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
@@ -1005,10 +1005,69 @@ describe("settings storage", () => {
|
||||
expect(loaded.items["item-backup"]?.fileName).toBe("backup-file.rar");
|
||||
|
||||
const restoredPrimary = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { packages?: Record<string, unknown> };
|
||||
expect(restoredPrimary.packages && "pkg-backup" in restoredPrimary.packages).toBe(true);
|
||||
});
|
||||
|
||||
it("returns defaults when config file contains invalid JSON", () => {
|
||||
expect(restoredPrimary.packages && "pkg-backup" in restoredPrimary.packages).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves resume recovery state across a session save and reload", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const session = emptySession();
|
||||
const now = Date.now();
|
||||
const outputDir = path.join(dir, "out");
|
||||
const itemId = "item-resume";
|
||||
session.packageOrder = ["pkg-resume"];
|
||||
session.packages["pkg-resume"] = {
|
||||
id: "pkg-resume",
|
||||
name: "Resume Package",
|
||||
outputDir,
|
||||
extractDir: path.join(dir, "extract"),
|
||||
status: "queued",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId: "pkg-resume",
|
||||
url: "https://example.com/resume-file",
|
||||
provider: "megadebrid-web",
|
||||
status: "queued",
|
||||
retries: 2,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 8192,
|
||||
totalBytes: 16384,
|
||||
progressPercent: 50,
|
||||
fileName: "resume-file.bin",
|
||||
targetPath: path.join(outputDir, "resume-file.bin"),
|
||||
resumable: true,
|
||||
attempts: 3,
|
||||
lastError: "",
|
||||
fullStatus: "Resume-Link erneuern",
|
||||
resumeLinkRenewalFailures: 4,
|
||||
resumeHardResetUsed: true,
|
||||
resumeResetPending: true,
|
||||
http416FreshRestarts: 2,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
saveSession(paths, session);
|
||||
const loaded = loadSession(paths);
|
||||
|
||||
expect(loaded.items[itemId]).toEqual(expect.objectContaining({
|
||||
downloadedBytes: 8192,
|
||||
totalBytes: 16384,
|
||||
resumeLinkRenewalFailures: 4,
|
||||
resumeHardResetUsed: true,
|
||||
resumeResetPending: true,
|
||||
http416FreshRestarts: 2
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns defaults when config file contains invalid JSON", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
@@ -6,18 +6,39 @@ 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 { }
|
||||
}
|
||||
@@ -149,17 +170,336 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
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/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 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-"));
|
||||
@@ -215,6 +555,53 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
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);
|
||||
@@ -240,6 +627,113 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
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);
|
||||
@@ -344,6 +838,91 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
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);
|
||||
@@ -394,6 +973,99 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -430,6 +1102,32 @@ describe("support bundle export runner", () => {
|
||||
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);
|
||||
@@ -476,7 +1174,7 @@ describe("support bundle export runner", () => {
|
||||
}
|
||||
});
|
||||
|
||||
await expect(run()).rejects.toThrow("write failed");
|
||||
await expect(run()).rejects.toMatchObject({ name: "SupportBundleExportError", phase: "write" });
|
||||
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target });
|
||||
});
|
||||
|
||||
|
||||
+31
-3
@@ -17,7 +17,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("trace-log", () => {
|
||||
it("captures main log lines and explicit trace events when enabled", async () => {
|
||||
it("captures main log lines and explicit trace events when enabled", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
@@ -49,8 +49,36 @@ describe("trace-log", () => {
|
||||
const traceConfig = getTraceConfig();
|
||||
expect(traceConfig.enabled).toBe(true);
|
||||
expect(traceConfig.autoDisableAt).toBeTruthy();
|
||||
expect(JSON.parse(fs.readFileSync(traceConfigPath!, "utf8")).enabled).toBe(true);
|
||||
});
|
||||
expect(JSON.parse(fs.readFileSync(traceConfigPath!, "utf8")).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("redacts sensitive messages and fields before writing the trace log", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-redaction-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
configureLogger(baseDir);
|
||||
initTraceLog(baseDir);
|
||||
setTraceEnabled(true, "redaction-test");
|
||||
logTraceEvent("WARN", "download", "Failed https://rapidgator.net/file/private at C:\\Users\\Admin\\Desktop\\private.rar", {
|
||||
directUrl: "https://cdn.example/private?token=secret",
|
||||
targetPath: "C:\\Users\\Admin\\Desktop\\private.rar",
|
||||
email: "user@example.com",
|
||||
password: "hunter2"
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
const content = fs.readFileSync(getTraceLogPath()!, "utf8");
|
||||
expect(content).toContain("rapidgator.net#");
|
||||
expect(content).toContain("cdn.example#");
|
||||
expect(content).toContain("<redacted-path>");
|
||||
expect(content).toContain("<redacted-account>");
|
||||
expect(content).toContain("<redacted>");
|
||||
expect(content).not.toContain("/file/private");
|
||||
expect(content).not.toContain("C:\\Users\\Admin");
|
||||
expect(content).not.toContain("user@example.com");
|
||||
expect(content).not.toContain("hunter2");
|
||||
});
|
||||
|
||||
it("auto-disables support trace after the requested duration", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-expire-"));
|
||||
|
||||
Reference in New Issue
Block a user