Advisor-Fund: der Settings-only-Import stellte die Ferndiagnose-Einstellungen wieder her, der Full-Backup-Import (Download-Liste mitgesichert) aber NICHT — er liess die debug_*-Dateien unangetastet, und der nachfolgende Relaunch bootete mit den alten Werten. Damit waere "MCP-Settings im Backup" auf dem Full-Backup-Pfad still gescheitert. Fix: restoreMcpRemoteFromBackup nimmt jetzt restartNow. Settings-only ruft mit true (laufenden Server sofort neu laden), Full-Backup mit false (nur Dateien schreiben — der Relaunch-Boot liest sie via startDebugServer). Beide Pfade tragen die Sektion jetzt. Zusatz-Tests: normalizeSettings bewahrt backupIncludeMcp (der Dreh- und Angelpunkt — ohne das wuerde der Schalter bei jedem Save auf false zuruecksetzen und das Feature tot sein); Full-Backup-Write-Pfad schreibt debug_host/port/allowlist auf Platte ohne Restart (In-Memory bleibt stale → Boot uebernimmt). Suite 919 gruen, tsc=6.
200 lines
8.5 KiB
TypeScript
200 lines
8.5 KiB
TypeScript
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);
|
|
});
|
|
});
|