diff --git a/package.json b/package.json index e09cd4b..c4a318c 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,9 @@ "type": "commonjs", "scripts": { "combine:css": "node scripts/combine-css.js", - "test": "NODE_PATH=./src mocha test/units/**/*.mjs", + "test": "mocha test/**/*.unit.test.js test/**/*.property.test.js", "start": "nodemon ./src/app.js --trace-exit", + "debug": "nodemon --inspect-brk ./src/app.js --trace-exit", "maildev": "maildev", "main": "pm2 start ecosystem.config.js --only expressjs-blog-main", "testing": "pm2 start ecosystem.config.js --only expressjs-blog-testing", diff --git a/src/controllers/adminTokenController.js b/src/controllers/adminTokenController.js index 3dc8f98..96ad6d7 100644 --- a/src/controllers/adminTokenController.js +++ b/src/controllers/adminTokenController.js @@ -13,9 +13,8 @@ if (!token) return next(); if (!validateToken(token)) { - const error = new HttpError("Invalid or expired token", 401, { token }); - req.log.warn({ err: error, token }, "Token validation failed"); - return next(); + const error = new SecurityEvent("INVALID_TOKEN", { token }); + return next(error); } const scheme = req.protocol; diff --git a/src/controllers/analyticsControllers.js b/src/controllers/analyticsControllers.js index 6c8f12a..38d8da2 100644 --- a/src/controllers/analyticsControllers.js +++ b/src/controllers/analyticsControllers.js @@ -1,7 +1,7 @@ const db = require("../utils/sqlite3"); // Route: JavaScript-enabled tracking -module.exports = (req, res) => { +module.exports = (context) => (req, res) => { const { url = "", referrer = "", @@ -15,21 +15,18 @@ const directIp = req.connection.remoteAddress; const timestamp = Date.now(); - db.run( - `INSERT INTO analytics (timestamp, url, referrer, user_agent, viewport, load_time, event, forwardedIp, directIp, js_enabled) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - timestamp, - url, - referrer, - userAgent, - viewport, - loadTime, - event, - forwardedIp, - directIp, - 1, - ] - ); + req.log.analytics({ + context, + timestamp, + url, + referrer, + userAgent, + viewport, + loadTime, + event, + forwardedIp, + directIp, + js_enabled: true, + }); res.sendStatus(204); }; diff --git a/src/controllers/contactControllers.js b/src/controllers/contactControllers.js index 17ca0eb..6cd81b5 100644 --- a/src/controllers/contactControllers.js +++ b/src/controllers/contactControllers.js @@ -1,26 +1,16 @@ // src/routes/contact.js const { sendContactMail } = require("../utils/sendContactMail"); - const verifyHCaptcha = require("../utils/verifyHCaptcha"); const { qualifyLink } = require("../utils/qualifyLinks"); -const { - captureSecurityData, - analyzeThreatLevel, - logSecurityEvent, -} = require("../utils/securityForensics"); +const { analyzeThreatLevel } = require("../utils/securityForensics"); const { validateAndSanitizeEmail } = require("../utils/emailValidator"); const { isValidInput, - handleInvalidInput, buildSecurityData, - logSubmission, - handleCaptchaFailure, - blockHighThreat, prepareEmail, - logSuccess, - logUnhandledError, -} = require("./heplers/contactHelpers"); +} = require("./helpers/contactHelpers"); +const SecurityEvent = require("#src/utils/SecurityEvent.js"); module.exports.handleContactFormPost = async (req, res, next) => { try { @@ -29,15 +19,21 @@ req.body.hcaptchaToken || req.body["g-recaptcha-response"]; const emailResult = validateAndSanitizeEmail(email); + // Validate input and handle failures if (!isValidInput(name, subject, message, emailResult)) { - return await handleInvalidInput( + const validationError = SecurityEvent.fromRequest( req, - next, - { name, email, subject, message }, - emailResult + "VALIDATION_FAILURE", + { + formData: { name, email, subject, message }, + failureReason: emailResult.message || "invalid_input", + processingStep: "validation", + } ); + return next(validationError); } + // Build security context const securityData = buildSecurityData(req, { formData: { name, email, message, subject }, captchaProvided: !!hcaptchaToken, @@ -45,61 +41,118 @@ step: "initial_validation", }); + // Analyze threat level const threatAnalysis = analyzeThreatLevel( { name, email, message, subject }, securityData ); - await logSubmission(securityData, threatAnalysis, { - name, - email, - message, - subject, + // Log form submission attempt + await SecurityEvent.logEvent("CONTACT_SUBMISSION", { + ...securityData, + threatAnalysis, + formData: { + name, + email, + hasMessage: !!message, + hasSubject: !!subject, + }, }); - if (!hcaptchaToken) - return await handleCaptchaFailure( - securityData, + // Check for CAPTCHA token + if (!hcaptchaToken) { + const captchaError = SecurityEvent.fromRequest(req, "MISSING_CAPTCHA", { threatAnalysis, - next, - "missing_captcha" - ); + failureReason: "missing_captcha", + processingStep: "captcha_validation", + }); + return next(captchaError); + } + // Verify CAPTCHA const captchaValid = await verifyHCaptcha(hcaptchaToken); - if (!captchaValid) - return await handleCaptchaFailure( - securityData, + if (!captchaValid) { + const captchaError = SecurityEvent.fromRequest(req, "CAPTCHA_FAILED", { threatAnalysis, - next, - "captcha_failed" - ); + failureReason: "captcha_verification_failed", + processingStep: "captcha_validation", + }); + return next(captchaError); + } + // Block high-threat submissions if (threatAnalysis.level === "high") { - await blockHighThreat(securityData, threatAnalysis); + await SecurityEvent.create("THREAT_BLOCKED", { + ...securityData, + threatAnalysis, + action: "blocked_high_threat", + processingStep: "threat_analysis", + }); + + // Still show success to user to avoid revealing blocking res.customRedirect("/contact/thankyou"); return; } + // Prepare and send email const emailData = prepareEmail( { name, email, message, subject }, threatAnalysis ); - await sendContactMail(emailData); - await logSuccess(securityData, threatAnalysis); - res.customRedirect("/contact/thankyou"); + try { + await sendContactMail(emailData); + + // Log successful processing + await SecurityEvent.logEvent("CONTACT_SUCCESS", { + ...securityData, + threatAnalysis, + processingResult: "success", + emailSent: true, + }); + + res.customRedirect("/contact/thankyou"); + } catch (emailError) { + // Log email sending failure + const emailFailureEvent = SecurityEvent.fromRequest( + req, + "CONTACT_ERROR", + { + ...securityData, + threatAnalysis, + error: { + message: emailError.message, + stack: emailError.stack, + name: emailError.name, + }, + processingStep: "email_sending", + } + ); + + return next(emailFailureEvent); + } } catch (err) { - await logUnhandledError(req, err); - next(err); + // Log any unhandled errors + const systemError = SecurityEvent.fromRequest(req, "CONTACT_ERROR", { + error: { + message: err.message, + stack: err.stack, + name: err.name, + }, + processingStep: "error_handling", + }); + + next(systemError); } }; -module.exports.renderContactForm = async (req, res) => { - const securityData = captureSecurityData(req, { - pageAccess: "contact_form", - processingStep: "page_render", - }); - await logSecurityEvent(securityData, "page_access"); +module.exports.renderContactForm = async (req, res) => { + // Log page access + await SecurityEvent.logAccess(req, { + page: "contact_form", + userAgent: req.get("User-Agent"), + referrer: req.get("Referrer"), + }); const context = { csrfToken: res.locals.csrfToken, @@ -107,15 +160,17 @@ formAction: qualifyLink("/contact"), formMethod: "POST", }; + res.renderWithBaseContext("pages/contact.handlebars", context); }; -module.exports.renderThankYouPage = async (req, res) => { - const securityData = captureSecurityData(req, { - pageAccess: "thankyou_page", - processingStep: "page_render", - }); - await logSecurityEvent(securityData, "thankyou_access"); +module.exports.renderThankYouPage = async (req, res) => { + // Log thank you page access + await SecurityEvent.logAccess(req, { + page: "thankyou_page", + userAgent: req.get("User-Agent"), + referrer: req.get("Referrer"), + }); res.renderGenericMessage({ title: "Thank You", diff --git a/src/controllers/heplers/contactHelpers.js b/src/controllers/heplers/contactHelpers.js index 922c36d..c44be4a 100644 --- a/src/controllers/heplers/contactHelpers.js +++ b/src/controllers/heplers/contactHelpers.js @@ -1,9 +1,6 @@ // src/routes/helpers/contactHelpers.js -const HttpError = require("../../utils/HttpError"); -const { - captureSecurityData, - logSecurityEvent, -} = require("../../utils/securityForensics"); +const SecurityEvent = require("#src/utils/SecurityEvent.js"); +const { captureSecurityData } = require("../../utils/securityForensics"); function isReasonableLength(str, maxLen) { return ( @@ -19,16 +16,22 @@ isReasonableLength(message, 2000) ); } + +/** + * Handle invalid input with consistent security logging + */ async function handleInvalidInput(req, next, formData, emailResult) { - const invalidData = captureSecurityData(req, { + SecurityEvent.handleValidationFailure( + req, formData, - failureReason: emailResult.message || "invalid_input", - processingStep: "validation", - }); - await logSecurityEvent(invalidData, "validation_failure"); - next(new HttpError("Invalid input", 400, invalidData)); + emailResult.message || "invalid_input", + next + ); } +/** + * Build security data for logging + */ function buildSecurityData( req, { formData, captchaProvided, clientData, step } @@ -41,73 +44,66 @@ }); } +/** + * Log successful form submission + */ async function logSubmission(securityData, threatAnalysis, formData) { - await logSecurityEvent( - { - ...securityData, - threatAnalysis, - formData: { - name: formData.name, - email: formData.email, - hasMessage: !!formData.message, - hasSubject: !!formData.subject, - }, + await SecurityEvent.logEvent("CONTACT_SUCCESS", { + ...securityData, + threatAnalysis, + formData: { + name: formData.name, + email: formData.email, + hasMessage: !!formData.message, + hasSubject: !!formData.subject, }, - "contact_submission" - ); + }); } -async function handleCaptchaFailure( - securityData, - threatAnalysis, - next, - reason -) { - await logSecurityEvent( - { - ...securityData, - threatAnalysis, - validationResult: "failed", - failureReason: reason, - }, - "validation_failure" - ); - next(new HttpError("Captcha verification failed", 400)); +/** + * Handle CAPTCHA failure with consistent logging + */ +async function handleCaptchaFailure(req, threatAnalysis, next, reason) { + SecurityEvent.handleCaptchaFailure(req, reason, threatAnalysis, next); } -async function blockHighThreat(securityData, threatAnalysis) { - await logSecurityEvent( - { - ...securityData, - threatAnalysis, - action: "blocked_high_threat", - }, - "threat_blocked" - ); +/** + * Block high threat submissions + */ +async function blockHighThreat(req, threatAnalysis) { + return await SecurityEvent.blockThreat(req, threatAnalysis); } +/** + * Prepare email content with security flags if needed + */ function prepareEmail({ name, email, message, subject }, threatAnalysis) { const base = { name, email, message, subject }; + if (threatAnalysis.level === "medium") { base.securityFlag = `[SECURITY REVIEW REQUIRED - Score: ${threatAnalysis.score}]`; } + return base; } +/** + * Log successful email sending + */ async function logSuccess(securityData, threatAnalysis) { - await logSecurityEvent( - { - ...securityData, - threatAnalysis, - processingResult: "success", - emailSent: true, - }, - "contact_success" - ); + await SecurityEvent.logEvent("CONTACT_SUCCESS", { + ...securityData, + threatAnalysis, + processingResult: "success", + emailSent: true, + }); } +/** + * Log unhandled errors with security context + */ async function logUnhandledError(req, err) { - const errorData = captureSecurityData(req, { + SecurityEvent.fromRequest(req, "CONTACT_ERROR", { error: { message: err.message, stack: err.stack, @@ -115,7 +111,6 @@ }, processingStep: "error_handling", }); - await logSecurityEvent(errorData, "contact_error"); } module.exports = { diff --git a/src/controllers/secured/logsController.js b/src/controllers/secured/logsController.js index 5faac9c..481fe3f 100644 --- a/src/controllers/secured/logsController.js +++ b/src/controllers/secured/logsController.js @@ -4,7 +4,17 @@ const { winstonLogger } = require("../../utils/logging"); const analyticsDb = require("../../utils/sqlite3"); -const allowedLevels = ["warn", "error", "info", "debug", "functions", "notice"]; +const allowedLevels = [ + "warn", + "error", + "security", + "event", + "analytics", + "info", + "debug", + "functions", + "notice", +]; const logsDbPath = path.resolve(__dirname, "../../../data/logs.sqlite3"); if (!fs.existsSync(logsDbPath)) { diff --git a/src/middleware/adaptiveBodyParser.js b/src/middleware/adaptiveBodyParser.js new file mode 100644 index 0000000..b189229 --- /dev/null +++ b/src/middleware/adaptiveBodyParser.js @@ -0,0 +1,72 @@ +// src/setupMiddleware.js +const express = require("express"); +const { winstonLogger } = require("../utils/logging"); + +const { + EXCLUDED_PATHS, + DATA_LIMIT_BYTES, + RAW_BODY_LIMIT_BYTES, + RAW_BODY_TYPE, + FALLBACK_ENCODING, + FALLBACK_BODY, +} = require("../constants/middlewareConstants"); + +// Body parsing with different limits for excluded vs normal paths +module.exports = (req, res, next) => { + const isExcludedPath = EXCLUDED_PATHS.includes(req.path); + const limit = isExcludedPath ? RAW_BODY_LIMIT_BYTES : DATA_LIMIT_BYTES; + + const contentType = req.get("content-type") || ""; + + if (contentType.includes("application/json")) { + // Parse JSON with appropriate limit + express.json({ limit })(req, res, (err) => { + if (err) { + winstonLogger.error("JSON parsing error:", err.message); + return next(err); + } + // winstonLogger.debug("Parsed JSON body:", req.body); + next(); + }); + } else if (contentType.includes("application/x-www-form-urlencoded")) { + // Parse form data with appropriate limit + express.urlencoded({ extended: false, limit })(req, res, (err) => { + if (err) { + winstonLogger.error("Form parsing error:", err.message); + return next(err); + } + // winstonLogger.debug("Parsed form body:", req.body); + next(); + }); + } else if (contentType.includes("multipart/form-data")) { + // For multipart, we'd need multer or similar, but pass through for now + winstonLogger.debug( + "Multipart form detected - may need additional handling" + ); + next(); + } else { + // Try form parsing first (most common for HTML forms), then JSON + express.urlencoded({ extended: false, limit })(req, res, (formErr) => { + if (formErr) { + winstonLogger.warn( + "Form parsing failed, trying JSON:", + formErr.message + ); + express.json({ limit })(req, res, (jsonErr) => { + if (jsonErr) { + winstonLogger.error("Both parsers failed:", { + formErr: formErr.message, + jsonErr: jsonErr.message, + }); + return next(jsonErr); + } + // winstonLogger.warn("Parsed JSON body (fallback):", req.body); + next(); + }); + } else { + // winstonLogger.debug("Parsed form body (default):", req.body); + next(); + } + }); + } +}; diff --git a/src/middleware/analytics.js b/src/middleware/analytics.js index 7548b3d..53a768c 100644 --- a/src/middleware/analytics.js +++ b/src/middleware/analytics.js @@ -1,19 +1,25 @@ -const db = require("../utils/sqlite3"); +// src/middleware/analytics.js +module.exports = (context) => { + return (req, res, next) => { + if (req.method === "GET" && req.accepts("html")) { + const forwardedIp = req.ip; + const directIp = req.connection.remoteAddress; + const timestamp = Date.now(); + const url = req.originalUrl; + const referrer = req.get("Referer") || ""; + const userAgent = req.get("User-Agent") || ""; -module.exports = (req, res, next) => { - if (req.method === "GET" && req.accepts("html")) { - const forwardedIp = req.ip; - const directIp = req.connection.remoteAddress; - const timestamp = Date.now(); - const url = req.originalUrl; - const referrer = req.get("Referer") || ""; - const userAgent = req.get("User-Agent") || ""; - - db.run( - `INSERT INTO analytics (timestamp, url, referrer, user_agent, js_enabled, forwardedIp, directIp) - VALUES (?, ?, ?, ?, ?, ?, ?)`, // fixme, join together in main table? i dont know what i was suppose to fix. it works fine - [timestamp, url, referrer, userAgent, 0, forwardedIp, directIp] - ); - } - next(); + req.log.analytics({ + context, + timestamp, + url, + referrer, + userAgent, + js_enabled: false, + forwardedIp, + directIp, + }); + } + next(); + }; }; diff --git a/src/middleware/authCheck.js b/src/middleware/authCheck.js index 928b9ab..63ab9a2 100644 --- a/src/middleware/authCheck.js +++ b/src/middleware/authCheck.js @@ -38,9 +38,9 @@ if (SAFE_IPS.includes(clientIp)) { req.isAuthenticated = true; // Mark as authenticated (bypassed) if (req.log) { - req.log.info(`Bypassing authentication for safe IP: ${clientIp}`); + req.log.security(`Bypassing authentication for safe IP: ${clientIp}`); } else { - console.info(`Bypassing authentication for safe IP: ${clientIp}`); + console.security(`Bypassing authentication for safe IP: ${clientIp}`); } return next(); // Proceed to the next middleware/route handler } diff --git a/src/middleware/index.js b/src/middleware/index.js index 1767be6..686411d 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -1,6 +1,5 @@ // src/setupMiddleware.js const express = require("express"); -const bodyParser = require("body-parser"); const compression = require("compression"); const routes = require("../routes"); @@ -13,95 +12,42 @@ const hbs = require("./hbs"); const authCheck = require("./authCheck"); const { redirectMiddleware } = require("./redirect"); -const { winstonLogger } = require("../utils/logging"); -const { - TRUST_PROXY, - EXCLUDED_PATHS, - DATA_LIMIT_BYTES, - RAW_BODY_LIMIT_BYTES, - RAW_BODY_TYPE, - FALLBACK_ENCODING, - FALLBACK_BODY, -} = require("../constants/middlewareConstants"); +const { TRUST_PROXY } = require("../constants/middlewareConstants"); const { loggingMiddleware, morganInfo, morganWarn, morganError, + morganEvent, + morganAnalytics, + morganSecurity, } = require("./logging"); +const securedMiddleware = require("./secured"); +const securedRoutes = require("../routes/secured"); +const adaptiveBodyParser = require("./adaptiveBodyParser"); +const analytics = require("../controllers/analyticsControllers"); function setupApp() { const app = express(); app.disable("x-powered-by"); app.set("trust proxy", TRUST_PROXY); - - // Body parsing with different limits for excluded vs normal paths - app.use((req, res, next) => { - const isExcludedPath = EXCLUDED_PATHS.includes(req.path); - const limit = isExcludedPath ? RAW_BODY_LIMIT_BYTES : DATA_LIMIT_BYTES; - - const contentType = req.get("content-type") || ""; - - if (contentType.includes("application/json")) { - // Parse JSON with appropriate limit - express.json({ limit })(req, res, (err) => { - if (err) { - winstonLogger.error("JSON parsing error:", err.message); - return next(err); - } - // winstonLogger.debug("Parsed JSON body:", req.body); - next(); - }); - } else if (contentType.includes("application/x-www-form-urlencoded")) { - // Parse form data with appropriate limit - express.urlencoded({ extended: false, limit })(req, res, (err) => { - if (err) { - winstonLogger.error("Form parsing error:", err.message); - return next(err); - } - // winstonLogger.debug("Parsed form body:", req.body); - next(); - }); - } else if (contentType.includes("multipart/form-data")) { - // For multipart, we'd need multer or similar, but pass through for now - winstonLogger.debug( - "Multipart form detected - may need additional handling" - ); - next(); - } else { - // Try form parsing first (most common for HTML forms), then JSON - express.urlencoded({ extended: false, limit })(req, res, (formErr) => { - if (formErr) { - winstonLogger.warn( - "Form parsing failed, trying JSON:", - formErr.message - ); - express.json({ limit })(req, res, (jsonErr) => { - if (jsonErr) { - winstonLogger.error("Both parsers failed:", { - formErr: formErr.message, - jsonErr: jsonErr.message, - }); - return next(jsonErr); - } - // winstonLogger.warn("Parsed JSON body (fallback):", req.body); - next(); - }); - } else { - // winstonLogger.debug("Parsed form body (default):", req.body); - next(); - } - }); - } - }); + app.use(adaptiveBodyParser); app.use(hbs); // Setup logging - app.use(logEvent, morganInfo, morganWarn, morganError, loggingMiddleware); + app.use( + morganInfo, + morganWarn, + morganError, + morganEvent, + morganAnalytics, + morganSecurity, + loggingMiddleware + ); app.use(authCheck); @@ -120,7 +66,11 @@ app.use(validateRequestIntegrity); app.use(formatHtml); app.use(redirectMiddleware); - app.use(routes); + app.post("/track", logEvent("analytics"), analytics); + app.post("/analytics", logEvent("analytics"), analytics); + app.use("/admin", logEvent("admin"), securedMiddleware, securedRoutes); + app.use(logEvent("public"), routes); + app.use(errorHandler); return app; diff --git a/src/middleware/logging.js b/src/middleware/logging.js index f8ac029..7a36796 100644 --- a/src/middleware/logging.js +++ b/src/middleware/logging.js @@ -4,6 +4,9 @@ const morganInfo = structuredLogger("info"); const morganWarn = structuredLogger("warn"); const morganError = structuredLogger("error"); +const morganEvent = structuredLogger("event"); +const morganAnalytics = structuredLogger("analytics"); +const morganSecurity = structuredLogger("security"); // Middleware to inject logger into req const loggingMiddleware = (req, res, next) => { @@ -16,4 +19,7 @@ morganInfo, morganWarn, morganError, + morganEvent, + morganAnalytics, + morganSecurity, }; diff --git a/src/routes/index.js b/src/routes/index.js index c1d50a7..1658abc 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -3,7 +3,6 @@ const router = express.Router(); const path = require("path"); -const analytics = require("../controllers/analyticsControllers"); const robots = require("../controllers/robotsController"); const csrfToken = require("../middleware/csrfToken"); const errorPage = require("../controllers/errorPageController"); @@ -19,8 +18,6 @@ const rssFeedController = require("../controllers/rssFeedController"); const HttpError = require("../utils/HttpError"); -const securedMiddleware = require("../middleware/secured"); -const securedRoutes = require("./secured"); const stack = require("../controllers/techkStackController"); const favicon = require("serve-favicon"); @@ -31,15 +28,10 @@ res.sendStatus(200); }); -router.use("/admin", securedMiddleware, securedRoutes); - router.get("/error", errorPage); // Landing page after error is logged router.use(admin); -router.post("/track", analytics); -router.post("/analytics", analytics); - router.use( "/static", express.static("public", { diff --git a/src/utils/SecurityEvent.js b/src/utils/SecurityEvent.js new file mode 100644 index 0000000..b50023e --- /dev/null +++ b/src/utils/SecurityEvent.js @@ -0,0 +1,281 @@ +// src/utils/SecurityEvent.js +const fs = require("fs").promises; +const path = require("path"); +const HttpError = require("./HttpError"); +const { winstonLogger } = require("./logging"); +const { captureSecurityData } = require("./securityForensics"); + +class SecurityEvent extends HttpError { + constructor(eventType, metadata = {}, options = {}) { + // Handle both string event types and direct metadata for backwards compatibility + let actualEventType, actualMetadata; + + if (typeof eventType === "string" && EVENT_TYPES[eventType.toUpperCase()]) { + actualEventType = eventType.toUpperCase(); + actualMetadata = metadata; + } else if (typeof eventType === "string") { + // Legacy support - treat as custom event + actualEventType = "CUSTOM_EVENT"; + actualMetadata = { customEventType: eventType, ...metadata }; + } else { + // If first param is metadata, treat as generic security event + actualEventType = "SYSTEM_ERROR"; + actualMetadata = eventType || {}; + } + + const eventConfig = + EVENT_TYPES[actualEventType] || EVENT_TYPES.SYSTEM_ERROR; + + super(eventConfig.message, eventConfig.statusCode, actualMetadata); + + this.name = "SecurityEvent"; + this.eventType = actualEventType; + this.level = eventConfig.level; + this.category = eventConfig.category; + this.timestamp = new Date().toISOString(); + this.cause = options.cause || null; + this.autoLog = options.autoLog !== false; // Default to true + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, SecurityEvent); + } + + // Auto-log unless explicitly disabled + if (this.autoLog) { + this.log(); + } + } + + /** + * Log this security event + */ + log(additionalContext = {}) { + const logData = { + eventType: this.eventType, + level: this.level, + category: this.category, + message: this.message, + timestamp: this.timestamp, + metadata: this.metadata, + ...additionalContext, + }; + + winstonLogger.security(logData); + + // Handle high-threat events with special logging + if ( + this.level === "critical" || + this.metadata.threatAnalysis?.level === "high" + ) { + this._logHighThreatEvent(logData); + } + } + + /** + * Create and log a security event in one call + */ + static create(eventType, metadata = {}, options = {}) { + return new SecurityEvent(eventType, metadata, options); + } + + /** + * Create and log a security event from a request context + */ + static fromRequest(req, eventType, additionalData = {}, options = {}) { + const securityData = captureSecurityData(req, additionalData); + return new SecurityEvent(eventType, securityData, options); + } + + /** + * Log a security event without creating an error (for success events) + */ + static async logEvent(eventType, metadata = {}, additionalContext = {}) { + try { + const eventConfig = + EVENT_TYPES[eventType.toUpperCase()] || EVENT_TYPES.SYSTEM_ERROR; + + const logEntry = { + eventType: eventType.toUpperCase(), + level: eventConfig.level, + category: eventConfig.category, + message: eventConfig.message, + timestamp: new Date().toISOString(), + metadata, + ...additionalContext, + }; + + winstonLogger.security(logEntry); + + // Handle high-threat events + if ( + eventConfig.level === "critical" || + metadata.threatAnalysis?.level === "high" + ) { + await SecurityEvent._logHighThreatEvent(logEntry); + } + + return logEntry; + } catch (error) { + winstonLogger.error(`Failed to log security event: ${error.message}`); + } + } + + /** + * Log page access events + */ + static async logAccess(req, pageData = {}, additionalData = {}) { + const securityData = captureSecurityData(req, { + pageAccess: pageData, + processingStep: "page_render", + ...additionalData, + }); + + return await SecurityEvent.logEvent("PAGE_ACCESS", securityData); + } + + /** + * Create a SecurityEvent from any error + */ + static fromError( + error, + eventType = "SYSTEM_ERROR", + additionalMetadata = {}, + options = {} + ) { + if (error instanceof SecurityEvent) { + return error; + } + + const metadata = { + originalError: { + name: error.name, + message: error.message, + stack: error.stack, + }, + ...additionalMetadata, + }; + + return new SecurityEvent(eventType, metadata, { + cause: error, + ...options, + }); + } + + /** + * Handle validation failures with consistent logging + */ + static handleValidationFailure(req, formData, reason, next) { + const securityData = captureSecurityData(req, { + formData, + failureReason: reason, + processingStep: "validation", + }); + + const securityEvent = new SecurityEvent("VALIDATION_FAILURE", securityData); + next(securityEvent); + } + + /** + * Handle CAPTCHA failures + */ + static handleCaptchaFailure(req, reason, threatAnalysis = null, next) { + const securityData = captureSecurityData(req, { + failureReason: reason, + threatAnalysis, + processingStep: "captcha_validation", + }); + + const securityEvent = new SecurityEvent("CAPTCHA_FAILED", securityData); + next(securityEvent); + } + + /** + * Handle threat blocking + */ + static async blockThreat( + req, + threatAnalysis, + reason = "high_threat_detected" + ) { + const securityData = captureSecurityData(req, { + threatAnalysis, + action: "blocked", + blockReason: reason, + processingStep: "threat_analysis", + }); + + return new SecurityEvent("THREAT_BLOCKED", securityData); + } + + /** + * Private method to handle high-threat event logging + */ + static async _logHighThreatEvent(logEntry) { + try { + const date = new Date().toISOString().split("T")[0]; + const logDir = path.join(__dirname, "..", "..", "logs", "security"); + await fs.mkdir(logDir, { recursive: true }); + + const alertFile = path.join(logDir, `high_threat_${date}.log`); + const message = JSON.stringify(logEntry, null, 2); + await fs.appendFile(alertFile, message + "\n"); + } catch (error) { + winstonLogger.error(`Failed to log high-threat event: ${error.message}`); + } + } + + /** + * Instance method for high-threat logging + */ + async _logHighThreatEvent(logEntry) { + return SecurityEvent._logHighThreatEvent(logEntry); + } + + /** + * Convert to JSON for serialization + */ + toJSON() { + return { + name: this.name, + eventType: this.eventType, + level: this.level, + category: this.category, + message: this.message, + statusCode: this.statusCode, + timestamp: this.timestamp, + metadata: this.metadata, + stack: this.stack, + cause: + this.cause instanceof Error + ? { + name: this.cause.name, + message: this.cause.message, + stack: this.cause.stack, + } + : this.cause, + }; + } + + /** + * Check if this is a specific type of security event + */ + isType(eventType) { + return this.eventType === eventType.toUpperCase(); + } + + /** + * Check if this is in a specific category + */ + isCategory(category) { + return this.category === category.toLowerCase(); + } + + /** + * Check if this is a critical event + */ + isCritical() { + return this.level === "critical"; + } +} + +module.exports = SecurityEvent; diff --git a/src/utils/logging/config.js b/src/utils/logging/config.js index 01d067e..4ed1a36 100644 --- a/src/utils/logging/config.js +++ b/src/utils/logging/config.js @@ -1,3 +1,4 @@ +// src/utils/logging/config.js const path = require("path"); const customLevels = { @@ -5,17 +6,21 @@ error: 0, warn: 1, security: 2, + event: 2, notice: 3, info: 4, debug: 5, + analytics: 6, // use a unique value }, colors: { error: "red", warn: "yellow", security: "magenta", + event: "cyan", notice: "cyan", info: "green", debug: "blue", + analytics: "gray", // or another distinct color }, }; @@ -34,7 +39,10 @@ notice: path.join(logDir, "notice", "notice.log"), error: path.join(logDir, "error", "error.log"), warn: path.join(logDir, "warn", "warn.log"), + event: path.join(logDir, "event", "event.log"), + security: path.join(logDir, "security", "security.log"), debug: path.join(logDir, "debug", "debug.log"), + analytics: path.join(logDir, "debug", "analytics.log"), }; module.exports = { diff --git a/src/utils/logging/consolePatch.js b/src/utils/logging/consolePatch.js index d77c886..e933005 100644 --- a/src/utils/logging/consolePatch.js +++ b/src/utils/logging/consolePatch.js @@ -14,40 +14,40 @@ writeLog( "INFO", logStreams.info, - originalConsole.log, sessionTransport, + originalConsole.log, ...args ); console.error = (...args) => writeLog( "ERROR", logStreams.error, - originalConsole.error, sessionTransport, + originalConsole.error, ...args ); console.warn = (...args) => writeLog( "WARN", logStreams.warn, - originalConsole.warn, sessionTransport, + originalConsole.warn, ...args ); console.info = (...args) => writeLog( "INFO", logStreams.info, - originalConsole.info, sessionTransport, + originalConsole.info, ...args ); console.debug = (...args) => writeLog( "DEBUG", logStreams.debug, - originalConsole.debug, sessionTransport, + originalConsole.debug, ...args ); return originalConsole; @@ -109,14 +109,22 @@ return { timestamp, safeArgs, message, logLine }; } -function writeLog(level, stream, consoleFn, sessionTransport, ...args) { +function writeLog(level, stream, sessionTransport, consoleFn, ...args) { if (!shouldLog(level)) return; const { timestamp, safeArgs, message, logLine } = formatLog(level, ...args); stream.write(logLine); - sessionTransport.write({ level: level.toLowerCase(), message, timestamp }); - consoleFn(`[${timestamp}] [${level}]`, ...safeArgs); + if (!sessionTransport) { + originalConsole.warn( + `sessionTransport for log level '${level} is undefined` + ); + } else { + sessionTransport.write({ level: level.toLowerCase(), message, timestamp }); + } + if (consoleFn) { + consoleFn(`[${timestamp}] [${level}]`, ...safeArgs); + } } module.exports = { diff --git a/src/utils/logging/index.js b/src/utils/logging/index.js index 71a76a4..2704b3e 100644 --- a/src/utils/logging/index.js +++ b/src/utils/logging/index.js @@ -51,33 +51,45 @@ streams: logStreams, function: (...args) => functionLog(functionsLogDir, ...args), info: (...args) => - writeLog("INFO", logStreams.info, console.log, sessionTransport, ...args), + writeLog("INFO", logStreams.info, sessionTransport, console.log, ...args), + event: (...args) => + writeLog("EVENT", logStreams.event, sessionTransport, console.log, ...args), notice: (...args) => writeLog( "NOTICE", logStreams.notice, - console.log, sessionTransport, + console.log, ...args ), warn: (...args) => - writeLog("WARN", logStreams.warn, console.warn, sessionTransport, ...args), + writeLog("WARN", logStreams.warn, sessionTransport, console.warn, ...args), + security: (...args) => + writeLog( + "SECURITY", + logStreams.security, + sessionTransport, + console.warn, + ...args + ), error: (...args) => writeLog( "ERROR", logStreams.error, - console.error, sessionTransport, + console.error, ...args ), debug: (...args) => writeLog( "DEBUG", logStreams.debug, - console.debug, sessionTransport, + console.debug, ...args ), + analytics: (...args) => + writeLog("ANALYTICS", logStreams.analytics, sessionTransport, ...args), sessionInfo: () => ({ sessionId: sessionTimestamp, sessionDir, @@ -95,6 +107,7 @@ ), transports: [ buildTransport("info", "info"), + buildTransport("event", "event"), buildTransport("error", "error"), buildTransport("warn", "warn"), buildTransport("debug", "debug"), diff --git a/src/utils/logging/logger.js b/src/utils/logging/logger.js deleted file mode 100644 index 08b5bb6..0000000 --- a/src/utils/logging/logger.js +++ /dev/null @@ -1,141 +0,0 @@ -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 index 8ffb470..09003c6 100644 --- a/src/utils/logging/streams.js +++ b/src/utils/logging/streams.js @@ -13,6 +13,8 @@ error: fs.createWriteStream(files.error, { flags: "a" }), warn: fs.createWriteStream(files.warn, { flags: "a" }), debug: fs.createWriteStream(files.debug, { flags: "a" }), + security: fs.createWriteStream(files.security, { flags: "a" }), + event: fs.createWriteStream(files.event, { flags: "a" }), }; } @@ -43,10 +45,9 @@ level, format: format.combine( format.timestamp(), - format.printf( - ({ timestamp, level, message }) => - `[${timestamp}] [${level.toUpperCase()}] ${message}` - ) + format.printf(({ timestamp, level, message }) => { + return `[${timestamp}] [${level.toUpperCase()}] ${message}`; + }) ), }); } diff --git a/src/utils/securityForensics.js b/src/utils/securityForensics.js index 27db36d..9669fe3 100644 --- a/src/utils/securityForensics.js +++ b/src/utils/securityForensics.js @@ -187,7 +187,7 @@ }; // Log security event at custom 'security' level - winstonLogger.log("security", logEntry); + winstonLogger.security(logEntry); // Separate high-threat log file if (data.threatAnalysis?.level === "high") { diff --git a/test/units/utils/emailValidator/validaterAndSanitizeEmail.property.test.js b/test/units/utils/emailValidator/validaterAndSanitizeEmail.property.test.js deleted file mode 100644 index e07bac2..0000000 --- a/test/units/utils/emailValidator/validaterAndSanitizeEmail.property.test.js +++ /dev/null @@ -1,93 +0,0 @@ -// test/validateAndSanitizeEmail.fastcheck.test.js -const { expect } = require("chai"); -const fc = require("fast-check"); -const validator = require("validator"); - -const { - validateAndSanitizeEmail, - MESSAGES, - MAX_EMAIL_LENGTH, -} = require("../../../../src/utils/emailValidator"); - -describe("validateAndSanitizeEmail - fast-check property-based tests", () => { - it("should not throw for arbitrary strings", () => { - fc.assert( - fc.property(fc.string(), (str) => { - const result = validateAndSanitizeEmail(str); - expect(result).to.have.property("valid").that.is.a("boolean"); - if (result.valid) { - expect(result).to.have.property("email").that.is.a("string"); - expect(result.email.length).to.be.at.most(MAX_EMAIL_LENGTH); - expect(validator.isEmail(result.email)).to.equal(true); - } else { - expect(result).to.have.property("message").that.is.a("string"); - expect(Object.values(MESSAGES)).to.include(result.message); - } - }) - ); - }); - - it("should always return valid=true for valid, normalized, RFC-compliant email addresses", () => { - fc.assert( - fc.property(fc.emailAddress(), (email) => { - const result = validateAndSanitizeEmail(email); - expect(result.valid).to.equal(true); - expect(result.email).to.be.a("string"); - expect(validator.isEmail(result.email)).to.equal(true); - expect(result.email.length).to.be.at.most(MAX_EMAIL_LENGTH); - expect(result.email.includes("..")).to.equal(false); - expect(result.email.startsWith(".")).to.equal(false); - expect(result.email.endsWith(".")).to.equal(false); - }) - ); - }); - - it("should reject emails longer than MAX_EMAIL_LENGTH", () => { - const longLocal = "a".repeat(64); - const longDomain = "b".repeat(MAX_EMAIL_LENGTH); - const longEmail = `${longLocal}@${longDomain}.com`; // definitely > 320 - - const result = validateAndSanitizeEmail(longEmail); - expect(result.valid).to.equal(false); - expect(result.message).to.equal(MESSAGES.TOO_LONG); - }); - - it("should reject strings that normalize to null", () => { - const nonEmailInput = "invalid input string"; - - const result = validateAndSanitizeEmail(nonEmailInput); - if (result.valid) { - expect(result.email).to.be.a("string"); - } else { - expect([MESSAGES.INVALID, MESSAGES.REQUIRED]).to.include(result.message); - } - }); - - it('should reject emails with ".." in them', () => { - fc.assert( - fc.property(fc.emailAddress(), (email) => { - const mutated = email.replace("@", "..@"); - const result = validateAndSanitizeEmail(mutated); - expect(result.valid).to.equal(false); - expect(result.message).to.equal(MESSAGES.INVALID); - }) - ); - }); - - it('should reject emails starting or ending with "."', () => { - fc.assert( - fc.property(fc.emailAddress(), (email) => { - const startDot = `.${email}`; - const endDot = `${email}.`; - - const result1 = validateAndSanitizeEmail(startDot); - const result2 = validateAndSanitizeEmail(endDot); - - expect(result1.valid).to.equal(false); - expect(result2.valid).to.equal(false); - expect(result1.message).to.equal(MESSAGES.INVALID); - expect(result2.message).to.equal(MESSAGES.INVALID); - }) - ); - }); -}); diff --git a/test/units/utils/emailValidator/validaterAndSanitizeEmail.unit.test.js b/test/units/utils/emailValidator/validaterAndSanitizeEmail.unit.test.js deleted file mode 100644 index 5c052a3..0000000 --- a/test/units/utils/emailValidator/validaterAndSanitizeEmail.unit.test.js +++ /dev/null @@ -1,148 +0,0 @@ -// test/utils/emailValidator/validateAndSanitizeEmail.test.js -const { expect } = require("chai"); -const sinon = require("sinon"); -const validator = require("validator"); - -const { - validateAndSanitizeEmail, - MESSAGES, - MAX_EMAIL_LENGTH, -} = require("../../../../src/utils/emailValidator"); - -describe("validateAndSanitizeEmail", () => { - afterEach(() => { - sinon.restore(); - }); - - it("should return REQUIRED if input is undefined", () => { - const result = validateAndSanitizeEmail(undefined); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.REQUIRED }); - }); - - it("should return REQUIRED if input is null", () => { - const result = validateAndSanitizeEmail(null); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.REQUIRED }); - }); - - it("should return REQUIRED if input is a non-string type (number)", () => { - const result = validateAndSanitizeEmail(123); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.REQUIRED }); - }); - - it("should return REQUIRED if input is an empty string", () => { - const result = validateAndSanitizeEmail(""); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.REQUIRED }); - }); - - it("should return INVALID if normalized email is null (validator.normalizeEmail returns null)", () => { - sinon.stub(validator, "normalizeEmail").returns(null); - const result = validateAndSanitizeEmail("notanemail"); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.INVALID }); - }); - - it("should return INVALID if normalized email is not valid (validator.isEmail returns false)", () => { - sinon.stub(validator, "normalizeEmail").returns("invalid@domain"); - sinon.stub(validator, "isEmail").returns(false); - const result = validateAndSanitizeEmail("invalid@domain"); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.INVALID }); - }); - - it("should return TOO_LONG if email exceeds MAX_EMAIL_LENGTH", () => { - const localPart = "a".repeat(64); - const domain = "b".repeat(255 - localPart.length - 1); // keep it under 320 when combined - const email = `${localPart}@${domain}.com`; - - const tooLongEmail = `${email}${"x".repeat(MAX_EMAIL_LENGTH - email.length + 1)}`; // force >320 - - sinon.stub(validator, "normalizeEmail").returns(tooLongEmail); - sinon.stub(validator, "isEmail").returns(true); - - const result = validateAndSanitizeEmail(tooLongEmail); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.TOO_LONG }); - }); - - it('should return INVALID if email contains ".."', () => { - const badEmail = "test..dot@example.com"; - sinon.stub(validator, "normalizeEmail").returns(badEmail); - sinon.stub(validator, "isEmail").returns(true); - - const result = validateAndSanitizeEmail(badEmail); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.INVALID }); - }); - - it('should return INVALID if email starts with "."', () => { - const badEmail = ".start@example.com"; - sinon.stub(validator, "normalizeEmail").returns(badEmail); - sinon.stub(validator, "isEmail").returns(true); - - const result = validateAndSanitizeEmail(badEmail); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.INVALID }); - }); - - it('should return INVALID if email ends with "."', () => { - const badEmail = "end.@example.com"; - sinon.stub(validator, "normalizeEmail").returns(badEmail); - sinon.stub(validator, "isEmail").returns(true); - - const result = validateAndSanitizeEmail(badEmail); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.INVALID }); - }); - - it("should return valid email if all conditions are satisfied", () => { - const rawEmail = " John.Doe@Example.com "; - const normalized = "john.doe@example.com"; - - sinon.stub(validator, "normalizeEmail").callsFake((email) => { - // simulate trimming + lowercasing + normalization - return email === "john.doe@example.com" ? normalized : null; - }); - - sinon.stub(validator, "isEmail").returns(true); - - const result = validateAndSanitizeEmail(rawEmail); - expect(result).to.deep.equal({ valid: true, email: normalized }); - }); - - it('should return INVALID for email with multiple "@" characters', () => { - const result = validateAndSanitizeEmail("john@doe@example.com"); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.INVALID }); - }); - - it('should return INVALID for email with no "@" character', () => { - const result = validateAndSanitizeEmail("johndoe.example.com"); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.INVALID }); - }); - - it("should return VALID for a minimally valid email address", () => { - const result = validateAndSanitizeEmail("a@b.co"); - expect(result.valid).to.equal(true); - expect(result.email).to.be.a("string"); - }); - - it("should return INVALID for email with spaces in local part", () => { - const result = validateAndSanitizeEmail("john doe@example.com"); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.INVALID }); - }); - - it('should return INVALID for email with space after "@"', () => { - const result = validateAndSanitizeEmail("john@ example.com"); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.INVALID }); - }); - - it("should return INVALID for email with quoted local part (validator accepts, you might not)", () => { - const result = validateAndSanitizeEmail('"john.doe"@example.com'); - // Accept if validator does; reject if you disallow quotes - // Adjust depending on your business rules - expect(result.valid).to.equal(true); - }); - - it("should return INVALID for email with emoji in local part", () => { - const result = validateAndSanitizeEmail("🧟@example.com"); - expect(result).to.deep.equal({ valid: false, message: MESSAGES.INVALID }); - }); - - it("should return VALID for email with subdomain", () => { - const result = validateAndSanitizeEmail("user@sub.example.com"); - expect(result.valid).to.equal(true); - }); -}); diff --git a/test/units/utils/logging/config.unit.test.js b/test/units/utils/logging/config.unit.test.js deleted file mode 100644 index c8b2b0c..0000000 --- a/test/units/utils/logging/config.unit.test.js +++ /dev/null @@ -1,79 +0,0 @@ -// test/units/utils/logging/config.test.js -const { expect } = require("chai"); -const fs = require("fs"); -const path = require("path"); -const proxyquire = require("proxyquire").noPreserveCache(); - -const { - projectRoot, - logDir, - sessionTimestamp, - sessionDir, - logFiles, - LOG_LEVELS, -} = require("../../../../src/utils/logging/config"); - -describe("config.js", () => { - it("projectRoot contains package.json", () => { - const pkgJsonPath = path.join(projectRoot, "package.json"); - const exists = fs.existsSync(pkgJsonPath); - expect(exists).to.equal(true, `package.json not found in ${projectRoot}`); - }); - - it("projectRoot matches resolved 3-levels-up path", () => { - const expected = path.resolve(__dirname, "../../../../"); - expect(projectRoot).to.equal(expected); - }); - - it("logDir is within projectRoot and ends with 'logs'", () => { - expect(logDir.startsWith(projectRoot)).to.be.true; - expect(path.basename(logDir)).to.equal("logs"); - }); - - it("sessionTimestamp matches expected ISO pattern with no colons or dots", () => { - expect(sessionTimestamp).to.match( - /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/ - ); - }); - - it("sessionDir is built from logDir and sessionTimestamp", () => { - const expected = path.join(logDir, "sessions", sessionTimestamp); - expect(sessionDir).to.equal(expected); - }); - - it("logFiles.session points to session.log in sessionDir", () => { - expect(logFiles.session).to.equal(path.join(sessionDir, "session.log")); - }); - - ["info", "notice", "error", "warn", "debug"].forEach((level) => { - it(`logFiles.${level} points to ${level}.log in correct subdir`, () => { - expect(logFiles[level]).to.equal( - path.join(logDir, level, `${level}.log`) - ); - }); - }); - - it("LOG_LEVELS defines correct level-to-priority mapping", () => { - expect(LOG_LEVELS).to.deep.equal({ - error: 0, - warn: 1, - security: 2, - notice: 3, - info: 4, - debug: 5, - }); - }); - - it("LOG_LEVEL defaults to 'info' when process.env.LOG_LEVEL is unset", () => { - const original = process.env.LOG_LEVEL; - delete process.env.LOG_LEVEL; - - const { LOG_LEVEL } = proxyquire( - "../../../../src/utils/logging/config", - {} - ); - expect(LOG_LEVEL).to.equal("info"); - - if (original !== undefined) process.env.LOG_LEVEL = original; - }); -}); diff --git a/test/units/utils/logging/createLogStreams.unit.test.js b/test/units/utils/logging/createLogStreams.unit.test.js deleted file mode 100644 index bb5482c..0000000 --- a/test/units/utils/logging/createLogStreams.unit.test.js +++ /dev/null @@ -1,31 +0,0 @@ -// test/units/utils/logging/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/utils/logging/formatFunctionName.unit.test.js b/test/units/utils/logging/formatFunctionName.unit.test.js deleted file mode 100644 index def137f..0000000 --- a/test/units/utils/logging/formatFunctionName.unit.test.js +++ /dev/null @@ -1,14 +0,0 @@ -// test/units/utils/logging/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/utils/logging/formatLog.unit.test.js b/test/units/utils/logging/formatLog.unit.test.js deleted file mode 100644 index c073a81..0000000 --- a/test/units/utils/logging/formatLog.unit.test.js +++ /dev/null @@ -1,26 +0,0 @@ -const { formatLog } = require("../../../../src/utils/logging/consolePatch"); -const { expect } = require("chai"); - -describe("Logger Format Function Tests", () => { - it("should format circular objects without throwing and stringify correctly", () => { - const circular = { name: "test" }; - circular.ref = circular; - - const methods = ["INFO", "WARN", "ERROR", "DEBUG", "NOTICE"]; - - methods.forEach((level) => { - const { timestamp, safeArgs, message, logLine } = formatLog( - level, - circular - ); - - expect(timestamp).to.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/); - expect(safeArgs).to.be.an("array"); - expect(message).to.include("name"); - expect(message).to.include("test"); - expect(message).to.not.include("[object Object]"); - expect(logLine).to.include(`[${timestamp}] [${level}]`); - expect(logLine).to.include(message); - }); - }); -}); diff --git a/test/units/utils/logging/formatLogMessage.unit.test.js b/test/units/utils/logging/formatLogMessage.unit.test.js deleted file mode 100644 index 0dc0aeb..0000000 --- a/test/units/utils/logging/formatLogMessage.unit.test.js +++ /dev/null @@ -1,15 +0,0 @@ -// test/units/utils/logging/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/utils/logging/handleUncaughtException.unit.test.js b/test/units/utils/logging/handleUncaughtException.unit.test.js deleted file mode 100644 index 6e4e3c5..0000000 --- a/test/units/utils/logging/handleUncaughtException.unit.test.js +++ /dev/null @@ -1,28 +0,0 @@ -// test/units/utils/logging/handleUncaughtException.test.js -const { expect } = require("chai"); -const sinon = require("sinon"); -const proxyquire = require("proxyquire").noCallThru(); - -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/utils/logging/handleUnhandledRejection.unit.test.js b/test/units/utils/logging/handleUnhandledRejection.unit.test.js deleted file mode 100644 index 74e720b..0000000 --- a/test/units/utils/logging/handleUnhandledRejection.unit.test.js +++ /dev/null @@ -1,22 +0,0 @@ -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/utils/logging/initializeLogDirectories.unit.test.js b/test/units/utils/logging/initializeLogDirectories.unit.test.js deleted file mode 100644 index 15acc5a..0000000 --- a/test/units/utils/logging/initializeLogDirectories.unit.test.js +++ /dev/null @@ -1,44 +0,0 @@ -// test/units/utils/logging/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: "../test/logs/info/info.log", - error: "../test/logs/error/error.log", - warn: "../test/logs/warn/warn.log", - notice: "../test/logs/notice/notice.log", - debug: "../test/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; - }, - { "../test/logs/functions": {} } - ); - - mockFs(dirs); - - expect(() => initializeLogDirectories(customLogFiles)).to.not.throw(); - }); -}); diff --git a/test/units/utils/logging/object-formatting.unit.test.js b/test/units/utils/logging/object-formatting.unit.test.js deleted file mode 100644 index 0649a60..0000000 --- a/test/units/utils/logging/object-formatting.unit.test.js +++ /dev/null @@ -1,521 +0,0 @@ -const { expect } = require("chai"); -const sinon = require("sinon"); -const fs = require("fs"); -const path = require("path"); -const { Writable } = require("stream"); - -// Mock dependencies -const mockLogStreams = { - info: new Writable({ write() {} }), - error: new Writable({ write() {} }), - warn: new Writable({ write() {} }), - debug: new Writable({ write() {} }), - notice: new Writable({ write() {} }), -}; - -const mockSessionTransport = { - write: sinon.stub(), -}; - -// Import the modules under test -const { writeLog } = require("../../../../src/utils/logging/consolePatch"); -const { - manualLogger, - winstonLogger, -} = require("../../../../src/utils/logging/index"); - -describe("Logger Object Expansion Tests", () => { - let streamWriteStubs; - - beforeEach(() => { - // Create fresh stream write stubs for each test - streamWriteStubs = { - info: sinon.stub(mockLogStreams.info, "write"), - error: sinon.stub(mockLogStreams.error, "write"), - warn: sinon.stub(mockLogStreams.warn, "write"), - debug: sinon.stub(mockLogStreams.debug, "write"), - notice: sinon.stub(mockLogStreams.notice, "write"), - }; - - // Reset session transport - mockSessionTransport.write.reset(); - }); - - afterEach(() => { - // Restore all stubs - sinon.restore(); // This restores all stubs created by sinon.stub() - }); - - describe("writeLog function", () => { - let consoleLogStub; // Declare stub for this describe block - - beforeEach(() => { - // Stub console.log specifically for this describe block - consoleLogStub = sinon.stub(console, "log"); - }); - - afterEach(() => { - // Restore console.log stub after each test in this block - consoleLogStub.restore(); - }); - - it("should never log [object Object] for simple objects", () => { - const testObject = { name: "test", value: 42 }; - - writeLog( - "INFO", - mockLogStreams.info, - console.log, - mockSessionTransport, - testObject - ); - - // Check console output - expect(consoleLogStub.called).to.be.true; - const consoleArgs = consoleLogStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("name"); - expect(outputString).to.include("test"); - expect(outputString).to.include("value"); - expect(outputString).to.include("42"); - - // Check stream output - expect(streamWriteStubs.info.called).to.be.true; - const streamOutput = streamWriteStubs.info.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("name"); - expect(streamOutput).to.include("test"); - }); - - it("should properly expand nested objects", () => { - const nestedObject = { - user: { - id: 123, - profile: { - name: "John Doe", - settings: { theme: "dark", notifications: true }, - }, - }, - }; - - writeLog( - "INFO", - mockLogStreams.info, - console.log, - mockSessionTransport, - nestedObject - ); - - expect(consoleLogStub.called).to.be.true; - const consoleArgs = consoleLogStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("John Doe"); - expect(outputString).to.include("theme"); - expect(outputString).to.include("dark"); - expect(outputString).to.include("notifications"); - }); - - it("should handle circular references without [object Object]", () => { - const circularObj = { name: "circular" }; - circularObj.self = circularObj; - circularObj.nested = { parent: circularObj }; - - writeLog( - "INFO", - mockLogStreams.info, - console.log, - mockSessionTransport, - circularObj - ); - - expect(consoleLogStub.called).to.be.true; - const consoleArgs = consoleLogStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("name"); - expect(outputString).to.include("circular"); - // Should handle circular reference gracefully - expect(outputString).to.include("self"); - }); - - it("should expand arrays containing objects", () => { - const arrayWithObjects = [ - { id: 1, name: "first" }, - { id: 2, name: "second", nested: { value: "test" } }, - ]; - - writeLog( - "INFO", - mockLogStreams.info, - console.log, - mockSessionTransport, - arrayWithObjects - ); - - expect(consoleLogStub.called).to.be.true; - const consoleArgs = consoleLogStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("first"); - expect(outputString).to.include("second"); - expect(outputString).to.include("nested"); - expect(outputString).to.include("test"); - }); - - it("should handle mixed argument types without [object Object]", () => { - const mixedArgs = [ - "String message", - { obj: "value" }, - 42, - ["array", "items"], - { deeply: { nested: { object: "here" } } }, - ]; - - writeLog( - "INFO", - mockLogStreams.info, - console.log, - mockSessionTransport, - ...mixedArgs - ); - - expect(consoleLogStub.called).to.be.true; - const consoleArgs = consoleLogStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("String message"); - expect(outputString).to.include("obj"); - expect(outputString).to.include("value"); - expect(outputString).to.include("deeply"); - expect(outputString).to.include("here"); - }); - - it("should handle Error objects without [object Object]", () => { - const error = new Error("Test error"); - error.customProperty = { details: "additional info" }; - - // Stub console.error for this test (local stub, not interfering with console.log stub) - const consoleErrorStub = sinon.stub(console, "error"); - - writeLog( - "ERROR", - mockLogStreams.error, - console.error, - mockSessionTransport, - error - ); - - expect(consoleErrorStub.called).to.be.true; - const consoleArgs = consoleErrorStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("Test error"); - - consoleErrorStub.restore(); - }); - - it("should handle objects with special properties", () => { - const specialObj = { - toString: () => "custom toString", - valueOf: () => 99, - [Symbol.toStringTag]: "CustomObject", - normalProp: "normal value", - }; - - writeLog( - "INFO", - mockLogStreams.info, - console.log, - mockSessionTransport, - specialObj - ); - - expect(consoleLogStub.called).to.be.true; - const consoleArgs = consoleLogStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("normalProp"); - expect(outputString).to.include("normal value"); - }); - }); - - describe("Manual Logger Methods", () => { - let manualLoggerStubs; - // Removed writeLogStub as manualLogger directly interacts with console - - beforeEach(() => { - // Create fresh stubs for manual logger streams if they exist - manualLoggerStubs = {}; - if (manualLogger.streams) { - Object.keys(manualLogger.streams).forEach((level) => { - if ( - manualLogger.streams[level] && - typeof manualLogger.streams[level].write === "function" - ) { - if (!manualLogger.streams[level].write.isSinonProxy) { - manualLoggerStubs[level] = sinon.stub( - manualLogger.streams[level], - "write" - ); - } - } - }); - } - }); - - afterEach(() => { - // Restore all stubs created within this describe block or its tests - sinon.restore(); - }); - - it("should not produce [object Object] in manualLogger.info", () => { - const testObj = { key: "value", nested: { deep: "property" } }; - - // Stub console.log locally for this specific test - const consoleLogStub = sinon.stub(console, "log"); - manualLogger.info(testObj); - - expect(consoleLogStub.called).to.be.true; // Check if console.log was called - const consoleArgs = consoleLogStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); // Join them to check content - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("key"); - expect(outputString).to.include("nested"); - expect(outputString).to.include("deep"); - consoleLogStub.restore(); // Restore after test - }); - - it("should not produce [object Object] in manualLogger.error", () => { - const errorObj = { - error: "Something went wrong", - context: { userId: 123, action: "login" }, - }; - - // Stub console.error for this test - const consoleErrorStub = sinon.stub(console, "error"); - - manualLogger.error(errorObj); - - expect(consoleErrorStub.called).to.be.true; - const consoleArgs = consoleErrorStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("Something went wrong"); - expect(outputString).to.include("userId"); - expect(outputString).to.include("login"); - - consoleErrorStub.restore(); - }); - }); - - describe("Winston Logger", () => { - let winstonInfoStub; - - beforeEach(() => { - winstonInfoStub = sinon.stub(winstonLogger, "info"); - }); - - afterEach(() => { - winstonInfoStub.restore(); // Ensure winston stub is restored - }); - - it("should not produce [object Object] in winston logs", () => { - const logData = { - user: { id: 456, name: "Jane" }, - action: "update", - metadata: { timestamp: Date.now() }, - }; - - winstonLogger.info("User action", logData); - - // Check that winston was called with properly formatted data - expect(winstonInfoStub.called).to.be.true; - const logCall = winstonInfoStub.getCall(0).args; - // Winston typically stringifies objects, so we check the stringified output - const logString = JSON.stringify(logCall); - expect(logString).to.not.include("[object Object]"); - expect(logString).to.include("Jane"); - expect(logString).to.include("update"); - }); - }); - - describe("Edge Cases", () => { - let consoleLogStub; // Declare stub for this describe block - - beforeEach(() => { - // Stub console.log specifically for this describe block - consoleLogStub = sinon.stub(console, "log"); - }); - - afterEach(() => { - // Restore console.log stub after each test in this block - consoleLogStub.restore(); - }); - - it("should handle null and undefined without [object Object]", () => { - writeLog( - "INFO", - mockLogStreams.info, - console.log, - mockSessionTransport, - null, - undefined - ); - - expect(consoleLogStub.called).to.be.true; - const consoleArgs = consoleLogStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("null"); - expect(outputString).to.include("undefined"); - }); - - it("should handle objects with null prototype", () => { - const nullProtoObj = Object.create(null); - nullProtoObj.key = "value"; - - writeLog( - "INFO", - mockLogStreams.info, - console.log, - mockSessionTransport, - nullProtoObj - ); - - expect(consoleLogStub.called).to.be.true; - const consoleArgs = consoleLogStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("key"); - expect(outputString).to.include("value"); - }); - - it("should handle Date objects", () => { - const dateObj = new Date("2023-01-01T12:00:00.000Z"); // Use ISO string for consistent output - - writeLog( - "INFO", - mockLogStreams.info, - console.log, - mockSessionTransport, - dateObj - ); - - expect(consoleLogStub.called).to.be.true; - const consoleArgs = consoleLogStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - // Check for parts of the date string that are likely to be present in ISO format - // Console.log's output for Date objects can vary, but the ISO string is often included or derived. - expect(outputString).to.include("2023"); - // Check for the time part of the ISO string for more robustness - expect(outputString).to.include("T12:00:00.000Z"); - }); - - it("should handle RegExp objects", () => { - const regexObj = /test.*pattern/gi; - - writeLog( - "INFO", - mockLogStreams.info, - console.log, - mockSessionTransport, - regexObj - ); - - expect(consoleLogStub.called).to.be.true; - const consoleArgs = consoleLogStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("test"); - expect(outputString).to.include("pattern"); - expect(outputString).to.include("gi"); // Check for flags - }); - - it("should handle very deeply nested objects", () => { - let deepObj = { level: 0 }; - let current = deepObj; - - // Create 10 levels deep - for (let i = 1; i <= 10; i++) { - current.next = { level: i }; - current = current.next; - } - - writeLog( - "INFO", - mockLogStreams.info, - console.log, - mockSessionTransport, - deepObj - ); - - expect(consoleLogStub.called).to.be.true; - const consoleArgs = consoleLogStub.getCall(0).args; - expect(consoleArgs).to.exist; - const outputString = consoleArgs.join(" "); - expect(outputString).to.not.include("[object Object]"); - expect(outputString).to.include("level"); - // Check for presence of multiple levels - expect(outputString.match(/level/g).length).to.be.at.least(10); - }); - }); - - describe("Stream Output Validation", () => { - let consoleLogStub; // Declare stub for this describe block - - beforeEach(() => { - // Stub console.log specifically for this describe block - consoleLogStub = sinon.stub(console, "log"); - }); - - afterEach(() => { - // Restore console.log stub after each test in this block - consoleLogStub.restore(); - }); - - it("should ensure stream writes never contain [object Object]", () => { - const testObjects = [ - { simple: "object" }, - { nested: { deep: { value: "test" } } }, - [{ array: "item" }], - { mixed: ["array", { in: "object" }] }, - ]; - - testObjects.forEach((obj, index) => { - streamWriteStubs.info.resetHistory(); // Reset history for each iteration - writeLog( - "INFO", - mockLogStreams.info, - console.log, - mockSessionTransport, - obj - ); - - expect(streamWriteStubs.info.called).to.be.true; - const streamWrites = streamWriteStubs.info.getCalls(); - streamWrites.forEach((call) => { - const writeData = call.args[0]; - // Ensure the written data is a string and does not contain "[object Object]" - expect(typeof writeData).to.equal("string"); - expect(writeData).to.not.include("[object Object]"); - }); - }); - }); - }); -}); diff --git a/test/units/utils/logging/writeLog.unit.test.js b/test/units/utils/logging/writeLog.unit.test.js deleted file mode 100644 index 85070bb..0000000 --- a/test/units/utils/logging/writeLog.unit.test.js +++ /dev/null @@ -1,341 +0,0 @@ -// test/units/utils/logging/writeLog.test.js -const { expect } = require("chai"); -const sinon = require("sinon"); -const { writeLog } = require("../../../../src/utils/logging/consolePatch"); - -describe("writeLog - Object Expansion Tests", () => { - 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(); - sessionTransport = { write: sinon.spy() }; - clock = sinon.useFakeTimers(fixedDate.getTime()); - }); - - afterEach(() => { - clock.restore(); - sinon.restore(); - }); - - describe("prevents [object Object] output", () => { - it("expands simple objects instead of showing [object Object]", () => { - const testObject = { name: "test", value: 42 }; - - writeLog("INFO", stream, consoleFn, sessionTransport, testObject); - - const expectedTimestamp = fixedDate.toISOString(); - - // Check stream output doesn't contain [object Object] - expect(stream.write.called).to.be.true; - const streamCall = stream.write.getCall(0); - const streamOutput = streamCall.args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("name"); - expect(streamOutput).to.include("test"); - expect(streamOutput).to.include("value"); - expect(streamOutput).to.include("42"); - - // Check console output doesn't contain [object Object] - expect(consoleFn.called).to.be.true; - const consoleCall = consoleFn.getCall(0); - expect(consoleCall).to.exist; - const consoleArgs = consoleCall.args; - expect(consoleArgs).to.exist; - expect(Array.isArray(consoleArgs)).to.be.true; - const consoleOutput = consoleArgs.join(" "); - expect(consoleOutput).to.not.include("[object Object]"); - expect(consoleArgs).to.include.members([`[${expectedTimestamp}] [INFO]`]); - - // Check sessionTransport message doesn't contain [object Object] - expect(sessionTransport.write.called).to.be.true; - const sessionCall = sessionTransport.write.getCall(0); - const sessionData = sessionCall.args[0]; - expect(sessionData.message).to.not.include("[object Object]"); - expect(sessionData.message).to.include("name"); - expect(sessionData.message).to.include("test"); - }); - - it("expands nested objects completely", () => { - const nestedObject = { - user: { - id: 123, - profile: { - name: "John Doe", - settings: { theme: "dark", notifications: true }, - }, - }, - }; - - writeLog("INFO", stream, consoleFn, sessionTransport, nestedObject); - - // Check all outputs expand the nested structure - expect(stream.write.called).to.be.true; - const streamOutput = stream.write.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("John Doe"); - expect(streamOutput).to.include("theme"); - expect(streamOutput).to.include("dark"); - expect(streamOutput).to.include("notifications"); - - expect(consoleFn.called).to.be.true; - const consoleCall = consoleFn.getCall(0); - expect(consoleCall).to.exist; - const consoleOutput = consoleCall.args.join(" "); - expect(consoleOutput).to.not.include("[object Object]"); - expect(consoleOutput).to.include("John Doe"); - expect(consoleOutput).to.include("theme"); - - expect(sessionTransport.write.called).to.be.true; - const sessionMessage = sessionTransport.write.getCall(0).args[0].message; - expect(sessionMessage).to.not.include("[object Object]"); - expect(sessionMessage).to.include("John Doe"); - }); - - it("expands arrays containing objects", () => { - const arrayWithObjects = [ - { id: 1, name: "first" }, - { id: 2, name: "second", nested: { value: "test" } }, - ]; - - writeLog("INFO", stream, consoleFn, sessionTransport, arrayWithObjects); - - expect(stream.write.called).to.be.true; - const streamOutput = stream.write.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("first"); - expect(streamOutput).to.include("second"); - expect(streamOutput).to.include("nested"); - expect(streamOutput).to.include("test"); - - expect(consoleFn.called).to.be.true; - const consoleCall = consoleFn.getCall(0); - expect(consoleCall).to.exist; - const consoleOutput = consoleCall.args.join(" "); - expect(consoleOutput).to.not.include("[object Object]"); - expect(consoleOutput).to.include("first"); - expect(consoleOutput).to.include("second"); - }); - - it("handles mixed argument types without [object Object]", () => { - const mixedArgs = [ - "String message", - { obj: "value" }, - 42, - ["array", "items"], - { deeply: { nested: { object: "here" } } }, - ]; - - writeLog("INFO", stream, consoleFn, sessionTransport, ...mixedArgs); - - expect(stream.write.called).to.be.true; - const streamOutput = stream.write.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("String message"); - expect(streamOutput).to.include("obj"); - expect(streamOutput).to.include("value"); - expect(streamOutput).to.include("deeply"); - expect(streamOutput).to.include("here"); - - expect(consoleFn.called).to.be.true; - const consoleCall = consoleFn.getCall(0); - expect(consoleCall).to.exist; - const consoleOutput = consoleCall.args.join(" "); - expect(consoleOutput).to.not.include("[object Object]"); - expect(consoleOutput).to.include("String message"); - expect(consoleOutput).to.include("obj"); - }); - - it("expands Error objects properly", () => { - const error = new Error("Test error"); - error.customProperty = { details: "additional info" }; - - writeLog("ERROR", stream, consoleFn, sessionTransport, error); - - expect(stream.write.called).to.be.true; - const streamOutput = stream.write.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("Test error"); - - expect(consoleFn.called).to.be.true; - const consoleCall = consoleFn.getCall(0); - expect(consoleCall).to.exist; - const consoleOutput = consoleCall.args.join(" "); - expect(consoleOutput).to.not.include("[object Object]"); - expect(consoleOutput).to.include("Test error"); - }); - - it("handles objects with special properties", () => { - const specialObj = { - toString: () => "custom toString", - valueOf: () => 99, - normalProp: "normal value", - anotherProp: { nested: "data" }, - }; - - writeLog("INFO", stream, consoleFn, sessionTransport, specialObj); - - expect(stream.write.called).to.be.true; - const streamOutput = stream.write.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("normalProp"); - expect(streamOutput).to.include("normal value"); - expect(streamOutput).to.include("nested"); - expect(streamOutput).to.include("data"); - - expect(sessionTransport.write.called).to.be.true; - const sessionMessage = sessionTransport.write.getCall(0).args[0].message; - expect(sessionMessage).to.not.include("[object Object]"); - expect(sessionMessage).to.include("normalProp"); - }); - }); - - describe("edge cases", () => { - it("handles null and undefined without [object Object]", () => { - writeLog("INFO", stream, consoleFn, sessionTransport, null, undefined); - - expect(stream.write.called).to.be.true; - const streamOutput = stream.write.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("null"); - expect(streamOutput).to.include("undefined"); - - expect(consoleFn.called).to.be.true; - const consoleCall = consoleFn.getCall(0); - expect(consoleCall).to.exist; - const consoleOutput = consoleCall.args.join(" "); - expect(consoleOutput).to.not.include("[object Object]"); - }); - - it("handles Date objects", () => { - const dateObj = new Date("2023-01-01"); - - writeLog("INFO", stream, consoleFn, sessionTransport, dateObj); - - expect(stream.write.called).to.be.true; - const streamOutput = stream.write.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("2023"); - - expect(consoleFn.called).to.be.true; - const consoleCall = consoleFn.getCall(0); - expect(consoleCall).to.exist; - const consoleOutput = consoleCall.args.join(" "); - expect(consoleOutput).to.not.include("[object Object]"); - }); - - it("handles RegExp objects", () => { - const regexObj = /test.*pattern/gi; - - writeLog("INFO", stream, consoleFn, sessionTransport, regexObj); - - expect(stream.write.called).to.be.true; - const streamOutput = stream.write.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("test"); - expect(streamOutput).to.include("pattern"); - }); - - it("handles objects with null prototype", () => { - const nullProtoObj = Object.create(null); - nullProtoObj.key = "value"; - nullProtoObj.nested = { prop: "data" }; - - writeLog("INFO", stream, consoleFn, sessionTransport, nullProtoObj); - - expect(stream.write.called).to.be.true; - const streamOutput = stream.write.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("key"); - expect(streamOutput).to.include("value"); - expect(streamOutput).to.include("prop"); - expect(streamOutput).to.include("data"); - }); - - it("handles very deeply nested objects", () => { - let deepObj = { level: 0 }; - let current = deepObj; - - // Create 5 levels deep (reasonable for testing) - for (let i = 1; i <= 5; i++) { - current.next = { level: i, data: `level${i}data` }; - current = current.next; - } - - writeLog("INFO", stream, consoleFn, sessionTransport, deepObj); - - expect(stream.write.called).to.be.true; - const streamOutput = stream.write.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("level"); - expect(streamOutput).to.include("level5data"); - }); - }); - - describe("different log levels", () => { - const levels = ["INFO", "WARN", "ERROR", "DEBUG", "NOTICE"]; - - levels.forEach((level) => { - it(`expands objects properly for ${level} level`, () => { - const testObj = { - level: level.toLowerCase(), - data: { nested: "value" }, - array: [{ item: "test" }], - }; - - writeLog(level, stream, consoleFn, sessionTransport, testObj); - - // Only check if the function was called for levels that should log - if (stream.write.called) { - const streamOutput = stream.write.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("nested"); - expect(streamOutput).to.include("value"); - expect(streamOutput).to.include("item"); - expect(streamOutput).to.include("test"); - } - - if (sessionTransport.write.called) { - const sessionMessage = - sessionTransport.write.getCall(0).args[0].message; - expect(sessionMessage).to.not.include("[object Object]"); - expect(sessionMessage).to.include("nested"); - } - }); - }); - }); - - describe("multiple objects in single call", () => { - it("expands all objects in arguments", () => { - const obj1 = { first: "object", nested: { value: 1 } }; - const obj2 = { second: "object", array: [{ item: "test" }] }; - const obj3 = { third: { deeply: { nested: "value" } } }; - - writeLog("INFO", stream, consoleFn, sessionTransport, obj1, obj2, obj3); - - expect(stream.write.called).to.be.true; - const streamOutput = stream.write.getCall(0).args[0]; - expect(streamOutput).to.not.include("[object Object]"); - expect(streamOutput).to.include("first"); - expect(streamOutput).to.include("second"); - expect(streamOutput).to.include("third"); - expect(streamOutput).to.include("deeply"); - expect(streamOutput).to.include("nested"); - expect(streamOutput).to.include("item"); - expect(streamOutput).to.include("test"); - - expect(consoleFn.called).to.be.true; - const consoleCall = consoleFn.getCall(0); - expect(consoleCall).to.exist; - const consoleOutput = consoleCall.args.join(" "); - expect(consoleOutput).to.not.include("[object Object]"); - expect(consoleOutput).to.include("first"); - expect(consoleOutput).to.include("second"); - expect(consoleOutput).to.include("third"); - }); - }); -}); diff --git a/test/units/utils/sendContactMail/sanitizeInput.property.test.js b/test/units/utils/sendContactMail/sanitizeInput.property.test.js deleted file mode 100644 index 3620788..0000000 --- a/test/units/utils/sendContactMail/sanitizeInput.property.test.js +++ /dev/null @@ -1,175 +0,0 @@ -const { expect } = require("chai"); -const fc = require("fast-check"); -const { sanitizeInput } = require("../../../../src/utils/sendContactMail"); - -describe("sanitizeInput", () => { - it("should remove all newline, carriage return, and angle brackets", () => { - fc.assert( - fc.property(fc.string(), (str) => { - const result = sanitizeInput(str); - expect(result).to.not.include("\r"); - expect(result).to.not.include("\n"); - expect(result).to.not.include("<"); - expect(result).to.not.include(">"); - }) - ); - }); - - it("should return a string for any input", () => { - fc.assert( - fc.property( - fc.anything(), // This can generate any value, including complex objects - (input) => { - const result = sanitizeInput(input); - expect(typeof result).to.equal("string"); - } - ) - ); - }); - - it("should preserve safe characters when only safe characters are present", () => { - fc.assert( - fc.property( - fc.string({ - // ONLY include characters that won't be filtered out - // Explicitly exclude: \r, \n, <, > - minLength: 0, // Allow empty string - maxLength: 100, // Or some reasonable max length for your test - // Generate strings of characters that are NOT \r, \n, <, or > - // We'll filter the characters after generation - // For individual characters, you'd typically generate a string of length 1 - // and then map/filter the characters from that string. - // A common pattern is to use fc.array(fc.char()), but fc.char() doesn't exist. - // So, we'll generate strings with specific character sets, or filter a broader set. - }), - (input) => { - // Filter the input string to ensure it only contains allowed characters - // This is a common pattern when specific character sets are needed - const filteredInput = [...input] - .filter((c) => !["\r", "\n", "<", ">"].includes(c)) - .join(""); - - const result = sanitizeInput(filteredInput); // Pass the filtered input to your sanitize function - expect(result).to.equal(filteredInput.trim()); - } - ) - ); - }); - - // You might still want a separate test for only "safe" characters - // where it confirms that characters *not* in the removal list are preserved. - it("should preserve characters NOT in the removal list when only such characters are present", () => { - fc.assert( - fc.property( - fc.string({ - // ONLY include characters that *should not* be filtered out - characters: - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;'\":,./?`~ \t", // Excludes \r, \n, <, > - }), - (input) => { - if (/[<>\r\n]/.test(input)) fc.pre(false); // Reject inputs with excluded chars - const result = sanitizeInput(input); - // For inputs that *only* contain allowed characters, - // the result should simply be the trimmed version of the input. - expect(result).to.equal(input.trim()); - } - ) - ); - }); - - it("should remove carriage returns, newlines, angle brackets, and trim whitespace", () => { - fc.assert( - fc.property( - fc.string({ - // Include characters that are *expected* to be removed by sanitizeInput - // along with regular safe characters to ensure comprehensive testing. - characters: - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !@#$%^&*()_+-=[]{}|;':\",./?`~<>\\r\\n \t", // Added spaces and tabs for trim testing - }), - (input) => { - const result = sanitizeInput(input); - - // Manually apply the *expected* sanitization logic to create the expected output. - // This should match precisely what sanitizeInput is designed to do. - const expectedOutput = String(input) - .replace(/[\r\n<>]/g, "") // Remove \r, \n, <, > - .trim(); // Trim whitespace - - expect(result).to.equal(expectedOutput); - } - ) - ); - }); - - it("should remove dangerous characters from any string", () => { - fc.assert( - fc.property( - fc.string(), // Any string - (input) => { - const result = sanitizeInput(input); - - // Result should be a string - expect(typeof result).to.equal("string"); - - // Result should not contain dangerous characters - expect(result).to.not.include("\r"); - expect(result).to.not.include("\n"); - expect(result).to.not.include("<"); - expect(result).to.not.include(">"); - - // Result should be trimmed - expect(result).to.equal(result.trim()); - } - ) - ); - }); - - it("should handle edge cases correctly", () => { - const testCases = [ - { input: "", expected: "" }, - { input: null, expected: "null" }, - { input: undefined, expected: "undefined" }, - { input: " ", expected: "" }, - { input: "hello"); - expect(result).to.equal("scriptalert(1)/script"); - }); - - it("trims leading and trailing spaces", () => { - const result = sanitizeInput(" test input "); - expect(result).to.equal("test input"); - }); - - it("coerces non-strings to strings", () => { - const result = sanitizeInput(12345); - expect(result).to.equal("12345"); - }); - - it("returns empty string for null", () => { - const result = sanitizeInput(null); - expect(result).to.equal("null"); - }); - - it("returns empty string for undefined", () => { - const result = sanitizeInput(undefined); - expect(result).to.equal("undefined"); - }); -}); diff --git a/test/units/utils/sendContactMail/sendContactMail.property.test.js b/test/units/utils/sendContactMail/sendContactMail.property.test.js deleted file mode 100644 index e164235..0000000 --- a/test/units/utils/sendContactMail/sendContactMail.property.test.js +++ /dev/null @@ -1,115 +0,0 @@ -const { expect } = require("chai"); -const sinon = require("sinon"); -const fc = require("fast-check"); -const proxyquire = require("proxyquire"); - -describe("sendContactMail", () => { - let sendContactMail; - let transporterStub; - let fsStub; - let validateStub; - let loggerStub; - let HttpError; - - const MAIL_DOMAIN = "example.com"; - const MAIL_USER = "admin@example.com"; - const DEFAULT_SUBJECT = "Default Subject"; - const EMAIL_LOG_PATH = "/tmp/test-mail-log.json"; - - beforeEach(() => { - transporterStub = { - sendMail: sinon.stub().resolves("OK"), - }; - - fsStub = { - readFile: sinon.stub().resolves("[]"), - writeFile: sinon.stub().resolves(), - }; - - validateStub = sinon.stub().callsFake((email) => ({ - valid: /^[^@]+@[^@]+\.[^@]+$/.test(email), - email, - message: "Invalid email", - })); - - loggerStub = { error: sinon.stub() }; - - HttpError = class extends Error { - constructor(message, code) { - super(message); - this.code = code; - } - }; - - const mod = proxyquire("../../../../src/utils/sendContactMail", { - "./transporter": transporterStub, - fs: { promises: fsStub }, - path: require("path"), - "../utils/emailValidator": { validateAndSanitizeEmail: validateStub }, - "../utils/logging": { winstonLogger: loggerStub }, - "../config/emailConfig": { - MAIL_DOMAIN, - MAIL_USER, - DEFAULT_SUBJECT, - EMAIL_LOG_PATH, - }, - "./HttpError": HttpError, - }); - - sendContactMail = mod.sendContactMail; - }); - - it("should send mail and write log for any valid email", async () => { - await fc.assert( - fc.asyncProperty( - fc.record({ - name: fc.string(), - email: fc.emailAddress(), - subject: fc.option(fc.string(), { nil: undefined }), - message: fc.string(), - }), - async (input) => { - await sendContactMail(input); - - expect(transporterStub.sendMail.calledOnce).to.be.true; - expect(fsStub.writeFile.calledOnce).to.be.true; - - const args = transporterStub.sendMail.firstCall.args[0]; - expect(args).to.include.keys( - "from", - "to", - "replyTo", - "subject", - "text" - ); - - transporterStub.sendMail.resetHistory(); - fsStub.writeFile.resetHistory(); - } - ) - ); - }); - - it("should throw HttpError on invalid email", async () => { - await fc.assert( - fc.asyncProperty( - fc.record({ - name: fc.string(), - email: fc.string().filter((s) => !/^[^@]+@[^@]+\.[^@]+$/.test(s)), // force invalid - subject: fc.string(), - message: fc.string(), - }), - async (input) => { - try { - await sendContactMail(input); - expect.fail("Expected HttpError"); - } catch (err) { - expect(err).to.be.instanceOf(HttpError); - expect(err.message).to.equal("Invalid email"); - expect(err.code).to.equal(400); - } - } - ) - ); - }); -}); diff --git a/test/units/utils/sendContactMail/sendContactMail.unit.test.js b/test/units/utils/sendContactMail/sendContactMail.unit.test.js deleted file mode 100644 index f8e3a43..0000000 --- a/test/units/utils/sendContactMail/sendContactMail.unit.test.js +++ /dev/null @@ -1,207 +0,0 @@ -const chai = require("chai"); -const sinon = require("sinon"); -const fs = require("fs").promises; -const path = require("path"); - -const chaiAsPromised = - require("chai-as-promised").default || require("chai-as-promised"); - -const HttpError = require("../../../../src/utils/HttpError"); - -chai.use(chaiAsPromised); -const { expect } = chai; - -describe("sendContactMail", () => { - let validateAndSanitizeEmailStub; - let transporterStub; - let loggerStub; - let sendContactMail; - let fsReadStub; - let fsWriteStub; - - const validInput = { - name: "Jane Doe", - email: "jane@example.com", - subject: "Hello", - message: "This is a test.", - }; - - const mockEmailResponse = { - accepted: ["admin@example.com"], - rejected: [], - response: "250 Message accepted", - envelope: { - from: "no-reply@example.com", - to: ["admin@example.com"], - }, - messageId: "", - }; - - const mockEmailConfig = { - MAIL_DOMAIN: "example.com", - MAIL_USER: "admin@example.com", - DEFAULT_SUBJECT: "New Contact Form Submission", - EMAIL_LOG_PATH: path.join(__dirname, "../../../data/emails.json"), - }; - - beforeEach(() => { - // Clear module cache - delete require.cache[ - require.resolve("../../../../src/utils/sendContactMail") - ]; - delete require.cache[ - require.resolve("../../../../src/utils/emailValidator") - ]; - delete require.cache[require.resolve("../../../../src/utils/transporter")]; - delete require.cache[require.resolve("../../../../src/utils/logging")]; - delete require.cache[require.resolve("../../../../src/config/emailConfig")]; - - // Create stubs - validateAndSanitizeEmailStub = sinon.stub().returns({ - valid: true, - email: "jane@example.com", - }); - - transporterStub = { - sendMail: sinon.stub().resolves(mockEmailResponse), - }; - - loggerStub = { - error: sinon.stub(), - }; - - require.cache[require.resolve("../../../../src/config/emailConfig")] = { - exports: mockEmailConfig, - }; - - // Mock modules in require cache - require.cache[require.resolve("../../../../src/utils/emailValidator")] = { - exports: { validateAndSanitizeEmail: validateAndSanitizeEmailStub }, - }; - - require.cache[require.resolve("../../../../src/utils/transporter")] = { - exports: transporterStub, - }; - - require.cache[require.resolve("../../../../src/utils/logging")] = { - exports: { winstonLogger: loggerStub }, - }; - - // Set environment variables - process.env.MAIL_DOMAIN = "example.com"; - process.env.MAIL_USER = "admin@example.com"; - - // Create fs stubs - fsReadStub = sinon.stub(fs, "readFile"); - fsWriteStub = sinon.stub(fs, "writeFile"); - - // Require the module after mocking - const module = require("../../../../src/utils/sendContactMail"); - sendContactMail = module.sendContactMail; - }); - - afterEach(() => { - sinon.restore(); - }); - - it("sends an email with valid input", async () => { - fsReadStub.resolves("[]"); - fsWriteStub.resolves(); - - const result = await sendContactMail(validInput); - - // Check the result matches what transporter.sendMail returns - expect(result).to.deep.equal(mockEmailResponse); - - // Verify transporter.sendMail was called once - expect(transporterStub.sendMail.calledOnce).to.be.true; - - // Check the email parameters - const sendArgs = transporterStub.sendMail.getCall(0).args[0]; - expect(sendArgs.from).to.equal('"Contact Form" '); - expect(sendArgs.to).to.equal("admin@example.com"); - expect(sendArgs.replyTo).to.equal('"Jane Doe" '); - expect(sendArgs.subject).to.equal("Hello"); - expect(sendArgs.text).to.equal("This is a test."); - - // Verify file operations - expect(fsReadStub.calledOnce).to.be.true; - expect(fsWriteStub.calledOnce).to.be.true; - }); - - it("uses default subject if none provided", async () => { - const input = { ...validInput, subject: undefined }; - fsReadStub.resolves("[]"); - fsWriteStub.resolves(); - - const result = await sendContactMail(input); - - // Check that we get the mock response, not an empty object - expect(result).to.deep.equal(mockEmailResponse); - - const args = transporterStub.sendMail.firstCall.args[0]; - expect(args.subject).to.equal("New Contact Form Submission"); - }); - - it("throws HttpError on invalid email", async () => { - validateAndSanitizeEmailStub.returns({ - valid: false, - message: "Invalid email format", - }); - - await expect(sendContactMail(validInput)).to.be.rejectedWith(HttpError); - }); - - it("logs and rethrows on readFile error", async () => { - const error = new Error("Disk failure"); - fsReadStub.rejects(error); - - await expect(sendContactMail(validInput)).to.be.rejectedWith( - "Disk failure" - ); - - // Verify logger was called - expect(loggerStub.error.calledOnce).to.be.true; - expect(loggerStub.error.calledWith("Failed to log email to file:", error)) - .to.be.true; - }); - - it("throws on invalid email without message", async () => { - validateAndSanitizeEmailStub.returns({ - valid: false, - message: undefined, - }); - - await expect(sendContactMail(validInput)).to.be.rejectedWith(HttpError); - }); - - it("handles non-empty existing log file", async () => { - const existingLogs = JSON.stringify([{ test: true }]); - fsReadStub.resolves(existingLogs); - fsWriteStub.resolves(); - - const result = await sendContactMail(validInput); - expect(result).to.deep.equal(mockEmailResponse); - - // Verify the log was written with existing data plus new entry - expect(fsWriteStub.calledOnce).to.be.true; - const writtenData = JSON.parse(fsWriteStub.firstCall.args[1]); - expect(writtenData).to.be.an("array").with.lengthOf(2); - expect(writtenData[0]).to.deep.equal({ test: true }); - }); - - it("throws if writing log file fails", async () => { - fsReadStub.resolves("[]"); - const error = new Error("Write failed"); - fsWriteStub.rejects(error); - - await expect(sendContactMail(validInput)).to.be.rejectedWith( - "Write failed" - ); - - // Verify logger was called - expect(loggerStub.error.calledOnce).to.be.true; - expect(loggerStub.error.calledWith("Failed to log email to file:", error)) - .to.be.true; - }); -}); diff --git a/test/units/utils/sendNewsletterSubscriptionMail.unit.test.js b/test/units/utils/sendNewsletterSubscriptionMail.unit.test.js deleted file mode 100644 index 9a30e5d..0000000 --- a/test/units/utils/sendNewsletterSubscriptionMail.unit.test.js +++ /dev/null @@ -1,51 +0,0 @@ -// test/units/utils/sendNewsletterSubscriptionMail.test.js -const sinon = require("sinon"); -const transporter = require("../../../src/utils/transporter"); -const { winstonLogger } = require("../../../src/utils/logging"); -const sendNewsletterSubscriptionMail = require("../../../src/utils/sendNewsletterSubscriptionMail"); - -describe("sendNewsletterSubscriptionMail", () => { - let sendMailStub; - let errorStub; - - beforeEach(() => { - process.env.MAIL_DOMAIN = "example.com"; - process.env.MAIL_NEWSLETTER = "newsletter@example.com"; - - sendMailStub = sinon.stub(transporter, "sendMail"); - errorStub = sinon.stub(winstonLogger, "error"); - }); - - afterEach(() => { - sendMailStub.restore(); - errorStub.restore(); - delete process.env.MAIL_DOMAIN; - delete process.env.MAIL_NEWSLETTER; - }); - - it("calls transporter.sendMail with correct mail data", async () => { - sendMailStub.resolves("sent"); - - const email = "user@example.com"; - const result = await sendNewsletterSubscriptionMail({ email }); - - sinon.assert.calledOnce(sendMailStub); - sinon.assert.calledWith(sendMailStub, { - from: "Newsletter ", - to: email, - subject: "New Newsletter Subscription", - text: "Please add this email to the newsletter list: newsletter@example.com", - }); - sinon.assert.notCalled(errorStub); - }); - - it("logs error when transporter.sendMail rejects", async () => { - const error = new Error("send failed"); - sendMailStub.rejects(error); - - await sendNewsletterSubscriptionMail({ email: "fail@example.com" }); - - sinon.assert.calledOnce(errorStub); - sinon.assert.calledWith(errorStub, error); - }); -}); diff --git a/test/utils/logging/config.test.js b/test/utils/logging/config.test.js new file mode 100644 index 0000000..7b6b82a --- /dev/null +++ b/test/utils/logging/config.test.js @@ -0,0 +1,79 @@ +// test/units/utils/logging/config.test.js +const { expect } = require("chai"); +const fs = require("fs"); +const path = require("path"); +const proxyquire = require("proxyquire").noPreserveCache(); + +const { + projectRoot, + logDir, + sessionTimestamp, + sessionDir, + logFiles, + LOG_LEVELS, +} = require("../../../src/utils/logging/config"); + +describe("config.js", () => { + it("projectRoot contains package.json", () => { + const pkgJsonPath = path.join(projectRoot, "package.json"); + const exists = fs.existsSync(pkgJsonPath); + expect(exists).to.equal(true, `package.json not found in ${projectRoot}`); + }); + + it("projectRoot matches resolved 3-levels-up path", () => { + const expected = path.resolve(__dirname, "../../../../"); + expect(projectRoot).to.equal(expected); + }); + + it("logDir is within projectRoot and ends with 'logs'", () => { + expect(logDir.startsWith(projectRoot)).to.be.true; + expect(path.basename(logDir)).to.equal("logs"); + }); + + it("sessionTimestamp matches expected ISO pattern with no colons or dots", () => { + expect(sessionTimestamp).to.match( + /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/ + ); + }); + + it("sessionDir is built from logDir and sessionTimestamp", () => { + const expected = path.join(logDir, "sessions", sessionTimestamp); + expect(sessionDir).to.equal(expected); + }); + + it("logFiles.session points to session.log in sessionDir", () => { + expect(logFiles.session).to.equal(path.join(sessionDir, "session.log")); + }); + + ["info", "notice", "error", "warn", "debug"].forEach((level) => { + it(`logFiles.${level} points to ${level}.log in correct subdir`, () => { + expect(logFiles[level]).to.equal( + path.join(logDir, level, `${level}.log`) + ); + }); + }); + + it("LOG_LEVELS defines correct level-to-priority mapping", () => { + expect(LOG_LEVELS).to.deep.equal({ + error: 0, + warn: 1, + security: 2, + notice: 3, + info: 4, + debug: 5, + }); + }); + + it("LOG_LEVEL defaults to 'info' when process.env.LOG_LEVEL is unset", () => { + const original = process.env.LOG_LEVEL; + delete process.env.LOG_LEVEL; + + const { LOG_LEVEL } = proxyquire( + "../../../../src/utils/logging/config", + {} + ); + expect(LOG_LEVEL).to.equal("info"); + + if (original !== undefined) process.env.LOG_LEVEL = original; + }); +}); diff --git a/test/utils/logging/createLogStreams.test.js b/test/utils/logging/createLogStreams.test.js new file mode 100644 index 0000000..253578c --- /dev/null +++ b/test/utils/logging/createLogStreams.test.js @@ -0,0 +1,31 @@ +// test/units/utils/logging/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); + } + }); +});