feat(auth): add an RFC complient validation function for an email address; cover it with tests

This commit is contained in:
Ilia Mashkov
2026-03-25 10:18:55 +03:00
parent 6751c7fc04
commit 5b49398665
5 changed files with 139 additions and 4 deletions

View File

@@ -1,2 +1,4 @@
export { useAuth } from "./hooks/useAuth/useAuth"; export { useAuth } from "./hooks/useAuth/useAuth";
export { AuthGuard } from "./hocs/AuthGuard/AuthGuard"; export { AuthGuard } from "./hocs/AuthGuard/AuthGuard";
export * from "./validators";

View File

@@ -0,0 +1 @@
export { validateEmail } from "./validateEmail/validateEmail";

View File

@@ -0,0 +1,101 @@
import {
MAX_DOMAIN_EXTENSION_LENGTH,
MAX_DOMAIN_LABEL_LENGTH,
MAX_DOMAIN_LENGTH,
MAX_LOCAL_PART_LENGTH,
validateEmail,
} from "./validateEmail";
describe("validateEmail", () => {
describe("Absence of some parts of an email", () => {
it("should return false if there's no separator (@ symbol)", () => {
expect(validateEmail("testexample.com")).toBe(false);
});
it("should return false for an email without a domain extension", () => {
expect(validateEmail("test@example")).toBe(false);
});
it("should return false for an email without a domain", () => {
expect(validateEmail("test@")).toBe(false);
});
it("should return false for an email with no local part", () => {
expect(validateEmail("@example.com")).toBe(false);
});
});
describe("Excessive amount of some parts of an email", () => {
it("should return false for an email with multiple separators", () => {
expect(validateEmail("test@example@com")).toBe(false);
});
});
describe("Local part", () => {
it("should return false for consecutive dots in local part (unquoted)", () => {
expect(validateEmail("john..doe@example.com")).toBe(false);
});
it("should return true for consecutive dots inside a quoted string", () => {
expect(validateEmail('"john..doe"@example.com')).toBe(true);
});
it("should return false for leading/trailing dots in local part", () => {
expect(validateEmail(".john@example.com")).toBe(false);
expect(validateEmail("john.@example.com")).toBe(false);
});
it("should return true for special characters in unquoted local part", () => {
expect(validateEmail("!#$%&'*+-/=?^_`{|}~@example.com")).toBe(true);
});
it("should return true for escaped characters and spaces inside quotes", () => {
expect(validateEmail('"John Doe"@example.com')).toBe(true);
expect(validateEmail('"John\\"Doe"@example.com')).toBe(true);
});
it("should return false if the local part is too long", () => {
const longLocalPart = "a".repeat(MAX_LOCAL_PART_LENGTH + 1);
expect(validateEmail(longLocalPart + "@example.com")).toBe(false);
});
});
describe("Domain", () => {
it("should return true for a domain starting with a digit", () => {
expect(validateEmail("test@123example.com")).toBe(true);
});
it("should return false for a domain label ending with a hyphen", () => {
expect(validateEmail("test@example-.com")).toBe(false);
});
it("should return false for a domain label starting with a hyphen", () => {
expect(validateEmail("test@-example.com")).toBe(false);
});
it("should return false if a middle label exceeds maximum allowed characters", () => {
const longLabel = "a".repeat(MAX_DOMAIN_LABEL_LENGTH + 1);
expect(validateEmail(`test@${longLabel}.com`)).toBe(false);
});
it("should return true for complex subdomains", () => {
expect(validateEmail("test@sub.sub-label.example.com")).toBe(true);
});
it("should return false if the domain is too long", () => {
const longDomain = "a".repeat(MAX_DOMAIN_LENGTH + 1);
expect(validateEmail(`test@${longDomain}.com`)).toBe(false);
});
it("should return false if the domain extension is too long", () => {
const longExtension = "a".repeat(MAX_DOMAIN_EXTENSION_LENGTH + 1);
expect(validateEmail(`test@example.${longExtension}`)).toBe(false);
});
});
describe("Valid emails", () => {
it("should return true for a valid email", () => {
expect(validateEmail("test@example.com")).toBe(true);
});
});
});

View File

@@ -0,0 +1,34 @@
export const MAX_LOCAL_PART_LENGTH = 63;
export const MAX_DOMAIN_LENGTH = 255;
export const MAX_DOMAIN_EXTENSION_LENGTH = 63;
export const MAX_DOMAIN_LABEL_LENGTH = 63;
const MAX_DOMAIN_LABEL_LENGTH_WITHOUT_START_AND_END_CHARS = Math.max(
MAX_DOMAIN_LABEL_LENGTH - 2,
0,
);
// RFC 5322 compliant email regex
export const EMAIL_REGEX = new RegExp(
"^" +
`(?=[^@]{1,${MAX_LOCAL_PART_LENGTH}}@)` +
"(?:(?:" +
'"(?:[^"\\\\]|\\\\.)+"' + // Quoted string support
")|(?:" +
"[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+" +
"(?:\\.[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+)*" + // Dot restrictions
"))" +
"@" +
`(?=.{1,${MAX_DOMAIN_LENGTH}}$)` +
`(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,${MAX_DOMAIN_LABEL_LENGTH_WITHOUT_START_AND_END_CHARS}}[a-zA-Z0-9])?\\.)+` +
`[a-zA-Z]{2,${MAX_DOMAIN_EXTENSION_LENGTH}}` +
"$",
);
/**
* Validates an email address using a regular expression.
* @param email The email address to validate.
* @returns `true` if the email is valid, `false` otherwise.
*/
export function validateEmail(email: string): boolean {
return EMAIL_REGEX.test(email);
}

View File

@@ -4,6 +4,7 @@ import { login, logout, register } from "../../../api";
import { callApi } from "shared/utils"; import { callApi } from "shared/utils";
import { UNEXPECTED_ERROR_MESSAGE } from "shared/api"; import { UNEXPECTED_ERROR_MESSAGE } from "shared/api";
import { selectAuthData, selectFormValid } from "../../selectors"; import { selectAuthData, selectFormValid } from "../../selectors";
import { validateEmail } from "../../../lib";
export const defaultStoreState: Readonly<AuthStoreState> = { export const defaultStoreState: Readonly<AuthStoreState> = {
formData: { formData: {
@@ -15,10 +16,6 @@ export const defaultStoreState: Readonly<AuthStoreState> = {
error: null, error: null,
}; };
function validateEmail(email: string): boolean {
return Boolean(email);
}
function validatePassword(password: string): boolean { function validatePassword(password: string): boolean {
return Boolean(password); return Boolean(password);
} }