mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
* test(infra): retry recursive temp-dir removal on main (main twin of #11968)
`main` has been red since b342c1a361 on the vitest and integration gates:
✖ tests/unit/autoCombo/provider-family-combos.test.ts > auto/<family>
✖ chat pipeline applies Codex OAuth fingerprint and priority tier inside combos
Both call resetStorage() from beforeEach, which does an fs.rmSync(TEST_DATA_DIR,
{recursive: true, force: true}) with no retry, and intermittently loses the race
with a not-yet-released SQLite handle (ENOTEMPTY).
release/v3.8.51 fixed this in #11968 with a mechanical codemod adding
maxRetries/retryDelay to every recursive rm/rmSync/rmdirSync under tests/, but
that PR landed only on the release branch. Because main only receives work at
the release squash, it stayed broken for the whole cycle — and repo-wide gates
then turn every open PR into main red on checks unrelated to their diff.
This is the --base main twin: re-runs the same codemod that already shipped on
the release branch (scripts/ad-hoc/codemod-rm-maxretries.mjs), so the two
branches converge on identical test-teardown semantics. Test-only; no product
logic is touched.
The remaining three failures reported on #12133 (unit full suite exceeding its
4800s ceiling, package-artifact exceeding 1200s, and the boot-smoke that is
skipped as a consequence) are runner-contention timeouts, not code defects —
validate-release-green.mjs runs those heavy gates concurrently on one shared
hosted runner. There is no fix to port for those.
* chore(scripts): carry the rm-maxretries codemod onto main alongside its output
The codemod that generated the previous commit lives in the repo on
release/v3.8.51 (added by #11968) but was never on main. Bringing it over keeps
the tool next to the change it produced, so the transformation stays
reproducible and auditable from either branch.
519 lines
18 KiB
TypeScript
519 lines
18 KiB
TypeScript
/**
|
|
* Tests for Electron main process (electron/main.js)
|
|
*
|
|
* Covers:
|
|
* - URL validation & RCE prevention
|
|
* - IPC channel security
|
|
* - Server readiness polling logic
|
|
* - Restart timeout + SIGKILL
|
|
* - Port change lifecycle
|
|
* - CSP header structure
|
|
* - Platform-conditional window options
|
|
*/
|
|
|
|
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, rmSync, readFileSync } 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 { waitForServer } = require("../../electron/lib/serverReadiness");
|
|
|
|
function raceDelays(firstMs, secondMs) {
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const first = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(second);
|
|
resolve(firstMs);
|
|
}, firstMs);
|
|
const second = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(first);
|
|
resolve(secondMs);
|
|
}, secondMs);
|
|
});
|
|
}
|
|
|
|
// ─── Packaging manifest completeness (#3292 regression) ──────
|
|
//
|
|
// The packaged Electron app (electron-builder asar) only ships the files
|
|
// listed in `build.files`. When #3292 added `electron/loginManager.js` and a
|
|
// `require("./loginManager")` in main.js without adding it to `build.files`,
|
|
// the packaged app crashed at startup with "Cannot find module './loginManager'"
|
|
// — caught only by the CI smoke test on Linux/macOS. This guard asserts that
|
|
// every local `require("./x")` in the Electron entry points is shipped.
|
|
|
|
describe("Electron packaging manifest completeness (#3292)", () => {
|
|
const electronDir = join(import.meta.dirname, "../../electron");
|
|
const pkg = JSON.parse(readFileSync(join(electronDir, "package.json"), "utf8"));
|
|
const files: string[] = pkg.build?.files ?? [];
|
|
|
|
function localRequires(entry: string): string[] {
|
|
const src = readFileSync(join(electronDir, entry), "utf8");
|
|
const out = new Set<string>();
|
|
for (const m of src.matchAll(/require\(["'](\.\/[A-Za-z0-9_./-]+)["']\)/g)) {
|
|
// normalize "./loginManager" -> "loginManager.js" (entry points require sibling .js)
|
|
let rel = m[1].replace(/^\.\//, "");
|
|
if (!/\.[a-z]+$/.test(rel)) rel += ".js";
|
|
out.add(rel);
|
|
}
|
|
return [...out];
|
|
}
|
|
|
|
for (const entry of ["main.js", "preload.js"]) {
|
|
it(`ships every local require() of ${entry} in build.files`, () => {
|
|
for (const dep of localRequires(entry)) {
|
|
assert.ok(
|
|
files.includes(dep),
|
|
`electron/${dep} is require()d by ${entry} but missing from package.json build.files — the packaged app will crash with "Cannot find module"`
|
|
);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── Auto-updater unhandled-rejection guard (v3.8.13) ────────
|
|
//
|
|
// checkForUpdates() is fired unawaited from a setTimeout at startup. The
|
|
// underlying autoUpdater.checkForUpdates() rejects on a 404 (no published
|
|
// update manifest yet), offline, or rate-limit — and an uncaught rejection
|
|
// there surfaces as an "Unhandled Rejection" that the packaged-app smoke test
|
|
// treats as fatal (it failed the macOS-intel build for v3.8.13). The call must
|
|
// be wrapped so the rejection never escapes.
|
|
|
|
describe("Electron auto-updater rejection handling (v3.8.13)", () => {
|
|
const mainSrc = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8");
|
|
|
|
it("wraps autoUpdater.checkForUpdates() so a 404/offline check can't become an unhandled rejection", () => {
|
|
const fn = mainSrc.match(/async function checkForUpdates\([\s\S]*?\n}/);
|
|
assert.ok(fn, "checkForUpdates function should exist in electron/main.js");
|
|
const body = fn![0];
|
|
assert.match(body, /try\s*\{/, "checkForUpdates must wrap the update check in try/catch");
|
|
assert.match(
|
|
body,
|
|
/catch\s*\([\s\S]*?\)\s*\{/,
|
|
"checkForUpdates must catch the rejecting autoUpdater.checkForUpdates() call"
|
|
);
|
|
assert.match(
|
|
body,
|
|
/try[\s\S]*await autoUpdater\.checkForUpdates\(\)[\s\S]*catch/,
|
|
"autoUpdater.checkForUpdates() must sit inside the try block"
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── URL Validation Tests ────────────────────────────────────
|
|
|
|
describe("Electron URL Validation", () => {
|
|
function validateExternalUrl(url) {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
if (["http:", "https:"].includes(parsedUrl.protocol)) {
|
|
return { allowed: true, url };
|
|
}
|
|
return { allowed: false, reason: `Blocked protocol: ${parsedUrl.protocol}` };
|
|
} catch {
|
|
return { allowed: false, reason: "Invalid URL" };
|
|
}
|
|
}
|
|
|
|
it("should allow http URLs", () => {
|
|
assert.equal(validateExternalUrl("http://example.com").allowed, true);
|
|
});
|
|
|
|
it("should allow https URLs", () => {
|
|
assert.equal(validateExternalUrl("https://github.com/diegosouzapw/OmniRoute").allowed, true);
|
|
});
|
|
|
|
it("should block file:// protocol (RCE risk)", () => {
|
|
const result = validateExternalUrl("file:///etc/passwd");
|
|
assert.equal(result.allowed, false);
|
|
assert.match(result.reason, /Blocked protocol/);
|
|
});
|
|
|
|
it("should block javascript: protocol (XSS risk)", () => {
|
|
assert.equal(validateExternalUrl("javascript:alert(1)").allowed, false);
|
|
});
|
|
|
|
it("should block custom protocol handlers", () => {
|
|
assert.equal(validateExternalUrl("vscode://extensions/install?name=malware").allowed, false);
|
|
});
|
|
|
|
it("should block data: URIs", () => {
|
|
assert.equal(validateExternalUrl("data:text/html,<script>alert(1)</script>").allowed, false);
|
|
});
|
|
|
|
it("should reject empty string", () => {
|
|
assert.equal(validateExternalUrl("").allowed, false);
|
|
});
|
|
|
|
it("should reject malformed URL", () => {
|
|
const result = validateExternalUrl("not a url");
|
|
assert.equal(result.allowed, false);
|
|
assert.match(result.reason, /Invalid URL/);
|
|
});
|
|
|
|
it("should allow localhost URLs", () => {
|
|
assert.equal(validateExternalUrl("http://localhost:20128/dashboard").allowed, true);
|
|
});
|
|
|
|
it("should allow URLs with paths and query params", () => {
|
|
assert.equal(validateExternalUrl("https://example.com/path?q=test&page=1#hash").allowed, true);
|
|
});
|
|
});
|
|
|
|
// ─── Window Open Handler Tests ───────────────────────────────
|
|
|
|
describe("Electron Window Open Handler", () => {
|
|
function windowOpenHandler({ url }) {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
if (["http:", "https:"].includes(parsedUrl.protocol)) {
|
|
return { action: "allow-external" };
|
|
}
|
|
return { action: "deny" };
|
|
} catch {
|
|
return { action: "deny" };
|
|
}
|
|
}
|
|
|
|
it("should deny all windows (external links go to browser)", () => {
|
|
const result = windowOpenHandler({ url: "https://example.com" });
|
|
assert.ok(result.action);
|
|
});
|
|
|
|
it("should deny file:// URLs", () => {
|
|
assert.equal(windowOpenHandler({ url: "file:///etc/passwd" }).action, "deny");
|
|
});
|
|
});
|
|
|
|
// ─── IPC Channel Validation Tests ────────────────────────────
|
|
|
|
describe("IPC Channel Validation", () => {
|
|
const VALID_CHANNELS = {
|
|
invoke: ["get-app-info", "open-external", "get-data-dir", "restart-server"],
|
|
send: ["window-minimize", "window-maximize", "window-close"],
|
|
receive: ["server-status", "port-changed"],
|
|
};
|
|
|
|
function isValidChannel(channel, type) {
|
|
return VALID_CHANNELS[type]?.includes(channel) ?? false;
|
|
}
|
|
|
|
it("should allow valid invoke channels", () => {
|
|
assert.equal(isValidChannel("get-app-info", "invoke"), true);
|
|
assert.equal(isValidChannel("open-external", "invoke"), true);
|
|
assert.equal(isValidChannel("get-data-dir", "invoke"), true);
|
|
assert.equal(isValidChannel("restart-server", "invoke"), true);
|
|
});
|
|
|
|
it("should allow valid send channels", () => {
|
|
assert.equal(isValidChannel("window-minimize", "send"), true);
|
|
assert.equal(isValidChannel("window-maximize", "send"), true);
|
|
assert.equal(isValidChannel("window-close", "send"), true);
|
|
});
|
|
|
|
it("should allow valid receive channels", () => {
|
|
assert.equal(isValidChannel("server-status", "receive"), true);
|
|
assert.equal(isValidChannel("port-changed", "receive"), true);
|
|
});
|
|
|
|
it("should block unknown channels", () => {
|
|
assert.equal(isValidChannel("execute-arbitrary-code", "invoke"), false);
|
|
assert.equal(isValidChannel("delete-all-data", "send"), false);
|
|
assert.equal(isValidChannel("malicious-event", "receive"), false);
|
|
assert.equal(isValidChannel("", "invoke"), false);
|
|
});
|
|
|
|
it("should handle undefined type gracefully", () => {
|
|
assert.equal(isValidChannel("get-app-info", "nonexistent"), false);
|
|
assert.equal(isValidChannel("test", undefined), false);
|
|
});
|
|
|
|
it("should block prototype pollution attempts", () => {
|
|
assert.equal(isValidChannel("constructor", "invoke"), false);
|
|
assert.equal(isValidChannel("__proto__", "invoke"), false);
|
|
assert.equal(isValidChannel("toString", "invoke"), false);
|
|
});
|
|
});
|
|
|
|
// ─── Server Port Validation Tests ────────────────────────────
|
|
|
|
describe("Server Port Management", () => {
|
|
it("should have valid default port", () => {
|
|
const DEFAULT_PORT = 20128;
|
|
assert.ok(DEFAULT_PORT > 0 && DEFAULT_PORT <= 65535);
|
|
});
|
|
|
|
it("should validate port numbers", () => {
|
|
function isValidPort(port) {
|
|
return Number.isFinite(port) && port > 0 && port <= 65535;
|
|
}
|
|
assert.equal(isValidPort(20128), true);
|
|
assert.equal(isValidPort(3000), true);
|
|
assert.equal(isValidPort(8080), true);
|
|
assert.equal(isValidPort(0), false);
|
|
assert.equal(isValidPort(-1), false);
|
|
assert.equal(isValidPort(70000), false);
|
|
assert.equal(isValidPort(NaN), false);
|
|
});
|
|
|
|
it("should generate correct server URL", () => {
|
|
const port = 20128;
|
|
assert.equal(`http://localhost:${port}`, "http://localhost:20128");
|
|
});
|
|
});
|
|
|
|
// ─── Server Readiness Tests (#1) ─────────────────────────────
|
|
|
|
describe("Server Readiness Logic", () => {
|
|
it("waitForServer should timeout and return false", async () => {
|
|
const result = await waitForServer("http://localhost:59999/api/health/ping", 20, {
|
|
fetchFn: async () => ({ ok: false }),
|
|
pollIntervalMs: 1,
|
|
requestTimeoutMs: 5,
|
|
warnFn: () => {},
|
|
});
|
|
assert.equal(result, false);
|
|
});
|
|
|
|
// #2460: on a slow first launch (long DB migrations) the initial readiness probe can
|
|
// time out. The window must not be left on a hanging connection — a background retry
|
|
// must keep polling and reload the window once the server finally responds.
|
|
it("reloads the window once the server becomes ready after an initial timeout (#2460)", async () => {
|
|
let serverUp = false;
|
|
// Server "comes up" after ~60ms, simulating long first-launch migrations.
|
|
const upTimer = setTimeout(() => {
|
|
serverUp = true;
|
|
}, 60);
|
|
|
|
const readinessOptions = {
|
|
fetchFn: async () => ({ ok: serverUp }),
|
|
pollIntervalMs: 5,
|
|
requestTimeoutMs: 5,
|
|
warnFn: () => {},
|
|
};
|
|
|
|
try {
|
|
// Initial probe with a short budget times out (server not up yet).
|
|
const initialReady = await waitForServer(
|
|
"http://localhost/api/health/ping",
|
|
20,
|
|
readinessOptions
|
|
);
|
|
assert.equal(initialReady, false);
|
|
|
|
let reloaded = false;
|
|
const mainWindow = {
|
|
isDestroyed: () => false,
|
|
loadURL: (_url?: string) => {
|
|
reloaded = true;
|
|
},
|
|
};
|
|
|
|
// Background retry with a generous budget should succeed and reload the window.
|
|
const retryReady = await waitForServer(
|
|
"http://localhost/api/health/ping",
|
|
5000,
|
|
readinessOptions
|
|
);
|
|
if (retryReady && mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.loadURL("http://localhost");
|
|
}
|
|
|
|
assert.equal(retryReady, true);
|
|
assert.equal(reloaded, true, "window should reload once the server is ready");
|
|
} finally {
|
|
clearTimeout(upTimer);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Restart Timeout Tests (#2) ──────────────────────────────
|
|
|
|
describe("Restart Timeout Logic", () => {
|
|
it("should resolve even if process doesn't exit", async () => {
|
|
// Simulate the timeout race
|
|
const start = Date.now();
|
|
await raceDelays(100000, 50);
|
|
const elapsed = Date.now() - start;
|
|
assert.ok(elapsed < 200, "Should resolve in ~50ms via timeout");
|
|
});
|
|
|
|
it("should resolve immediately if process exits first", async () => {
|
|
const start = Date.now();
|
|
await raceDelays(10, 5000);
|
|
const elapsed = Date.now() - start;
|
|
assert.ok(elapsed < 200, "Should resolve in ~10ms via exit");
|
|
});
|
|
});
|
|
|
|
// ─── CSP Tests (#15) ─────────────────────────────────────────
|
|
|
|
describe("Content Security Policy", () => {
|
|
it("should have all required CSP directives", () => {
|
|
const directives = [
|
|
"default-src",
|
|
"connect-src",
|
|
"script-src",
|
|
"script-src-attr",
|
|
"style-src",
|
|
"font-src",
|
|
"img-src",
|
|
"media-src",
|
|
"object-src",
|
|
"frame-src",
|
|
"child-src",
|
|
];
|
|
|
|
const csp = [
|
|
"default-src 'self'",
|
|
"base-uri 'self'",
|
|
"object-src 'none'",
|
|
"frame-ancestors 'none'",
|
|
"frame-src 'none'",
|
|
"child-src 'none'",
|
|
"form-action 'self'",
|
|
"script-src 'self' 'unsafe-inline' blob:",
|
|
"script-src-attr 'none'",
|
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
"font-src 'self' https://fonts.gstatic.com data:",
|
|
"img-src 'self' data: blob: https:",
|
|
"media-src 'self' data: blob:",
|
|
"connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*",
|
|
"worker-src 'self' blob:",
|
|
"manifest-src 'self'",
|
|
].join("; ");
|
|
|
|
for (const directive of directives) {
|
|
assert.ok(csp.includes(directive), `CSP should contain ${directive}`);
|
|
}
|
|
});
|
|
|
|
it("should not allow unsafe script sources from external domains", () => {
|
|
const scriptSrc = "script-src 'self' 'unsafe-inline' blob:";
|
|
assert.equal(scriptSrc.indexOf("http://"), -1, "Should not allow external http scripts");
|
|
assert.equal(scriptSrc.indexOf("*"), -1, "Should not wildcard script sources");
|
|
assert.equal(scriptSrc.indexOf("'unsafe-eval'"), -1, "Production CSP should not allow eval");
|
|
});
|
|
});
|
|
|
|
// ─── Platform-Conditional Tests (#9) ─────────────────────────
|
|
|
|
describe("Platform-Conditional Window Options", () => {
|
|
it("should return hiddenInset for macOS", () => {
|
|
const platform = "darwin";
|
|
const options =
|
|
platform === "darwin"
|
|
? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } }
|
|
: { titleBarStyle: "default" };
|
|
|
|
assert.equal(options.titleBarStyle, "hiddenInset");
|
|
assert.deepEqual(options.trafficLightPosition, { x: 16, y: 16 });
|
|
});
|
|
|
|
it("should return default for Windows/Linux", () => {
|
|
for (const platform of ["win32", "linux"]) {
|
|
const options =
|
|
platform === "darwin" ? { titleBarStyle: "hiddenInset" } : { titleBarStyle: "default" };
|
|
assert.equal(options.titleBarStyle, "default");
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── SQLite Credential Inspection Tests ─────────────────────
|
|
|
|
// Mock node:sqlite for older Node.js versions where it's not built-in
|
|
let DatabaseSync;
|
|
try {
|
|
DatabaseSync = require("node:sqlite").DatabaseSync;
|
|
} catch {
|
|
const Database = require("better-sqlite3");
|
|
class MockDatabaseSync {
|
|
db: any;
|
|
constructor(dbPath, options) {
|
|
const dbOpts: any = {};
|
|
if (options && typeof options.readOnly === "boolean") {
|
|
dbOpts.readonly = options.readOnly;
|
|
}
|
|
this.db = new Database(dbPath, dbOpts);
|
|
}
|
|
exec(sql) {
|
|
return this.db.exec(sql);
|
|
}
|
|
prepare(sql) {
|
|
const stmt = this.db.prepare(sql);
|
|
return {
|
|
run: (...args) => stmt.run(...args),
|
|
get: (...args) => stmt.get(...args),
|
|
};
|
|
}
|
|
close() {
|
|
return this.db.close();
|
|
}
|
|
}
|
|
DatabaseSync = MockDatabaseSync;
|
|
|
|
const Module = require("node:module");
|
|
const originalRequire = Module.prototype.require;
|
|
Module.prototype.require = function (id) {
|
|
if (id === "node:sqlite") {
|
|
return { DatabaseSync: MockDatabaseSync };
|
|
}
|
|
return originalRequire.apply(this, arguments);
|
|
};
|
|
}
|
|
|
|
describe("Electron SQLite credential inspection", () => {
|
|
const {
|
|
hasEncryptedCredentials,
|
|
openNodeSqliteReadOnly,
|
|
} = require("../../electron/sqlite-inspection.js");
|
|
|
|
function withTempDb(fn) {
|
|
const dir = mkdtempSync(join(tmpdir(), "omniroute-electron-db-"));
|
|
const dbPath = join(dir, "storage.sqlite");
|
|
const db = new DatabaseSync(dbPath);
|
|
|
|
try {
|
|
db.exec(`
|
|
CREATE TABLE provider_connections (
|
|
access_token TEXT,
|
|
refresh_token TEXT,
|
|
api_key TEXT,
|
|
id_token TEXT
|
|
)
|
|
`);
|
|
fn(dbPath, db);
|
|
} finally {
|
|
db.close();
|
|
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
}
|
|
}
|
|
|
|
it("should inspect encrypted credentials with node:sqlite fallback", () => {
|
|
withTempDb((dbPath, db) => {
|
|
db.prepare("INSERT INTO provider_connections (api_key) VALUES (?)").run("enc:v1:test");
|
|
|
|
assert.equal(hasEncryptedCredentials(dbPath, openNodeSqliteReadOnly), true);
|
|
});
|
|
});
|
|
|
|
it("should return false when credentials are not encrypted", () => {
|
|
withTempDb((dbPath, db) => {
|
|
db.prepare("INSERT INTO provider_connections (api_key) VALUES (?)").run("plain-text-key");
|
|
|
|
assert.equal(hasEncryptedCredentials(dbPath, openNodeSqliteReadOnly), false);
|
|
});
|
|
});
|
|
|
|
it("should return false when the database file does not exist", () => {
|
|
assert.equal(hasEncryptedCredentials(join(tmpdir(), "missing-omniroute.sqlite")), false);
|
|
});
|
|
});
|