diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 7273f7a..901e7d7 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -3,14 +3,48 @@ import path from "path"; import os from "os"; import { ui } from "../environment/userInteraction.js"; +/** + * @typedef {Object} SafeChainConfig + * + * This should be a number, but can be anything because it is user-input. + * We cannot trust the input and should add the necessary validations. + * @property {any} scanTimeout + */ + /** * @returns {number} */ export function getScanTimeout() { - const config = /** @type {{scanTimeout?: number}} */ (readConfigFile()); + const config = readConfigFile(); - // @ts-expect-error values of process.env can be string | undefined - return parseInt(process.env.AIKIDO_SCAN_TIMEOUT_MS) || config.scanTimeout || 10000 // Default to 10 seconds + if (process.env.AIKIDO_SCAN_TIMEOUT_MS) { + const scanTimeout = validateTimeout(process.env.AIKIDO_SCAN_TIMEOUT_MS); + if (scanTimeout != null) { + return scanTimeout; + } + } + + if (config.scanTimeout) { + const scanTimeout = validateTimeout(config.scanTimeout); + if (scanTimeout != null) { + return scanTimeout; + } + } + + return 10000; // Default to 10 seconds +} + +/** + * + * @param {any} value + * @returns {number?} + */ +function validateTimeout(value) { + const timeout = Number(value); + if (!Number.isNaN(timeout) && timeout > 0) { + return timeout; + } + return null; } /** @@ -68,17 +102,25 @@ export function readDatabaseFromLocalCache() { } /** - * @returns {unknown} + * @returns {SafeChainConfig} */ function readConfigFile() { const configFilePath = getConfigFilePath(); if (!fs.existsSync(configFilePath)) { - return {}; + return { + scanTimeout: undefined, + }; } - const data = fs.readFileSync(configFilePath, "utf8"); - return JSON.parse(data); + try { + const data = fs.readFileSync(configFilePath, "utf8"); + return JSON.parse(data); + } catch { + return { + scanTimeout: undefined, + }; + } } /** diff --git a/packages/safe-chain/src/config/configFile.spec.js b/packages/safe-chain/src/config/configFile.spec.js new file mode 100644 index 0000000..8ec980c --- /dev/null +++ b/packages/safe-chain/src/config/configFile.spec.js @@ -0,0 +1,172 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +describe("getScanTimeout", () => { + let originalEnv; + let fsMock; + let getScanTimeout; + + beforeEach(async () => { + // Save original environment + originalEnv = process.env.AIKIDO_SCAN_TIMEOUT_MS; + + // Mock fs module + fsMock = { + existsSync: mock.fn(() => false), + readFileSync: mock.fn(() => "{}"), + writeFileSync: mock.fn(), + mkdirSync: mock.fn(), + }; + + mock.module("fs", { + namedExports: fsMock, + }); + + // Re-import the module to get the mocked version + const configFileModule = await import( + `./configFile.js?update=${Date.now()}` + ); + getScanTimeout = configFileModule.getScanTimeout; + }); + + afterEach(() => { + // Restore original environment + if (originalEnv !== undefined) { + process.env.AIKIDO_SCAN_TIMEOUT_MS = originalEnv; + } else { + delete process.env.AIKIDO_SCAN_TIMEOUT_MS; + } + + // Reset all mocks + mock.restoreAll(); + }); + + it("should return default timeout of 10000ms when no config or env var is set", () => { + delete process.env.AIKIDO_SCAN_TIMEOUT_MS; + // Mock: config file doesn't exist + fsMock.existsSync.mock.mockImplementation(() => false); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 10000); + }); + + it("should return timeout from config file when set", () => { + delete process.env.AIKIDO_SCAN_TIMEOUT_MS; + // Mock: config file exists with scanTimeout: 5000 + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: 5000 }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 5000); + }); + + it("should prioritize environment variable over config file", () => { + process.env.AIKIDO_SCAN_TIMEOUT_MS = "20000"; + // Mock: config file exists with scanTimeout: 5000 + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: 5000 }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 20000); + }); + + it("should handle invalid environment variable and fall back to config", () => { + process.env.AIKIDO_SCAN_TIMEOUT_MS = "invalid"; + // Mock: config file exists with scanTimeout: 7000 + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: 7000 }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 7000); + }); + + it("should ignore zero and negative values and fall back to default", () => { + // Mock: config file doesn't exist + fsMock.existsSync.mock.mockImplementation(() => false); + + process.env.AIKIDO_SCAN_TIMEOUT_MS = "0"; + + let timeout = getScanTimeout(); + assert.strictEqual(timeout, 10000); + + process.env.AIKIDO_SCAN_TIMEOUT_MS = "-5000"; + + timeout = getScanTimeout(); + assert.strictEqual(timeout, 10000); + }); + + it("should ignore textual non-numeric values in environment variable and fall back to config", () => { + process.env.AIKIDO_SCAN_TIMEOUT_MS = "fast"; + // Mock: config file exists with scanTimeout: 8000 + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: 8000 }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 8000); + }); + + it("should ignore textual non-numeric values in config file and fall back to default", () => { + delete process.env.AIKIDO_SCAN_TIMEOUT_MS; + // Mock: config file exists with scanTimeout: "slow" + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: "slow" }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 10000); + }); + + it("should ignore textual non-numeric values in both env and config, fall back to default", () => { + process.env.AIKIDO_SCAN_TIMEOUT_MS = "quick"; + // Mock: config file exists with scanTimeout: "medium" + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: "medium" }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 10000); + }); + + it("should ignore mixed alphanumeric strings in environment variable", () => { + process.env.AIKIDO_SCAN_TIMEOUT_MS = "5000ms"; + // Mock: config file exists with scanTimeout: 6000 + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: 6000 }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 6000); + }); + + it("should ignore mixed alphanumeric strings in config file", () => { + delete process.env.AIKIDO_SCAN_TIMEOUT_MS; + // Mock: config file exists with scanTimeout: "3000ms" + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: "3000ms" }) + ); + + const timeout = getScanTimeout(); + + assert.strictEqual(timeout, 10000); + }); +});