release: harden provider rotation and download recovery

Apply account and provider changes to active conversions without restarting, isolate API and Web state, and abort the exact fallback attempt when settings change.

Bound resume recovery, make disk reservations abortable, preserve cleanup totals and history, stabilize compact UI state, and canonicalize RapidGator host aliases.

Expand bounded support diagnostics while redacting account identities, local paths, package names, and file names from current and rotated logs.

Add regression coverage for rotation, live settings, HTTP 416 recovery, disk waits, cleanup, context menus, history failures, and support bundle privacy.
This commit is contained in:
Sucukdeluxe
2026-08-13 20:44:43 +02:00
parent d287056a2a
commit 4972858c9d
34 changed files with 2103 additions and 317 deletions
+83 -8
View File
@@ -1,14 +1,14 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getMegaDebridAccountId, getMegaDebridAccountStatusId } from "../src/shared/mega-debrid-accounts";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { AppSettings } from "../src/shared/types";
import { defaultSettings } from "../src/main/constants";
import { configureCredentialProtector } from "../src/main/credential-protection";
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
import { addHistoryEntryForRetention, clearHistory, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
const tempDirs: string[] = [];
type SettingsSaveMode = "sync" | "async";
@@ -29,8 +29,9 @@ beforeEach(() => {
});
});
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
afterEach(() => {
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
@@ -204,6 +205,49 @@ describe("settings storage", () => {
expect(loaded.allDebridToken).toBe("all-token");
});
it("preserves mode-specific Mega-Debrid account statuses across save and load", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const accountId = getMegaDebridAccountId("shared-login");
const apiStatusId = getMegaDebridAccountStatusId(accountId, "api");
const webStatusId = getMegaDebridAccountStatusId(accountId, "web");
const settings = {
...defaultSettings(),
rememberToken: true,
megaDebridApiCredentials: "shared-login:api-password",
megaDebridWebCredentials: "shared-login:web-password",
debridAccountStatuses: {
[apiStatusId]: {
accountId: apiStatusId,
provider: "megadebrid" as const,
label: "API account",
maskedLogin: "sh*******in",
valid: false,
isPremium: false,
premiumUntilMs: null,
message: "API login failed",
checkedAt: 100
},
[webStatusId]: {
accountId: webStatusId,
provider: "megadebrid" as const,
label: "Web account",
maskedLogin: "sh*******in",
valid: true,
isPremium: true,
premiumUntilMs: 200,
message: "Web login succeeded",
checkedAt: 101
}
}
};
saveSettings(paths, settings);
expect(loadSettings(paths).debridAccountStatuses).toEqual(settings.debridAccountStatuses);
});
it.each(["sync", "async"] as const)("preserves the previous recoverable settings state during a %s save", async (mode) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
@@ -702,7 +746,7 @@ describe("settings storage", () => {
expect(loadHistoryForRetention(paths, "never")).toEqual([]);
});
it("clears persisted history for session retention mode", () => {
it("clears persisted history for session retention mode", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
@@ -723,8 +767,39 @@ describe("settings storage", () => {
resetHistoryForRetention(paths, "session");
expect(loadHistory(paths)).toEqual([]);
});
expect(loadHistory(paths)).toEqual([]);
});
it("propagates a history deletion failure instead of reporting success", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveHistory(paths, [{
id: "hist-locked",
name: "locked",
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid",
completedAt: Date.now(),
durationSeconds: 1,
status: "completed",
outputDir: path.join(dir, "out"),
urls: []
}]);
const originalUnlink = fs.unlinkSync;
vi.spyOn(fs, "unlinkSync").mockImplementation((target) => {
if (target === paths.historyFile) {
const error = new Error("EPERM: history file is locked") as NodeJS.ErrnoException;
error.code = "EPERM";
throw error;
}
return originalUnlink(target);
});
expect(() => clearHistory(paths)).toThrow(/EPERM/);
expect(loadHistory(paths)).toHaveLength(1);
});
it("caps persisted history to the configured maxEntries", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));