diff --git a/test/units/utils/emailValidator/validaterAndSanitizeEmail.property.test.js b/test/units/utils/emailValidator/validaterAndSanitizeEmail.property.test.js new file mode 100644 index 0000000..e07bac2 --- /dev/null +++ b/test/units/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/units/utils/emailValidator/validaterAndSanitizeEmail.unit.test.js b/test/units/utils/emailValidator/validaterAndSanitizeEmail.unit.test.js new file mode 100644 index 0000000..5c052a3 --- /dev/null +++ b/test/units/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/units/utils/logging/config.unit.test.js b/test/units/utils/logging/config.unit.test.js new file mode 100644 index 0000000..c8b2b0c --- /dev/null +++ b/test/units/utils/logging/config.unit.test.js @@ -0,0 +1,79 @@ +// test/units/utils/logging/config.test.js +const { expect } = require("chai"); +const fs = require("fs"); +const path = require("path"); +const proxyquire = require("proxyquire").noPreserveCache(); + +const { + projectRoot, + logDir, + sessionTimestamp, + sessionDir, + logFiles, + LOG_LEVELS, +} = require("../../../../src/utils/logging/config"); + +describe("config.js", () => { + it("projectRoot contains package.json", () => { + const pkgJsonPath = path.join(projectRoot, "package.json"); + const exists = fs.existsSync(pkgJsonPath); + expect(exists).to.equal(true, `package.json not found in ${projectRoot}`); + }); + + it("projectRoot matches resolved 3-levels-up path", () => { + const expected = path.resolve(__dirname, "../../../../"); + expect(projectRoot).to.equal(expected); + }); + + it("logDir is within projectRoot and ends with 'logs'", () => { + expect(logDir.startsWith(projectRoot)).to.be.true; + expect(path.basename(logDir)).to.equal("logs"); + }); + + it("sessionTimestamp matches expected ISO pattern with no colons or dots", () => { + expect(sessionTimestamp).to.match( + /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/ + ); + }); + + it("sessionDir is built from logDir and sessionTimestamp", () => { + const expected = path.join(logDir, "sessions", sessionTimestamp); + expect(sessionDir).to.equal(expected); + }); + + it("logFiles.session points to session.log in sessionDir", () => { + expect(logFiles.session).to.equal(path.join(sessionDir, "session.log")); + }); + + ["info", "notice", "error", "warn", "debug"].forEach((level) => { + it(`logFiles.${level} points to ${level}.log in correct subdir`, () => { + expect(logFiles[level]).to.equal( + path.join(logDir, level, `${level}.log`) + ); + }); + }); + + it("LOG_LEVELS defines correct level-to-priority mapping", () => { + expect(LOG_LEVELS).to.deep.equal({ + error: 0, + warn: 1, + security: 2, + notice: 3, + info: 4, + debug: 5, + }); + }); + + it("LOG_LEVEL defaults to 'info' when process.env.LOG_LEVEL is unset", () => { + const original = process.env.LOG_LEVEL; + delete process.env.LOG_LEVEL; + + const { LOG_LEVEL } = proxyquire( + "../../../../src/utils/logging/config", + {} + ); + expect(LOG_LEVEL).to.equal("info"); + + if (original !== undefined) process.env.LOG_LEVEL = original; + }); +}); diff --git a/test/units/utils/logging/createLogStreams.unit.test.js b/test/units/utils/logging/createLogStreams.unit.test.js new file mode 100644 index 0000000..bb5482c --- /dev/null +++ b/test/units/utils/logging/createLogStreams.unit.test.js @@ -0,0 +1,31 @@ +// test/units/utils/logging/createLogStreams.test.js +const fs = require("fs"); +const path = require("path"); +const { expect } = require("chai"); +const { createLogStreams } = require("../../../../src/utils/logging"); + +describe("createLogStreams", () => { + const testDir = path.join(__dirname, "..", "..", "..", "..", "test", "logs"); + const files = { + info: path.join(testDir, "info.log"), + error: path.join(testDir, "error.log"), + warn: path.join(testDir, "warn.log"), + notice: path.join(testDir, "notice.log"), + debug: path.join(testDir, "debug.log"), + }; + + afterEach(() => { + Object.values(files).forEach((file) => { + try { + fs.unlinkSync(file); + } catch (_) {} + }); + }); + + it("should create write streams for all log files", () => { + const streams = createLogStreams(files); + for (const key of Object.keys(files)) { + expect(streams[key]).to.be.an.instanceof(fs.WriteStream); + } + }); +}); diff --git a/test/units/utils/logging/formatFunctionName.unit.test.js b/test/units/utils/logging/formatFunctionName.unit.test.js new file mode 100644 index 0000000..def137f --- /dev/null +++ b/test/units/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/units/utils/logging/formatLog.unit.test.js b/test/units/utils/logging/formatLog.unit.test.js new file mode 100644 index 0000000..c073a81 --- /dev/null +++ b/test/units/utils/logging/formatLog.unit.test.js @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..0dc0aeb --- /dev/null +++ b/test/units/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/units/utils/logging/handleUncaughtException.unit.test.js b/test/units/utils/logging/handleUncaughtException.unit.test.js new file mode 100644 index 0000000..6e4e3c5 --- /dev/null +++ b/test/units/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/units/utils/logging/handleUnhandledRejection.unit.test.js b/test/units/utils/logging/handleUnhandledRejection.unit.test.js new file mode 100644 index 0000000..74e720b --- /dev/null +++ b/test/units/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/units/utils/logging/initializeLogDirectories.unit.test.js b/test/units/utils/logging/initializeLogDirectories.unit.test.js new file mode 100644 index 0000000..15acc5a --- /dev/null +++ b/test/units/utils/logging/initializeLogDirectories.unit.test.js @@ -0,0 +1,44 @@ +// 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 new file mode 100644 index 0000000..0649a60 --- /dev/null +++ b/test/units/utils/logging/object-formatting.unit.test.js @@ -0,0 +1,521 @@ +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 new file mode 100644 index 0000000..85070bb --- /dev/null +++ b/test/units/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, 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 new file mode 100644 index 0000000..3620788 --- /dev/null +++ b/test/units/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/units/utils/sendContactMail/sendContactMail.property.test.js b/test/units/utils/sendContactMail/sendContactMail.property.test.js new file mode 100644 index 0000000..e164235 --- /dev/null +++ b/test/units/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/units/utils/sendContactMail/sendContactMail.unit.test.js b/test/units/utils/sendContactMail/sendContactMail.unit.test.js new file mode 100644 index 0000000..f8e3a43 --- /dev/null +++ b/test/units/utils/sendContactMail/sendContactMail.unit.test.js @@ -0,0 +1,207 @@ +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 new file mode 100644 index 0000000..9a30e5d --- /dev/null +++ b/test/units/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); + }); +});