'Immédiate', '1_mois' => 'Sous 1 mois', '2_mois' => 'Sous 2 mois', '3_mois' => 'Sous 3 mois', 'date' => 'Date précise', 'a_preciser' => 'À préciser']; const TELETRAVAIL = ['non' => 'Sur site', 'partiel' => 'Télétravail partiel', 'complet' => 'Télétravail complet']; require __DIR__ . '/../src/i18n.php'; require __DIR__ . '/../src/pays.php'; require __DIR__ . '/../src/securite.php'; require __DIR__ . '/../src/Import.php'; require __DIR__ . '/../src/depot.php'; require __DIR__ . '/../src/traduction.php'; require __DIR__ . '/../src/europass.php'; // Copie de src/bootstrap.php, qui ne se charge pas sans configuration function date_fr(?string $date, bool $heure = false): string { return $date ? (new DateTimeImmutable($date))->format($heure ? 'd/m/Y H:i' : 'd/m/Y') : ''; } $ok = 0; $ko = 0; function verifier(string $nom, bool $cond): void { global $ok, $ko; if ($cond) { $ok++; } else { $ko++; echo "ECHEC : $nom\n"; } } // --- Codes à usage unique : vecteurs de la RFC 6238 (SHA-1, clé « 12345678901234567890 »), six derniers chiffres $cle = Totp::base32('12345678901234567890'); verifier('base32 : encodage de la clé de la RFC', $cle === 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'); verifier('base32 : aller-retour', Totp::base32Decoder($cle) === '12345678901234567890'); verifier('base32 : espaces, tirets et minuscules ignorés', Totp::base32Decoder('gezd gnbv-gy3t qojq gezd gnbv gy3t qojq') === '12345678901234567890'); verifier('base32 : caractère interdit', Totp::base32Decoder('GEZ1') === ''); foreach ([59 => '287082', 1111111109 => '081804', 1111111111 => '050471', 1234567890 => '005924', 2000000000 => '279037', 20000000000 => '353130'] as $t => $attendu) { verifier("RFC 6238 : T = $t", Totp::code($cle, Totp::pas($t)) === $attendu); } $t = 1234567890; verifier('vérification : code courant accepté', Totp::verifier($cle, '005924', null, $t) === Totp::pas($t)); verifier('vérification : espaces tolérés', Totp::verifier($cle, '005 924', null, $t) === Totp::pas($t)); verifier('vérification : pas précédent accepté (horloge en retard)', Totp::verifier($cle, Totp::code($cle, Totp::pas($t) - 1), null, $t) === Totp::pas($t) - 1); verifier('vérification : pas suivant accepté (horloge en avance)', Totp::verifier($cle, Totp::code($cle, Totp::pas($t) + 1), null, $t) === Totp::pas($t) + 1); verifier('vérification : deux pas d\'écart refusés', Totp::verifier($cle, Totp::code($cle, Totp::pas($t) - 2), null, $t) === null); verifier('vérification : code rejoué refusé', Totp::verifier($cle, '005924', Totp::pas($t), $t) === null); verifier('vérification : code plus ancien que le dernier refusé', Totp::verifier($cle, Totp::code($cle, Totp::pas($t) - 1), Totp::pas($t), $t) === null); verifier('vérification : longueur fausse', Totp::verifier($cle, '05924', null, $t) === null && Totp::verifier($cle, '', null, $t) === null); $nouvelle = Totp::nouvelleCle(); verifier('nouvelle clé : 32 caractères base32, 160 bits', strlen($nouvelle) === 32 && strlen(Totp::base32Decoder($nouvelle)) === 20); verifier('adresse otpauth', str_starts_with(Totp::adresse($cle, 'yann', 'Vivier'), 'otpauth://totp/Vivier%3Ayann?secret=' . $cle . '&issuer=Vivier')); verifier('clé lisible par groupes de 4', Totp::lisible('ABCDEFGH') === 'ABCD EFGH'); // --- Blocages : 5 échecs = 15 min, 10 = 30 min, 15 = 60 min ; adresse : 20 échecs dans l'heure $maintenant = 1_800_000_000; $serie = static fn (int $n, int $dernier): array => array_map(static fn ($i) => $dernier - ($n - 1 - $i) * 10, range(0, $n - 1)); verifier('blocage : 4 échecs, pas de blocage', fin_blocage_compte($serie(4, $maintenant), $maintenant) === null); verifier('blocage : 5 échecs, 15 minutes', fin_blocage_compte($serie(5, $maintenant), $maintenant) === $maintenant + 900); verifier('blocage : levé après 15 minutes', fin_blocage_compte($serie(5, $maintenant - 901), $maintenant) === null); $m = array_merge($serie(5, $maintenant - 2000), $serie(5, $maintenant)); verifier('blocage : 10 échecs, 30 minutes', fin_blocage_compte($m, $maintenant) === $maintenant + 1800); $m = array_merge($serie(10, $maintenant - 5000), $serie(5, $maintenant)); verifier('blocage : 15 échecs, 60 minutes', fin_blocage_compte($m, $maintenant) === $maintenant + 3600); $m = array_merge($serie(15, $maintenant - 9000), $serie(5, $maintenant)); verifier('blocage : au-delà, 60 minutes', fin_blocage_compte($m, $maintenant) === $maintenant + 3600); verifier('blocage : 6e échec après la fin du premier blocage, pas de nouveau blocage', fin_blocage_compte(array_merge($serie(5, $maintenant - 1000), [$maintenant]), $maintenant) === null); verifier('adresse : 19 échecs dans l\'heure, pas de blocage', fin_blocage_ip($serie(19, $maintenant), $maintenant) === null); $m = $serie(20, $maintenant); verifier('adresse : 20 échecs dans l\'heure, bloquée une heure après le premier', fin_blocage_ip($m, $maintenant) === $m[0] + 3600); verifier('adresse : échecs de plus d\'une heure ignorés', fin_blocage_ip($serie(20, $maintenant - 3700), $maintenant) === null); // --- Réseau, navigateur verifier('réseau IPv4 : /24', reseau_ip('192.168.12.34') === '192.168.12.0/24'); verifier('réseau IPv6 : /48', reseau_ip('2001:db8:abcd:12::1') === '2001:db8:abcd::/48'); verifier('navigateur lisible', navigateur_lisible('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36') === 'Chrome, Windows'); verifier('navigateur lisible : Edge sur Mac', navigateur_lisible('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Chrome/129.0 Safari/537.36 Edg/129.0') === 'Edge, macOS'); // --- Nationalités verifier('nationalités : codes ISO, doublons et inconnus écartés', codes_nationalites('fr, LU ;fr,XX') === ['FR', 'LU']); verifier('nationalités : 4 au plus', count(codes_nationalites(['FR', 'LU', 'BE', 'DE', 'IT'])) === 4); verifier('hors UE : aucune nationalité, inconnu', hors_ue([]) === null); verifier('hors UE : Union européenne', hors_ue(['FR']) === 0); verifier('hors UE : EEE et Suisse comptent comme UE', hors_ue(['NO']) === 0 && hors_ue(['CH']) === 0 && hors_ue(['IS']) === 0 && hors_ue(['LI']) === 0); verifier('hors UE : Maroc', hors_ue(['MA']) === 1); verifier('hors UE : double nationalité dont une européenne', hors_ue(['MA', 'FR']) === 0); verifier('hors UE : Royaume-Uni hors UE', hors_ue(['GB']) === 1); verifier('nom de pays : français et anglais', nom_pays('DE', 'fr') === 'Allemagne' && nom_pays('DE', 'en') === 'Germany'); verifier('liste des pays : 27 pays de l\'UE plus EEE et Suisse', count(PAYS_LIBRE_CIRCULATION) === 31); // --- Disponibilité $auj = new DateTimeImmutable('2026-10-15'); verifier('disponibilité : date française', classer_disponibilite('Disponible le 01/12/2026', $auj) === ['code' => 'date', 'date' => '2026-12-01']); verifier('disponibilité : date ISO', classer_disponibilite('à partir du 2027-01-04', $auj) === ['code' => 'date', 'date' => '2027-01-04']); verifier('disponibilité : immédiate', classer_disponibilite('Immédiate', $auj)['code'] === 'immediate' && classer_disponibilite('Available now', $auj)['code'] === 'immediate'); verifier('disponibilité : préavis de 2 mois', classer_disponibilite('Préavis de 2 mois', $auj) === ['code' => '2_mois', 'date' => '2026-12-15']); verifier('disponibilité : one month notice', classer_disponibilite('One month notice', $auj)['code'] === '1_mois'); verifier('disponibilité : 3 mois', classer_disponibilite('trois mois de préavis', $auj)['code'] === '3_mois'); verifier('disponibilité : 12 mois ne passe pas pour 2 mois', classer_disponibilite('12 mois', $auj)['code'] === 'a_preciser'); verifier('disponibilité : texte flou', classer_disponibilite('à discuter', $auj) === ['code' => 'a_preciser', 'date' => null]); verifier('disponibilité : vide', classer_disponibilite(' ', $auj) === ['code' => null, 'date' => null]); verifier('libellé : sous 2 mois avec estimation', libelle_disponibilite(['disponibilite_code' => '2_mois', 'disponible_le' => '2026-12-15'], 'fr') === 'Sous 2 mois · vers le 15/12/2026'); verifier('libellé : sans estimation sur un CV', libelle_disponibilite(['disponibilite_code' => '2_mois', 'disponible_le' => '2026-12-15'], 'en', false) === 'Within 2 months'); verifier('libellé : date précise', libelle_disponibilite(['disponibilite_code' => 'date', 'disponible_le' => '2027-01-04'], 'de') === 'Ab 04/01/2027'); verifier('libellé : aucun code', libelle_disponibilite(['disponibilite_code' => null, 'disponible_le' => null], 'fr') === ''); // --- Traduction verifier('langues du contenu : CV anglais', langues_du_contenu(['source_langue' => 'en', 'json_source' => '{"source":{"langue_cv":"en"}}']) !== []); $paquet = ['titre' => 'Développeur', 'experiences' => ['12' => ['poste' => 'Chef de projet', 'description' => 'Pilotage']], 'formations' => ['7' => 'Master']]; [$fusion, $n] = fusionner_traduction($paquet, ['titre' => 'Developer', 'experiences' => ['12' => ['poste' => 'Project manager']], 'formations' => ['7' => ' '], 'pirate' => 'x']); verifier('fusion : textes traduits repris', $fusion['titre'] === 'Developer' && $fusion['experiences']['12']['poste'] === 'Project manager'); verifier('fusion : texte manquant ou vide, original gardé', $fusion['experiences']['12']['description'] === 'Pilotage' && $fusion['formations']['7'] === 'Master'); verifier('fusion : clé inconnue ignorée', !isset($fusion['pirate'])); verifier('fusion : nombre de textes traduits', $n === 2); verifier('empreinte : les métiers ne comptent pas', empreinte_paquet($paquet) === empreinte_paquet($paquet + ['metiers' => ['développeur' => 'developer']])); verifier('empreinte : un texte changé la change', empreinte_paquet($paquet) !== empreinte_paquet(['titre' => 'Architecte'] + $paquet)); // --- Import au format 1.2 $exemple = json_decode((string) file_get_contents(__DIR__ . '/../claude/exemple-profil.json'), true); verifier('import 1.2 : l\'exemple est valide', Import::valider($exemple) === []); $e = $exemple; $e['identite']['nationalites'] = ['FRA']; verifier('import 1.2 : nationalité sur trois lettres refusée', count(Import::valider($e)) === 1); $e = $exemple; $e['disponibilite_code'] = 'date'; $e['disponibilite_date'] = null; verifier('import 1.2 : date obligatoire pour « date »', count(Import::valider($e)) === 1); $e = $exemple; $e['disponibilite_code'] = 'bientot'; verifier('import 1.2 : code de disponibilité inconnu', count(Import::valider($e)) === 1); $e = $exemple; $e['langues'][1]['cecrl'] = ['ecoute' => 'C1', 'lecture' => 'C2', 'production_orale' => 'B2', 'interaction_orale' => 'B2', 'ecriture' => 'C1']; verifier('import 1.2 : grille européenne acceptée', Import::valider($e) === []); $e['langues'][1]['cecrl']['ecoute'] = 'D1'; verifier('import 1.2 : niveau de grille inconnu refusé', count(Import::valider($e)) === 1); $e = $exemple; $e['schema_version'] = '1.1'; unset($e['identite']['nationalites'], $e['disponibilite_code'], $e['disponibilite_date'], $e['benevolat'], $e['permis_conduire']); foreach ($e['experiences'] as &$x) { unset($x['projet'], $x['mission'], $x['charge']); } unset($x); foreach ($e['formations'] as &$f) { unset($f['date_debut'], $f['date_fin'], $f['lieu']); } unset($f); foreach ($e['langues'] as &$l) { unset($l['cecrl']); } unset($l); verifier('import 1.1 : toujours accepté', Import::valider($e) === []); $colonnes = static fn (array $p, DateTimeImmutable $d): array => (new ReflectionMethod(Import::class, 'colonnes'))->invoke(null, $p, $d); $col = $colonnes($exemple, $auj); verifier('import 1.2 : colonnes de nationalité', $col['nationalites'] === 'FR' && $col['hors_ue'] === 0); verifier('import 1.2 : disponibilité classée et date estimée', $col['disponibilite_code'] === '2_mois' && $col['disponible_le'] === '2026-12-15'); verifier('import 1.2 : bénévolat et permis', str_contains((string) $col['benevolat'], 'ateliers') && $col['permis_conduire'] === 'B'); $col = $colonnes($e, $auj); verifier('import 1.1 : disponibilité classée depuis le texte', $col['disponibilite_code'] === '2_mois' && $col['nationalites'] === null && $col['hors_ue'] === null); // --- CV Europass $c = [ 'id' => 3, 'prenom' => 'Camille', 'nom' => 'EXEMPLE', 'titre' => 'Développeuse Java', 'resume' => "Camille conçoit des API.\nDix ans d'expérience.", 'email' => 'camille@example.com', 'telephone' => '+352 1', 'rue' => '1 rue X', 'code_postal' => 'L-1000', 'ville' => 'Luxembourg', 'pays' => 'Luxembourg', 'photo' => 'abc.jpg', 'liens' => [['type' => 'linkedin', 'url' => 'https://linkedin.com/in/c']], 'nationalites' => 'MA', 'permis_travail' => 'Titre salarié', 'benevolat' => "Ateliers de code\nMaraudes", 'permis_conduire' => 'B, A', 'experiences' => [['id' => 1, 'poste' => 'Développeuse', 'employeur' => 'ACME', 'client' => 'Banque X', 'lieu' => 'Luxembourg', 'date_debut' => '2020-03', 'date_fin' => null, 'description' => "- Conçoit des API\n- Revue de code", 'technologies' => 'Java, Spring', 'projet' => 'Refonte', 'mission' => 'Développement back-end', 'charge' => '100 %']], 'formations' => [['id' => 1, 'diplome' => 'Master Informatique', 'etablissement' => 'Université', 'annee_fin' => 2017, 'date_debut' => '2015-09', 'date_fin' => '2017-06', 'lieu' => 'Metz']], 'certifications' => [['id' => 1, 'nom' => 'PSM I']], 'technologies' => [['nom' => 'Java', 'categorie' => 'langage'], ['nom' => 'PostgreSQL', 'categorie' => 'base_de_donnees']], 'langues' => [['langue' => 'Français', 'niveau' => 'natif'], ['langue' => 'Anglais', 'niveau' => 'C1', 'ecoute' => null, 'lecture' => null, 'production_orale' => null, 'interaction_orale' => null, 'ecriture' => null]], ]; $d = donnees_europass($c, ['photo' => true, 'coordonnees' => true], 'fr'); verifier('Europass : nom et coordonnées', $d['nom'] === 'Camille EXEMPLE' && count($d['coordonnees']) === 4 && $d['photo'] === 'abc.jpg'); verifier('Europass : ni nationalité ni permis de travail', !str_contains(json_encode($d, JSON_UNESCAPED_UNICODE), 'Titre salarié') && !str_contains(json_encode($d), '"MA"')); verifier('Europass : dates au format mm/aaaa', $d['experiences'][0]['dates'] === '03/2020 – EN COURS' && $d['formations'][0]['dates'] === '09/2015 – 06/2017'); verifier('Europass : projet, mission, charge', array_column($d['experiences'][0]['details'], 1) === ['Banque X', 'Refonte', 'Développement back-end', 'Java, Spring', '100 %']); verifier('Europass : réalisations sans puces', $d['experiences'][0]['realisations'] === ['Conçoit des API', 'Revue de code']); verifier('Europass : langue sans grille, niveau global', $d['avec_grille'] === false && $d['autres'][0]['niveau'] === 'C1' && $d['maternelles'] === ['FRANÇAIS']); verifier('Europass : compétences groupées', array_keys($d['competences']) === ['Certifications', 'Technique', 'Bases de données']); verifier('Europass : bénévolat et permis', $d['benevolat'] === ['Ateliers de code', 'Maraudes'] && $d['permis'] === ['B', 'A']); $a = donnees_europass($c, ['photo' => true, 'coordonnees' => true, 'anonyme' => true], 'en'); verifier('Europass anonymisé : ni nom, ni photo, ni coordonnées', $a['nom'] === 'Profile ITC-0003' && $a['photo'] === null && $a['coordonnees'] === []); verifier('Europass anonymisé : prénom retiré des textes', !str_contains(implode(' ', $a['resume']), 'Camille')); verifier('Europass en anglais : intitulés', $a['libelles']['experience'] === 'Work experience' && $a['libelles']['grille']['ecoute'] === 'Listening'); $c['langues'][1] = ['langue' => 'Anglais', 'niveau' => 'C1', 'ecoute' => 'C1', 'lecture' => 'C2', 'production_orale' => 'B2', 'interaction_orale' => 'B2', 'ecriture' => 'C1']; $g = donnees_europass($c, [], 'de'); verifier('Europass : grille européenne', $g['avec_grille'] && $g['autres'][0]['grille']['lecture'] === 'C2' && $g['libelles']['langues'] === 'Sprachkenntnisse'); verifier('Europass : sans coordonnées ni photo par défaut', $g['coordonnees'] === [] && $g['photo'] === null); echo "$ok réussis, $ko échoués\n"; exit($ko > 0 ? 1 : 0);