feat(validation): Implement form validation and error handling utilities

- Add validation rules for form fields with XSS protection
- Create API error handler to parse and manage server validation errors
- Integrate validation rules into contact and inquiries forms
- Enhance error handling with user feedback for form submissions
- Introduce unit tests for validation and error handling functionalities
This commit is contained in:
AmirReza Jamali
2026-02-08 13:35:07 +03:30
parent 5fc6469752
commit 7b6cd4d965
9 changed files with 4834 additions and 108 deletions
+210
View File
@@ -0,0 +1,210 @@
import { AxiosError } from "axios";
import { handleApiError, parseApiError } from "../apiErrorHandler";
describe("API Error Handler", () => {
describe("parseApiError", () => {
it("should return default message for non-Error objects", () => {
const result = parseApiError("string error");
expect(result.generalMessage).toBe("Something went wrong. Please try again.");
expect(result.fieldErrors).toEqual({});
});
it("should handle network errors (no response)", () => {
const error = new Error("Network Error") as AxiosError;
error.isAxiosError = true;
(error as AxiosError).response = undefined;
const result = parseApiError(error);
expect(result.generalMessage).toContain("Unable to connect");
});
it("should parse 400 errors with field-level errors", () => {
const error = new Error("Bad Request") as AxiosError;
error.isAxiosError = true;
(error as AxiosError).response = {
status: 400,
data: {
errors: {
email: "Invalid email format",
full_name: ["Name is too short", "Name contains invalid characters"],
},
},
statusText: "Bad Request",
headers: {},
config: {} as any,
};
const result = parseApiError(error);
expect(result.generalMessage).toContain("Validation error");
expect(result.fieldErrors.email).toBe("Invalid email format");
expect(result.fieldErrors.full_name).toContain("Name is too short");
});
it("should parse 400 errors with message only", () => {
const error = new Error("Bad Request") as AxiosError;
error.isAxiosError = true;
(error as AxiosError).response = {
status: 400,
data: {
message: "Custom validation error message",
},
statusText: "Bad Request",
headers: {},
config: {} as any,
};
const result = parseApiError(error);
expect(result.generalMessage).toBe("Custom validation error message");
});
it("should handle 401 unauthorized errors", () => {
const error = new Error("Unauthorized") as AxiosError;
error.isAxiosError = true;
(error as AxiosError).response = {
status: 401,
data: {},
statusText: "Unauthorized",
headers: {},
config: {} as any,
};
const result = parseApiError(error);
expect(result.generalMessage).toContain("Authentication required");
});
it("should handle 422 unprocessable entity errors", () => {
const error = new Error("Unprocessable Entity") as AxiosError;
error.isAxiosError = true;
(error as AxiosError).response = {
status: 422,
data: {
errors: {
phone: "invalid_phone",
},
},
statusText: "Unprocessable Entity",
headers: {},
config: {} as any,
};
const result = parseApiError(error);
expect(result.generalMessage).toContain("Validation error");
expect(result.fieldErrors.phone).toContain("phone number");
});
it("should handle 429 rate limit errors", () => {
const error = new Error("Too Many Requests") as AxiosError;
error.isAxiosError = true;
(error as AxiosError).response = {
status: 429,
data: {},
statusText: "Too Many Requests",
headers: {},
config: {} as any,
};
const result = parseApiError(error);
expect(result.generalMessage).toContain("Too many requests");
});
it("should handle 500 server errors", () => {
const error = new Error("Internal Server Error") as AxiosError;
error.isAxiosError = true;
(error as AxiosError).response = {
status: 500,
data: {},
statusText: "Internal Server Error",
headers: {},
config: {} as any,
};
const result = parseApiError(error);
expect(result.generalMessage).toContain("Server error");
});
});
describe("handleApiError", () => {
it("should call setError for each field error", () => {
const mockSetError = jest.fn();
const mockShowToast = jest.fn();
const error = new Error("Bad Request") as AxiosError;
error.isAxiosError = true;
(error as AxiosError).response = {
status: 400,
data: {
errors: {
email: "Invalid email",
phone: "Invalid phone",
},
},
statusText: "Bad Request",
headers: {},
config: {} as any,
};
handleApiError(error, mockSetError, mockShowToast, ["email", "phone"]);
expect(mockSetError).toHaveBeenCalledTimes(2);
expect(mockSetError).toHaveBeenCalledWith("email", {
type: "server",
message: "Invalid email",
});
expect(mockSetError).toHaveBeenCalledWith("phone", {
type: "server",
message: "Invalid phone",
});
expect(mockShowToast).toHaveBeenCalledWith(
"Please correct the errors in the highlighted fields."
);
});
it("should only set errors for fields in formFields list", () => {
const mockSetError = jest.fn();
const mockShowToast = jest.fn();
const error = new Error("Bad Request") as AxiosError;
error.isAxiosError = true;
(error as AxiosError).response = {
status: 400,
data: {
errors: {
email: "Invalid email",
unknown_field: "Unknown error",
},
},
statusText: "Bad Request",
headers: {},
config: {} as any,
};
handleApiError(error, mockSetError, mockShowToast, ["email"]);
expect(mockSetError).toHaveBeenCalledTimes(1);
expect(mockSetError).toHaveBeenCalledWith("email", {
type: "server",
message: "Invalid email",
});
});
it("should show general message when no field errors", () => {
const mockSetError = jest.fn();
const mockShowToast = jest.fn();
const error = new Error("Internal Server Error") as AxiosError;
error.isAxiosError = true;
(error as AxiosError).response = {
status: 500,
data: {},
statusText: "Internal Server Error",
headers: {},
config: {} as any,
};
handleApiError(error, mockSetError, mockShowToast, ["email"]);
expect(mockSetError).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining("Server error"));
});
});
});
+121
View File
@@ -0,0 +1,121 @@
import {
containsDangerousContent,
createFieldValidation,
createXSSValidator,
validationRules,
} from "../validation";
describe("Validation Functions", () => {
describe("containsDangerousContent", () => {
it("should detect script tags", () => {
expect(containsDangerousContent("<script>alert('XSS')</script>")).toBe(true);
expect(containsDangerousContent("<SCRIPT>alert('XSS')</SCRIPT>")).toBe(true);
});
it("should detect javascript: protocol", () => {
expect(containsDangerousContent("javascript:alert('XSS')")).toBe(true);
expect(containsDangerousContent("JAVASCRIPT:alert('XSS')")).toBe(true);
});
it("should detect event handlers", () => {
expect(containsDangerousContent("onclick=alert('XSS')")).toBe(true);
expect(containsDangerousContent("onerror=alert('XSS')")).toBe(true);
expect(containsDangerousContent("onload=alert('XSS')")).toBe(true);
});
it("should detect iframe tags", () => {
expect(containsDangerousContent("<iframe src='evil.com'>")).toBe(true);
});
it("should detect data: text/html", () => {
expect(containsDangerousContent("data: text/html,<script>")).toBe(true);
});
it("should return false for safe content", () => {
expect(containsDangerousContent("Hello World")).toBe(false);
expect(containsDangerousContent("john.doe@example.com")).toBe(false);
expect(containsDangerousContent("+1234567890")).toBe(false);
expect(containsDangerousContent("This is a normal message.")).toBe(false);
});
it("should handle empty or null input", () => {
expect(containsDangerousContent("")).toBe(false);
});
});
describe("createXSSValidator", () => {
it("should return error message for dangerous content", () => {
const validator = createXSSValidator("Full Name");
expect(validator("<script>alert('XSS')</script>")).toContain("Full Name");
expect(validator("<script>alert('XSS')</script>")).toContain("invalid");
});
it("should return true for safe content", () => {
const validator = createXSSValidator("Full Name");
expect(validator("John Doe")).toBe(true);
expect(validator("Jane Smith")).toBe(true);
});
});
describe("createFieldValidation", () => {
it("should create validation with XSS check", () => {
const validation = createFieldValidation("Email", true);
expect(validation).toHaveProperty("validate.noXSS");
expect(validation).toHaveProperty("required");
});
it("should include additional rules", () => {
const validation = createFieldValidation("Phone", false, {
pattern: { value: /^[+\d]+$/, message: "Invalid phone" },
});
expect(validation).toHaveProperty("pattern");
expect(validation).toHaveProperty("validate.noXSS");
});
});
describe("validationRules", () => {
describe("fullName rules", () => {
it("should have required validation", () => {
expect(validationRules.fullName.required.value).toBe(true);
});
it("should have min/max length", () => {
expect(validationRules.fullName.minLength.value).toBe(2);
expect(validationRules.fullName.maxLength.value).toBe(100);
});
it("should have XSS validation", () => {
expect(validationRules.fullName.validate.noXSS).toBeDefined();
});
});
describe("email rules", () => {
it("should have pattern validation", () => {
expect(validationRules.email.pattern.value).toBeDefined();
});
it("should have XSS validation", () => {
expect(validationRules.email.validate.noXSS).toBeDefined();
});
});
describe("phone rules", () => {
it("should have pattern validation for phone format", () => {
const pattern = validationRules.phone.pattern.value;
expect(pattern.test("+1234567890")).toBe(true);
expect(pattern.test("(123) 456-7890")).toBe(true);
expect(pattern.test("abc123")).toBe(false);
});
});
describe("message rules", () => {
it("should have XSS validation", () => {
expect(validationRules.message.validate.noXSS).toBeDefined();
});
it("should have max length of 1000", () => {
expect(validationRules.message.maxLength.value).toBe(1000);
});
});
});
});
+184
View File
@@ -0,0 +1,184 @@
/**
* API Error Handler Utility
* Parses server error responses and extracts field-level validation errors
*/
import { AxiosError } from "axios";
import { FieldValues, Path, UseFormSetError } from "react-hook-form";
/**
* Standard API error response structure
*/
export interface ApiErrorResponse {
message?: string;
errors?: Record<string, string | string[]>;
error?: string;
detail?: string | Record<string, string[]>;
}
/**
* Parsed error result
*/
export interface ParsedApiError {
generalMessage: string;
fieldErrors: Record<string, string>;
}
/**
* Field name mapping for common API field names to form field names
*/
const FIELD_NAME_MAP: Record<string, string> = {
fullName: "full_name",
full_name: "full_name",
email: "email",
phone: "phone",
phoneNumber: "phone_number",
phone_number: "phone_number",
message: "message",
companyName: "company_name",
company_name: "company_name",
workEmail: "work_email",
work_email: "work_email",
};
/**
* Common error messages for validation types
*/
const ERROR_MESSAGES: Record<string, string> = {
invalid_input: "Invalid input detected. Please remove any special characters or scripts.",
xss_detected: "Invalid content detected. Please remove any HTML tags or scripts.",
validation_failed: "Validation failed. Please check your input.",
required: "This field is required.",
invalid_email: "Please enter a valid email address.",
invalid_phone: "Please enter a valid phone number.",
too_short: "Input is too short.",
too_long: "Input is too long.",
};
/**
* Normalizes field name from API to form field name
*/
function normalizeFieldName(fieldName: string): string {
return FIELD_NAME_MAP[fieldName] || fieldName;
}
/**
* Extracts a human-readable error message from various error formats
*/
function extractErrorMessage(error: string | string[] | unknown): string {
if (typeof error === "string") {
return ERROR_MESSAGES[error] || error;
}
if (Array.isArray(error)) {
return error.map((e) => ERROR_MESSAGES[e] || e).join(". ");
}
return "Invalid input";
}
/**
* Parses API error response and extracts field-level errors
* @param error - The axios error object
* @returns Parsed error with general message and field-specific errors
*/
export function parseApiError(error: unknown): ParsedApiError {
const result: ParsedApiError = {
generalMessage: "Something went wrong. Please try again.",
fieldErrors: {},
};
if (!(error instanceof Error)) {
return result;
}
const axiosError = error as AxiosError<ApiErrorResponse>;
if (!axiosError.response) {
// Network error
result.generalMessage = "Unable to connect to server. Please check your connection.";
return result;
}
const { status, data } = axiosError.response;
// Handle different HTTP status codes
if (status === 400) {
result.generalMessage = "Validation error. Please check the highlighted fields.";
// Extract field errors from different response formats
if (data?.errors) {
// Format: { errors: { field1: "error", field2: ["error1", "error2"] } }
Object.entries(data.errors).forEach(([field, error]) => {
const normalizedField = normalizeFieldName(field);
result.fieldErrors[normalizedField] = extractErrorMessage(error);
});
} else if (data?.detail && typeof data.detail === "object") {
// Django REST framework format: { detail: { field1: ["error"] } }
Object.entries(data.detail).forEach(([field, errors]) => {
const normalizedField = normalizeFieldName(field);
result.fieldErrors[normalizedField] = extractErrorMessage(errors);
});
} else if (data?.message) {
result.generalMessage = data.message;
} else if (data?.error) {
result.generalMessage = data.error;
}
} else if (status === 401) {
result.generalMessage = "Authentication required. Please log in and try again.";
} else if (status === 403) {
result.generalMessage = "You don't have permission to perform this action.";
} else if (status === 404) {
result.generalMessage = "The requested resource was not found.";
} else if (status === 422) {
// Unprocessable Entity - validation error
result.generalMessage = "Validation error. Please check the highlighted fields.";
if (data?.errors) {
Object.entries(data.errors).forEach(([field, error]) => {
const normalizedField = normalizeFieldName(field);
result.fieldErrors[normalizedField] = extractErrorMessage(error);
});
}
} else if (status === 429) {
result.generalMessage = "Too many requests. Please wait a moment and try again.";
} else if (status >= 500) {
result.generalMessage = "Server error. Please try again later.";
}
return result;
}
/**
* Handles API error and sets form field errors using react-hook-form
* @param error - The error from API call
* @param setError - react-hook-form setError function
* @param showToast - Function to show toast notification
* @param formFields - Optional list of form field names to validate against
*/
export function handleApiError<T extends FieldValues>(
error: unknown,
setError: UseFormSetError<T>,
showToast: (message: string) => void,
formFields?: string[]
): void {
const parsedError = parseApiError(error);
// Set field-specific errors
let hasFieldErrors = false;
Object.entries(parsedError.fieldErrors).forEach(([field, message]) => {
// Only set error if field exists in form (or if formFields not specified)
if (!formFields || formFields.includes(field)) {
setError(field as Path<T>, {
type: "server",
message,
});
hasFieldErrors = true;
}
});
// Show toast with general message
// If we have field errors, use a more specific message
if (hasFieldErrors) {
showToast("Please correct the errors in the highlighted fields.");
} else {
showToast(parsedError.generalMessage);
}
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Validation utilities for form fields
* Detects potentially harmful content (XSS patterns) and provides validation rules
*/
// Patterns that indicate potentially malicious content
const DANGEROUS_PATTERNS = [
/<script[\s\S]*?>/i,
/<\/script>/i,
/javascript:/i,
/vbscript:/i,
/on\w+\s*=/i, // onclick=, onerror=, onload=, etc.
/<iframe[\s\S]*?>/i,
/<object[\s\S]*?>/i,
/<embed[\s\S]*?>/i,
/<link[\s\S]*?>/i,
/<meta[\s\S]*?>/i,
/data:\s*text\/html/i,
/expression\s*\(/i, // CSS expression
/url\s*\(\s*["']?\s*javascript:/i,
];
/**
* Checks if text contains potentially dangerous patterns (XSS)
* @param text - The text to check
* @returns true if dangerous patterns are found
*/
export function containsDangerousContent(text: string): boolean {
if (!text) return false;
return DANGEROUS_PATTERNS.some((pattern) => pattern.test(text));
}
/**
* Validation function for react-hook-form that checks for XSS patterns
* @param fieldName - The human-readable name of the field for error messages
* @returns Validation function for react-hook-form
*/
export function createXSSValidator(fieldName: string) {
return (value: string) => {
if (containsDangerousContent(value)) {
return `${fieldName} contains invalid characters or content. Please remove any special HTML tags or scripts.`;
}
return true;
};
}
/**
* Standard validation rules with XSS protection for common field types
*/
export const validationRules = {
fullName: {
required: {
value: true,
message: "Full name is required",
},
minLength: {
value: 2,
message: "Full name must be at least 2 characters",
},
maxLength: {
value: 100,
message: "Full name must not exceed 100 characters",
},
validate: {
noXSS: createXSSValidator("Full name"),
},
},
email: {
required: {
value: true,
message: "Email is required",
},
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "Please enter a valid email address",
},
validate: {
noXSS: createXSSValidator("Email"),
},
},
phone: {
required: {
value: true,
message: "Phone number is required",
},
minLength: {
value: 10,
message: "Phone number must be at least 10 characters",
},
maxLength: {
value: 20,
message: "Phone number must not exceed 20 characters",
},
pattern: {
value: /^[+\d\s()-]+$/,
message: "Please enter a valid phone number (digits, +, spaces, parentheses, and dashes only)",
},
},
message: {
required: {
value: true,
message: "Message is required",
},
minLength: {
value: 10,
message: "Message must be at least 10 characters",
},
maxLength: {
value: 1000,
message: "Message must not exceed 1000 characters",
},
validate: {
noXSS: createXSSValidator("Message"),
},
},
companyName: {
maxLength: {
value: 200,
message: "Company name must not exceed 200 characters",
},
validate: {
noXSS: createXSSValidator("Company name"),
},
},
};
/**
* Creates validation rules for a field with optional XSS check
* @param fieldName - Human-readable field name
* @param isRequired - Whether the field is required
* @param additionalRules - Additional validation rules to merge
*/
export function createFieldValidation(
fieldName: string,
isRequired: boolean = false,
additionalRules: Record<string, unknown> = {}
) {
const rules: Record<string, unknown> = {
validate: {
noXSS: createXSSValidator(fieldName),
},
...additionalRules,
};
if (isRequired) {
rules.required = `${fieldName} is required`;
}
return rules;
}