From c02180fb4d7b78cd28d3fd8fc4fd46de121ae66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Sun, 26 Jul 2026 20:22:05 +0400 Subject: [PATCH] test(php): add native unit tests for security, cache, format, markdown --- tests/php/bootstrap.php | 34 ++++++++ tests/php/cache-test.php | 76 +++++++++++++++++ tests/php/format-test.php | 145 +++++++++++++++++++++++++++++++++ tests/php/markdown-test.php | 88 ++++++++++++++++++++ tests/php/run.php | 116 ++++++++++++++++++++++++++ tests/php/security-test.php | 157 ++++++++++++++++++++++++++++++++++++ 6 files changed, 616 insertions(+) create mode 100644 tests/php/bootstrap.php create mode 100644 tests/php/cache-test.php create mode 100644 tests/php/format-test.php create mode 100644 tests/php/markdown-test.php create mode 100644 tests/php/run.php create mode 100644 tests/php/security-test.php diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php new file mode 100644 index 0000000..c0f6d06 --- /dev/null +++ b/tests/php/bootstrap.php @@ -0,0 +1,34 @@ +setAccessible(true); + +$tmpDir = sys_get_temp_dir() . '/annu-kute-cache-test-' . getmypid(); +if (!is_dir($tmpDir)) { + mkdir($tmpDir, 0755, true); +} +$cacheDirProperty->setValue($cache, $tmpDir); + +// --- get sur un cache vide -------------------------------------------------- + +assertNull($cache->get('videos'), 'get retourne null quand aucune entrée n\'existe'); + +// --- set puis get ----------------------------------------------------------- + +$content = ['data' => ['video1', 'video2']]; +$cache->set('videos', ['count' => 6], $content); +assertEquals($content, $cache->get('videos', ['count' => 6]), 'get retourne le contenu après set'); + +// Le fichier stocké contient bien content / expires / created +$files = glob($tmpDir . '/cache_*.json'); +assertEquals(1, count($files), 'set crée exactement un fichier de cache'); +$stored = json_decode(file_get_contents($files[0]), true); +assertEquals($content, $stored['content'], 'le fichier stocke le contenu sous la clé "content"'); +assertTrue(isset($stored['expires']) && $stored['expires'] > time(), 'le fichier stocke une expiration future'); +assertTrue(isset($stored['created']), 'le fichier stocke la date de création'); + +// --- Ordre des paramètres --------------------------------------------------- + +// ksort() dans getCacheKey : l'ordre des paramètres ne change pas la clé +$cache->set('videos', ['b' => 2, 'a' => 1], 'ordonné'); +assertEquals( + 'ordonné', + $cache->get('videos', ['a' => 1, 'b' => 2]), + 'get retrouve l\'entrée malgré un ordre de paramètres différent' +); +assertNull( + $cache->get('videos', ['a' => 9]), + 'get retourne null pour des paramètres différents' +); + +// --- Expiration ------------------------------------------------------------- + +$cache->set('videos', ['old' => 1], 'périmé', -1); // TTL négatif : déjà expiré +$filesBefore = count(glob($tmpDir . '/cache_*.json')); +assertNull($cache->get('videos', ['old' => 1]), 'get retourne null pour une entrée expirée'); +assertEquals( + $filesBefore - 1, + count(glob($tmpDir . '/cache_*.json')), + 'get supprime le fichier expiré' +); + +// --- cleanup ---------------------------------------------------------------- + +$cache->set('cle-valide', [], 'valide', 300); +$cache->set('cle-perimee', [], 'périmée', -10); +$cleaned = $cache->cleanup(); +assertEquals(1, $cleaned, 'cleanup supprime uniquement les entrées expirées'); +assertEquals('valide', $cache->get('cle-valide'), 'cleanup conserve les entrées valides'); +assertNull($cache->get('cle-perimee'), 'cleanup a bien supprimé l\'entrée expirée'); + +// --- Nettoyage du dossier temporaire ---------------------------------------- + +foreach (glob($tmpDir . '/cache_*.json') as $file) { + unlink($file); +} +rmdir($tmpDir); diff --git a/tests/php/format-test.php b/tests/php/format-test.php new file mode 100644 index 0000000..191ad81 --- /dev/null +++ b/tests/php/format-test.php @@ -0,0 +1,145 @@ + 'abc-123', + 'name' => 'Ma vidéo', + 'previewPath' => '/lazy/abc.jpg', + 'duration' => 125, + 'channel' => [ + 'displayName' => 'Chaîne', + 'avatars' => [['path' => '/avatars/a.png']] + ], + 'views' => 42, + 'publishedAt' => '2024-01-01T00:00:00.000Z', + 'aspectRatio' => 1.78, + 'description' => 'Une description', + 'tags' => ['musique'], + 'isLive' => true, + ], + [ + // Vidéo minimale : sans vignette, avatar, description, tags ni isLive + 'uuid' => 'def-456', + 'name' => 'Minimale', + 'duration' => 5, + 'channel' => ['displayName' => 'Autre chaîne'], + 'views' => 0, + 'publishedAt' => '2024-01-02T00:00:00.000Z', + 'aspectRatio' => 1.78, + ], +]; + +$videos = formatVideosData($rawVideos); + +assertEquals(2, count($videos), 'formatVideosData retourne une entrée par vidéo'); + +assertEquals('abc-123', $videos[0]['id'], 'formatVideosData mappe uuid vers id'); +assertEquals('Ma vidéo', $videos[0]['title'], 'formatVideosData mappe name vers title'); +assertEquals( + PEERTUBE_URL . '/lazy/abc.jpg', + $videos[0]['thumbnail'], + 'formatVideosData préfixe la vignette avec PEERTUBE_URL' +); +assertEquals( + PEERTUBE_URL . '/avatars/a.png', + $videos[0]['channelAvatar'], + 'formatVideosData préfixe l\'avatar avec PEERTUBE_URL' +); +assertEquals(125, $videos[0]['duration'], 'formatVideosData conserve la durée'); +assertEquals('Chaîne', $videos[0]['channel'], 'formatVideosData mappe le nom de la chaîne'); +assertEquals(42, $videos[0]['views'], 'formatVideosData conserve les vues'); +assertEquals('2024-01-01T00:00:00.000Z', $videos[0]['date'], 'formatVideosData mappe publishedAt vers date'); +assertEquals('Une description', $videos[0]['description'], 'formatVideosData conserve la description'); +assertEquals(['musique'], $videos[0]['tags'], 'formatVideosData conserve les tags'); +assertTrue($videos[0]['isLive'], 'formatVideosData conserve isLive'); + +assertEquals( + 'img/default-thumbnail.jpg', + $videos[1]['thumbnail'], + 'formatVideosData utilise une vignette par défaut si absente' +); +assertEquals( + 'img/default-avatar.png', + $videos[1]['channelAvatar'], + 'formatVideosData utilise un avatar par défaut si absent' +); +assertEquals('', $videos[1]['description'], 'formatVideosData met une description vide par défaut'); +assertEquals([], $videos[1]['tags'], 'formatVideosData met des tags vides par défaut'); +assertFalse($videos[1]['isLive'], 'formatVideosData met isLive à false par défaut'); + +// --- truncateText (includes/structured-data.php) ---------------------------- + +assertEquals('court', truncateText('court', 200), 'truncateText laisse un texte court intact'); +assertEquals('exact', truncateText('exact', 5), 'truncateText laisse un texte à la limite exacte intact'); + +$textWithSpace = str_repeat('a', 150) . ' ' . str_repeat('b', 100); // 251 caractères +assertEquals( + str_repeat('a', 150) . '...', + truncateText($textWithSpace, 200), + 'truncateText coupe au dernier espace avant la limite' +); + +$textNoSpace = str_repeat('a', 250); +assertEquals( + str_repeat('a', 200) . '...', + truncateText($textNoSpace, 200), + 'truncateText coupe à la limite quand il n\'y a pas d\'espace' +); diff --git a/tests/php/markdown-test.php b/tests/php/markdown-test.php new file mode 100644 index 0000000..7f790a2 --- /dev/null +++ b/tests/php/markdown-test.php @@ -0,0 +1,88 @@ +alert("x")'), + 'markdown_to_html échappe le HTML brut' +); +assertNotContains( + ''), + 'markdown_to_html ne laisse passer aucune balise script' +); + +// --- Gras et italique -------------------------------------------------------- + +assertEquals( + 'gras', + markdown_to_html('**gras**'), + 'markdown_to_html convertit **texte** en ' +); +assertEquals( + 'ital', + markdown_to_html('*ital*'), + 'markdown_to_html convertit *texte* en ' +); + +// --- Liens Markdown ---------------------------------------------------------- + +assertEquals( + 'exemple', + markdown_to_html('[exemple](https://exemple.com)'), + 'markdown_to_html convertit un lien Markdown avec attributs de sécurité' +); +assertEquals( + 'site', + markdown_to_html('[site](exemple.com)'), + 'markdown_to_html ajoute http:// aux liens sans protocole' +); + +// --- URLs brutes ------------------------------------------------------------- + +assertEquals( + 'Visite https://exemple.com/page pour info', + markdown_to_html('Visite https://exemple.com/page pour info'), + 'markdown_to_html rend les URLs http(s) brutes cliquables' +); +assertEquals( + 'va sur o-k-i.net maintenant', + markdown_to_html('va sur o-k-i.net maintenant'), + 'markdown_to_html rend les domaines nus cliquables (avec http://)' +); +assertEquals( + 'version v1.2 dispo', + markdown_to_html('version v1.2 dispo'), + 'markdown_to_html ne transforme pas un numéro de version en lien' +); + +// --- Listes ------------------------------------------------------------------ + +// Note : la passe des listes numérotées s'applique aussi aux
  • déjà +// produits par la passe des puces, d'où un double enveloppement
        . +// C'est le comportement actuel, verrouillé ici contre toute régression. +assertEquals( + "
          1. a
          2. \n
          3. b
        ", + markdown_to_html("- a\n- b"), + 'markdown_to_html convertit les listes à puces (double enveloppement actuel)' +); +assertEquals( + "
        1. a
        2. \n
        3. b
        ", + markdown_to_html("1. a\n2. b"), + 'markdown_to_html convertit les listes numérotées en
          ' +); + +// --- Retours à la ligne ------------------------------------------------------ + +assertEquals( + "ligne1
          \nligne2", + markdown_to_html("ligne1\nligne2"), + 'markdown_to_html convertit les sauts de ligne en
          ' +); diff --git a/tests/php/run.php b/tests/php/run.php new file mode 100644 index 0000000..8ab91c5 --- /dev/null +++ b/tests/php/run.php @@ -0,0 +1,116 @@ +getMessage()); + } +} + +// --- Résumé ---------------------------------------------------------------- + +$passed = $GLOBALS['tests_passed']; +$failed = $GLOBALS['tests_failed']; + +echo "\n----------------------------------------\n"; +echo "Résumé : $passed réussi(s), $failed échoué(s).\n"; + +if ($failed > 0) { + echo "Tests en échec :\n"; + foreach ($GLOBALS['tests_failures'] as $failure) { + echo " - $failure\n"; + } + exit(1); +} + +exit(0); diff --git a/tests/php/security-test.php b/tests/php/security-test.php new file mode 100644 index 0000000..1798e85 --- /dev/null +++ b/tests/php/security-test.php @@ -0,0 +1,157 @@ +'), + 'validateSearchQuery supprime les chevrons' +); +assertEquals( + 'recherche btest/b', + validateSearchQuery('recherche test'), + 'validateSearchQuery supprime les caractères dangereux <>"\'' +); +assertEquals('terme', validateSearchQuery(' terme '), 'validateSearchQuery nettoie les espaces'); +assertFalse(validateSearchQuery(''), 'validateSearchQuery refuse une chaîne vide'); +assertFalse( + validateSearchQuery(str_repeat('a', 201)), + 'validateSearchQuery refuse plus de 200 caractères' +); +assertEquals( + str_repeat('a', 200), + validateSearchQuery(str_repeat('a', 200)), + 'validateSearchQuery accepte exactement 200 caractères' +); + +// --- validateCategoryId ----------------------------------------------------- + +assertEquals(1, validateCategoryId(1), 'validateCategoryId accepte la borne basse 1'); +assertEquals(20, validateCategoryId(20), 'validateCategoryId accepte la borne haute 20'); +assertEquals(5, validateCategoryId('5'), 'validateCategoryId convertit une chaîne numérique'); +assertFalse(validateCategoryId(0), 'validateCategoryId refuse 0'); +assertFalse(validateCategoryId(21), 'validateCategoryId refuse 21'); +assertFalse(validateCategoryId(-3), 'validateCategoryId refuse un nombre négatif'); +assertFalse(validateCategoryId('abc'), 'validateCategoryId refuse une chaîne non numérique'); + +// --- validatePageNumber ----------------------------------------------------- + +assertEquals(1, validatePageNumber(0), 'validatePageNumber remonte 0 à 1'); +assertEquals(1, validatePageNumber(-5), 'validatePageNumber remonte un négatif à 1'); +assertEquals(3, validatePageNumber(3), 'validatePageNumber conserve une page valide'); +assertEquals(2, validatePageNumber('2'), 'validatePageNumber convertit une chaîne numérique'); +assertEquals(1, validatePageNumber('abc'), 'validatePageNumber ramène une chaîne non numérique à 1'); + +// --- generateCSRFToken / validateCSRFToken ---------------------------------- + +$token = generateCSRFToken(); +assertContains(':', $token, 'generateCSRFToken produit le format "timestamp:hash"'); + +[$timestamp, $hash] = explode(':', $token, 2); +assertTrue(ctype_digit($timestamp), 'generateCSRFToken : la partie timestamp est numérique'); +assertEquals( + hash_hmac('sha256', $timestamp, CSRF_SECRET), + $hash, + 'generateCSRFToken : le hash est un HMAC-SHA256 du timestamp avec CSRF_SECRET' +); +assertTrue(validateCSRFToken($token), 'validateCSRFToken accepte un token fraîchement généré'); + +assertFalse(validateCSRFToken(''), 'validateCSRFToken refuse une chaîne vide'); +assertFalse(validateCSRFToken('sans-deux-points'), 'validateCSRFToken refuse un token sans séparateur'); +assertFalse(validateCSRFToken('abc:' . $hash), 'validateCSRFToken refuse un timestamp non numérique'); +assertFalse( + validateCSRFToken($timestamp . ':' . hash_hmac('sha256', $timestamp, 'autre-secret')), + 'validateCSRFToken refuse un hash signé avec un autre secret' +); +assertFalse( + validateCSRFToken($timestamp . ':0' . $hash), + 'validateCSRFToken refuse un hash modifié' +); + +// Token expiré : horodatage d'il y a 2 heures (limite : 1 heure) +$oldTimestamp = (string) (time() - 7200); +$oldToken = $oldTimestamp . ':' . hash_hmac('sha256', $oldTimestamp, CSRF_SECRET); +assertFalse(validateCSRFToken($oldToken), 'validateCSRFToken refuse un token expiré (> 1 heure)'); + +// Timestamp trop loin dans le futur (l'écart absolu est également plafonné) +$futureTimestamp = (string) (time() + 7200); +$futureToken = $futureTimestamp . ':' . hash_hmac('sha256', $futureTimestamp, CSRF_SECRET); +assertFalse(validateCSRFToken($futureToken), 'validateCSRFToken refuse un timestamp trop futur'); + +// --- isValidPeerTubeUrl (includes/config.php) ------------------------------- + +assertTrue(isValidPeerTubeUrl('https://peertube.example.com'), 'isValidPeerTubeUrl accepte une URL HTTPS publique'); +assertTrue(isValidPeerTubeUrl('http://peertube.example.com'), 'isValidPeerTubeUrl accepte HTTP (développement)'); +assertTrue(isValidPeerTubeUrl('https://8.8.8.8'), 'isValidPeerTubeUrl accepte une IP publique'); +assertFalse(isValidPeerTubeUrl('ftp://example.com'), 'isValidPeerTubeUrl refuse un schéma non HTTP(S)'); +assertFalse(isValidPeerTubeUrl('pas-une-url'), 'isValidPeerTubeUrl refuse une chaîne mal formée'); +assertFalse(isValidPeerTubeUrl('https://localhost'), 'isValidPeerTubeUrl refuse localhost'); +assertFalse(isValidPeerTubeUrl('http://127.0.0.1'), 'isValidPeerTubeUrl refuse 127.0.0.1'); +assertFalse(isValidPeerTubeUrl('http://192.168.1.1'), 'isValidPeerTubeUrl refuse une IP privée'); +assertFalse(isValidPeerTubeUrl('http://10.0.0.5'), 'isValidPeerTubeUrl refuse une IP privée (10.x)'); +assertFalse( + isValidPeerTubeUrl('https://metadata.google.internal'), + 'isValidPeerTubeUrl refuse metadata.google.internal' +); + +// --- isValidApiEndpoint (includes/config.php) ------------------------------- + +assertTrue(isValidApiEndpoint('videos'), 'isValidApiEndpoint accepte "videos"'); +assertTrue(isValidApiEndpoint('videos/categories'), 'isValidApiEndpoint accepte "videos/categories"'); +assertTrue(isValidApiEndpoint('search/videos'), 'isValidApiEndpoint accepte "search/videos"'); +assertTrue(isValidApiEndpoint('accounts'), 'isValidApiEndpoint accepte "accounts"'); +assertTrue( + isValidApiEndpoint('videos/9c5de94d-8e1a-4b3c-9d2e-1234567890ab'), + 'isValidApiEndpoint accepte un endpoint vidéo dynamique' +); +assertTrue( + isValidApiEndpoint('videos/abc/comment-threads'), + 'isValidApiEndpoint accepte les fils de commentaires' +); +assertTrue( + isValidApiEndpoint('accounts/annu_kute_ced/videos'), + 'isValidApiEndpoint accepte les vidéos d\'un compte' +); +assertFalse(isValidApiEndpoint('../config'), 'isValidApiEndpoint refuse le path traversal'); +assertFalse(isValidApiEndpoint('videos/../x'), 'isValidApiEndpoint refuse ".." dans le chemin'); +assertFalse(isValidApiEndpoint('videos//categories'), 'isValidApiEndpoint refuse un double slash'); +assertFalse(isValidApiEndpoint('wp-admin'), 'isValidApiEndpoint refuse un endpoint hors liste blanche'); +assertFalse(isValidApiEndpoint('videos.json'), 'isValidApiEndpoint refuse les caractères non autorisés (.)'); +assertFalse(isValidApiEndpoint('videos?count=1'), 'isValidApiEndpoint refuse une query string'); +assertFalse(isValidApiEndpoint(''), 'isValidApiEndpoint refuse une chaîne vide'); + +// --- getCspNonce ------------------------------------------------------------ + +$nonce = getCspNonce(); +assertTrue(is_string($nonce) && $nonce !== '', 'getCspNonce retourne une chaîne non vide'); +assertEquals(16, strlen(base64_decode($nonce, true)), 'getCspNonce est un base64 de 16 octets aléatoires'); +assertEquals($nonce, getCspNonce(), 'getCspNonce retourne le même nonce durant toute la requête');