diff --git a/package.json b/package.json index c4a318c..b0e1b2f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "commonjs", "scripts": { "combine:css": "node scripts/combine-css.js", - "test": "mocha test/**/*.unit.test.js test/**/*.property.test.js", + "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", diff --git a/src/utils/logging/config.js b/src/utils/logging/config.js index 4ed1a36..69bbf85 100644 --- a/src/utils/logging/config.js +++ b/src/utils/logging/config.js @@ -5,12 +5,12 @@ levels: { error: 0, warn: 1, - security: 2, + security: 3, event: 2, - notice: 3, - info: 4, - debug: 5, - analytics: 6, // use a unique value + notice: 4, + info: 5, + debug: 6, + analytics: 7, // use a unique value }, colors: { error: "red", diff --git a/src/utils/logging/index.js b/src/utils/logging/index.js index 2704b3e..cb23ed1 100644 --- a/src/utils/logging/index.js +++ b/src/utils/logging/index.js @@ -144,7 +144,6 @@ manualLogger, winstonLogger, initializeLogDirectories, - createLogStreams, sessionTransport, patchConsole, shouldLog, diff --git a/src/utils/logging/streams.js b/src/utils/logging/streams.js index 09003c6..70975f5 100644 --- a/src/utils/logging/streams.js +++ b/src/utils/logging/streams.js @@ -15,6 +15,7 @@ debug: fs.createWriteStream(files.debug, { flags: "a" }), security: fs.createWriteStream(files.security, { flags: "a" }), event: fs.createWriteStream(files.event, { flags: "a" }), + analytics: fs.createWriteStream(files.analytics, { flags: "a" }), }; } 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/emailValidator/validaterAndSanitizeEmail.property.test.js b/test/utils/emailValidator/validaterAndSanitizeEmail.property.test.js new file mode 100644 index 0000000..678583f --- /dev/null +++ b/test/utils/emailValidator/validaterAndSanitizeEmail.property.test.js @@ -0,0 +1,93 @@ +// 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/utils/emailValidator/validaterAndSanitizeEmail.unit.test.js b/test/utils/emailValidator/validaterAndSanitizeEmail.unit.test.js new file mode 100644 index 0000000..927eef1 --- /dev/null +++ b/test/utils/emailValidator/validaterAndSanitizeEmail.unit.test.js @@ -0,0 +1,148 @@ +// 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/utils/logging/config.test.js b/test/utils/logging/config.test.js index 7b6b82a..33dd3ab 100644 --- a/test/utils/logging/config.test.js +++ b/test/utils/logging/config.test.js @@ -21,7 +21,7 @@ }); it("projectRoot matches resolved 3-levels-up path", () => { - const expected = path.resolve(__dirname, "../../../../"); + const expected = path.resolve(__dirname, "../../../"); expect(projectRoot).to.equal(expected); }); @@ -68,10 +68,7 @@ const original = process.env.LOG_LEVEL; delete process.env.LOG_LEVEL; - const { LOG_LEVEL } = proxyquire( - "../../../../src/utils/logging/config", - {} - ); + 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/config.unit.test.js b/test/utils/logging/config.unit.test.js new file mode 100644 index 0000000..f3be6ef --- /dev/null +++ b/test/utils/logging/config.unit.test.js @@ -0,0 +1,78 @@ +// 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, + event: 2, + security: 3, + notice: 4, + info: 5, + debug: 6, + analytics: 7, + }); + }); + + 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 index 253578c..784902f 100644 --- a/test/utils/logging/createLogStreams.test.js +++ b/test/utils/logging/createLogStreams.test.js @@ -2,16 +2,19 @@ const fs = require("fs"); const path = require("path"); const { expect } = require("chai"); -const { createLogStreams } = require("../../../src/utils/logging"); +const { createLogStreams } = require("../../../src/utils/logging/streams"); describe("createLogStreams", () => { - const testDir = path.join(__dirname, "..", "..", "..", "..", "test", "logs"); + const testDir = path.join(__dirname, "..", "..", "logs"); const files = { info: path.join(testDir, "info.log"), error: path.join(testDir, "error.log"), warn: path.join(testDir, "warn.log"), + event: path.join(testDir, "event.log"), + security: path.join(testDir, "security.log"), notice: path.join(testDir, "notice.log"), debug: path.join(testDir, "debug.log"), + analytics: path.join(testDir, "analytics.log"), }; afterEach(() => { diff --git a/test/utils/logging/createLogStreams.unit.test.js b/test/utils/logging/createLogStreams.unit.test.js new file mode 100644 index 0000000..7dd9ce2 --- /dev/null +++ b/test/utils/logging/createLogStreams.unit.test.js @@ -0,0 +1,34 @@ +// 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/streams"); + +describe("createLogStreams", () => { + const testDir = path.join(__dirname, "..", "..", "logs"); + const files = { + analytics: path.join(testDir, "analytics.log"), + error: path.join(testDir, "error.log"), + security: path.join(testDir, "security.log"), + warn: path.join(testDir, "warn.log"), + notice: path.join(testDir, "notice.log"), + event: path.join(testDir, "event.log"), + info: path.join(testDir, "info.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/utils/logging/formatFunctionName.unit.test.js b/test/utils/logging/formatFunctionName.unit.test.js new file mode 100644 index 0000000..5c61d09 --- /dev/null +++ b/test/utils/logging/formatFunctionName.unit.test.js @@ -0,0 +1,14 @@ +// 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/utils/logging/formatLog.unit.test.js b/test/utils/logging/formatLog.unit.test.js new file mode 100644 index 0000000..5bf410d --- /dev/null +++ b/test/utils/logging/formatLog.unit.test.js @@ -0,0 +1,35 @@ +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", + "EVENT", + "WARN", + "SECURITY", + "ERROR", + "DEBUG", + "NOTICE", + "ANALYTICS", + ]; + + 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/utils/logging/formatLogMessage.unit.test.js b/test/utils/logging/formatLogMessage.unit.test.js new file mode 100644 index 0000000..adb7351 --- /dev/null +++ b/test/utils/logging/formatLogMessage.unit.test.js @@ -0,0 +1,15 @@ +// 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/utils/logging/handleUncaughtException.unit.test.js b/test/utils/logging/handleUncaughtException.unit.test.js new file mode 100644 index 0000000..823ceb4 --- /dev/null +++ b/test/utils/logging/handleUncaughtException.unit.test.js @@ -0,0 +1,28 @@ +// 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/utils/logging/handleUnhandledRejection.unit.test.js b/test/utils/logging/handleUnhandledRejection.unit.test.js new file mode 100644 index 0000000..8bdd3e3 --- /dev/null +++ b/test/utils/logging/handleUnhandledRejection.unit.test.js @@ -0,0 +1,22 @@ +const { expect } = require("chai"); +const sinon = require("sinon"); +const path = require("path"); +const proxyquire = require("proxyquire"); + +describe("handleUnhandledRejection", () => { + it("logs rejection using winstonLogger", () => { + const errorStub = sinon.stub(); + const reason = new Error("rejection"); + + const handlers = proxyquire( + path.resolve(__dirname, "../../../src/utils/logging/handlers"), + { + "../logging": { winstonLogger: { error: errorStub } }, + } + ); + + handlers.handleUnhandledRejection(reason); + expect(errorStub.calledWith("Unhandled Rejection:", reason.stack)).to.be + .true; + }); +}); diff --git a/test/utils/logging/initializeLogDirectories.unit.test.js b/test/utils/logging/initializeLogDirectories.unit.test.js new file mode 100644 index 0000000..b61e23a --- /dev/null +++ b/test/utils/logging/initializeLogDirectories.unit.test.js @@ -0,0 +1,47 @@ +// 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", + security: "../test/logs/security/security.log", + warn: "../test/logs/warn/warn.log", + event: "../test/logs/event/event.log", + notice: "../test/logs/notice/notice.log", + debug: "../test/logs/debug/debug.log", + analytics: "../test/logs/analytics/analytics.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/utils/logging/object-formatting.unit.test.js b/test/utils/logging/object-formatting.unit.test.js new file mode 100644 index 0000000..a7d6d0c --- /dev/null +++ b/test/utils/logging/object-formatting.unit.test.js @@ -0,0 +1,527 @@ +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() {} }), + security: new Writable({ write() {} }), + analytics: new Writable({ write() {} }), + event: 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"), + security: sinon.stub(mockLogStreams.security, "write"), + event: sinon.stub(mockLogStreams.event, "write"), + analytics: sinon.stub(mockLogStreams.analytics, "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, + mockSessionTransport, + console.log, + 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, + mockSessionTransport, + console.log, + 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, + mockSessionTransport, + console.log, + 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, + mockSessionTransport, + console.log, + 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, + mockSessionTransport, + console.log, + ...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, + mockSessionTransport, + console.error, + 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, + mockSessionTransport, + console.log, + 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, + mockSessionTransport, + console.log, + 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, + mockSessionTransport, + console.log, + 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, + mockSessionTransport, + console.log, + 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, + mockSessionTransport, + console.log, + 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, + mockSessionTransport, + console.log, + 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, + mockSessionTransport, + console.log, + 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/utils/logging/writeLog.unit.test.js b/test/utils/logging/writeLog.unit.test.js new file mode 100644 index 0000000..96624ff --- /dev/null +++ b/test/utils/logging/writeLog.unit.test.js @@ -0,0 +1,341 @@ +// 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, sessionTransport, consoleFn, 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, sessionTransport, consoleFn, 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, sessionTransport, consoleFn, 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, sessionTransport, consoleFn, ...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, sessionTransport, consoleFn, 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, sessionTransport, consoleFn, 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, sessionTransport, consoleFn, 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, sessionTransport, consoleFn, 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, sessionTransport, consoleFn, 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, sessionTransport, consoleFn, 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, sessionTransport, consoleFn, 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, sessionTransport, consoleFn, 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, sessionTransport, consoleFn, 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/utils/sendContactMail/sanitizeInput.property.test.js b/test/utils/sendContactMail/sanitizeInput.property.test.js new file mode 100644 index 0000000..0c03f85 --- /dev/null +++ b/test/utils/sendContactMail/sanitizeInput.property.test.js @@ -0,0 +1,175 @@ +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/utils/sendContactMail/sendContactMail.property.test.js b/test/utils/sendContactMail/sendContactMail.property.test.js new file mode 100644 index 0000000..ffef81c --- /dev/null +++ b/test/utils/sendContactMail/sendContactMail.property.test.js @@ -0,0 +1,115 @@ +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/utils/sendContactMail/sendContactMail.unit.test.js b/test/utils/sendContactMail/sendContactMail.unit.test.js new file mode 100644 index 0000000..b73528b --- /dev/null +++ b/test/utils/sendContactMail/sendContactMail.unit.test.js @@ -0,0 +1,203 @@ +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/utils/sendNewsletterSubscriptionMail.unit.test.js b/test/utils/sendNewsletterSubscriptionMail.unit.test.js new file mode 100644 index 0000000..f6b75ea --- /dev/null +++ b/test/utils/sendNewsletterSubscriptionMail.unit.test.js @@ -0,0 +1,51 @@ +// 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); + }); +});