diff --git a/src/features/auth/lib/index.ts b/src/features/auth/lib/index.ts index c80285f..f8dbaa0 100644 --- a/src/features/auth/lib/index.ts +++ b/src/features/auth/lib/index.ts @@ -1,2 +1,4 @@ export { useAuth } from "./hooks/useAuth/useAuth"; export { AuthGuard } from "./hocs/AuthGuard/AuthGuard"; + +export * from "./validators"; diff --git a/src/features/auth/lib/validators/index.ts b/src/features/auth/lib/validators/index.ts new file mode 100644 index 0000000..beaa628 --- /dev/null +++ b/src/features/auth/lib/validators/index.ts @@ -0,0 +1 @@ +export { validateEmail } from "./validateEmail/validateEmail"; diff --git a/src/features/auth/lib/validators/validateEmail/validateEmail.spec.ts b/src/features/auth/lib/validators/validateEmail/validateEmail.spec.ts new file mode 100644 index 0000000..67ffc33 --- /dev/null +++ b/src/features/auth/lib/validators/validateEmail/validateEmail.spec.ts @@ -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); + }); + }); +}); diff --git a/src/features/auth/lib/validators/validateEmail/validateEmail.ts b/src/features/auth/lib/validators/validateEmail/validateEmail.ts new file mode 100644 index 0000000..07a6534 --- /dev/null +++ b/src/features/auth/lib/validators/validateEmail/validateEmail.ts @@ -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); +} diff --git a/src/features/auth/model/stores/authStore/authStore.ts b/src/features/auth/model/stores/authStore/authStore.ts index 152ee4e..84300d6 100644 --- a/src/features/auth/model/stores/authStore/authStore.ts +++ b/src/features/auth/model/stores/authStore/authStore.ts @@ -4,6 +4,7 @@ import { login, logout, register } from "../../../api"; import { callApi } from "shared/utils"; import { UNEXPECTED_ERROR_MESSAGE } from "shared/api"; import { selectAuthData, selectFormValid } from "../../selectors"; +import { validateEmail } from "../../../lib"; export const defaultStoreState: Readonly = { formData: { @@ -15,10 +16,6 @@ export const defaultStoreState: Readonly = { error: null, }; -function validateEmail(email: string): boolean { - return Boolean(email); -} - function validatePassword(password: string): boolean { return Boolean(password); }