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.
229 lines
10 KiB
TypeScript
229 lines
10 KiB
TypeScript
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 () => {
|
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "TEST", { link: "x" });
|
|
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" });
|
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" });
|
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
|
|
await Promise.resolve();
|
|
});
|
|
|
|
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?.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[] = [];
|
|
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
|
|
expect(captured).toHaveLength(0);
|
|
});
|
|
|
|
it("isolates two parallel item sinks (no cross-attribution)", async () => {
|
|
const a: RotationEvent[] = [];
|
|
const b: RotationEvent[] = [];
|
|
await Promise.all([
|
|
runWithRotationItemSink((ev) => a.push(ev), async () => {
|
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "TEST");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "OK");
|
|
}),
|
|
runWithRotationItemSink((ev) => b.push(ev), async () => {
|
|
logAccountRotation("INFO", "Debrid-Link", "Key 1 (b)", "TEST");
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
|
|
})
|
|
]);
|
|
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"]);
|
|
});
|
|
|
|
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")).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", () => {
|
|
logAccountRotation("WARN", "Mega-Debrid Web", "Account 10 (xy)", "TIMEOUT_COOLDOWN", {
|
|
reason: "aborted:debrid",
|
|
cooldownSec: 30,
|
|
next: "Account 11 (yz)"
|
|
});
|
|
|
|
const event = getRecentRotationEvents(10).find((entry) => entry.accountLabel === "Account 10");
|
|
|
|
expect(event).toMatchObject({
|
|
event: "TIMEOUT_COOLDOWN",
|
|
reason: "Versuch fehlgeschlagen: aborted:debrid"
|
|
});
|
|
});
|
|
});
|