feat: improve proxy account setup and backup portability
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
createAccountToggleQueue,
|
||||
enqueueAccountToggleIntent,
|
||||
filterAccountDialogOptions,
|
||||
formatAccountOperationError,
|
||||
getAvailableAccountOptions,
|
||||
getAccountDialogSelectableOptions,
|
||||
isAccountRowSelectionKey,
|
||||
@@ -33,6 +34,24 @@ describe("account mode filter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("account operation errors", () => {
|
||||
it("replaces wrapped IPC proxy markers with actionable messages", () => {
|
||||
expect(formatAccountOperationError(
|
||||
"Account konnte nicht gespeichert werden",
|
||||
new Error("Error invoking remote method: proxy_only_account:proxy_list_missing")
|
||||
)).toBe("Account konnte nicht gespeichert werden: Proxy-only ist aktiviert, aber es ist keine Proxy-Liste hinterlegt. Hinterlege sie unter Einstellungen → Geschwindigkeit.");
|
||||
expect(formatAccountOperationError(
|
||||
"Prüfung fehlgeschlagen",
|
||||
"proxy_only_account:proxy_unreachable"
|
||||
)).toContain("feste API-Proxy ist nicht erreichbar");
|
||||
});
|
||||
|
||||
it("preserves non-proxy account failures", () => {
|
||||
expect(formatAccountOperationError("Prüfung fehlgeschlagen", new Error("Ungültiger API-Key")))
|
||||
.toBe("Prüfung fehlgeschlagen: Error: Ungültiger API-Key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("account dialog filter", () => {
|
||||
const options = [
|
||||
{ id: "rd-api", serviceLabel: "Real-Debrid", title: "Real-Debrid API", modeLabel: "API", pickerDescription: "API-Token" },
|
||||
|
||||
@@ -7,6 +7,8 @@ import { defaultSettings } from "../src/main/constants";
|
||||
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||
import { prepareDailyStartSettingsPatch } from "../src/main/daily-start-scheduler";
|
||||
import { logger } from "../src/main/logger";
|
||||
import { getManagedOnlineProxyListPath } from "../src/main/online-proxy-list";
|
||||
import { configureNetworkProxy } from "../src/main/network-proxy";
|
||||
import { createStatisticsLedger, loadStatisticsLedger, saveStatisticsLedger } from "../src/main/statistics-ledger";
|
||||
import { createStoragePaths, emptySession, loadHistory, loadSession, loadSettings, saveHistory, saveSession, saveSettings } from "../src/main/storage";
|
||||
import type { CollectorPersistenceState } from "../src/shared/collector";
|
||||
@@ -202,6 +204,7 @@ afterEach(async () => {
|
||||
debugStorage.configError = null;
|
||||
debugStorage.restartCalls = 0;
|
||||
debugStorage.restartErrors = [];
|
||||
configureNetworkProxy(defaultSettings());
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
@@ -431,7 +434,101 @@ describe("AppController full backup history restore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppController Proxy-only account validation", () => {
|
||||
it("rejects account checks before network access when no proxy list is configured", async () => {
|
||||
const controller = createController({
|
||||
...defaultSettings(),
|
||||
proxyDownloadEnabled: true,
|
||||
proxyListPath: ""
|
||||
}) as any;
|
||||
controller.applyNetworkProxyConfiguration();
|
||||
|
||||
await expect(controller.checkAccountCredentials({
|
||||
kind: "realdebrid-api",
|
||||
secret: "test-token"
|
||||
})).rejects.toThrow("proxy_only_account:proxy_list_missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppController settings-only backup transactions", () => {
|
||||
it("restores an online proxy list to a managed local path", async () => {
|
||||
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
|
||||
const importedSettings = {
|
||||
...currentSettings,
|
||||
outputDir: "C:\\Imported",
|
||||
proxyDownloadEnabled: true,
|
||||
proxyListPath: "C:\\OtherServer\\proxy.txt",
|
||||
proxyApiProxyIndex: 1
|
||||
};
|
||||
const controller = createController(currentSettings) as any;
|
||||
controller.applyNetworkProxyConfiguration = vi.fn();
|
||||
const content = "proxy-user:proxy-secret@192.0.2.10:8080\n";
|
||||
|
||||
const result = await controller.applySettingsOnlyBackup(importedSettings, undefined, false, content);
|
||||
const managedPath = getManagedOnlineProxyListPath(controller.storagePaths.baseDir);
|
||||
|
||||
expect(result).toEqual({ proxyListRestored: true, proxyOnlyDisabled: false });
|
||||
expect(fs.readFileSync(managedPath, "utf8")).toBe(content);
|
||||
expect(controller.getSettings().proxyDownloadEnabled).toBe(true);
|
||||
expect(controller.getSettings().proxyListPath).toBe(managedPath);
|
||||
expect(loadSettings(controller.storagePaths).proxyListPath).toBe(managedPath);
|
||||
});
|
||||
|
||||
it("disables Proxy-only when an older online backup has no embedded proxy list", async () => {
|
||||
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
|
||||
const importedSettings = {
|
||||
...currentSettings,
|
||||
outputDir: "C:\\Imported",
|
||||
proxyDownloadEnabled: true,
|
||||
proxyListPath: "C:\\OtherServer\\proxy.txt"
|
||||
};
|
||||
const controller = createController(currentSettings) as any;
|
||||
controller.applyNetworkProxyConfiguration = vi.fn();
|
||||
|
||||
const result = await controller.applySettingsOnlyBackup(importedSettings, undefined, false, null);
|
||||
|
||||
expect(result).toEqual({ proxyListRestored: false, proxyOnlyDisabled: true });
|
||||
expect(controller.getSettings().proxyDownloadEnabled).toBe(false);
|
||||
expect(controller.getSettings().proxyListPath).toBe("");
|
||||
});
|
||||
|
||||
it("restores the previous managed proxy file when online settings persistence fails", async () => {
|
||||
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
|
||||
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported", proxyDownloadEnabled: true };
|
||||
const controller = createController(currentSettings) as any;
|
||||
controller.applyNetworkProxyConfiguration = vi.fn();
|
||||
const managedPath = getManagedOnlineProxyListPath(controller.storagePaths.baseDir);
|
||||
fs.writeFileSync(managedPath, "192.0.2.1:8080\n", "utf8");
|
||||
bootStorage.settingsSaveError = new Error("online settings locked");
|
||||
|
||||
await expect(controller.applySettingsOnlyBackup(
|
||||
importedSettings,
|
||||
undefined,
|
||||
false,
|
||||
"198.51.100.1:3128\n"
|
||||
)).rejects.toThrow("online settings locked");
|
||||
|
||||
expect(fs.readFileSync(managedPath, "utf8")).toBe("192.0.2.1:8080\n");
|
||||
expect(controller.getSettings().outputDir).toBe("C:\\Current");
|
||||
});
|
||||
|
||||
it("preserves local-backup proxy paths when no online proxy-list mode is supplied", async () => {
|
||||
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
|
||||
const importedSettings = {
|
||||
...currentSettings,
|
||||
outputDir: "C:\\Imported",
|
||||
proxyDownloadEnabled: true,
|
||||
proxyListPath: "C:\\LocalBackup\\proxy.txt"
|
||||
};
|
||||
const controller = createController(currentSettings) as any;
|
||||
controller.applyNetworkProxyConfiguration = vi.fn();
|
||||
|
||||
await controller.applySettingsOnlyBackup(importedSettings);
|
||||
|
||||
expect(controller.getSettings().proxyDownloadEnabled).toBe(true);
|
||||
expect(controller.getSettings().proxyListPath).toBe("C:\\LocalBackup\\proxy.txt");
|
||||
});
|
||||
|
||||
it("applies runtime settings before releasing the successful import barrier", async () => {
|
||||
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
|
||||
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
|
||||
|
||||
@@ -28,6 +28,27 @@ describe("renderer localization", () => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Proxy-only ist aktiviert, aber es ist keine Proxy-Liste hinterlegt. Hinterlege sie unter Einstellungen → Geschwindigkeit.", "Proxy-only is enabled, but no proxy list is configured. Add one under Settings → Speed."],
|
||||
["Proxy-only ist aktiviert, aber die hinterlegte Proxy-Liste kann nicht gelesen werden. Prüfe die Datei unter Einstellungen → Geschwindigkeit.", "Proxy-only is enabled, but the configured proxy list cannot be read. Check the file under Settings → Speed."],
|
||||
["Proxy-only ist aktiviert, aber die hinterlegte Proxy-Liste ist leer oder enthält keine gültigen HTTP-Proxys.", "Proxy-only is enabled, but the configured proxy list is empty or contains no valid HTTP proxies."],
|
||||
["Proxy-only ist aktiviert, aber der feste API-Proxy ist in der Liste nicht verfügbar. Prüfe den Listeneintrag unter Einstellungen → Geschwindigkeit.", "Proxy-only is enabled, but the fixed API proxy is not available in the list. Check the list entry under Settings → Speed."],
|
||||
["Proxy-only ist aktiviert, aber der feste API-Proxy ist nicht erreichbar oder lehnt die Verbindung ab. Prüfe den Proxy unter Einstellungen → Geschwindigkeit.", "Proxy-only is enabled, but the fixed API proxy is unreachable or refuses the connection. Check the proxy under Settings → Speed."]
|
||||
])("translates Proxy-only account guidance %s", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf.", "This key restores your settings, including saved credentials and the configured proxy list. Keep it as secure as a password."],
|
||||
["Füge den vollständigen MDD2-Schlüssel ein. Einstellungen und eine enthaltene Proxy-Liste werden durch die gespeicherte Version ersetzt.", "Paste the complete MDD2 key. Settings and any included proxy list will be replaced by the stored version."],
|
||||
["Einstellungen und Proxy-Liste aus Online-Sicherung wiederhergestellt", "Settings and proxy list restored from online backup"],
|
||||
["Einstellungen wiederhergestellt; Proxy-only wurde deaktiviert, weil die Online-Sicherung keine Proxy-Liste enthält", "Settings restored; Proxy-only was disabled because the online backup contains no proxy list"]
|
||||
])("translates online proxy-list backup text %s", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Erfolgsmeldungen senden", "Send success notifications"],
|
||||
["Gesammelt (alle 2 Minuten)", "Grouped (every 2 minutes)"],
|
||||
|
||||
@@ -41,15 +41,19 @@ describe("online backup key", () => {
|
||||
});
|
||||
|
||||
it("never places credentials or the decryption secret in the server record", () => {
|
||||
const created = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z");
|
||||
const proxyList = "proxy-user:proxy-secret@192.0.2.10:8080\n";
|
||||
const created = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z", proxyList);
|
||||
const serialized = JSON.stringify(created.record);
|
||||
const parsed = parseOnlineBackupKey(created.key);
|
||||
|
||||
expect(serialized).not.toContain("rd-secret-token");
|
||||
expect(serialized).not.toContain("fixture-deepbrid-online-key-6jK8");
|
||||
expect(serialized).not.toContain("backup-password");
|
||||
expect(serialized).not.toContain("proxy-user");
|
||||
expect(serialized).not.toContain("proxy-secret");
|
||||
expect(serialized).not.toContain(parsed.masterKey.toString("base64url"));
|
||||
expect(Object.keys(created.record).sort()).toEqual(["blob", "deleteVerifier", "id"]);
|
||||
expect(restoreOnlineBackup(created.key, created.record.blob).proxyList).toEqual({ version: 1, content: proxyList });
|
||||
});
|
||||
|
||||
it("rejects corrupted keys and encrypted payloads before returning settings", () => {
|
||||
@@ -67,6 +71,7 @@ describe("online backup key", () => {
|
||||
const oversized = { ...settings(), archivePasswordList: "x".repeat(600_000) };
|
||||
|
||||
expect(() => createOnlineBackup(oversized, "2.0.0")).toThrow(/zu groß/i);
|
||||
expect(() => createOnlineBackup(settings(), "2.0.0", undefined, "invalid proxy line\n")).toThrow(/keine gültigen/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { captureOnlineProxyList, getManagedOnlineProxyListPath, MAX_ONLINE_PROXY_LIST_BYTES, writeImportedOnlineProxyList } from "../src/main/online-proxy-list";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function tempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-online-proxy-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("online proxy list", () => {
|
||||
it("captures and restores the exact proxy-list content into the managed runtime file", () => {
|
||||
const dir = tempDir();
|
||||
const source = path.join(dir, "premium.txt");
|
||||
const content = "user:secret@192.0.2.10:8080\r\nhttp://198.51.100.2:3128\r\n";
|
||||
fs.writeFileSync(source, content, "utf8");
|
||||
|
||||
const captured = captureOnlineProxyList({ proxyDownloadEnabled: true, proxyListPath: source });
|
||||
const target = writeImportedOnlineProxyList(dir, captured as string);
|
||||
|
||||
expect(target).toBe(getManagedOnlineProxyListPath(dir));
|
||||
expect(fs.readFileSync(target, "utf8")).toBe(content);
|
||||
});
|
||||
|
||||
it("replaces a previously imported managed list", () => {
|
||||
const dir = tempDir();
|
||||
const first = writeImportedOnlineProxyList(dir, "192.0.2.1:8080\n");
|
||||
const second = writeImportedOnlineProxyList(dir, "198.51.100.1:3128\n");
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(fs.readFileSync(second, "utf8")).toBe("198.51.100.1:3128\n");
|
||||
});
|
||||
|
||||
it("rejects missing, unreadable, empty, invalid and oversized configured lists", () => {
|
||||
const dir = tempDir();
|
||||
const empty = path.join(dir, "empty.txt");
|
||||
const invalid = path.join(dir, "invalid.txt");
|
||||
const oversized = path.join(dir, "oversized.txt");
|
||||
fs.writeFileSync(empty, "", "utf8");
|
||||
fs.writeFileSync(invalid, "not a proxy\n", "utf8");
|
||||
fs.writeFileSync(oversized, Buffer.alloc(MAX_ONLINE_PROXY_LIST_BYTES + 1, 120));
|
||||
|
||||
expect(() => captureOnlineProxyList({ proxyDownloadEnabled: true, proxyListPath: "" })).toThrow(/keine Proxy-Liste/i);
|
||||
expect(() => captureOnlineProxyList({ proxyDownloadEnabled: true, proxyListPath: path.join(dir, "missing.txt") })).toThrow(/nicht gelesen/i);
|
||||
expect(() => captureOnlineProxyList({ proxyDownloadEnabled: true, proxyListPath: empty })).toThrow(/leer/i);
|
||||
expect(() => captureOnlineProxyList({ proxyDownloadEnabled: true, proxyListPath: invalid })).toThrow(/keine gültigen/i);
|
||||
expect(() => captureOnlineProxyList({ proxyDownloadEnabled: true, proxyListPath: oversized })).toThrow(/zu groß/i);
|
||||
});
|
||||
|
||||
it("omits an unconfigured list while Proxy-only is disabled", () => {
|
||||
expect(captureOnlineProxyList({ proxyDownloadEnabled: false, proxyListPath: "" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createProxyOnlyAccountError, resolveProxyOnlyAccountErrorCode } from "../src/main/proxy-account-errors";
|
||||
|
||||
const enabledSettings = { proxyDownloadEnabled: true, proxyListPath: "C:\\proxy.txt" };
|
||||
|
||||
describe("Proxy-only account errors", () => {
|
||||
it("distinguishes missing, unreadable, empty and unavailable fixed proxy settings", () => {
|
||||
expect(resolveProxyOnlyAccountErrorCode(
|
||||
{ proxyDownloadEnabled: true, proxyListPath: "" },
|
||||
{ status: "blocked", reason: "proxy_file_unavailable" }
|
||||
)).toBe("proxy_list_missing");
|
||||
expect(resolveProxyOnlyAccountErrorCode(
|
||||
enabledSettings,
|
||||
{ status: "blocked", reason: "proxy_file_unavailable" }
|
||||
)).toBe("proxy_list_unreadable");
|
||||
expect(resolveProxyOnlyAccountErrorCode(
|
||||
enabledSettings,
|
||||
{ status: "blocked", reason: "no_valid_proxies" }
|
||||
)).toBe("proxy_list_empty");
|
||||
expect(resolveProxyOnlyAccountErrorCode(
|
||||
enabledSettings,
|
||||
{ status: "blocked", reason: "proxy_index_unavailable" }
|
||||
)).toBe("proxy_index_unavailable");
|
||||
});
|
||||
|
||||
it("maps only transport failures from an active fixed proxy", () => {
|
||||
const active = { status: "active", selectedIndex: 1, proxyCount: 20 } as const;
|
||||
expect(resolveProxyOnlyAccountErrorCode(enabledSettings, active, "Prüfung fehlgeschlagen: fetch failed"))
|
||||
.toBe("proxy_unreachable");
|
||||
expect(resolveProxyOnlyAccountErrorCode(enabledSettings, active, "Prüfung fehlgeschlagen: The operation was aborted due to timeout"))
|
||||
.toBe("proxy_unreachable");
|
||||
expect(resolveProxyOnlyAccountErrorCode(enabledSettings, active, "Prüfung fehlgeschlagen (HTTP 407)"))
|
||||
.toBe("proxy_unreachable");
|
||||
expect(resolveProxyOnlyAccountErrorCode(enabledSettings, active, "Prüfung fehlgeschlagen (HTTP 503)"))
|
||||
.toBeNull();
|
||||
expect(resolveProxyOnlyAccountErrorCode(enabledSettings, active, "Ungültiger API-Token"))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it("does not classify errors while Proxy-only is disabled", () => {
|
||||
expect(resolveProxyOnlyAccountErrorCode(
|
||||
{ proxyDownloadEnabled: false, proxyListPath: "" },
|
||||
{ status: "disabled" },
|
||||
"fetch failed"
|
||||
)).toBeNull();
|
||||
expect(createProxyOnlyAccountError("proxy_list_missing").message).toBe("proxy_only_account:proxy_list_missing");
|
||||
});
|
||||
});
|
||||
@@ -95,3 +95,32 @@ describe("global Escape selection routing", () => {
|
||||
expect(api.releaseAccountSelectionFocus?.(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("global Ctrl+A selection routing", () => {
|
||||
it("routes the account overview to account selection and preserves text editing", () => {
|
||||
const api = selection as typeof selection & {
|
||||
resolveSelectAllSelectionScope?: (
|
||||
view: string,
|
||||
settingsSection: string,
|
||||
accountPanel: string,
|
||||
tagName: string,
|
||||
inputType?: string
|
||||
) => string | null;
|
||||
};
|
||||
|
||||
expect(api.resolveSelectAllSelectionScope).toBeTypeOf("function");
|
||||
expect(api.resolveSelectAllSelectionScope?.("settings", "accounts", "overview", "BODY")).toBe("accounts");
|
||||
expect(api.resolveSelectAllSelectionScope?.("settings", "accounts", "overview", "INPUT", "checkbox")).toBe("accounts");
|
||||
expect(api.resolveSelectAllSelectionScope?.("settings", "accounts", "rules", "BODY")).toBeNull();
|
||||
expect(api.resolveSelectAllSelectionScope?.("settings", "accounts", "runtime", "BODY")).toBeNull();
|
||||
expect(api.resolveSelectAllSelectionScope?.("settings", "accounts", "overview", "INPUT", "text")).toBeNull();
|
||||
expect(api.resolveSelectAllSelectionScope?.("settings", "accounts", "overview", "TEXTAREA")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves the existing shortcut scopes outside account management", () => {
|
||||
expect(selection.resolveSelectAllSelectionScope("downloads", "allgemein", "overview", "BODY")).toBe("downloads");
|
||||
expect(selection.resolveSelectAllSelectionScope("collector", "allgemein", "overview", "BODY")).toBe("collector");
|
||||
expect(selection.resolveSelectAllSelectionScope("history", "allgemein", "overview", "BODY")).toBe("history");
|
||||
expect(selection.resolveSelectAllSelectionScope("downloads", "allgemein", "overview", "INPUT", "checkbox")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1167,6 +1167,15 @@ describe("account workspace", () => {
|
||||
expect(html).toContain("− Entfernen (3)");
|
||||
});
|
||||
|
||||
it("selects every visible account row from the global Ctrl+A shortcut", () => {
|
||||
const shortcutBlock = sourceBlock(appSource, "if (!e.shiftKey && e.key.toLowerCase() === \"a\")", "return;\n }");
|
||||
|
||||
expect(shortcutBlock).toContain("resolveSelectAllSelectionScope");
|
||||
expect(shortcutBlock).toContain("settingsSubTabRef.current");
|
||||
expect(shortcutBlock).toContain("accountManagementTabRef.current");
|
||||
expect(shortcutBlock).toContain("setSelectedAccountRowKeys(new Set(visibleAccountRowKeysRef.current))");
|
||||
});
|
||||
|
||||
it("removes the redundant global account activation switch", () => {
|
||||
const legacyActions = { ...workspaceActions(), onSetAllEnabled: () => {} } as AccountWorkspaceActions;
|
||||
const legacyModel = { ...workspaceModel(), allEnabled: true } as AccountWorkspaceViewModel;
|
||||
|
||||
Reference in New Issue
Block a user