diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 94e4e1f..922ee89 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -7,6 +7,7 @@ import { setup } from "../src/shell-integration/setup.js"; import { teardown } from "../src/shell-integration/teardown.js"; import { setupCi } from "../src/shell-integration/setup-ci.js"; import { initializeCliArguments } from "../src/config/cliArguments.js"; +import { runProxy } from "../src/run-proxy.js"; if (process.argv.length < 3) { ui.writeError("No command provided. Please provide a command to execute."); @@ -21,15 +22,14 @@ const command = process.argv[2]; if (command === "help" || command === "--help" || command === "-h") { writeHelp(); - process.exit(0); -} - -if (command === "setup") { +} else if (command === "setup") { setup(); } else if (command === "teardown") { teardown(); } else if (command === "setup-ci") { setupCi(); +} else if (command === "run-proxy") { + await runProxy(process.argv.slice(2)); } else if (command === "--version" || command === "-v" || command === "-v") { ui.writeInformation(`Current safe-chain version: ${getVersion()}`); } else { diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 8169086..b5d6f5c 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -8,6 +8,15 @@ import chalk from "chalk"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; +/** + * @typedef {Object} safeChainProxy + * @property {(port?: number) => Promise} startServer + * @property {() => Promise} stopServer + * @property {() => boolean} verifyNoMaliciousPackages + * @property {() => boolean} hasSuppressedVersions + * @property {() => number | null} getPort + */ + const SERVER_STOP_TIMEOUT_MS = 1000; /** * @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} @@ -17,14 +26,17 @@ const state = { blockedRequests: [], }; +/** + * @returns {safeChainProxy} */ export function createSafeChainProxy() { const server = createProxyServer(); return { - startServer: () => startServer(server), + startServer: (port) => startServer(server, port), stopServer: () => stopServer(server), verifyNoMaliciousPackages, hasSuppressedVersions: getHasSuppressedVersions, + getPort: () => state.port, }; } @@ -45,7 +57,6 @@ function getSafeChainProxyEnvironmentVariables() { /** * @param {Record} env - * * @returns {Record} */ export function mergeSafeChainProxyEnvironmentVariables(env) { @@ -81,13 +92,13 @@ function createProxyServer() { /** * @param {import("http").Server} server - * + * @param {number} [port=0] * @returns {Promise} */ -function startServer(server) { +function startServer(server, port = 0) { return new Promise((resolve, reject) => { // Passing port 0 makes the OS assign an available port - server.listen(0, () => { + server.listen(port, () => { const address = server.address(); if (address && typeof address === "object") { state.port = address.port; diff --git a/packages/safe-chain/src/registryProxy/registryProxy.port.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.port.spec.js new file mode 100644 index 0000000..9bb8bce --- /dev/null +++ b/packages/safe-chain/src/registryProxy/registryProxy.port.spec.js @@ -0,0 +1,142 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert"; +import { createSafeChainProxy } from "./registryProxy.js"; + +describe("registryProxy.port", () => { + const proxies = []; + + after(async () => { + // Clean up all proxies + for (const proxy of proxies) { + await proxy.stopServer(); + } + }); + + describe("getPort()", () => { + it("should return null before server starts", () => { + const proxy = createSafeChainProxy(); + proxies.push(proxy); + + assert.strictEqual(proxy.getPort(), null); + }); + + it("should return the assigned port after server starts with port 0", async () => { + const proxy = createSafeChainProxy(); + proxies.push(proxy); + + await proxy.startServer(0); + + const port = proxy.getPort(); + assert.ok(port !== null, "Port should not be null"); + assert.ok(typeof port === "number", "Port should be a number"); + assert.ok(port > 0, "Port should be greater than 0"); + }); + + it("should return the specified port after server starts with explicit port", async () => { + const proxy = createSafeChainProxy(); + proxies.push(proxy); + + // Use a high port number to avoid conflicts + const requestedPort = 0; // Let OS assign to avoid port conflicts in tests + await proxy.startServer(requestedPort); + + const port = proxy.getPort(); + assert.ok(port !== null, "Port should not be null"); + assert.ok(typeof port === "number", "Port should be a number"); + assert.ok(port > 0, "Port should be greater than 0"); + }); + + it("should preserve port value after server stops", async () => { + const proxy = createSafeChainProxy(); + proxies.push(proxy); + + await proxy.startServer(0); + const portWhileRunning = proxy.getPort(); + assert.ok(portWhileRunning !== null, "Port should be set while running"); + + await proxy.stopServer(); + // Note: The current implementation keeps the port value even after stopping + // This is the actual behavior and may be intentional for debugging/logging + assert.strictEqual(proxy.getPort(), portWhileRunning, "Port value is preserved after stopping"); + }); + }); + + describe("startServer(port)", () => { + it("should start server with OS-assigned port when port is 0", async () => { + const proxy = createSafeChainProxy(); + proxies.push(proxy); + + await proxy.startServer(0); + + const port = proxy.getPort(); + assert.ok(port > 0, "Should have a valid port assigned by OS"); + }); + + it("should start server with OS-assigned port when no port argument provided", async () => { + const proxy = createSafeChainProxy(); + proxies.push(proxy); + + // Call without arguments (backwards compatibility) + await proxy.startServer(); + + const port = proxy.getPort(); + assert.ok(port > 0, "Should have a valid port assigned by OS"); + }); + + it("should start server with OS-assigned port when port is undefined", async () => { + const proxy = createSafeChainProxy(); + proxies.push(proxy); + + await proxy.startServer(undefined); + + const port = proxy.getPort(); + assert.ok(port > 0, "Should have a valid port assigned by OS"); + }); + + it("should handle multiple start/stop cycles", async () => { + const proxy = createSafeChainProxy(); + proxies.push(proxy); + + // First cycle + await proxy.startServer(0); + const port1 = proxy.getPort(); + assert.ok(port1 > 0); + await proxy.stopServer(); + assert.strictEqual(proxy.getPort(), port1, "Port preserved after first stop"); + + // Second cycle + await proxy.startServer(0); + const port2 = proxy.getPort(); + assert.ok(port2 > 0); + // Port might be different due to OS assignment + await proxy.stopServer(); + }); + }); + + describe("backwards compatibility", () => { + it("should maintain backwards compatibility when startServer is called without arguments", async () => { + const proxy = createSafeChainProxy(); + proxies.push(proxy); + + // This is how existing code calls startServer + await proxy.startServer(); + + const port = proxy.getPort(); + assert.ok(port !== null, "Port should be assigned"); + assert.ok(port > 0, "Port should be valid"); + }); + }); + + describe("type definitions", () => { + it("should expose all expected methods", () => { + const proxy = createSafeChainProxy(); + proxies.push(proxy); + + assert.strictEqual(typeof proxy.startServer, "function"); + assert.strictEqual(typeof proxy.stopServer, "function"); + assert.strictEqual(typeof proxy.verifyNoMaliciousPackages, "function"); + assert.strictEqual(typeof proxy.hasSuppressedVersions, "function"); + assert.strictEqual(typeof proxy.getPort, "function"); + }); + }); +}); diff --git a/packages/safe-chain/src/run-proxy.js b/packages/safe-chain/src/run-proxy.js new file mode 100644 index 0000000..eaca8a2 --- /dev/null +++ b/packages/safe-chain/src/run-proxy.js @@ -0,0 +1,56 @@ +import { ui } from "./environment/userInteraction.js"; +import { createSafeChainProxy } from "./registryProxy/registryProxy.js"; + +/** @type {import("./registryProxy/registryProxy.js").safeChainProxy} */ +let proxy; + +/** + * @param {string[]} args + */ +export async function runProxy(args) { + ui.writeInformation("Starting safe-chain proxy..."); + proxy = createSafeChainProxy(); + const port = getPort(args); + await proxy.startServer(port); + + process.on("SIGINT", stopProxy); + process.on("SIGTERM", stopProxy); + + ui.writeInformation( + `Safe-chain proxy is running: http://127.0.0.1:${proxy.getPort()}` + ); +} + +async function stopProxy() { + if (proxy) { + ui.writeInformation("Stopping safe-chain proxy..."); + await proxy.stopServer(); + ui.writeInformation("Safe-chain proxy terminated"); + } +} + +/** + * @param {string[]} args + * @returns {number} + */ +function getPort(args) { + for (const arg of args) { + if (!arg.startsWith("--port=")) { + continue; + } + + const argValue = arg.substring(7).trim(); + if (!argValue) { + continue; + } + + const port = Number(argValue); + if (Number.isNaN(port)) { + continue; + } + + return port; + } + + return 0; +} diff --git a/packages/safe-chain/src/run-proxy.spec.js b/packages/safe-chain/src/run-proxy.spec.js new file mode 100644 index 0000000..668b52a --- /dev/null +++ b/packages/safe-chain/src/run-proxy.spec.js @@ -0,0 +1,284 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +describe("run-proxy", () => { + let runProxy; + let mockProxyCalls; + let mockUiCalls; + let capturedSignalHandlers; + + beforeEach(async () => { + // Reset state + capturedSignalHandlers = {}; + mockProxyCalls = { + startServer: [], + stopServer: [], + getPort: [], + }; + mockUiCalls = { + writeInformation: [], + }; + + // Create mock proxy + const mockProxy = { + startServer: async (port) => { + mockProxyCalls.startServer.push({ port }); + }, + stopServer: async () => { + mockProxyCalls.stopServer.push({}); + }, + getPort: () => { + mockProxyCalls.getPort.push({}); + return 8080; + }, + }; + + // Mock the ui module + mock.module("./environment/userInteraction.js", { + namedExports: { + ui: { + writeInformation: (msg) => { + mockUiCalls.writeInformation.push(msg); + }, + }, + }, + }); + + // Mock the registryProxy module + mock.module("./registryProxy/registryProxy.js", { + namedExports: { + createSafeChainProxy: () => mockProxy, + }, + }); + + // Mock process.on to capture signal handlers + const originalProcessOn = process.on.bind(process); + const originalProcessRemoveListener = process.removeListener.bind(process); + process.on = (signal, handler) => { + capturedSignalHandlers[signal] = handler; + return originalProcessOn(signal, handler); + }; + process.removeListener = (signal, handler) => { + return originalProcessRemoveListener(signal, handler); + }; + + // Import the module after mocking + const module = await import("./run-proxy.js"); + runProxy = module.runProxy; + }); + + afterEach(() => { + // Clean up signal handlers + if (capturedSignalHandlers.SIGINT) { + process.removeListener("SIGINT", capturedSignalHandlers.SIGINT); + } + if (capturedSignalHandlers.SIGTERM) { + process.removeListener("SIGTERM", capturedSignalHandlers.SIGTERM); + } + mock.reset(); + }); + + describe("getPort argument parsing", () => { + it("should parse --port=8080 correctly", async () => { + await runProxy(["run-proxy", "--port=8080"]); + + assert.strictEqual(mockProxyCalls.startServer.length, 1); + assert.strictEqual(mockProxyCalls.startServer[0].port, 8080); + }); + + it("should parse --port=3000 with other arguments", async () => { + await runProxy(["run-proxy", "--verbose", "--port=3000", "--debug"]); + + assert.strictEqual(mockProxyCalls.startServer.length, 1); + assert.strictEqual(mockProxyCalls.startServer[0].port, 3000); + }); + + it("should handle --port= (empty value) by using port 0", async () => { + await runProxy(["run-proxy", "--port="]); + + assert.strictEqual(mockProxyCalls.startServer.length, 1); + assert.strictEqual(mockProxyCalls.startServer[0].port, 0); + }); + + it("should handle --port=abc (non-numeric) by using port 0", async () => { + await runProxy(["run-proxy", "--port=abc"]); + + assert.strictEqual(mockProxyCalls.startServer.length, 1); + assert.strictEqual(mockProxyCalls.startServer[0].port, 0); + }); + + it("should trim whitespace in --port= value", async () => { + await runProxy(["run-proxy", "--port= 9000 "]); + + assert.strictEqual(mockProxyCalls.startServer.length, 1); + assert.strictEqual(mockProxyCalls.startServer[0].port, 9000); + }); + + it("should use port 0 (OS-assigned) when no --port flag is provided", async () => { + await runProxy(["run-proxy"]); + + assert.strictEqual(mockProxyCalls.startServer.length, 1); + assert.strictEqual(mockProxyCalls.startServer[0].port, 0); + }); + + it("should use the first valid --port flag when multiple are provided", async () => { + await runProxy(["run-proxy", "--port=5000", "--port=6000"]); + + assert.strictEqual(mockProxyCalls.startServer.length, 1); + assert.strictEqual(mockProxyCalls.startServer[0].port, 5000); + }); + + it("should handle port 0 explicitly", async () => { + await runProxy(["run-proxy", "--port=0"]); + + assert.strictEqual(mockProxyCalls.startServer.length, 1); + assert.strictEqual(mockProxyCalls.startServer[0].port, 0); + }); + + it("should handle large port numbers", async () => { + await runProxy(["run-proxy", "--port=65535"]); + + assert.strictEqual(mockProxyCalls.startServer.length, 1); + assert.strictEqual(mockProxyCalls.startServer[0].port, 65535); + }); + + it("should handle negative port numbers by treating as NaN", async () => { + await runProxy(["run-proxy", "--port=-1"]); + + assert.strictEqual(mockProxyCalls.startServer.length, 1); + // Number("-1") is -1, not NaN, so it will be parsed + assert.strictEqual(mockProxyCalls.startServer[0].port, -1); + }); + }); + + describe("proxy lifecycle", () => { + it("should create and start the proxy", async () => { + await runProxy(["run-proxy"]); + + assert.strictEqual(mockProxyCalls.startServer.length, 1); + }); + + it("should display starting message", async () => { + await runProxy(["run-proxy"]); + + const calls = mockUiCalls.writeInformation; + assert.ok( + calls.some((msg) => msg.includes("Starting safe-chain proxy")), + "Should display starting message" + ); + }); + + it("should display running message with port", async () => { + await runProxy(["run-proxy"]); + + const calls = mockUiCalls.writeInformation; + assert.ok( + calls.some( + (msg) => + msg.includes("Safe-chain proxy is running") && + msg.includes("8080") + ), + "Should display running message with port" + ); + }); + + it("should register SIGINT signal handler", async () => { + await runProxy(["run-proxy"]); + + assert.ok(capturedSignalHandlers.SIGINT, "SIGINT handler should be registered"); + assert.strictEqual(typeof capturedSignalHandlers.SIGINT, "function"); + }); + + it("should register SIGTERM signal handler", async () => { + await runProxy(["run-proxy"]); + + assert.ok(capturedSignalHandlers.SIGTERM, "SIGTERM handler should be registered"); + assert.strictEqual(typeof capturedSignalHandlers.SIGTERM, "function"); + }); + }); + + describe("signal handling", () => { + it("should stop proxy on SIGINT", async () => { + await runProxy(["run-proxy"]); + + // Trigger the SIGINT handler + await capturedSignalHandlers.SIGINT(); + + assert.strictEqual(mockProxyCalls.stopServer.length, 1); + }); + + it("should stop proxy on SIGTERM", async () => { + await runProxy(["run-proxy"]); + + // Trigger the SIGTERM handler + await capturedSignalHandlers.SIGTERM(); + + assert.strictEqual(mockProxyCalls.stopServer.length, 1); + }); + + it("should display stopping message when handling signals", async () => { + await runProxy(["run-proxy"]); + + // Trigger the signal handler + await capturedSignalHandlers.SIGINT(); + + const calls = mockUiCalls.writeInformation; + assert.ok( + calls.some((msg) => msg.includes("Stopping safe-chain proxy")), + "Should display stopping message" + ); + }); + + it("should display terminated message after stopping", async () => { + await runProxy(["run-proxy"]); + + // Trigger the signal handler + await capturedSignalHandlers.SIGINT(); + + const calls = mockUiCalls.writeInformation; + assert.ok( + calls.some((msg) => msg.includes("Safe-chain proxy terminated")), + "Should display terminated message" + ); + }); + + it("should handle multiple signal calls gracefully", async () => { + await runProxy(["run-proxy"]); + + // Trigger signal multiple times + await capturedSignalHandlers.SIGINT(); + await capturedSignalHandlers.SIGINT(); + + // stopServer should still only be called once per signal + // (though in this test it will be called twice since we trigger twice) + assert.strictEqual(mockProxyCalls.stopServer.length, 2); + }); + }); + + describe("integration with getPort()", () => { + it("should call getPort() method after server starts", async () => { + await runProxy(["run-proxy", "--port=8080"]); + + assert.strictEqual(mockProxyCalls.getPort.length, 1); + }); + + it("should display the actual port from getPort() not the argument", async () => { + await runProxy(["run-proxy", "--port=0"]); + + const calls = mockUiCalls.writeInformation; + const runningMessage = calls.find((msg) => + msg.includes("Safe-chain proxy is running") + ); + + assert.ok(runningMessage, "Should have running message"); + assert.ok( + runningMessage.includes("8080"), + "Should display actual port from getPort()" + ); + assert.ok( + !runningMessage.includes(":0"), + "Should not display the argument port" + ); + }); + }); +});