Files
Vivier/tests/test_securite.php

173 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// Tests de la version 1.8 sans base de données : php tests/test_securite.php
// Double authentification, blocages, nationalités, disponibilité, traduction, CV Europass, import 1.2.
declare(strict_types=1);
const VIVIER_RACINE = __DIR__ . '/..';
const LONGUEUR_MOT_DE_PASSE = 12;
const DISPONIBILITES = ['immediate' => '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);