diff --git a/.gitignore b/.gitignore index da15b0f..9ea2e52 100755 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ .last_tested_commit certs/* +test/logs/* diff --git a/package-lock.json b/package-lock.json index ac7266d..7d9b1a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,10 +47,13 @@ "@faker-js/faker": "^9.8.0", "chai": "^5.2.1", "mocha": "^11.7.1", + "mock-fs": "^5.5.0", "node-fetch": "^2.7.0", "pm2": "^6.0.6", "postcss": "^8.5.6", - "postcss-import": "^16.1.1" + "postcss-import": "^16.1.1", + "proxyquire": "^2.1.3", + "sinon": "^21.0.0" } }, "node_modules/@babel/code-frame": { @@ -951,6 +954,48 @@ "streamx": "^2.15.0" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -3129,6 +3174,30 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/fill-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", + "integrity": "sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-object": "~1.0.1", + "merge-descriptors": "~1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fill-keys/node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4127,6 +4196,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", + "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", @@ -4500,6 +4579,14 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -5263,6 +5350,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/mock-fs": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", + "integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -5270,6 +5367,13 @@ "dev": true, "license": "MIT" }, + "node_modules/module-not-found-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", + "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==", + "dev": true, + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -6455,6 +6559,18 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/proxyquire": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", + "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.1", + "resolve": "^1.11.1" + } + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -7412,6 +7528,47 @@ "node": ">=10" } }, + "node_modules/sinon": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz", + "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.5", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -8015,6 +8172,16 @@ "json-stringify-safe": "^5.0.1" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", diff --git a/package.json b/package.json index 2e7583d..cf122cd 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,10 @@ "version": "1.0.0", "description": "", "main": "index.js", + "type": "module", "scripts": { "combine:css": "node scripts/combine-css.js", + "test": "NODE_PATH=./src mocha test/units/**/*.mjs", "start": "nodemon ./src/app.js --trace-exit", "maildev": "maildev", "main": "pm2 start ecosystem.config.js --only expressjs-blog-main", @@ -15,6 +17,9 @@ "test:postreceive": "node scripts/test-postreceive.js", "update-submodules": "git submodule update --init --recursive --remote" }, + "imports": { + "#src/*": "./src/*" + }, "keywords": [], "author": "", "license": "ISC", @@ -58,9 +63,12 @@ "@faker-js/faker": "^9.8.0", "chai": "^5.2.1", "mocha": "^11.7.1", + "mock-fs": "^5.5.0", "node-fetch": "^2.7.0", "pm2": "^6.0.6", "postcss": "^8.5.6", - "postcss-import": "^16.1.1" + "postcss-import": "^16.1.1", + "proxyquire": "^2.1.3", + "sinon": "^21.0.0" } } diff --git a/src/app.js b/src/app.js index 8b3c592..7992412 100644 --- a/src/app.js +++ b/src/app.js @@ -12,7 +12,7 @@ const { startTokenCleanup } = require("./utils/tokenCleanup"); const { cleanupOldSessions } = require("./utils/logManager"); -const SERVER_PORT = process.env.TEST_PORT || process.env.SERVER_PORT || 3400; +const SERVER_PORT = process.env.SERVER_PORT || 3400; const SERVER_LISTEN_LOG = (port) => `Server listening on http://localhost:${port}`; const NODE_ENV_LOG = `NODE_ENV: ${process.env.NODE_ENV}`; diff --git a/src/utils/SQLiteTransport.js b/src/utils/SQLiteTransport.js index ab9709a..f382fb7 100644 --- a/src/utils/SQLiteTransport.js +++ b/src/utils/SQLiteTransport.js @@ -1,7 +1,6 @@ const Transport = require("winston-transport"); const Database = require("better-sqlite3"); const path = require("path"); -const { winstonLogger } = require("./logging"); class SQLiteTransport extends Transport { constructor(opts) { @@ -107,7 +106,9 @@ try { insertLogTxn(); } catch (error) { - winstonLogger.error("SQLite logging error:", error); + // winstonLogger creates a circular dependency + // Not mission critical + console.error("SQLite logging error:", error); } callback(); diff --git a/src/utils/logging.js b/src/utils/logging.js deleted file mode 100644 index 5bfca14..0000000 --- a/src/utils/logging.js +++ /dev/null @@ -1,276 +0,0 @@ -// utils/logging.js - -const customLevels = { - levels: { - error: 0, - warn: 1, - security: 2, // Custom level - notice: 3, - info: 4, - debug: 5, - }, - colors: { - error: "red", - warn: "yellow", - security: "magenta", // Optional color - notice: "cyan", - info: "green", - debug: "blue", - }, -}; -const LOG_LEVEL = process.env.LOG_LEVEL?.toLowerCase() || "info"; -const LOG_LEVELS = customLevels.levels; - -const UNCUGHT_EXCEPTION_MSG = "Uncaught Exception:"; -const UNHANDLED_REJECTION_MSG = "Unhandled Rejection:"; - -const fs = require("fs"); -const path = require("path"); -const util = require("util"); - -const winston = require("winston"); -winston.addColors(customLevels.colors); -const { createLogger, format, transports } = winston; - -const DailyRotateFile = require("winston-daily-rotate-file"); -const SQLiteTransport = require("../utils/SQLiteTransport"); -const sqliteTransport = new SQLiteTransport(); - -// Define the root log directory -const logDir = path.join(__dirname, "..", "..", "logs"); -const projectRoot = path.join(__dirname, "..", ".."); - -// Create session-specific directory with timestamp -const sessionTimestamp = new Date().toISOString().replace(/[:.]/g, "-"); -const sessionDir = path.join(logDir, "sessions", sessionTimestamp); - -// Define log file paths -const logFiles = { - session: path.join(sessionDir, "session.log"), - info: path.join(logDir, "info", "info.log"), - notice: path.join(logDir, "notice", "notice.log"), - error: path.join(logDir, "error", "error.log"), - warn: path.join(logDir, "warn", "warn.log"), - debug: path.join(logDir, "debug", "debug.log"), -}; - -// Ensure log directories exist -Object.values(logFiles).forEach((filePath) => { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } -}); - -const functionsLogDir = path.join(logDir, "functions"); -if (!fs.existsSync(functionsLogDir)) { - fs.mkdirSync(functionsLogDir, { recursive: true }); -} - -const originalConsole = { ...console }; - -function shouldLog(level) { - return LOG_LEVELS[level.toLowerCase()] <= LOG_LEVELS[LOG_LEVEL]; -} - -// Create write streams -const logStreams = { - info: fs.createWriteStream(logFiles.info, { flags: "a" }), - notice: fs.createWriteStream(logFiles.notice, { flags: "a" }), - error: fs.createWriteStream(logFiles.error, { flags: "a" }), - warn: fs.createWriteStream(logFiles.warn, { flags: "a" }), - debug: fs.createWriteStream(logFiles.debug, { flags: "a" }), -}; - -// Session-specific daily rotate transport -const sessionTransport = new DailyRotateFile({ - dirname: sessionDir, - filename: "session-%DATE%.log", - datePattern: "YYYY-MM-DD", - zippedArchive: true, - maxFiles: "30d", // Keep session logs for 30 days - format: format.combine( - format.timestamp(), - format.printf( - ({ timestamp, level, message }) => - `[${timestamp}] [${level.toUpperCase()}] ${message}` - ) - ), -}); - -// Utility function for custom function logs -const dynamicCustomStreams = {}; - -function formatFunctionName(rawPath) { - const relative = path.relative(projectRoot, rawPath).replace(/\\/g, "/"); - return relative; -} - -function formatLogMessage(functionName, args) { - const timestamp = new Date().toISOString(); - return `[${timestamp}] ${args.join(" ")}\n`; -} - -const functionLog = (functionName, ...args) => { - const safeFunctionName = formatFunctionName(functionName).replace( - /[^a-z0-9_\-]/gi, - "_" - ); - const message = formatLogMessage(functionName, args); - - if (!dynamicCustomStreams[safeFunctionName]) { - const customFilePath = path.join( - functionsLogDir, - `${safeFunctionName}.log` - ); - dynamicCustomStreams[safeFunctionName] = fs.createWriteStream( - customFilePath, - { flags: "a" } - ); - } - - dynamicCustomStreams[safeFunctionName].write(message); - //console.log(`[${functionName}]`, ...args) -}; - -// Generic log writer with session logging -function writeLog(level, stream, consoleFn, ...args) { - if (!shouldLog(level)) return; - - const timestamp = new Date().toISOString(); - const message = args.join(" "); - const logLine = `[${timestamp}] [${level}] ${message}\n`; - - // Write to specific log file - stream.write(logLine); - - // Write to session log via winston transport - sessionTransport.write({ level: level.toLowerCase(), message, timestamp }); - - // Console output - consoleFn(`[${timestamp}] [${level}]`, ...args); -} - -function buildTransport(level, filename) { - return new DailyRotateFile({ - dirname: path.join(logDir, level), - filename: `${filename}-%DATE%.log`, - datePattern: "YYYY-MM-DD", - zippedArchive: true, - maxFiles: "14d", - level, - format: format.combine( - format.timestamp(), - format.printf( - ({ timestamp, level, message }) => - `[${timestamp}] [${level.toUpperCase()}] ${message}` - ) - ), - }); -} - -function patchConsole() { - console.log = (...args) => - writeLog("INFO", logStreams.info, originalConsole.log, ...args); - console.error = (...args) => - writeLog("ERROR", logStreams.error, originalConsole.error, ...args); - console.warn = (...args) => - writeLog("WARN", logStreams.warn, originalConsole.warn, ...args); - console.info = (...args) => - writeLog("INFO", logStreams.info, originalConsole.info, ...args); - console.debug = (...args) => - writeLog("DEBUG", logStreams.debug, originalConsole.debug, ...args); -} - -// Exported logger object -const manualLogger = { - streams: logStreams, - function: functionLog, - info: (...args) => writeLog("INFO", logStreams.info, console.log, ...args), - notice: (...args) => - writeLog("NOTICE", logStreams.notice, console.log, ...args), - warn: (...args) => writeLog("WARN", logStreams.warn, console.warn, ...args), - error: (...args) => - writeLog("ERROR", logStreams.error, console.error, ...args), - debug: (...args) => - writeLog("DEBUG", logStreams.debug, console.debug, ...args), - // Add session info method - sessionInfo: () => ({ - sessionId: sessionTimestamp, - sessionDir: sessionDir, - startTime: new Date().toISOString(), - }), -}; - -const winstonLogger = createLogger({ - levels: customLevels.levels, - format: format.combine( - format.timestamp(), - format.printf( - ({ timestamp, level, message }) => `[${timestamp}] [${level}] ${message}` - ) - ), - transports: [ - buildTransport("info", "info"), - buildTransport("error", "error"), - buildTransport("warn", "warn"), - buildTransport("debug", "debug"), - buildTransport("notice", "notice"), - buildTransport("security", "security"), - sessionTransport, // Add session transport to winston - new transports.Console({ - level: LOG_LEVEL, - format: format.combine( - format.colorize(), - format.timestamp(), - format.printf(({ timestamp, level, message, ...meta }) => { - let stack = meta.stack || ""; - if (stack) delete meta.stack; - - // Safely stringify message - let outputMsg; - if (typeof message === "string") { - outputMsg = message; - } else { - try { - outputMsg = JSON.stringify(message, null, 2); - } catch { - outputMsg = util.inspect(message, { depth: null, colors: false }); - } - } - - // Handle meta - let metaString = ""; - if (Object.keys(meta).length > 0) { - metaString = util.inspect(meta, { depth: null, colors: false }); - } - - return `[${timestamp}] [${level}] ${outputMsg}\n${stack}\n${metaString}`; - }) - ), - }), - sqliteTransport, - ], -}); - -function handleUncaughtException(err) { - winstonLogger.error(UNCUGHT_EXCEPTION_MSG, err.stack || err); -} - -function handleUnhandledRejection(reason) { - winstonLogger.error(UNHANDLED_REJECTION_MSG, reason?.stack || reason); -} - -if ( - process.env.NODE_ENV !== "production" && - process.env.NODE_ENV !== "testing" -) { - patchConsole(); -} - -module.exports = { - manualLogger, - winstonLogger, - handleUncaughtException, - handleUnhandledRejection, -}; diff --git a/src/utils/logging/config.js b/src/utils/logging/config.js new file mode 100644 index 0000000..9ccbee2 --- /dev/null +++ b/src/utils/logging/config.js @@ -0,0 +1,49 @@ +const path = require("path"); + +const customLevels = { + levels: { + error: 0, + warn: 1, + security: 2, + notice: 3, + info: 4, + debug: 5, + }, + colors: { + error: "red", + warn: "yellow", + security: "magenta", + notice: "cyan", + info: "green", + debug: "blue", + }, +}; + +const LOG_LEVEL = process.env.LOG_LEVEL?.toLowerCase() || "info"; +const LOG_LEVELS = customLevels.levels; + +const logDir = path.join(__dirname, "..", "..", "logs"); +const projectRoot = path.join(__dirname, "..", ".."); + +const sessionTimestamp = new Date().toISOString().replace(/[:.]/g, "-"); +const sessionDir = path.join(logDir, "sessions", sessionTimestamp); + +const logFiles = { + session: path.join(sessionDir, "session.log"), + info: path.join(logDir, "info", "info.log"), + notice: path.join(logDir, "notice", "notice.log"), + error: path.join(logDir, "error", "error.log"), + warn: path.join(logDir, "warn", "warn.log"), + debug: path.join(logDir, "debug", "debug.log"), +}; + +module.exports = { + customLevels, + LOG_LEVEL, + LOG_LEVELS, + logDir, + projectRoot, + sessionTimestamp, + sessionDir, + logFiles, +}; diff --git a/src/utils/logging/consolePatch.js b/src/utils/logging/consolePatch.js new file mode 100644 index 0000000..3d26005 --- /dev/null +++ b/src/utils/logging/consolePatch.js @@ -0,0 +1,38 @@ +const { LOG_LEVEL, LOG_LEVELS } = require("./config"); + +function shouldLog(level) { + return LOG_LEVELS[level.toLowerCase()] <= LOG_LEVELS[LOG_LEVEL]; +} + +const originalConsole = { ...console }; + +function patchConsole(logStreams) { + console.log = (...args) => + writeLog("INFO", logStreams.info, originalConsole.log, ...args); + console.error = (...args) => + writeLog("ERROR", logStreams.error, originalConsole.error, ...args); + console.warn = (...args) => + writeLog("WARN", logStreams.warn, originalConsole.warn, ...args); + console.info = (...args) => + writeLog("INFO", logStreams.info, originalConsole.info, ...args); + console.debug = (...args) => + writeLog("DEBUG", logStreams.debug, originalConsole.debug, ...args); +} + +function writeLog(level, stream, consoleFn, ...args) { + if (!shouldLog(level)) return; + + const timestamp = new Date().toISOString(); + const message = args.join(" "); + const logLine = `[${timestamp}] [${level}] ${message}\n`; + + stream.write(logLine); + sessionTransport.write({ level: level.toLowerCase(), message, timestamp }); + consoleFn(`[${timestamp}] [${level}]`, ...args); +} + +module.exports = { + patchConsole, + shouldLog, + writeLog, +}; diff --git a/src/utils/logging/directories.js b/src/utils/logging/directories.js new file mode 100644 index 0000000..324fe66 --- /dev/null +++ b/src/utils/logging/directories.js @@ -0,0 +1,22 @@ +const fs = require("fs"); +const path = require("path"); + +const { logDir, logFiles } = require("./config"); + +function initializeLogDirectories(files = logFiles) { + Object.values(files).forEach((filePath) => { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }); + + const functionsLogDir = path.join(logDir, "functions"); + if (!fs.existsSync(functionsLogDir)) { + fs.mkdirSync(functionsLogDir, { recursive: true }); + } + return functionsLogDir; +} +module.exports = { + initializeLogDirectories, +}; diff --git a/src/utils/logging/formatters.js b/src/utils/logging/formatters.js new file mode 100644 index 0000000..a137f49 --- /dev/null +++ b/src/utils/logging/formatters.js @@ -0,0 +1,14 @@ +// formatters.js +const path = require("path"); +const { projectRoot } = require("./config"); + +function formatFunctionName(rawPath, root = projectRoot) { + return path.relative(root, rawPath).replace(/\\/g, "/"); +} + +function formatLogMessage(functionName, args) { + const timestamp = new Date().toISOString(); + return `[${timestamp}] ${args.join(" ")}\n`; +} + +module.exports = { formatFunctionName, formatLogMessage }; diff --git a/src/utils/logging/functionLogger.js b/src/utils/logging/functionLogger.js new file mode 100644 index 0000000..d113b90 --- /dev/null +++ b/src/utils/logging/functionLogger.js @@ -0,0 +1,29 @@ +// functionLogger.js +const fs = require("fs"); +const path = require("path"); +const { formatFunctionName, formatLogMessage } = require("./formatters"); + +const dynamicCustomStreams = {}; + +function functionLog(functionsLogDir, functionName, ...args) { + const safeFunctionName = formatFunctionName(functionName).replace( + /[^a-z0-9_\-]/gi, + "_" + ); + const message = formatLogMessage(functionName, args); + + if (!dynamicCustomStreams[safeFunctionName]) { + const customFilePath = path.join( + functionsLogDir, + `${safeFunctionName}.log` + ); + dynamicCustomStreams[safeFunctionName] = fs.createWriteStream( + customFilePath, + { flags: "a" } + ); + } + + dynamicCustomStreams[safeFunctionName].write(message); +} + +module.exports = { functionLog }; diff --git a/src/utils/logging/handlers.js b/src/utils/logging/handlers.js new file mode 100644 index 0000000..e0b3524 --- /dev/null +++ b/src/utils/logging/handlers.js @@ -0,0 +1,17 @@ +const UNCUGHT_EXCEPTION_MSG = "Uncaught Exception:"; +const UNHANDLED_REJECTION_MSG = "Unhandled Rejection:"; + +const { winstonLogger } = require("./index"); + +function handleUncaughtException(err) { + winstonLogger.error(UNCUGHT_EXCEPTION_MSG, err.stack || err); +} + +function handleUnhandledRejection(reason) { + winstonLogger.error(UNHANDLED_REJECTION_MSG, reason?.stack || reason); +} + +module.exports = { + handleUncaughtException, + handleUnhandledRejection, +}; diff --git a/src/utils/logging/index.js b/src/utils/logging/index.js new file mode 100644 index 0000000..6d4d1c8 --- /dev/null +++ b/src/utils/logging/index.js @@ -0,0 +1,127 @@ +// src/utils/logging/index.js +const fs = require("fs"); +const path = require("path"); +const util = require("util"); +const winston = require("winston"); +const SQLiteTransport = require("../SQLiteTransport"); +const { createLogger, format, transports } = winston; + +const { patchConsole, shouldLog, writeLog } = require("./consolePatch"); +const { formatFunctionName, formatLogMessage } = require("./formatters"); +const { functionLog } = require("./functionLogger"); + +const { + customLevels, + LOG_LEVEL, + logDir, + sessionTimestamp, + sessionDir, + logFiles, +} = require("./config"); + +const { + createLogStreams, + buildTransport, + createSessionTransport, +} = require("./streams"); + +winston.addColors(customLevels.colors); + +function initializeLogDirectories(files = logFiles) { + Object.values(files).forEach((filePath) => { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }); + + const functionsLogDir = path.join(logDir, "functions"); + if (!fs.existsSync(functionsLogDir)) { + fs.mkdirSync(functionsLogDir, { recursive: true }); + } + return functionsLogDir; +} + +const logStreams = createLogStreams(logFiles); +const sessionTransport = createSessionTransport(sessionDir); +const sqliteTransport = new SQLiteTransport(); + +const manualLogger = { + streams: logStreams, + function: (...args) => functionLog(functionsLogDir, ...args), + info: (...args) => writeLog("INFO", logStreams.info, console.log, ...args), + notice: (...args) => + writeLog("NOTICE", logStreams.notice, console.log, ...args), + warn: (...args) => writeLog("WARN", logStreams.warn, console.warn, ...args), + error: (...args) => + writeLog("ERROR", logStreams.error, console.error, ...args), + debug: (...args) => + writeLog("DEBUG", logStreams.debug, console.debug, ...args), + sessionInfo: () => ({ + sessionId: sessionTimestamp, + sessionDir, + startTime: new Date().toISOString(), + }), +}; + +const winstonLogger = createLogger({ + levels: customLevels.levels, + format: format.combine( + format.timestamp(), + format.printf( + ({ timestamp, level, message }) => `[${timestamp}] [${level}] ${message}` + ) + ), + transports: [ + buildTransport("info", "info"), + buildTransport("error", "error"), + buildTransport("warn", "warn"), + buildTransport("debug", "debug"), + buildTransport("notice", "notice"), + buildTransport("security", "security"), + sessionTransport, + new transports.Console({ + level: LOG_LEVEL, + format: format.combine( + format.colorize(), + format.timestamp(), + format.printf(({ timestamp, level, message, ...meta }) => { + let stack = meta.stack || ""; + if (stack) delete meta.stack; + + let outputMsg; + if (typeof message === "string") { + outputMsg = message; + } else { + try { + outputMsg = JSON.stringify(message, null, 2); + } catch { + outputMsg = util.inspect(message, { depth: null, colors: false }); + } + } + + let metaString = ""; + if (Object.keys(meta).length > 0) { + metaString = util.inspect(meta, { depth: null, colors: false }); + } + + return `[${timestamp}] [${level}] ${outputMsg}\n${stack}\n${metaString}`; + }) + ), + }), + sqliteTransport, + ], +}); + +module.exports = { + manualLogger, + winstonLogger, + initializeLogDirectories, + createLogStreams, + createSessionTransport, + patchConsole, + shouldLog, + writeLog, + formatFunctionName, + formatLogMessage, +}; diff --git a/src/utils/logging/logger.js b/src/utils/logging/logger.js new file mode 100644 index 0000000..08b5bb6 --- /dev/null +++ b/src/utils/logging/logger.js @@ -0,0 +1,141 @@ +const fs = require("fs"); +const path = require("path"); +const util = require("util"); +const winston = require("winston"); +const SQLiteTransport = require("../utils/SQLiteTransport"); + +const { createLogger, format, transports } = winston; + +const { + customLevels, + LOG_LEVEL, + logDir, + projectRoot, + sessionTimestamp, + sessionDir, + logFiles, +} = require("./config"); + +function initializeLogDirectories(files = logFiles) { + Object.values(files).forEach((filePath) => { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }); + + const functionsLogDir = path.join(logDir, "functions"); + if (!fs.existsSync(functionsLogDir)) { + fs.mkdirSync(functionsLogDir, { recursive: true }); + } + return functionsLogDir; +} + +function formatFunctionName(rawPath) { + const relative = path.relative(projectRoot, rawPath).replace(/\\/g, "/"); + return relative; +} + +function formatLogMessage(functionName, args) { + const timestamp = new Date().toISOString(); + return `[${timestamp}] ${args.join(" ")}\n`; +} + +const dynamicCustomStreams = {}; +function functionLog(functionName, ...args) { + const safeFunctionName = formatFunctionName(functionName).replace( + /[^a-z0-9_\-]/gi, + "_" + ); + const message = formatLogMessage(functionName, args); + + if (!dynamicCustomStreams[safeFunctionName]) { + const customFilePath = path.join( + functionsLogDir, + `${safeFunctionName}.log` + ); + dynamicCustomStreams[safeFunctionName] = fs.createWriteStream( + customFilePath, + { flags: "a" } + ); + } + + dynamicCustomStreams[safeFunctionName].write(message); +} + +const functionsLogDir = initializeLogDirectories(); +const logStreams = createLogStreams(logFiles); +const sessionTransport = createSessionTransport(sessionDir); +const sqliteTransport = new SQLiteTransport(); + +const manualLogger = { + streams: logStreams, + function: functionLog, + info: (...args) => writeLog("INFO", logStreams.info, console.log, ...args), + notice: (...args) => + writeLog("NOTICE", logStreams.notice, console.log, ...args), + warn: (...args) => writeLog("WARN", logStreams.warn, console.warn, ...args), + error: (...args) => + writeLog("ERROR", logStreams.error, console.error, ...args), + debug: (...args) => + writeLog("DEBUG", logStreams.debug, console.debug, ...args), + sessionInfo: () => ({ + sessionId: sessionTimestamp, + sessionDir, + startTime: new Date().toISOString(), + }), +}; + +const winstonLogger = createLogger({ + levels: customLevels.levels, + format: format.combine( + format.timestamp(), + format.printf( + ({ timestamp, level, message }) => `[${timestamp}] [${level}] ${message}` + ) + ), + transports: [ + buildTransport("info", "info"), + buildTransport("error", "error"), + buildTransport("warn", "warn"), + buildTransport("debug", "debug"), + buildTransport("notice", "notice"), + buildTransport("security", "security"), + sessionTransport, + new transports.Console({ + level: LOG_LEVEL, + format: format.combine( + format.colorize(), + format.timestamp(), + format.printf(({ timestamp, level, message, ...meta }) => { + let stack = meta.stack || ""; + if (stack) delete meta.stack; + + let outputMsg; + if (typeof message === "string") { + outputMsg = message; + } else { + try { + outputMsg = JSON.stringify(message, null, 2); + } catch { + outputMsg = util.inspect(message, { depth: null, colors: false }); + } + } + + let metaString = ""; + if (Object.keys(meta).length > 0) { + metaString = util.inspect(meta, { depth: null, colors: false }); + } + + return `[${timestamp}] [${level}] ${outputMsg}\n${stack}\n${metaString}`; + }) + ), + }), + sqliteTransport, + ], +}); + +module.exports = { + manualLogger, + winstonLogger, +}; diff --git a/src/utils/logging/streams.js b/src/utils/logging/streams.js new file mode 100644 index 0000000..8ffb470 --- /dev/null +++ b/src/utils/logging/streams.js @@ -0,0 +1,58 @@ +const fs = require("fs"); +const path = require("path"); +const winston = require("winston"); +const DailyRotateFile = require("winston-daily-rotate-file"); +const { format } = winston; + +const { logDir } = require("./config"); + +function createLogStreams(files) { + return { + info: fs.createWriteStream(files.info, { flags: "a" }), + notice: fs.createWriteStream(files.notice, { flags: "a" }), + error: fs.createWriteStream(files.error, { flags: "a" }), + warn: fs.createWriteStream(files.warn, { flags: "a" }), + debug: fs.createWriteStream(files.debug, { flags: "a" }), + }; +} + +function createSessionTransport(dir) { + return new DailyRotateFile({ + dirname: dir, + filename: "session-%DATE%.log", + datePattern: "YYYY-MM-DD", + zippedArchive: true, + maxFiles: "30d", + format: format.combine( + format.timestamp(), + format.printf( + ({ timestamp, level, message }) => + `[${timestamp}] [${level.toUpperCase()}] ${message}` + ) + ), + }); +} + +function buildTransport(level, filename) { + return new DailyRotateFile({ + dirname: path.join(logDir, level), + filename: `${filename}-%DATE%.log`, + datePattern: "YYYY-MM-DD", + zippedArchive: true, + maxFiles: "14d", + level, + format: format.combine( + format.timestamp(), + format.printf( + ({ timestamp, level, message }) => + `[${timestamp}] [${level.toUpperCase()}] ${message}` + ) + ), + }); +} + +module.exports = { + createLogStreams, + createSessionTransport, + buildTransport, +}; diff --git a/test/units/middleware/logging/createLogStreams.test.js b/test/units/middleware/logging/createLogStreams.test.js new file mode 100644 index 0000000..ff2647f --- /dev/null +++ b/test/units/middleware/logging/createLogStreams.test.js @@ -0,0 +1,31 @@ +// test/createLogStreams.test.js +const fs = require("fs"); +const path = require("path"); +const { expect } = require("chai"); +const { createLogStreams } = require("../../../../src/utils/logging"); + +describe("createLogStreams", () => { + const testDir = path.join(__dirname, "..", "..", "..", "..", "test", "logs"); + const files = { + info: path.join(testDir, "info.log"), + error: path.join(testDir, "error.log"), + warn: path.join(testDir, "warn.log"), + notice: path.join(testDir, "notice.log"), + debug: path.join(testDir, "debug.log"), + }; + + afterEach(() => { + Object.values(files).forEach((file) => { + try { + fs.unlinkSync(file); + } catch (_) {} + }); + }); + + it("should create write streams for all log files", () => { + const streams = createLogStreams(files); + for (const key of Object.keys(files)) { + expect(streams[key]).to.be.an.instanceof(fs.WriteStream); + } + }); +}); diff --git a/test/units/middleware/logging/formatFunctionName.test.js b/test/units/middleware/logging/formatFunctionName.test.js new file mode 100644 index 0000000..9c847ad --- /dev/null +++ b/test/units/middleware/logging/formatFunctionName.test.js @@ -0,0 +1,14 @@ +// test/formatFunctionName.test.js +const { expect } = require("chai"); +const path = require("path"); +const { formatFunctionName } = require("../../../../src/utils/logging"); + +describe("formatFunctionName", () => { + it("returns relative path with forward slashes", () => { + const base = path.join(__dirname, "..", "..", "..", ".."); + const testPath = path.join(base, "src", "somefile.js"); + const result = formatFunctionName(testPath, base); + + expect(result).to.equal("src/somefile.js"); + }); +}); diff --git a/test/units/middleware/logging/formatLogMessage.test.js b/test/units/middleware/logging/formatLogMessage.test.js new file mode 100644 index 0000000..4b30976 --- /dev/null +++ b/test/units/middleware/logging/formatLogMessage.test.js @@ -0,0 +1,15 @@ +// test/formatLogMessage.test.js +const { expect } = require("chai"); +const { formatLogMessage } = require("../../../../src/utils/logging"); + +describe("formatLogMessage", () => { + it("formats message with timestamp and args", () => { + const fn = "testFunc.js"; + const args = ["arg1", "arg2"]; + const result = formatLogMessage(fn, args); + + expect(result).to.match(/\[\d{4}-\d{2}-\d{2}T/); // ISO date start + expect(result).to.include("arg1 arg2"); + expect(result).to.match(/\n$/); + }); +}); diff --git a/test/units/middleware/logging/handleUncaughtException.test.js b/test/units/middleware/logging/handleUncaughtException.test.js new file mode 100644 index 0000000..d650708 --- /dev/null +++ b/test/units/middleware/logging/handleUncaughtException.test.js @@ -0,0 +1,28 @@ +// test/handleUncaughtException.test.js +const { expect } = require("chai"); +const sinon = require("sinon"); +const proxyquire = require("proxyquire").noCallThru(); // prevents loading actual file if stubbed + +describe("handleUncaughtException", () => { + it("logs error using winstonLogger", () => { + const errorStub = sinon.stub(); + + const fakeLogger = { + winstonLogger: { + error: errorStub, + }, + }; + + const { handleUncaughtException } = proxyquire( + "../../../../src/utils/logging/handlers", + { + "./index": fakeLogger, + } + ); + + const err = new Error("fail"); + handleUncaughtException(err); + + expect(errorStub.calledWith("Uncaught Exception:", err.stack)).to.be.true; + }); +}); diff --git a/test/units/middleware/logging/handleUnhandledRejection.test.js b/test/units/middleware/logging/handleUnhandledRejection.test.js new file mode 100644 index 0000000..74e720b --- /dev/null +++ b/test/units/middleware/logging/handleUnhandledRejection.test.js @@ -0,0 +1,22 @@ +const { expect } = require("chai"); +const sinon = require("sinon"); +const path = require("path"); +const proxyquire = require("proxyquire"); + +describe("handleUnhandledRejection", () => { + it("logs rejection using winstonLogger", () => { + const errorStub = sinon.stub(); + const reason = new Error("rejection"); + + const handlers = proxyquire( + path.resolve(__dirname, "../../../../src/utils/logging/handlers"), + { + "../logging": { winstonLogger: { error: errorStub } }, + } + ); + + handlers.handleUnhandledRejection(reason); + expect(errorStub.calledWith("Unhandled Rejection:", reason.stack)).to.be + .true; + }); +}); diff --git a/test/units/middleware/logging/initializeLogDirectories.test.js b/test/units/middleware/logging/initializeLogDirectories.test.js new file mode 100644 index 0000000..aa52d26 --- /dev/null +++ b/test/units/middleware/logging/initializeLogDirectories.test.js @@ -0,0 +1,44 @@ +// test/initializeLogDirectories.test.js +const { expect } = require("chai"); +const fs = require("fs"); +const path = require("path"); +const mockFs = require("mock-fs"); +const { initializeLogDirectories } = require("../../../../src/utils/logging"); + +describe("initializeLogDirectories", () => { + const customLogFiles = { + info: "logs/info/info.log", + error: "logs/error/error.log", + warn: "logs/warn/warn.log", + notice: "logs/notice/notice.log", + debug: "logs/debug/debug.log", + }; + + afterEach(() => mockFs.restore()); + + it("should create all required directories for given log files", () => { + mockFs({}); + const result = initializeLogDirectories(customLogFiles); + + for (const file of Object.values(customLogFiles)) { + const dir = path.dirname(file); + expect(fs.existsSync(dir)).to.be.true; + } + + expect(fs.existsSync(result)).to.be.true; + }); + + it("should not fail if directories already exist", () => { + const dirs = Object.values(customLogFiles).reduce( + (acc, file) => { + acc[path.dirname(file)] = {}; + return acc; + }, + { "logs/functions": {} } + ); + + mockFs(dirs); + + expect(() => initializeLogDirectories(customLogFiles)).to.not.throw(); + }); +}); diff --git a/test/units/middleware/logging/shouldLog.test.js b/test/units/middleware/logging/shouldLog.test.js new file mode 100644 index 0000000..d9b9ed7 --- /dev/null +++ b/test/units/middleware/logging/shouldLog.test.js @@ -0,0 +1,26 @@ +// test/shouldLog.test.js +const { expect } = require("chai"); +const path = require("path"); + +describe("shouldLog", () => { + const originalLogLevel = process.env.LOG_LEVEL; + + beforeEach(() => { + process.env.LOG_LEVEL = "warn"; + }); + + afterEach(() => { + process.env.LOG_LEVEL = originalLogLevel; + delete require.cache[ + require.resolve("../../../../src/utils/logging/consolePatch") + ]; + }); + + it("returns true if level is higher or equal to current log level", () => { + const { shouldLog } = require("../../../../src/utils/logging/consolePatch"); + + expect(shouldLog("error")).to.be.true; + expect(shouldLog("warn")).to.be.true; + expect(shouldLog("debug")).to.be.false; + }); +}); diff --git a/test/units/middleware/logging/writeLog.test.js b/test/units/middleware/logging/writeLog.test.js new file mode 100644 index 0000000..6ba1a61 --- /dev/null +++ b/test/units/middleware/logging/writeLog.test.js @@ -0,0 +1,83 @@ +// test/writeLog.test.js +const { expect } = require("chai"); +const sinon = require("sinon"); +const { writeLog } = require("../../../../src/utils/logging/consolePatch"); + +describe("writeLog", () => { + let stream; + let consoleFn; + let sessionTransport; + let clock; + const fixedDate = new Date("2025-07-25T12:00:00.000Z"); + + beforeEach(() => { + stream = { write: sinon.spy() }; + consoleFn = sinon.spy(); + + global.sessionTransport = { write: sinon.spy() }; + sessionTransport = global.sessionTransport; + + clock = sinon.useFakeTimers(fixedDate.getTime()); + }); + + afterEach(() => { + clock.restore(); + delete global.sessionTransport; + }); + + it("does not write when shouldLog returns false", () => { + const originalLogLevel = process.env.LOG_LEVEL; + process.env.LOG_LEVEL = "error"; + + writeLog("DEBUG", stream, consoleFn, "test message"); + + expect(stream.write.called).to.be.false; + expect(consoleFn.called).to.be.false; + expect(sessionTransport.write.called).to.be.false; + + process.env.LOG_LEVEL = originalLogLevel; + }); + + it("writes log line to stream and calls consoleFn and sessionTransport.write", () => { + writeLog("INFO", stream, consoleFn, "test", "message"); + + const expectedTimestamp = fixedDate.toISOString(); + const expectedLogLine = `[${expectedTimestamp}] [INFO] test message\n`; + + expect(stream.write.calledWith(expectedLogLine)).to.be.true; + expect( + sessionTransport.write.calledWith({ + level: "info", + message: "test message", + timestamp: expectedTimestamp, + }) + ).to.be.true; + expect( + consoleFn.calledWith(`[${expectedTimestamp}] [INFO]`, "test", "message") + ).to.be.true; + }); + + it("joins multiple args correctly in message", () => { + writeLog("WARN", stream, consoleFn, "part1", "part2", "part3"); + + const expectedTimestamp = fixedDate.toISOString(); + const expectedLogLine = `[${expectedTimestamp}] [WARN] part1 part2 part3\n`; + + expect(stream.write.calledWith(expectedLogLine)).to.be.true; + expect( + sessionTransport.write.calledWith({ + level: "warn", + message: "part1 part2 part3", + timestamp: expectedTimestamp, + }) + ).to.be.true; + expect( + consoleFn.calledWith( + `[${expectedTimestamp}] [WARN]`, + "part1", + "part2", + "part3" + ) + ).to.be.true; + }); +}); diff --git a/yarn.lock b/yarn.lock index ad17ada..ed54447 100644 --- a/yarn.lock +++ b/yarn.lock @@ -186,6 +186,29 @@ tar-fs "^3.0.8" yargs "^17.7.2" +"@sinonjs/commons@^3.0.1": + version "3.0.1" + resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^13.0.5": + version "13.0.5" + resolved "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz" + integrity sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw== + dependencies: + "@sinonjs/commons" "^3.0.1" + +"@sinonjs/samsam@^8.0.1": + version "8.0.2" + resolved "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz" + integrity sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw== + dependencies: + "@sinonjs/commons" "^3.0.1" + lodash.get "^4.4.2" + type-detect "^4.1.0" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz" @@ -1473,6 +1496,14 @@ resolved "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz" integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== +fill-keys@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz" + integrity sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA== + dependencies: + is-object "~1.0.1" + merge-descriptors "~1.0.0" + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz" @@ -2083,6 +2114,11 @@ resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-object@~1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz" + integrity sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA== + is-plain-obj@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz" @@ -2295,6 +2331,11 @@ dependencies: p-locate "^5.0.0" +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + lodash@^4.17.12, lodash@^4.17.14: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" @@ -2394,6 +2435,11 @@ resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz" integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== +merge-descriptors@~1.0.0: + version "1.0.3" + resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + merge2@^1.3.0: version "1.4.1" resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz" @@ -2652,11 +2698,21 @@ yargs-parser "^21.1.1" yargs-unparser "^2.0.0" +mock-fs@^5.5.0: + version "5.5.0" + resolved "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz" + integrity sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA== + module-details-from-path@^1.0.3: version "1.0.4" resolved "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz" integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== +module-not-found-error@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz" + integrity sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g== + moment@^2.29.1: version "2.30.1" resolved "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz" @@ -3303,6 +3359,15 @@ resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== +proxyquire@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz" + integrity sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg== + dependencies: + fill-keys "^1.0.2" + module-not-found-error "^1.0.1" + resolve "^1.11.1" + psl@^1.1.28: version "1.15.0" resolved "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz" @@ -3503,7 +3568,7 @@ resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve@^1.1.7, resolve@^1.22.1: +resolve@^1.1.7, resolve@^1.11.1, resolve@^1.22.1: version "1.22.10" resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz" integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== @@ -3809,6 +3874,17 @@ dependencies: semver "^7.5.3" +sinon@^21.0.0: + version "21.0.0" + resolved "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz" + integrity sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw== + dependencies: + "@sinonjs/commons" "^3.0.1" + "@sinonjs/fake-timers" "^13.0.5" + "@sinonjs/samsam" "^8.0.1" + diff "^7.0.0" + supports-color "^7.2.0" + smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz" @@ -4046,6 +4122,13 @@ dependencies: has-flag "^4.0.0" +supports-color@^7.2.0: + version "7.2.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + supports-color@^8.1.1: version "8.1.1" resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz" @@ -4223,6 +4306,16 @@ dependencies: json-stringify-safe "^5.0.1" +type-detect@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz" + integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== + +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + type-is@^1.6.12: version "1.6.18" resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz"