diff --git a/package-lock.json b/package-lock.json index 7d9b1a9..ba5039e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "devDependencies": { "@faker-js/faker": "^9.8.0", "chai": "^5.2.1", + "fast-check": "^4.2.0", "mocha": "^11.7.1", "mock-fs": "^5.5.0", "node-fetch": "^2.7.0", @@ -3078,6 +3079,29 @@ ], "license": "MIT" }, + "node_modules/fast-check": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.2.0.tgz", + "integrity": "sha512-buxrKEaSseOwFjt6K1REcGMeFOrb0wk3cXifeMAG8yahcE9kV20PjQn1OdzPGL6OBFTbYXfjleNBARf/aCfV1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^7.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6667,6 +6691,23 @@ } } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", diff --git a/package.json b/package.json index cf122cd..d80d0c8 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "", "main": "index.js", - "type": "module", + "type": "commonjs", "scripts": { "combine:css": "node scripts/combine-css.js", "test": "NODE_PATH=./src mocha test/units/**/*.mjs", @@ -23,7 +23,6 @@ "keywords": [], "author": "", "license": "ISC", - "type": "commonjs", "dependencies": { "better-sqlite3": "^12.2.0", "body-parser": "^2.2.0", @@ -62,6 +61,7 @@ "devDependencies": { "@faker-js/faker": "^9.8.0", "chai": "^5.2.1", + "fast-check": "^4.2.0", "mocha": "^11.7.1", "mock-fs": "^5.5.0", "node-fetch": "^2.7.0", diff --git a/src/utils/emailValidator.js b/src/utils/emailValidator.js index bf087b3..f1d4ce0 100644 --- a/src/utils/emailValidator.js +++ b/src/utils/emailValidator.js @@ -12,14 +12,35 @@ return { valid: false, message: MESSAGES.REQUIRED }; } - const email = validator.normalizeEmail(rawEmail.trim().toLowerCase()); + const trimmed = rawEmail.trim().toLowerCase(); - if (!email || !validator.isEmail(email)) { + if (trimmed.length > MAX_EMAIL_LENGTH) { + return { valid: false, message: MESSAGES.TOO_LONG }; + } + + const atCount = (trimmed.match(/@/g) || []).length; + if (atCount !== 1) { return { valid: false, message: MESSAGES.INVALID }; } - if (email.length > MAX_EMAIL_LENGTH) { - return { valid: false, message: MESSAGES.TOO_LONG }; + const [localPart] = trimmed.split("@"); + + if ( + localPart.includes("..") || + localPart.startsWith(".") || + localPart.endsWith(".") + ) { + return { valid: false, message: MESSAGES.INVALID }; + } + + const email = validator.normalizeEmail(trimmed); + + if (!email) { + return { valid: false, message: MESSAGES.INVALID }; + } + + if (!validator.isEmail(email)) { + return { valid: false, message: MESSAGES.INVALID }; } if (email.includes("..") || email.startsWith(".") || email.endsWith(".")) { 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/yarn.lock b/yarn.lock index ed54447..4a8d690 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1424,6 +1424,13 @@ resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz" integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== +fast-check@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/fast-check/-/fast-check-4.2.0.tgz" + integrity sha512-buxrKEaSseOwFjt6K1REcGMeFOrb0wk3cXifeMAG8yahcE9kV20PjQn1OdzPGL6OBFTbYXfjleNBARf/aCfV1A== + dependencies: + pure-rand "^7.0.0" + fast-deep-equal@^3.1.1: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" @@ -3417,6 +3424,11 @@ puppeteer-core "24.12.1" typed-query-selector "^2.12.0" +pure-rand@^7.0.0: + version "7.0.1" + resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz" + integrity sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ== + qs@^6.14.0: version "6.14.0" resolved "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz"