Source: validator.js

/**
 * @fileoverview Module de validation robuste avec gestion d'erreurs stricte
 * @version 1.0.0
 * @author Projet TDD - Ynov
 * @license ISC
 */

/**
 * Classe d'erreur personnalisée pour les validations.
 * Cette classe étend Error et ajoute un code d'erreur pour une meilleure gestion des erreurs.
 * 
 * @class ValidationError
 * @extends Error
 * 
 * @property {string} name - Nom de l'erreur (toujours 'ValidationError')
 * @property {string} code - Code d'erreur unique (ex: 'INVALID_DATE', 'XSS_DETECTED')
 * @property {string} message - Message descriptif de l'erreur
 * 
 * @example
 * throw new ValidationError('INVALID_DATE', 'La date est invalide');
 */
class ValidationError extends Error {
  /**
   * Crée une nouvelle erreur de validation
   * @param {string} code - Code d'erreur unique
   * @param {string} message - Message descriptif de l'erreur
   */
  constructor(code, message) {
    super(message);
    this.name = 'ValidationError';
    this.code = code;
    Error.captureStackTrace(this, this.constructor);
  }
}

/**
 * Résultat de la validation d'âge
 * @typedef {Object} AgeValidationResult
 * @property {boolean} isValid - Indique si l'âge est valide (toujours true si la fonction ne lève pas d'erreur)
 * @property {number} age - Âge calculé en années complètes
 * @property {Date} birthDate - Date de naissance validée
 */

/**
 * Valide l'âge d'une personne à partir de sa date de naissance.
 * 
 * Cette fonction effectue les vérifications suivantes:
 * - Vérifie que la valeur n'est pas null ou undefined
 * - Vérifie que le format est correct (Date ou string convertible)
 * - Vérifie que la date n'est pas dans le futur
 * - Calcule l'âge précis (gestion des années bissextiles et du 29 février)
 * - Rejette strictement si l'âge est inférieur à 18 ans
 * 
 * @function validateAge
 * @param {Date|string} birthDate - Date de naissance à valider
 * @returns {AgeValidationResult} Objet contenant le résultat de validation
 * @throws {ValidationError} Lance une erreur avec code INVALID_DATE si la date est invalide
 * @throws {ValidationError} Lance une erreur avec code DATE_IN_FUTURE si la date est future
 * @throws {ValidationError} Lance une erreur avec code AGE_BELOW_MINIMUM si l'âge < 18 ans
 * 
 * @example
 * // Cas valide
 * const result = validateAge(new Date('1990-05-15'));
 * console.log(result); // { isValid: true, age: 35, birthDate: Date }
 * 
 * @example
 * // Cas invalide - Mineur
 * try {
 *   validateAge(new Date('2010-01-01'));
 * } catch (error) {
 *   console.log(error.code); // 'AGE_BELOW_MINIMUM'
 * }
 */
function validateAge(birthDate) {
  // Vérification des valeurs nulles/undefined
  if (birthDate === null || birthDate === undefined) {
    throw new ValidationError(
      'INVALID_DATE',
      'La date de naissance ne peut pas être null ou undefined'
    );
  }

  // Vérification du type
  if (typeof birthDate === 'number' || (typeof birthDate === 'string' && birthDate.trim() === '')) {
    throw new ValidationError(
      'INVALID_DATE',
      'Format de date invalide'
    );
  }

  // Vérification des objets vides
  if (typeof birthDate === 'object' && !(birthDate instanceof Date)) {
    throw new ValidationError(
      'INVALID_DATE',
      'La date doit être une instance de Date'
    );
  }

  // Conversion en Date si nécessaire
  let dateObj;
  if (birthDate instanceof Date) {
    dateObj = birthDate;
  } else {
    dateObj = new Date(birthDate);
  }

  // Vérification de validité de la date
  if (isNaN(dateObj.getTime())) {
    throw new ValidationError(
      'INVALID_DATE',
      'Date de naissance invalide'
    );
  }

  // Vérification date future
  const today = new Date();
  if (dateObj > today) {
    throw new ValidationError(
      'DATE_IN_FUTURE',
      'La date de naissance ne peut pas être dans le futur'
    );
  }

  // Calcul précis de l'âge
  const age = calculateAge({ birth: dateObj });

  // Vérification âge minimum
  if (age < 18) {
    throw new ValidationError(
      'AGE_BELOW_MINIMUM',
      `Âge insuffisant: ${age} ans (minimum requis: 18 ans)`
    );
  }

  return {
    isValid: true,
    age: age,
    birthDate: dateObj
  };
}

