Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NJWE-2584: implemented backend validation for valid email and domain #3172

Merged
merged 24 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
37b3b2b
implemented backend validation for valid email and domain
denish-fearless Jan 15, 2025
4b5cef8
edge case, where subdomains exist, will break it to domain and check …
denish-fearless Jan 15, 2025
f25bd69
after some research found out that some subdomains do have mx records…
denish-fearless Jan 15, 2025
48d3ff3
good fence :)
denish-fearless Jan 15, 2025
676ad92
validate phone number
denish-fearless Jan 16, 2025
058c3b2
added test for phone validation
denish-fearless Jan 16, 2025
bdc802b
Merge branch 'main' into NJWE-2584
ChelseaKR Jan 16, 2025
95f1388
changed to in house validation logic
denish-fearless Jan 17, 2025
a81878c
Merge branch 'NJWE-2584' of https://github.com/newjersey/dol-mcnj-mai…
denish-fearless Jan 17, 2025
9025e62
removing unused libraries from package
denish-fearless Jan 17, 2025
8c26205
locking file
denish-fearless Jan 17, 2025
8032cfb
Merge branch 'main' into NJWE-2584
ChelseaKR Jan 21, 2025
02fb7c0
Merge branch 'main' into NJWE-2584
denish-fearless Feb 6, 2025
b875e9b
added full working mechanism for signupform
Feb 7, 2025
dcb52b1
Merge branch 'main' into NJWE-2584
denish-fearless Feb 10, 2025
49e0603
added some minor fixes
Feb 10, 2025
3c9d0cb
template literals avoided
Feb 10, 2025
f831f78
Merge branch 'main' into NJWE-2584
denish-fearless Feb 10, 2025
92c0ffe
opens on a new tab privacy policy and sms policy
Feb 10, 2025
7522a28
Merge branch 'NJWE-2584' of https://github.com/newjersey/dol-mcnj-mai…
Feb 10, 2025
e56768e
Merge branch 'main' into NJWE-2584
ChelseaKR Feb 10, 2025
e614953
Merge branch 'main' into NJWE-2584
ChelseaKR Feb 10, 2025
14fc0cf
update docs for env vars
ChelseaKR Feb 11, 2025
2cce074
i18n checkpt
ChelseaKR Feb 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ This will likely change as features are rolled out.
* `REACT_APP_FEATURE_MULTILANG` - Enable/disable multi-language support in the React app.
* `REACT_APP_FEATURE_CAREER_PATHWAYS` - Toggle the display of career pathways feature as well as any reference to it.
* `REACT_APP_FEATURE_CAREER_NAVIGATOR` - Toggle the display of the Career Navigator landing page as well as any references to it.
* `REACT_APP_FEATURE_PINPOINT` - Show or hide any instance of the Pinpoint email collection tool.

* `REACT_APP_SIGNUP_FOR_UPDATES` - Toggle the display of the user signup modal, which uses MailChimp on the backend.
*
##### Database

Dev, QA, and production databases are hosted in AWS as SQL instances running PostgreSQL.
Expand Down
6 changes: 5 additions & 1 deletion backend/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,8 @@ DB_HOST_DEV='localhost'
DB_PASS_AWSDEV=[DB_PASS_AWSDEV]
DB_PASS_ENCODED_AWSDEV=[DB_PASS_ENCODED_AWSDEV]
DB_PASS_TEST=[DB_PASS_TEST]
DB_PASS_DEV=[DB_PASS_DEV]
DB_PASS_DEV=[DB_PASS_DEV]

