diff --git a/package-lock.json b/package-lock.json index ba5039e..1689574 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "devDependencies": { "@faker-js/faker": "^9.8.0", "chai": "^5.2.1", + "chai-as-promised": "^8.0.1", "fast-check": "^4.2.0", "mocha": "^11.7.1", "mock-fs": "^5.5.0", @@ -1834,6 +1835,19 @@ "node": ">=18" } }, + "node_modules/chai-as-promised": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.1.tgz", + "integrity": "sha512-OIEJtOL8xxJSH8JJWbIoRjybbzR52iFuDHuF8eb+nTPD6tgXLjRqsgnUGqQfFODxYvq5QdirT0pN9dZ0+Gz6rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "check-error": "^2.0.0" + }, + "peerDependencies": { + "chai": ">= 2.1.2 < 6" + } + }, "node_modules/chalk": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", diff --git a/package.json b/package.json index d80d0c8..e09cd4b 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "devDependencies": { "@faker-js/faker": "^9.8.0", "chai": "^5.2.1", + "chai-as-promised": "^8.0.1", "fast-check": "^4.2.0", "mocha": "^11.7.1", "mock-fs": "^5.5.0", diff --git a/src/config/emailConfig.js b/src/config/emailConfig.js new file mode 100644 index 0000000..6530620 --- /dev/null +++ b/src/config/emailConfig.js @@ -0,0 +1,14 @@ +// src/config/emailConfig.js +const path = require("path"); + +const MAIL_DOMAIN = process.env.MAIL_DOMAIN; +const MAIL_USER = process.env.MAIL_USER; +const DEFAULT_SUBJECT = "New Contact Form Submission"; +const EMAIL_LOG_PATH = path.join(__dirname, "../../data/emails.json"); + +module.exports = { + MAIL_DOMAIN, + MAIL_USER, + DEFAULT_SUBJECT, + EMAIL_LOG_PATH, +}; diff --git a/src/controllers/contactControllers.js b/src/controllers/contactControllers.js index 6871156..17ca0eb 100644 --- a/src/controllers/contactControllers.js +++ b/src/controllers/contactControllers.js @@ -1,5 +1,5 @@ // src/routes/contact.js -const sendContactMail = require("../utils/sendContactMail"); +const { sendContactMail } = require("../utils/sendContactMail"); const verifyHCaptcha = require("../utils/verifyHCaptcha"); const { qualifyLink } = require("../utils/qualifyLinks"); diff --git a/src/utils/sendContactMail.js b/src/utils/sendContactMail.js index 6ebe133..8cbcbb4 100644 --- a/src/utils/sendContactMail.js +++ b/src/utils/sendContactMail.js @@ -1,14 +1,15 @@ +// src/utils/sendContactMail.js const transporter = require("./transporter"); const path = require("path"); const fs = require("fs").promises; const { validateAndSanitizeEmail } = require("../utils/emailValidator"); const { winstonLogger } = require("../utils/logging"); - -const MAIL_DOMAIN = process.env.MAIL_DOMAIN; -const MAIL_USER = process.env.MAIL_USER; -const DEFAULT_SUBJECT = "New Contact Form Submission"; -const EMAIL_LOG_PATH = path.join(__dirname, "../../data/emails.json"); - +const { + MAIL_DOMAIN, + MAIL_USER, + DEFAULT_SUBJECT, + EMAIL_LOG_PATH, +} = require("../config/emailConfig"); function sanitizeInput(input) { return String(input) .replace(/[\r\n<>]/g, "") @@ -28,7 +29,7 @@ message: errorMessage, } = validateAndSanitizeEmail(email); - if (!valid) throw new HttpError(errorMessage || ERRORS.INVALID_EMAIL, 400); + if (!valid) throw new HttpError(errorMessage, 400); const mailData = { from: `"Contact Form" `, @@ -57,4 +58,5 @@ return transporter.sendMail(mailData); } -module.exports = sendContactMail; +module.exports.sendContactMail = sendContactMail; +module.exports.sanitizeInput = sanitizeInput; diff --git a/test/units/utils/sendContactMail/sanitizeInput.unit.test.js b/test/units/utils/sendContactMail/sanitizeInput.unit.test.js new file mode 100644 index 0000000..e6e56c8 --- /dev/null +++ b/test/units/utils/sendContactMail/sanitizeInput.unit.test.js @@ -0,0 +1,40 @@ +const { expect } = require("chai"); + +const { sanitizeInput } = require("../../../../src/utils/sendContactMail"); + +describe("sanitizeInput", () => { + it("removes carriage returns", () => { + const result = sanitizeInput("hello\rworld"); + expect(result).to.equal("helloworld"); + }); + + it("removes newlines", () => { + const result = sanitizeInput("hello\nworld"); + expect(result).to.equal("helloworld"); + }); + + it("removes angle brackets", () => { + const result = sanitizeInput(""); + 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.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/yarn.lock b/yarn.lock index 4a8d690..0e86346 100644 --- a/yarn.lock +++ b/yarn.lock @@ -711,7 +711,14 @@ dependencies: follow-redirects "^1.15.6" -chai@^5.2.1: +chai-as-promised@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.1.tgz" + integrity sha512-OIEJtOL8xxJSH8JJWbIoRjybbzR52iFuDHuF8eb+nTPD6tgXLjRqsgnUGqQfFODxYvq5QdirT0pN9dZ0+Gz6rA== + dependencies: + check-error "^2.0.0" + +chai@^5.2.1, "chai@>= 2.1.2 < 6": version "5.2.1" resolved "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz" integrity sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A== @@ -743,7 +750,7 @@ resolved "https://registry.npmjs.org/charm/-/charm-0.1.2.tgz" integrity sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ== -check-error@^2.1.1: +check-error@^2.0.0, check-error@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz" integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==