/**
 * Calculate a person's age in years.
 * 
 * @private
 * @function calculateAge
 * @param {object} p An object representing a person, implementing a birth Date parameter.
 * @return {number} The age in years of p.
 */
function calculateAge(p) {
  let dateDiff = new Date(Date.now() - p.birth.getTime());
  let age = Math.abs(dateDiff.getUTCFullYear() - 1970);
  return age;
}

/**
 * Résultat de la validation de code postal
 * @typedef {Object} PostalCodeValidationResult
 * @property {boolean} isValid - Indique si le code postal est valide (toujours true si la fonction ne lève pas d'erreur)
 * @property {string} postalCode - Code postal validé
 */

/**
 * Valide un code postal français (format: 5 chiffres exacts).
 * 
 * Cette fonction effectue les vérifications suivantes:
 * - Vérifie que la valeur n'est pas null ou undefined
 * - Vérifie que le type est string (rejette les nombres)
 * - Vérifie que la chaîne n'est pas vide
 * - Vérifie le format: exactement 5 chiffres (regex: /^\d{5}$/)
 * 
 * @function validatePostalCode
 * @param {string} postalCode - Code postal à valider (doit être une string)
 * @returns {PostalCodeValidationResult} Objet contenant le résultat de validation
 * @throws {ValidationError} Lance une erreur avec code INVALID_POSTAL_CODE_FORMAT si le format est invalide
 */
function validatePostalCode(postalCode) {
  if (postalCode === null || postalCode === undefined) {
    throw new ValidationError(
      'INVALID_POSTAL_CODE_FORMAT',
      'Le code postal ne peut pas être null ou undefined'
    );
  }

  if (typeof postalCode !== 'string') {
    throw new ValidationError(
      'INVALID_POSTAL_CODE_FORMAT',
      'Le code postal doit être une chaîne de caractères'
    );
  }

  if (postalCode.trim() === '') {
    throw new ValidationError(
      'INVALID_POSTAL_CODE_FORMAT',
      'Le code postal ne peut pas être vide'
    );
  }

  // Format français: exactement 5 chiffres
  const postalCodeRegex = /^\d{5}$/;
  
  if (!postalCodeRegex.test(postalCode)) {
    throw new ValidationError(
      'INVALID_POSTAL_CODE_FORMAT',
      'Le code postal doit contenir exactement 5 chiffres'
    );
  }

  return {
    isValid: true,
    postalCode: postalCode
  };
}

/**
 * Résultat de la validation d'identité
 * @typedef {Object} IdentityValidationResult
 * @property {boolean} isValid - Indique si l'identité est valide (toujours true si la fonction ne lève pas d'erreur)
 * @property {string} identity - Identité validée
 */

/**
 * Valide un nom ou prénom avec protection XSS.
 * 
 * Cette fonction effectue les vérifications suivantes:
 * - Vérifie que la valeur n'est pas null ou undefined
 * - Vérifie que le type est string
 * - Vérifie que la chaîne n'est pas vide ou uniquement des espaces
 * - Détecte les tentatives d'injection XSS (caractères < ou >)
 * - Valide le format: lettres (avec accents), tirets, apostrophes, espaces uniquement
 * - Rejette les chiffres et caractères spéciaux
 * 
 * Caractères acceptés:
 * - Lettres: a-z, A-Z
 * - Accents: À-ÿ, caractères Unicode \u00C0-\u017F
 * - Tirets: -
 * - Apostrophes: '
 * - Espaces
 * 
 * @function validateIdentity
 * @param {string} identity - Nom ou prénom à valider
 * @returns {IdentityValidationResult} Objet contenant le résultat de validation
 * @throws {ValidationError} Lance une erreur avec code XSS_DETECTED si des balises HTML sont détectées
 * @throws {ValidationError} Lance une erreur avec code INVALID_IDENTITY_FORMAT si le format est invalide
 * 
 */
