Compare commits
3 Commits
c79a031be8
...
8f3681b160
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f3681b160 | ||
|
|
d9657a5459 | ||
|
|
a35ddf68b2 |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.223",
|
"version": "1.7.224",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -45,7 +45,7 @@ import { runInstallWithResume } from "./update-install-flow";
|
|||||||
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
|
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
|
||||||
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
|
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
|
||||||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
||||||
import { buildBackupPayload, planBackupImport } from "./backup-payload";
|
import { buildBackupPayload, planBackupImport, resolveMcpRemoteRestore, BackupMcpRemote } from "./backup-payload";
|
||||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
||||||
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
||||||
import { initConversionLog, shutdownConversionLog } from "./conversion-trace";
|
import { initConversionLog, shutdownConversionLog } from "./conversion-trace";
|
||||||
@ -372,6 +372,23 @@ export class AppController {
|
|||||||
return this.getRemoteDiagnostics();
|
return this.getRemoteDiagnostics();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private restoreMcpRemoteFromBackup(section: unknown, restartNow: boolean): void {
|
||||||
|
const restore = resolveMcpRemoteRestore(section);
|
||||||
|
if (!restore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeDebugServerConfig({ host: restore.host, port: restore.port, allowlist: restore.allowlist });
|
||||||
|
if (restartNow) {
|
||||||
|
void restartDebugServer().catch(() => {});
|
||||||
|
}
|
||||||
|
this.audit("INFO", "Ferndiagnose-Einstellungen aus Backup wiederhergestellt", {
|
||||||
|
port: restore.port ?? null,
|
||||||
|
allowlistCount: restore.allowlist?.length ?? 0,
|
||||||
|
host: restore.host ?? "unveraendert",
|
||||||
|
restartNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public getDebugSetupCheck(): DebugSetupCheckResult {
|
public getDebugSetupCheck(): DebugSetupCheckResult {
|
||||||
return getDebugSetupCheck(this.storagePaths.baseDir);
|
return getDebugSetupCheck(this.storagePaths.baseDir);
|
||||||
}
|
}
|
||||||
@ -724,13 +741,22 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public exportBackup(): Buffer {
|
public exportBackup(): Buffer {
|
||||||
const includeDownloads = Boolean(this.settings.backupIncludeDownloads);
|
let mcpRemote: BackupMcpRemote | undefined;
|
||||||
|
if (Boolean(this.settings.backupIncludeMcp)) {
|
||||||
|
const status = getDebugServerRuntimeStatus();
|
||||||
|
mcpRemote = {
|
||||||
|
allowlist: getDebugAllowlist(),
|
||||||
|
port: status.port,
|
||||||
|
hostMode: status.host === "0.0.0.0" ? "network" : "local"
|
||||||
|
};
|
||||||
|
}
|
||||||
const payloadObj = buildBackupPayload({
|
const payloadObj = buildBackupPayload({
|
||||||
settings: { ...this.settings },
|
settings: { ...this.settings },
|
||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
session: this.manager.getSession(),
|
session: this.manager.getSession(),
|
||||||
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits())
|
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
|
||||||
|
mcpRemote
|
||||||
});
|
});
|
||||||
this.audit("INFO", "Backup exportiert", {
|
this.audit("INFO", "Backup exportiert", {
|
||||||
kind: payloadObj.kind,
|
kind: payloadObj.kind,
|
||||||
@ -804,6 +830,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
this.settings = restoredSettings;
|
this.settings = restoredSettings;
|
||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
|
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
|
||||||
|
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, true);
|
||||||
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
|
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
|
||||||
accountSummary: buildAccountSummary(this.settings)
|
accountSummary: buildAccountSummary(this.settings)
|
||||||
});
|
});
|
||||||
@ -840,6 +867,8 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
|
|
||||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||||
|
|
||||||
|
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, false);
|
||||||
|
|
||||||
this.manager.skipShutdownPersist = true;
|
this.manager.skipShutdownPersist = true;
|
||||||
this.manager.blockAllPersistence = true;
|
this.manager.blockAllPersistence = true;
|
||||||
logger.info("Backup wiederhergestellt — App startet automatisch neu");
|
logger.info("Backup wiederhergestellt — App startet automatisch neu");
|
||||||
|
|||||||
@ -2,6 +2,12 @@ import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
|
|||||||
|
|
||||||
export type BackupKind = "full" | "settings-only";
|
export type BackupKind = "full" | "settings-only";
|
||||||
|
|
||||||
|
export interface BackupMcpRemote {
|
||||||
|
allowlist: string[];
|
||||||
|
port: number;
|
||||||
|
hostMode: "local" | "network";
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackupPayload {
|
export interface BackupPayload {
|
||||||
version: 2;
|
version: 2;
|
||||||
kind: BackupKind;
|
kind: BackupKind;
|
||||||
@ -10,6 +16,7 @@ export interface BackupPayload {
|
|||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
session?: SessionState;
|
session?: SessionState;
|
||||||
history?: HistoryEntry[];
|
history?: HistoryEntry[];
|
||||||
|
mcpRemote?: BackupMcpRemote;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildBackupInput {
|
export interface BuildBackupInput {
|
||||||
@ -19,6 +26,7 @@ export interface BuildBackupInput {
|
|||||||
/** Only bundled when includeDownloads is true. */
|
/** Only bundled when includeDownloads is true. */
|
||||||
session: SessionState;
|
session: SessionState;
|
||||||
history: HistoryEntry[];
|
history: HistoryEntry[];
|
||||||
|
mcpRemote?: BackupMcpRemote;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -40,9 +48,39 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
|||||||
base.session = input.session;
|
base.session = input.session;
|
||||||
base.history = input.history;
|
base.history = input.history;
|
||||||
}
|
}
|
||||||
|
if (Boolean(input.settings.backupIncludeMcp) && input.mcpRemote) {
|
||||||
|
base.mcpRemote = input.mcpRemote;
|
||||||
|
}
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface McpRemoteRestore {
|
||||||
|
host?: "127.0.0.1" | "0.0.0.0";
|
||||||
|
port?: number;
|
||||||
|
allowlist?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMcpRemoteRestore(section: unknown): McpRemoteRestore | null {
|
||||||
|
if (!section || typeof section !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown };
|
||||||
|
const allowlist = Array.isArray(s.allowlist)
|
||||||
|
? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim())
|
||||||
|
: undefined;
|
||||||
|
const port = (typeof s.port === "number" && Number.isInteger(s.port) && s.port >= 1024 && s.port <= 65535) ? s.port : undefined;
|
||||||
|
let host: "127.0.0.1" | "0.0.0.0" | undefined;
|
||||||
|
if (s.hostMode === "network") {
|
||||||
|
host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1";
|
||||||
|
} else if (s.hostMode === "local") {
|
||||||
|
host = "127.0.0.1";
|
||||||
|
}
|
||||||
|
if (host === undefined && port === undefined && allowlist === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { host, port, allowlist };
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImportPlan {
|
export interface ImportPlan {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
/** Restore the download list (session + history) and relaunch. */
|
/** Restore the download list (session + history) and relaunch. */
|
||||||
|
|||||||
@ -109,6 +109,7 @@ export function defaultSettings(): AppSettings {
|
|||||||
hideExtractedItems: true,
|
hideExtractedItems: true,
|
||||||
confirmDeleteSelection: true,
|
confirmDeleteSelection: true,
|
||||||
backupIncludeDownloads: false,
|
backupIncludeDownloads: false,
|
||||||
|
backupIncludeMcp: false,
|
||||||
notifyUrl: "",
|
notifyUrl: "",
|
||||||
notifyMention: "",
|
notifyMention: "",
|
||||||
notifyOnPackageCompleted: false,
|
notifyOnPackageCompleted: false,
|
||||||
|
|||||||
@ -461,6 +461,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
|
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
|
||||||
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
|
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
|
||||||
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
|
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
|
||||||
|
backupIncludeMcp: settings.backupIncludeMcp !== undefined ? Boolean(settings.backupIncludeMcp) : defaults.backupIncludeMcp,
|
||||||
notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl,
|
notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl,
|
||||||
notifyMention: asText(settings.notifyMention) || defaults.notifyMention,
|
notifyMention: asText(settings.notifyMention) || defaults.notifyMention,
|
||||||
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
|
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
|
||||||
|
|||||||
@ -856,7 +856,7 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
||||||
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
||||||
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
||||||
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false,
|
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeMcp: false,
|
||||||
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
||||||
accountListShowDetailedDebridLinkKeys: false,
|
accountListShowDetailedDebridLinkKeys: false,
|
||||||
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
||||||
@ -5442,6 +5442,8 @@ export function App(): ReactElement {
|
|||||||
<div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div>
|
<div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste mitsichern</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste mitsichern</label>
|
||||||
<div className="setting-hint">Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.</div>
|
<div className="setting-hint">Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.</div>
|
||||||
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeMcp} onChange={(e) => setBool("backupIncludeMcp", e.target.checked)} /> Ferndiagnose-Einstellungen mitsichern</label>
|
||||||
|
<div className="setting-hint">Allowlist, Port und Freigabemodus (lokal/Netzwerk) reisen mit. Verbindungs-Token und eigene Adresse bleiben pro Server – nach dem Import einmal „Aktivieren" drücken.</div>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
|
||||||
const next = e.target.checked ? "light" : "dark";
|
const next = e.target.checked ? "light" : "dark";
|
||||||
settingsDraftRevisionRef.current += 1;
|
settingsDraftRevisionRef.current += 1;
|
||||||
|
|||||||
@ -134,6 +134,7 @@ export interface AppSettings {
|
|||||||
hideExtractedItems: boolean;
|
hideExtractedItems: boolean;
|
||||||
confirmDeleteSelection: boolean;
|
confirmDeleteSelection: boolean;
|
||||||
backupIncludeDownloads: boolean;
|
backupIncludeDownloads: boolean;
|
||||||
|
backupIncludeMcp: boolean;
|
||||||
notifyUrl: string;
|
notifyUrl: string;
|
||||||
notifyMention: string;
|
notifyMention: string;
|
||||||
notifyOnPackageCompleted: boolean;
|
notifyOnPackageCompleted: boolean;
|
||||||
|
|||||||
199
tests/backup-mcp.test.ts
Normal file
199
tests/backup-mcp.test.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { once } from "node:events";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { buildBackupPayload, resolveMcpRemoteRestore, BackupMcpRemote } from "../src/main/backup-payload";
|
||||||
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
import { normalizeSettings } from "../src/main/storage";
|
||||||
|
import {
|
||||||
|
startDebugServer,
|
||||||
|
stopDebugServer,
|
||||||
|
restartDebugServer,
|
||||||
|
writeDebugServerConfig,
|
||||||
|
getDebugAllowlist,
|
||||||
|
getDebugServerRuntimeStatus
|
||||||
|
} from "../src/main/debug-server";
|
||||||
|
import type { DownloadManager } from "../src/main/download-manager";
|
||||||
|
import type { AppSettings, SessionState } from "../src/shared/types";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
function input(settingsOverride: Partial<AppSettings>, mcpRemote?: BackupMcpRemote) {
|
||||||
|
return {
|
||||||
|
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
|
||||||
|
appVersion: "1.7.224",
|
||||||
|
exportedAt: "2026-06-19T00:00:00.000Z",
|
||||||
|
session: {} as unknown as SessionState,
|
||||||
|
history: [],
|
||||||
|
mcpRemote
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFreePort(): Promise<number> {
|
||||||
|
const probe = http.createServer();
|
||||||
|
probe.listen(0, "127.0.0.1");
|
||||||
|
await once(probe, "listening");
|
||||||
|
const address = probe.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("port probe failed");
|
||||||
|
}
|
||||||
|
probe.close();
|
||||||
|
await once(probe, "close");
|
||||||
|
return address.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReady(url: string): Promise<void> {
|
||||||
|
const deadline = Date.now() + 5000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
}
|
||||||
|
throw new Error(`debug server not ready: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stopDebugServer();
|
||||||
|
while (tempDirs.length > 0) {
|
||||||
|
const dir = tempDirs.pop();
|
||||||
|
if (!dir) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("backup mcpRemote export gating", () => {
|
||||||
|
it("includes mcpRemote when backupIncludeMcp is on", () => {
|
||||||
|
const section: BackupMcpRemote = { allowlist: ["10.0.0.5", "192.168.1.0/24"], port: 9999, hostMode: "network" };
|
||||||
|
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, section));
|
||||||
|
expect(payload.mcpRemote).toEqual(section);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits mcpRemote when the toggle is off even if a section is provided", () => {
|
||||||
|
const payload = buildBackupPayload(input({ backupIncludeMcp: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||||
|
expect(payload.mcpRemote).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits mcpRemote when toggle on but no section gathered", () => {
|
||||||
|
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, undefined));
|
||||||
|
expect(payload.mcpRemote).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the mcpRemote section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
|
||||||
|
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||||
|
expect(payload.mcpRemote && Object.keys(payload.mcpRemote).sort()).toEqual(["allowlist", "hostMode", "port"]);
|
||||||
|
const sectionJson = JSON.stringify(payload.mcpRemote);
|
||||||
|
expect(sectionJson.toLowerCase()).not.toContain("token");
|
||||||
|
expect(sectionJson).not.toContain("publicHost");
|
||||||
|
expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("backupIncludeMcp settings persistence", () => {
|
||||||
|
it("normalizeSettings preserves backupIncludeMcp (the toggle survives save/load)", () => {
|
||||||
|
expect(normalizeSettings({ backupIncludeMcp: true } as unknown as AppSettings).backupIncludeMcp).toBe(true);
|
||||||
|
expect(normalizeSettings({ backupIncludeMcp: false } as unknown as AppSettings).backupIncludeMcp).toBe(false);
|
||||||
|
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeMcp).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveMcpRemoteRestore", () => {
|
||||||
|
it("maps network + non-empty allowlist to 0.0.0.0", () => {
|
||||||
|
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
|
||||||
|
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => {
|
||||||
|
expect(resolveMcpRemoteRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps local to 127.0.0.1", () => {
|
||||||
|
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an out-of-range or non-integer port", () => {
|
||||||
|
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
|
||||||
|
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
|
||||||
|
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters non-string and blank allowlist entries and trims", () => {
|
||||||
|
const r = resolveMcpRemoteRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
|
||||||
|
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for missing or empty/invalid sections", () => {
|
||||||
|
expect(resolveMcpRemoteRestore(undefined)).toBeNull();
|
||||||
|
expect(resolveMcpRemoteRestore(null)).toBeNull();
|
||||||
|
expect(resolveMcpRemoteRestore("x")).toBeNull();
|
||||||
|
expect(resolveMcpRemoteRestore({})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("backup mcpRemote live restore round-trip", () => {
|
||||||
|
it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => {
|
||||||
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp-"));
|
||||||
|
tempDirs.push(baseDir);
|
||||||
|
const startPort = await getFreePort();
|
||||||
|
const restorePort = await getFreePort();
|
||||||
|
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt-secret", "utf8");
|
||||||
|
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
|
||||||
|
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||||
|
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||||
|
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||||
|
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt-secret`);
|
||||||
|
expect(getDebugAllowlist()).toEqual([]);
|
||||||
|
|
||||||
|
const payload = buildBackupPayload(input(
|
||||||
|
{ backupIncludeMcp: true },
|
||||||
|
{ allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" }
|
||||||
|
));
|
||||||
|
|
||||||
|
const restore = resolveMcpRemoteRestore(payload.mcpRemote);
|
||||||
|
expect(restore).not.toBeNull();
|
||||||
|
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
||||||
|
const status = await restartDebugServer();
|
||||||
|
|
||||||
|
expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]);
|
||||||
|
expect(status.port).toBe(restorePort);
|
||||||
|
expect(status.host).toBe("0.0.0.0");
|
||||||
|
expect(status.allowlistCount).toBe(2);
|
||||||
|
|
||||||
|
expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret");
|
||||||
|
expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false);
|
||||||
|
|
||||||
|
await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => {
|
||||||
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp2-"));
|
||||||
|
tempDirs.push(baseDir);
|
||||||
|
const startPort = await getFreePort();
|
||||||
|
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8");
|
||||||
|
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
|
||||||
|
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||||
|
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||||
|
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||||
|
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`);
|
||||||
|
|
||||||
|
const restore = resolveMcpRemoteRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
|
||||||
|
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
||||||
|
|
||||||
|
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0");
|
||||||
|
expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100");
|
||||||
|
expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9");
|
||||||
|
expect(getDebugServerRuntimeStatus().port).toBe(startPort);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user