diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.spec.js new file mode 100644 index 0000000..26ced38 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.spec.js @@ -0,0 +1,125 @@ +import { describe, it, before, after, mock } from "node:test"; +import assert from "node:assert"; +import EventEmitter from "events"; + +// Mock dependencies before importing the module under test +const mockMitmConnect = mock.fn(); +const mockTunnelRequest = mock.fn(); +const mockUi = { writeVerbose: mock.fn() }; +const mockGetCaCertPath = mock.fn(() => "/fake/cert/path"); +const mockGetHasSuppressedVersions = mock.fn(() => false); + +/** @type {import("./interceptors/interceptorBuilder.js").Interceptor | undefined} */ +let mockInterceptor; + +mock.module("./mitmRequestHandler.js", { + namedExports: { mitmConnect: mockMitmConnect }, +}); +mock.module("./tunnelRequestHandler.js", { + namedExports: { tunnelRequest: mockTunnelRequest }, +}); +mock.module("./plainHttpProxy.js", { + namedExports: { handleHttpProxyRequest: mock.fn() }, +}); +mock.module("../../environment/userInteraction.js", { + namedExports: { ui: mockUi }, +}); +mock.module("./interceptors/createInterceptorForEcoSystem.js", { + namedExports: { + createInterceptorForUrl: mock.fn(() => mockInterceptor), + }, +}); +mock.module("./interceptors/npm/modifyNpmInfo.js", { + namedExports: { getHasSuppressedVersions: mockGetHasSuppressedVersions }, +}); +mock.module("./certUtils.js", { + namedExports: { getCaCertPath: mockGetCaCertPath }, +}); + +const { createBuiltInProxyServer } = await import( + "./createBuiltInProxyServer.js" +); + +describe("createBuiltInProxyServer event emission", () => { + /** @type {ReturnType} */ + let proxy; + + before(async () => { + proxy = createBuiltInProxyServer(); + await proxy.startServer(); + }); + + after(async () => { + await proxy.stopServer(); + }); + + it("emits malwareBlocked when the interceptor fires a malwareBlocked event", async () => { + // Create a real EventEmitter-based interceptor that we can trigger + const interceptorEmitter = new EventEmitter(); + mockInterceptor = Object.assign(interceptorEmitter, { + handleRequest: mock.fn(async () => ({ + blockResponse: { statusCode: 403, message: "blocked" }, + modifyRequestHeaders: (/** @type {any} */ h) => h, + modifiesResponse: () => false, + modifyBody: (/** @type {any} */ b) => b, + })), + }); + + const eventPromise = new Promise((resolve) => { + proxy.once("malwareBlocked", resolve); + }); + + // Trigger a CONNECT request to the proxy to wire up the interceptor + const port = proxy.getServerPort(); + assert.ok(port, "Server should have a port"); + + const net = await import("net"); + const socket = net.connect(port, "127.0.0.1", () => { + socket.write( + "CONNECT registry.npmjs.org:443 HTTP/1.1\r\nHost: registry.npmjs.org:443\r\n\r\n", + ); + }); + + // Wait for the CONNECT handler to run and subscribe to the interceptor + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Now fire the malwareBlocked event on the interceptor + interceptorEmitter.emit("malwareBlocked", { + packageName: "evil-package", + version: "1.0.0", + targetUrl: "https://registry.npmjs.org/evil-package/-/evil-package-1.0.0.tgz", + timestamp: Date.now(), + }); + + const received = await eventPromise; + assert.deepStrictEqual(received, { + packageName: "evil-package", + packageVersion: "1.0.0", + }); + + socket.destroy(); + }); + + it("does not emit malwareBlocked for non-intercepted hosts", async () => { + // No interceptor for this URL + mockInterceptor = undefined; + + let emitted = false; + proxy.on("malwareBlocked", () => { + emitted = true; + }); + + const port = proxy.getServerPort(); + const net = await import("net"); + const socket = net.connect(port, "127.0.0.1", () => { + socket.write( + "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n", + ); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.strictEqual(emitted, false, "Should not emit for non-intercepted hosts"); + + socket.destroy(); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.spec.js b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.spec.js new file mode 100644 index 0000000..e0c6eb8 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.spec.js @@ -0,0 +1,177 @@ +import { describe, it, before, after, mock } from "node:test"; +import assert from "node:assert"; +import EventEmitter from "node:events"; + +// --- Mock setup --- + +const mockReportingServer = Object.assign(new EventEmitter(), { + start: mock.fn(async () => {}), + stop: mock.fn(async () => {}), + getAddress: mock.fn(() => "http://127.0.0.1:9999"), +}); + +mock.module("./reportingServer.js", { + namedExports: { + getReportingServer: () => mockReportingServer, + }, +}); + +const mockKill = mock.fn(); +mock.module("node:child_process", { + namedExports: { + spawn: mock.fn(() => ({ kill: mockKill })), + }, +}); + +const mockExistsSync = mock.fn(() => true); +const mockMkdtempSync = mock.fn(() => "/tmp/safe-chain-proxy-abc"); +const mockReadFile = mock.fn( + (/** @type {string} */ path, /** @type {string} */ _encoding, /** @type {Function} */ cb) => { + if (path.endsWith("proxy.addr.txt")) { + cb(null, "127.0.0.1:8080"); + } else if (path.endsWith("meta.addr.txt")) { + cb(null, "127.0.0.1:8081"); + } else { + cb(new Error("unknown file")); + } + }, +); + +mock.module("node:fs", { + namedExports: { + existsSync: mockExistsSync, + mkdtempSync: mockMkdtempSync, + readFile: mockReadFile, + }, +}); + +mock.module("../../environment/userInteraction.js", { + namedExports: { ui: { writeVerbose: mock.fn() } }, +}); + +mock.module("../../config/settings.js", { + namedExports: { + getLoggingLevel: mock.fn(() => "default"), + LOGGING_VERBOSE: "verbose", + }, +}); + +const mockFetch = mock.method(globalThis, "fetch", async () => ({ + text: async () => "MOCK_CA_CERT_PEM", +})); + +const { getRamaPath, createRamaProxy } = await import( + "./createRamaProxy.js" +); + +describe("getRamaPath", () => { + it("returns path ending in safechain-proxy when existsSync returns true", () => { + mockExistsSync.mock.resetCalls(); + mockExistsSync.mock.mockImplementation(() => true); + + const result = getRamaPath(); + assert.ok(result?.endsWith("safechain-proxy"), `Expected path ending in safechain-proxy, got ${result}`); + }); + + it("returns null when existsSync returns false", () => { + mockExistsSync.mock.mockImplementation(() => false); + + const result = getRamaPath(); + assert.strictEqual(result, null); + + // Restore for other tests + mockExistsSync.mock.mockImplementation(() => true); + }); +}); + +describe("createRamaProxy — before startServer", () => { + /** @type {ReturnType} */ + let proxy; + + before(() => { + proxy = createRamaProxy("/fake/path/safechain-proxy"); + }); + + it("getServerPort() returns null", () => { + assert.strictEqual(proxy.getServerPort(), null); + }); + + it("getCaCert() returns null", () => { + assert.strictEqual(proxy.getCaCert(), null); + }); + + it("hasSuppressedVersions() returns false", () => { + assert.strictEqual(proxy.hasSuppressedVersions(), false); + }); +}); + +describe("createRamaProxy — after startServer", () => { + /** @type {ReturnType} */ + let proxy; + + before(async () => { + mockReportingServer.start.mock.resetCalls(); + mockReportingServer.stop.mock.resetCalls(); + mockKill.mock.resetCalls(); + mockFetch.mock.resetCalls(); + + proxy = createRamaProxy("/fake/path/safechain-proxy"); + await proxy.startServer(); + }); + + after(async () => { + await proxy.stopServer(); + }); + + it("transforms blockReceived into malwareBlocked event", async () => { + const eventPromise = new Promise((resolve) => { + proxy.once("malwareBlocked", resolve); + }); + + mockReportingServer.emit("blockReceived", { + ts_ms: Date.now(), + artifact: { + product: "npm", + identifier: "evil-pkg", + version: "2.0.0", + }, + }); + + const received = await eventPromise; + assert.deepStrictEqual(received, { + packageName: "evil-pkg", + packageVersion: "2.0.0", + }); + }); + + it("getServerPort() returns the correct port", () => { + assert.strictEqual(proxy.getServerPort(), 8080); + }); + + it("getCaCert() returns the mocked certificate", () => { + assert.strictEqual(proxy.getCaCert(), "MOCK_CA_CERT_PEM"); + }); +}); + +describe("createRamaProxy — stopServer", () => { + it("calls kill on spawned process and stop on reporting server", async () => { + mockReportingServer.start.mock.resetCalls(); + mockReportingServer.stop.mock.resetCalls(); + mockKill.mock.resetCalls(); + + const proxy = createRamaProxy("/fake/path/safechain-proxy"); + await proxy.startServer(); + await proxy.stopServer(); + + assert.strictEqual(mockKill.mock.callCount(), 1); + assert.strictEqual(mockReportingServer.stop.mock.callCount(), 1); + }); + + it("is safe to call when server was never started", async () => { + mockReportingServer.stop.mock.resetCalls(); + + const proxy = createRamaProxy("/fake/path/safechain-proxy"); + // Should not throw + await proxy.stopServer(); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.spec.js b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.spec.js new file mode 100644 index 0000000..d16d35b --- /dev/null +++ b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.spec.js @@ -0,0 +1,134 @@ +import { describe, it, after, before } from "node:test"; +import assert from "node:assert"; +import { getReportingServer } from "./reportingServer.js"; + +/** + * Helper: POST JSON to a URL and return the response status code. + * @param {string} url + * @param {string} body + * @returns {Promise} HTTP status code + */ +async function postJson(url, body) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + return res.status; +} + +describe("reportingServer", () => { + /** @type {ReturnType} */ + let server; + + before(async () => { + server = getReportingServer(); + await server.start(); + }); + + after(async () => { + await server.stop(); + }); + + describe("start / getAddress", () => { + it("returns a valid http://127.0.0.1: address after starting", () => { + const address = server.getAddress(); + assert.match( + address, + /^http:\/\/127\.0\.0\.1:\d+$/, + "Address should be http://127.0.0.1:", + ); + }); + }); + + describe("POST /events/block", () => { + it("emits a blockReceived event with the parsed JSON body", async () => { + const blockEvent = { + ts_ms: Date.now(), + artifact: { + product: "npm", + identifier: "malicious-pkg", + version: "1.0.0", + }, + }; + + const eventPromise = new Promise((resolve) => { + server.once("blockReceived", resolve); + }); + + const status = await postJson( + `${server.getAddress()}/events/block`, + JSON.stringify(blockEvent), + ); + + assert.strictEqual(status, 200); + + const received = await eventPromise; + assert.deepStrictEqual(received, blockEvent); + }); + }); + + describe("non-matching routes", () => { + it("returns 200 for GET requests but does not emit blockReceived", async () => { + let emitted = false; + const listener = () => { + emitted = true; + }; + server.on("blockReceived", listener); + + const res = await fetch(`${server.getAddress()}/other-route`); + assert.strictEqual(res.status, 200); + + // Give a tick for any event to fire + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.strictEqual(emitted, false, "Should not emit blockReceived for non-matching routes"); + + server.off("blockReceived", listener); + }); + + it("returns 200 for POST to a different path but does not emit blockReceived", async () => { + let emitted = false; + const listener = () => { + emitted = true; + }; + server.on("blockReceived", listener); + + const status = await postJson( + `${server.getAddress()}/other-path`, + JSON.stringify({ foo: "bar" }), + ); + assert.strictEqual(status, 200); + + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.strictEqual(emitted, false, "Should not emit blockReceived for non-block paths"); + + server.off("blockReceived", listener); + }); + }); +}); + +describe("reportingServer stop", () => { + it("stops cleanly and frees the port", async () => { + const server = getReportingServer(); + await server.start(); + const address = server.getAddress(); + assert.ok(address, "Server should have an address"); + + await server.stop(); + + // After stopping, the server should no longer accept connections + try { + await fetch(`${address}/events/block`); + assert.fail("Should not be able to connect to stopped server"); + } catch (err) { + // Expected: connection refused or similar + assert.ok(err, "Fetch should throw after server stops"); + } + }); + + it("stop is safe to call when server was never started", async () => { + const server = getReportingServer(); + // Should not throw + await server.stop(); + }); +});