# MailChimp
MAILCHIMP_API_KEY=[MAILCHIMP_API_KEY]
MAILCHIMP_LIST_ID=[MAILCHIMP_LIST_ID]
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { routerFactory } from "./routes/router";
import emailSubmissionRouter from './routes/emailRoutes';
import contentfulRouter from './contentful/index';
import contactRouter from './routes/contactRoutes'
import signUpRouter from './routes/signupRoutes'
import { PostgresDataClient } from "./database/data/PostgresDataClient";
import { PostgresSearchClient } from "./database/search/PostgresSearchClient";
import { findTrainingsByFactory } from "./domain/training/findTrainingsBy";
Expand Down Expand Up @@ -211,6 +212,7 @@ app.use(express.static(path.join(__dirname, "build"), { etag: false, lastModifie
app.use(express.json());
app.use("/api", router);
app.use('/api/contact', contactRouter)
app.use('/api/signup', signUpRouter)
app.use('/api/emails', emailSubmissionRouter);
app.use('/api/contentful', contentfulRouter);

Expand Down
110 changes: 110 additions & 0 deletions backend/src/controllers/signUpController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { submitSignupForm } from "./signUpController";
import { addSubscriberToMailchimp } from "../mailchimp/mailchimpAPI";
import { Request, Response } from "express";

// Mock Mailchimp function
jest.mock("../mailchimp/mailchimpAPI", () => ({
addSubscriberToMailchimp: jest.fn(),
}));

describe("submitSignupForm Controller", () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;

beforeEach(() => {
mockReq = { body: {} };
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
});

test("should return 200 if signup is successful", async () => {
(addSubscriberToMailchimp as jest.Mock).mockResolvedValueOnce(undefined);

mockReq.body = {
firstName: "John",
lastName: "Doe",
email: "test@example.com",
phone: "1234567890",
};

await submitSignupForm(mockReq as Request, mockRes as Response);

expect(addSubscriberToMailchimp).toHaveBeenCalledWith("John", "Doe", "test@example.com", "1234567890");
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith({ message: "Signup successful" });
});

test("should return 400 if email is already registered", async () => {
(addSubscriberToMailchimp as jest.Mock).mockRejectedValueOnce(new Error("is already a list member"));

mockReq.body = {
firstName: "John",
lastName: "Doe",
email: "test@example.com",
phone: "1234567890",
};

await submitSignupForm(mockReq as Request, mockRes as Response);

expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
error: `The email "test@example.com" is already registered for My Career NJ updates. If you believe this is an error or need assistance, please contact support.`,
});
});

test("should return 400 for an invalid email", async () => {
(addSubscriberToMailchimp as jest.Mock).mockRejectedValueOnce(new Error("invalid email"));

mockReq.body = {
firstName: "John",
lastName: "Doe",
email: "invalid-email",
phone: "1234567890",
};

await submitSignupForm(mockReq as Request, mockRes as Response);

expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
error: `The email address "invalid-email" is not valid. Please check for typos and try again.`,
});
});

test("should return 400 for an invalid phone number", async () => {
(addSubscriberToMailchimp as jest.Mock).mockRejectedValueOnce(new Error("phone number invalid"));

mockReq.body = {
firstName: "John",
lastName: "Doe",
email: "test@example.com",
phone: "123",
};

await submitSignupForm(mockReq as Request, mockRes as Response);

expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
error: `The phone number "123" is not valid. Please enter a US phone number in the format XXX-XXX-XXXX.`,
});
});

test("should return 400 for an unexpected error", async () => {
(addSubscriberToMailchimp as jest.Mock).mockRejectedValueOnce(new Error("Some unexpected error"));

mockReq.body = {
firstName: "John",
lastName: "Doe",
email: "test@example.com",
phone: "1234567890",
};

await submitSignupForm(mockReq as Request, mockRes as Response);

expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
error: "An unexpected error occurred. Please try again later.",
});
});
});
27 changes: 27 additions & 0 deletions backend/src/controllers/signUpController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Request, Response } from "express";
import { addSubscriberToMailchimp } from "../mailchimp/mailchimpAPI";

export const submitSignupForm = async (req: Request, res: Response) => {
const { firstName, lastName, email, phone } = req.body;

try {
await addSubscriberToMailchimp(firstName, lastName, email, phone);
return res.status(200).json({ message: "Signup successful" });
} catch (error: unknown) {
let errorMessage = "An unexpected error occurred. Please try again later.";

if (error instanceof Error) {
const errorMsg = error.message.toLowerCase();

if (errorMsg.includes("is already a list member")) {
errorMessage = `The email "${email}" is already registered for My Career NJ updates. If you believe this is an error or need assistance, please contact support.`;
} else if (errorMsg.includes("invalid email")) {
errorMessage = `The email address "${email}" is not valid. Please check for typos and try again.`;
} else if (errorMsg.includes("phone number invalid")) {
errorMessage = `The phone number "${phone}" is not valid. Please enter a US phone number in the format XXX-XXX-XXXX.`;
}
}

return res.status(400).json({ error: errorMessage });
}
};
93 changes: 93 additions & 0 deletions backend/src/helpers/emailValidator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { isValidEmail } from '../helpers/emailValidator';
import dns from 'dns';

jest.mock('dns');

const mockedDns = dns as jest.Mocked<typeof dns>;

