Files
OmniRoute/tests/unit/electron-remote-server.test.ts
2026-08-06 06:05:24 -03:00

255 lines
9.9 KiB
TypeScript

/**
* Tests for Electron Remote Server Mode
*
* Covers:
* - resolveRemoteServerUrl precedence (env > persisted prefs > null)
* - URL validation (only http/https accepted, trailing slash stripped)
* - Corrupt/partial prefs file handled gracefully (falls back to local server)
* - remoteServerPreferences read/write round-trip
* - main.js wiring: startNextServer() short-circuits in remote mode, tray
* menu exposes the toggle, packaging manifest ships the new files
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const {
resolveRemoteServerUrl,
isValidHttpUrl,
} = require("../../electron/lib/resolveRemoteServerUrl");
const {
readPreferences,
writeRemoteServerUrl,
} = require("../../electron/lib/remoteServerPreferences");
function withTempDir(fn: (dir: string) => void) {
const dir = mkdtempSync(join(tmpdir(), "omniroute-remote-server-"));
try {
fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe("resolveRemoteServerUrl precedence", () => {
it("returns null when neither env var nor prefs file are set", () => {
withTempDir((dir) => {
const prefsPath = join(dir, "electron-preferences.json");
const result = resolveRemoteServerUrl({ env: {}, prefsPath });
assert.equal(result, null);
});
});
it("prefers OMNIROUTE_REMOTE_URL env var over the persisted prefs file", () => {
withTempDir((dir) => {
const prefsPath = join(dir, "electron-preferences.json");
writeRemoteServerUrl(prefsPath, "http://from-prefs:20128");
const result = resolveRemoteServerUrl({
env: { OMNIROUTE_REMOTE_URL: "http://from-env:20128" },
prefsPath,
});
assert.equal(result, "http://from-env:20128");
});
});
it("falls back to the persisted prefs file when no env var is set", () => {
withTempDir((dir) => {
const prefsPath = join(dir, "electron-preferences.json");
writeRemoteServerUrl(prefsPath, "http://localhost:20128");
const result = resolveRemoteServerUrl({ env: {}, prefsPath });
assert.equal(result, "http://localhost:20128");
});
});
it("strips a trailing slash from the resolved URL", () => {
withTempDir((dir) => {
const prefsPath = join(dir, "electron-preferences.json");
const result = resolveRemoteServerUrl({
env: { OMNIROUTE_REMOTE_URL: "http://localhost:20128/" },
prefsPath,
});
assert.equal(result, "http://localhost:20128");
});
});
it("rejects a non-http(s) URL (e.g. file:// or javascript:) and returns null", () => {
withTempDir((dir) => {
const prefsPath = join(dir, "electron-preferences.json");
for (const bad of ["file:///etc/passwd", "javascript:alert(1)", "not a url", ""]) {
const result = resolveRemoteServerUrl({ env: { OMNIROUTE_REMOTE_URL: bad }, prefsPath });
assert.equal(result, null, `expected null for ${JSON.stringify(bad)}`);
}
});
});
it("ignores a corrupt prefs file and falls back to null rather than throwing", () => {
withTempDir((dir) => {
const prefsPath = join(dir, "electron-preferences.json");
require("node:fs").writeFileSync(prefsPath, "{ not valid json", "utf8");
const result = resolveRemoteServerUrl({ env: {}, prefsPath });
assert.equal(result, null);
});
});
it("treats a missing prefs file as absent rather than throwing", () => {
withTempDir((dir) => {
const prefsPath = join(dir, "does-not-exist.json");
assert.doesNotThrow(() => resolveRemoteServerUrl({ env: {}, prefsPath }));
});
});
});
describe("isValidHttpUrl", () => {
it("accepts http and https", () => {
assert.equal(isValidHttpUrl("http://localhost:20128"), true);
assert.equal(isValidHttpUrl("https://omniroute.example.com"), true);
});
it("rejects other protocols and invalid strings", () => {
assert.equal(isValidHttpUrl("ftp://example.com"), false);
assert.equal(isValidHttpUrl("file:///etc/passwd"), false);
assert.equal(isValidHttpUrl("javascript:alert(1)"), false);
assert.equal(isValidHttpUrl("not a url"), false);
});
});
describe("remoteServerPreferences read/write", () => {
it("round-trips a URL through write then read", () => {
withTempDir((dir) => {
const prefsPath = join(dir, "electron-preferences.json");
writeRemoteServerUrl(prefsPath, "http://localhost:20128");
assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: "http://localhost:20128" });
});
});
it("clearing with null removes the preference", () => {
withTempDir((dir) => {
const prefsPath = join(dir, "electron-preferences.json");
writeRemoteServerUrl(prefsPath, "http://localhost:20128");
writeRemoteServerUrl(prefsPath, null);
assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: null });
});
});
it("creates the parent directory if it does not exist yet", () => {
withTempDir((dir) => {
const prefsPath = join(dir, "nested", "deep", "electron-preferences.json");
assert.doesNotThrow(() => writeRemoteServerUrl(prefsPath, "http://localhost:20128"));
assert.equal(existsSync(prefsPath), true);
assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: "http://localhost:20128" });
});
});
it("reading a nonexistent prefs file returns remoteServerUrl: null", () => {
withTempDir((dir) => {
const prefsPath = join(dir, "electron-preferences.json");
assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: null });
});
});
});
// ─── main.js wiring (static-analysis style, matching the repo's existing
// convention for asserting structure without importing the Electron binary) ───
describe("Electron main.js Remote Server Mode wiring", () => {
const mainSrc = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8");
it("startNextServer() short-circuits before the isDev branch when remoteServerUrl is set", () => {
const fn = mainSrc.match(/function startNextServer\(\)[\s\S]*?\n}/);
assert.ok(fn, "startNextServer function should exist in electron/main.js");
const body = fn![0];
const remoteIdx = body.indexOf("if (remoteServerUrl)");
const devIdx = body.indexOf("if (isDev)");
assert.ok(remoteIdx !== -1, "startNextServer must check remoteServerUrl");
assert.ok(devIdx !== -1, "startNextServer must still check isDev");
assert.ok(remoteIdx < devIdx, "the remoteServerUrl check must come before the isDev check");
});
it("getServerUrl() prefers remoteServerUrl over the local port", () => {
assert.match(
mainSrc,
/const getServerUrl = \(\) => remoteServerUrl \|\| `http:\/\/localhost:\$\{serverPort\}`;/
);
});
it("exposes a tray menu entry to configure or clear the remote server", () => {
assert.match(mainSrc, /label: "Remote Server"/);
assert.match(mainSrc, /Connect to Remote Server/);
assert.match(mainSrc, /Disconnect \(use Local Server\)/);
});
it("the remote-server prompt window uses contextIsolation and disables nodeIntegration", () => {
const fn = mainSrc.match(/function showRemoteServerPrompt\(\)[\s\S]*?\n}/);
assert.ok(fn, "showRemoteServerPrompt function should exist");
const body = fn![0];
assert.match(body, /contextIsolation:\s*true/);
assert.match(body, /nodeIntegration:\s*false/);
});
// setRemoteServerUrl() is the runtime, UI-driven path (tray prompt / IPC) for
// applying an operator-supplied URL — distinct from resolveRemoteServerUrl()'s
// startup precedence, which is already covered above. A URL typed into the
// "Connect to Remote Server…" prompt must go through the same isValidHttpUrl
// guard (only http/https accepted) *before* any server-lifecycle mutation, so
// an arbitrary/malicious string (file://, javascript:, garbage) can never reach
// stopNextServer()/startNextServer() or get persisted to prefs. Exercised via
// static analysis (matching this file's convention) since setRemoteServerUrl
// requires the full Electron main process to invoke directly.
it("setRemoteServerUrl() validates via isValidHttpUrl and rejects before mutating server state", () => {
const fn = mainSrc.match(/async function setRemoteServerUrl\(nextUrl\)[\s\S]*?\n}/);
assert.ok(fn, "setRemoteServerUrl function should exist in electron/main.js");
const body = fn![0];
const validationIdx = body.indexOf("isValidHttpUrl(normalized)");
const stopServerIdx = body.indexOf("stopNextServer()");
assert.ok(validationIdx !== -1, "setRemoteServerUrl must validate via isValidHttpUrl");
assert.ok(
stopServerIdx !== -1,
"setRemoteServerUrl must stop the running server when switching modes"
);
assert.ok(
validationIdx < stopServerIdx,
"URL validation must run before any server-lifecycle mutation"
);
const rejectBranch = body.slice(validationIdx, stopServerIdx);
assert.match(
rejectBranch,
/return;/,
"an invalid URL must short-circuit setRemoteServerUrl instead of falling through"
);
assert.match(
rejectBranch,
/console\.warn/,
"an invalid URL should be logged so operators can see it was rejected"
);
});
});
describe("Electron packaging manifest includes Remote Server Mode files", () => {
const pkg = JSON.parse(
readFileSync(join(import.meta.dirname, "../../electron/package.json"), "utf8")
);
const files: string[] = pkg.build?.files ?? [];
for (const expected of [
"lib/resolveRemoteServerUrl.js",
"lib/remoteServerPreferences.js",
"remoteServerPromptPreload.js",
"remoteServerPromptRenderer.js",
"assets/remoteServerPrompt.html",
]) {
it(`ships ${expected} in package.json build.files`, () => {
assert.ok(files.includes(expected), `${expected} is missing from build.files`);
});
}
});