function validateIdentity(identity) {
  if (identity === null || identity === undefined) {
    throw new ValidationError('INVALID_IDENTITY_FORMAT', 'L\'identité ne peut pas être null ou undefined');
  }

  if (typeof identity !== 'string') {
    throw new ValidationError('INVALID_IDENTITY_FORMAT', 'L\'identité doit être une chaîne de caractères');
  }

  if (identity.trim() === '') {
    throw new ValidationError(
      'INVALID_IDENTITY_FORMAT',
      'L\'identité ne peut pas être vide'
    );
  }

  // Protection XSS: détection de balises HTML et caractères < ou >
  const xssRegex = /<|>/;
  if (xssRegex.test(identity)) {
    throw new ValidationError(
      'XSS_DETECTED',
      'Détection de balises HTML: tentative d\'injection potentielle'
    );
  }

  // Format valide: lettres (avec accents), tirets, apostrophes, espaces
  // Rejette: chiffres et caractères spéciaux
  const identityRegex = /^[a-zA-ZÀ-ÿ\u00C0-\u017F\s'-]+$/;
  
  if (!identityRegex.test(identity)) {
    throw new ValidationError(
      'INVALID_IDENTITY_FORMAT',
      'L\'identité ne doit contenir que des lettres, tirets, apostrophes et espaces'
    );
  }

  return {
    isValid: true,
    identity: identity
  };
}

/**
 * Résultat de la validation d'email
 * @typedef {Object} EmailValidationResult
 * @property {boolean} isValid - Indique si l'email est valide (toujours true si la fonction ne lève pas d'erreur)
 * @property {string} email - Email validé
 */

/**
 * Valide une adresse email selon le format standard.
 * 
 * Cette fonction effectue les vérifications suivantes:
 * - Vérifie que la valeur n'est pas null ou undefined
 * - Vérifie que le type est string
 * - Vérifie que la chaîne n'est pas vide ou uniquement des espaces
 * - Valide le format: partie_locale@domaine.extension
 * 
 * Format accepté:
 * - Partie locale: lettres, chiffres, points (.), underscores (_), tirets (-)
 * - Arobase: @ (obligatoire, un seul)
 * - Domaine: lettres, chiffres, points (.), tirets (-)
 * - Extension: au moins 2 lettres
 * 
 * @function validateEmail
 * @param {string} email - Adresse email à valider
 * @returns {EmailValidationResult} Objet contenant le résultat de validation
 * @throws {ValidationError} Lance une erreur avec code INVALID_EMAIL_FORMAT si le format est invalide
 */
function validateEmail(email) {
  if (email === null || email === undefined) {
    throw new ValidationError(
      'INVALID_EMAIL_FORMAT',
      'L\'email ne peut pas être null ou undefined'
    );
  }

  if (typeof email !== 'string') {
    throw new ValidationError(
      'INVALID_EMAIL_FORMAT',
      'L\'email doit être une chaîne de caractères'
    );
  }

  if (email.trim() === '') {
    throw new ValidationError(
      'INVALID_EMAIL_FORMAT',
      'L\'email ne peut pas être vide'
    );
  }

  // Regex pour validation email standard
  const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
  
  if (!emailRegex.test(email)) {
    throw new ValidationError(
      'INVALID_EMAIL_FORMAT',
      'Format d\'email invalide'
    );
  }

  return {
    isValid: true,
    email: email
  };
}

module.exports = {
  ValidationError,
  validateAge,
  validatePostalCode,
  validateIdentity,
  validateEmail
};