describe('isValidEmail Function (Full and Root Domain Validation)', () => {

afterEach(() => {
jest.resetAllMocks();
});

test('should return false for invalid email formats', async () => {
const invalidEmails = [
'',
'plainaddress',
'@missingusername.com',
'username@.nodomain',
'username@domain.c',
'username@domain_with_invalid_characters.com',
'toolongusername'.repeat(5) + '@example.com',
];

for (const email of invalidEmails) {
const result = await isValidEmail(email);
expect(result).toBe(false);
}
});

test('should return false for an invalid email format', async () => {
const result = await isValidEmail('invalid-email');
expect(result).toBe(false);
});

test('should return true for a valid email with MX records on the full domain', async () => {
mockedDns.resolveMx.mockImplementation((domain, callback) => {
if (domain === 'gmail.com') {
callback(null, [{ exchange: 'mail.google.com', priority: 10 }]);
} else {
callback(null, []);
}
});

const result = await isValidEmail('test@gmail.com');
expect(result).toBe(true);
});

test('should return true for a valid email where MX records exist on the root domain', async () => {
mockedDns.resolveMx.mockImplementation((domain, callback) => {
if (domain === 'subdomain.example.com') {
callback(null, []);
} else if (domain === 'example.com') {
callback(null, [{ exchange: 'mail.example.com', priority: 20 }]);
}
});

const result = await isValidEmail('test@subdomain.example.com');
expect(result).toBe(true);
});

test('should return false when neither the full nor root domain has MX records', async () => {
mockedDns.resolveMx.mockImplementation((domain, callback) => {
callback(null, []);
});

const result = await isValidEmail('test@nodns.com');
expect(result).toBe(false);
});

test('should return false when an error occurs during full domain lookup and no MX records exist on root', async () => {
mockedDns.resolveMx.mockImplementation((domain, callback) => {
if (domain === 'subdomain.fakeexample.com') {
callback(new Error('Domain not found'), []);
} else {
callback(null, []);
}
});

const result = await isValidEmail('test@subdomain.fakeexample.com');
expect(result).toBe(false);
});

test('should return false when an error occurs during both full and root domain lookups', async () => {
mockedDns.resolveMx.mockImplementation((domain, callback) => {
callback(new Error('Domain not found'), []);
});

const result = await isValidEmail('test@error.com');
expect(result).toBe(false);
});

});
76 changes: 76 additions & 0 deletions backend/src/helpers/emailValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import dns from 'dns';

const emailTester = /^[-!#$%&'*+/0-9=?A-Z^_a-z{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;

const validateEmailFormat = (email: string): boolean => {
if (!email || email.length > 254) {
return false;
}

const valid = emailTester.test(email);
if (!valid) {
return false;
}

const parts = email.split("@");
if (parts[0].length > 64) {
return false;
}

const domainParts = parts[1].split(".");
if (domainParts.some((part) => part.length > 63)) {
return false;
}

return true;
};

export const isValidEmail = async (email: string): Promise<boolean> => {
if (!validateEmailFormat(email)) {
console.log("Invalid email format");
return false;
}

try {
const isValidDomain = await validateDomainMX(email);
return isValidDomain;
} catch (error) {
console.error("Error during MX record validation:", error);
return false;
}
};

const validateDomainMX = (email: string): Promise<boolean> => {
const domain = email.split('@')[1];
const domainParts = domain.split('.');
const rootDomain = domainParts.length > 2 ? domainParts.slice(-2).join('.') : domain;

return new Promise((resolve) => {
// Trying full domain first
dns.resolveMx(domain, (err, addresses) => {
if (err) {
console.error("DNS error while resolving MX for domain", domain, ":", err.message);
}
if (!err && addresses.length > 0) {
console.log("Valid MX records found for", domain, ":", addresses);
return resolve(true);
}

// Fallback to root domain if the full domain fails
dns.resolveMx(rootDomain, (rootErr, rootAddresses) => {
if (rootErr) {
console.error("DNS error while resolving MX for root domain", rootDomain, ":", rootErr.message);
}
if (!rootErr && rootAddresses.length > 0) {
console.log("Valid MX records found for", rootDomain, ":", rootAddresses);
resolve(true);
} else {
console.log("Invalid domain or no MX records for:", domain);
resolve(false);
}
});
});
});
};


49 changes: 49 additions & 0 deletions backend/src/helpers/nameValidator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { isValidName } from "./nameValidator";

describe("isValidName", () => {
test("should return true for a valid name", () => {
expect(isValidName("John")).toBe(true);
expect(isValidName("Alice Smith")).toBe(true);
expect(isValidName("O'Connor")).toBe(true);
expect(isValidName("Jean-Luc")).toBe(true);
});

test("should return false for names exceeding 50 characters", () => {
expect(isValidName("A".repeat(51))).toBe(false);
});

test("should return false for names with numbers", () => {
expect(isValidName("John123")).toBe(false);
});

test("should return false for names with special characters", () => {
expect(isValidName("John@Doe")).toBe(false);
expect(isValidName("Jane#Doe")).toBe(false);
expect(isValidName("John$Doe")).toBe(false);
});

test("should return false for names with underscores", () => {
expect(isValidName("John_Doe")).toBe(false);
});

test("should return false for empty string", () => {
expect(isValidName("")).toBe(false);
});

test("should return false for null or undefined", () => {
expect(isValidName(null as unknown as string)).toBe(false);
expect(isValidName(undefined as unknown as string)).toBe(false);
});

test("should return false for non-string inputs", () => {
expect(isValidName(123 as unknown as string)).toBe(false);
expect(isValidName({} as unknown as string)).toBe(false);
expect(isValidName([] as unknown as string)).toBe(false);
});

test("should allow valid names with spaces, hyphens, and apostrophes", () => {
expect(isValidName("Anne-Marie")).toBe(true);
expect(isValidName("D'Angelo")).toBe(true);
expect(isValidName("Mary Jane")).toBe(true);
});
});
Loading
Loading