Compare commits
5
Commits
5c726f91b2
...
9b460a2550
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b460a2550 | ||
|
|
f895800c9d | ||
|
|
606175d718 | ||
|
|
54a4368ea8 | ||
|
|
c02180fb4d |
@@ -36,3 +36,9 @@ jobs:
|
||||
node --check "$f" || exit 1
|
||||
done
|
||||
echo "JS lint OK"
|
||||
|
||||
- name: Tests unitaires PHP
|
||||
run: php tests/php/run.php
|
||||
|
||||
- name: Tests unitaires JS
|
||||
run: node tests/js/run.js
|
||||
|
||||
@@ -37,6 +37,12 @@ jobs:
|
||||
done
|
||||
echo "JS lint OK"
|
||||
|
||||
- name: Tests unitaires PHP
|
||||
run: php tests/php/run.php
|
||||
|
||||
- name: Tests unitaires JS
|
||||
run: node tests/js/run.js
|
||||
|
||||
deploy:
|
||||
needs: check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -37,4 +37,9 @@ uploads/*
|
||||
# vendor/
|
||||
# node_modules/
|
||||
|
||||
# Artefacts de tests locaux
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
img/movement_presentation.png
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@ L'architecture retenue (identique à celle de pawol.nu) :
|
||||
└─────────────┘ └──────────────────┘ └─────────────┘
|
||||
----
|
||||
|
||||
. *Vérification* (`check-pr.yml` + job `check` de `deploy-prod.yml`) : lint PHP/JS. Les validations AsciiDoc, JSON, XML et shellcheck ne sont plus exécutées dans le CI : elles doivent être passées en local avec `scripts/check.sh`.
|
||||
. *Vérification* (`check-pr.yml` + job `check` de `deploy-prod.yml`) : lint PHP/JS et tests unitaires PHP/JS. Les validations AsciiDoc, JSON, XML et shellcheck ne sont pas exécutées dans le CI : elles doivent être passées en local avec `scripts/check.sh`.
|
||||
. *Déploiement* (`deploy-prod.yml`) : le runner se connecte en SSH au serveur, qui tient *un clone du dépôt*, fait `git pull --ff-only`, puis *bumpe la version des caches* du Service Worker (`sw.js`) pour déclencher le modal de mise à jour PWA chez les visiteurs.
|
||||
|
||||
Pourquoi un `git pull` sur le serveur plutôt qu'un rsync : tous les fichiers propres à l'instance (`config.local.php`, `.htaccess`, `sitemap.xml`, `robots.txt`, `site.webmanifest`, `mentions-legales.php`, `dons.php`, `uploads/`, `cache/`) sont ignorés par Git — un pull ne les écrase jamais. Le déploiement est ainsi sans risque pour la configuration de production.
|
||||
@@ -308,10 +308,10 @@ grep STATIC_CACHE_NAME sw.js # le suffixe de version a été bumpé à l'heure d
|
||||
| Événement | Résultat
|
||||
|
||||
| Pull request vers `main`
|
||||
| Workflow *Vérification PR* : lint PHP et JS bloquants en cas d'erreur
|
||||
| Workflow *Vérification PR* : lint PHP/JS et tests unitaires PHP/JS bloquants en cas d'erreur
|
||||
|
||||
| Push sur `main`
|
||||
| Workflow *Déploiement PROD* : lint PHP et JS, puis SSH → `git pull --ff-only` → bump de la version des caches `sw.js` → modal de mise à jour chez les visiteurs
|
||||
| Workflow *Déploiement PROD* : lint PHP/JS et tests unitaires PHP/JS, puis SSH → `git pull --ff-only` → bump de la version des caches `sw.js` → modal de mise à jour chez les visiteurs
|
||||
|
||||
| En local, avant de pousser
|
||||
| `scripts/check.sh` exécute l'ensemble des vérifications qualité (PHP, JS, AsciiDoc, JSON, XML, shellcheck)
|
||||
|
||||
+52
-6
@@ -937,13 +937,36 @@ Ce script lance : lint PHP (`php -l` sur tous les fichiers, samples inclus), lin
|
||||
|
||||
Pipelines Gitea Actions (`.gitea/workflows/`) :
|
||||
|
||||
- *Vérification PR* (`check-pr.yml`) : lint PHP et JS bloquants sur toute pull request vers `main`
|
||||
- *Déploiement PROD* (`deploy-prod.yml`) : lint PHP et JS sur push sur `main`, puis déploiement en SSH sur le serveur (`git pull --ff-only` + bump de version du Service Worker, qui déclenche le modal de mise à jour chez les visiteurs)
|
||||
- *Vérification PR* (`check-pr.yml`) : lint PHP et JS, tests unitaires PHP et JS bloquants sur toute pull request vers `main`
|
||||
- *Déploiement PROD* (`deploy-prod.yml`) : lint PHP et JS, tests unitaires PHP et JS sur push sur `main`, puis déploiement en SSH sur le serveur (`git pull --ff-only` + bump de version du Service Worker, qui déclenche le modal de mise à jour chez les visiteurs)
|
||||
|
||||
Les validations AsciiDoc, JSON, XML et shellcheck ne sont plus exécutées dans le CI : elles doivent être passées en local avec `scripts/check.sh`.
|
||||
Les validations AsciiDoc, JSON, XML et shellcheck ne sont pas exécutées dans le CI : elles doivent être passées en local avec `scripts/check.sh`.
|
||||
|
||||
La mise en place complète du serveur de production (clone, fichiers d'instance, clés SSH, secrets Gitea) est documentée dans link:DEPLOY.adoc[DEPLOY.adoc].
|
||||
|
||||
==== 🧪 Tests
|
||||
|
||||
Le projet dispose de trois niveaux de tests, tous *sans framework lourd* (aucune dépendance commitée dans le repo) :
|
||||
|
||||
- *Tests unitaires PHP* (`tests/php/`) : script PHP natif (`php tests/php/run.php`). Couvre les validateurs (`security.php`), le cache (`simple-cache.php`), les formateurs (`config.php`) et le rendu Markdown.
|
||||
- *Tests unitaires JS* (`tests/js/`) : `node:test` natif (`node tests/js/run.js`). Couvre `CountdownTimer` et l'adaptateur Pleroma → Mastodon.
|
||||
- *Tests E2E* (`tests/e2e/`) : Playwright (Python), installé en local uniquement. Couvre la homepage, la page vidéo et l'endpoint AJAX « Voir plus ».
|
||||
|
||||
[source,bash]
|
||||
----
|
||||
# Tests unitaires PHP et JS (aussi lancés par scripts/check.sh)
|
||||
php tests/php/run.php
|
||||
node tests/js/run.js
|
||||
|
||||
# Tests E2E (nécessite Playwright installé hors repo)
|
||||
python3 -m venv /tmp/test-venv
|
||||
/tmp/test-venv/bin/pip install playwright pytest
|
||||
/tmp/test-venv/bin/playwright install chromium
|
||||
/tmp/test-venv/bin/python -m pytest tests/e2e/
|
||||
----
|
||||
|
||||
NOTE: Les tests E2E démarrent un serveur PHP local (`php -S`) et s'appuient sur la configuration par défaut. Les appels réseau vers PeerTube sont sautés proprement si l'instance est injoignable.
|
||||
|
||||
=== 📜 Licence
|
||||
|
||||
Copyright (C) 2025 Cédric Famibelle-Pronzola & *ORGANISATION KA INTERNATIONALE*
|
||||
@@ -1884,13 +1907,36 @@ This script runs: PHP lint (`php -l` on every file, samples included), JS lint (
|
||||
|
||||
Gitea Actions pipelines (`.gitea/workflows/`):
|
||||
|
||||
- *PR check* (`check-pr.yml`): blocking PHP and JS lint on every pull request to `main`
|
||||
- *PROD deployment* (`deploy-prod.yml`): PHP and JS lint on push to `main`, then SSH deployment to the server (`git pull --ff-only` + Service Worker version bump, which triggers the update modal for visitors)
|
||||
- *PR check* (`check-pr.yml`): blocking PHP and JS lint plus PHP and JS unit tests on every pull request to `main`
|
||||
- *PROD deployment* (`deploy-prod.yml`): PHP and JS lint plus PHP and JS unit tests on push to `main`, then SSH deployment to the server (`git pull --ff-only` + Service Worker version bump, which triggers the update modal for visitors)
|
||||
|
||||
AsciDoc, JSON, XML and shellcheck validation are no longer run in CI: they must be run locally with `scripts/check.sh`.
|
||||
AsciDoc, JSON, XML and shellcheck validation are not run in CI: they must be run locally with `scripts/check.sh`.
|
||||
|
||||
The full production server setup (clone, instance files, SSH keys, Gitea secrets) is documented in link:DEPLOY.adoc[DEPLOY.adoc].
|
||||
|
||||
==== 🧪 Tests
|
||||
|
||||
The project has three levels of tests, all *without heavy frameworks* (no dependencies committed to the repo):
|
||||
|
||||
- *PHP unit tests* (`tests/php/`): native PHP script (`php tests/php/run.php`). Covers validators (`security.php`), cache (`simple-cache.php`), formatters (`config.php`) and Markdown rendering.
|
||||
- *JS unit tests* (`tests/js/`): native `node:test` (`node tests/js/run.js`). Covers `CountdownTimer` and the Pleroma → Mastodon adapter.
|
||||
- *E2E tests* (`tests/e2e/`): Playwright (Python), installed locally only. Covers the homepage, video page and the "Load more" AJAX endpoint.
|
||||
|
||||
[source,bash]
|
||||
----
|
||||
# PHP and JS unit tests (also run by scripts/check.sh)
|
||||
php tests/php/run.php
|
||||
node tests/js/run.js
|
||||
|
||||
# E2E tests (requires Playwright installed outside the repo)
|
||||
python3 -m venv /tmp/test-venv
|
||||
/tmp/test-venv/bin/pip install playwright pytest
|
||||
/tmp/test-venv/bin/playwright install chromium
|
||||
/tmp/test-venv/bin/python -m pytest tests/e2e/
|
||||
----
|
||||
|
||||
NOTE: E2E tests start a local PHP server (`php -S`) and rely on the default configuration. Network calls to PeerTube are skipped gracefully if the instance is unreachable.
|
||||
|
||||
=== 📜 License
|
||||
|
||||
Copyright (C) 2025 Cédric Famibelle-Pronzola & *ORGANISATION KA INTERNATIONALE*
|
||||
|
||||
@@ -98,6 +98,24 @@ if need shellcheck shellcheck; then
|
||||
fi
|
||||
fi
|
||||
|
||||
step "Tests unitaires PHP (tests/php/)"
|
||||
if need php php-cli; then
|
||||
if php tests/php/run.php; then
|
||||
echo "✅ Tests PHP OK"
|
||||
else
|
||||
fail=1
|
||||
fi
|
||||
fi
|
||||
|
||||
step "Tests unitaires JS (tests/js/)"
|
||||
if need node nodejs; then
|
||||
if node tests/js/run.js; then
|
||||
echo "✅ Tests JS OK"
|
||||
else
|
||||
fail=1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "❌ Des vérifications ont échoué — corrigez avant de pousser."
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Fixtures pytest pour les tests E2E : serveur PHP local + page Playwright.
|
||||
|
||||
Le serveur de développement PHP (`php -S`) est démarré une fois par session
|
||||
de test sur 127.0.0.1:8000 (ou sur un port libre si 8000 est déjà pris).
|
||||
Les appels aux API distantes (PeerTube, Castopod) passent par le vrai réseau :
|
||||
le premier chargement remplit le cache du site (cache/api), ce qui peut
|
||||
prendre un certain temps.
|
||||
|
||||
Lancement : /tmp/test-venv/bin/python -m pytest tests/e2e/
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
SERVER_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8000
|
||||
STARTUP_TIMEOUT = 30 # secondes pour que php -S accepte des connexions
|
||||
WARMUP_TIMEOUT = 90 # premier hit : remplit les caches API (Castopod/PeerTube)
|
||||
PAGE_TIMEOUT_MS = 60000
|
||||
|
||||
|
||||
def _port_libre(port):
|
||||
"""Retourne True si aucun service n'écoute sur le port donné."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
return s.connect_ex((SERVER_HOST, port)) != 0
|
||||
|
||||
|
||||
def _trouver_port():
|
||||
"""Utilise le port 8000 si possible, sinon un port libre quelconque."""
|
||||
if _port_libre(DEFAULT_PORT):
|
||||
return DEFAULT_PORT
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind((SERVER_HOST, 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def lire_constante_config(nom, defaut=None):
|
||||
"""
|
||||
Lit une constante define('NOM', 'valeur') dans la configuration PHP.
|
||||
|
||||
Cherche dans config.default.php puis dans config.local.php (qui a le
|
||||
dernier mot, comme dans includes/config.php). Ne gère que les valeurs
|
||||
chaînes simples — suffisant pour SITE_NAME, PEERTUBE_URL, etc.
|
||||
"""
|
||||
valeur = defaut
|
||||
for fichier in ("config.default.php", "config.local.php"):
|
||||
chemin = PROJECT_ROOT / "includes" / fichier
|
||||
if not chemin.is_file():
|
||||
continue
|
||||
texte = chemin.read_text(encoding="utf-8")
|
||||
motif = r"define\(\s*'" + re.escape(nom) + r"'\s*,\s*'((?:[^'\\]|\\.)*)'\s*\)"
|
||||
m = re.search(motif, texte)
|
||||
if m:
|
||||
valeur = m.group(1).replace("\\'", "'").replace("\\\\", "\\")
|
||||
return valeur
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url():
|
||||
"""Démarre `php -S` à la racine du projet et fournit l'URL de base."""
|
||||
port = _trouver_port()
|
||||
url = f"http://{SERVER_HOST}:{port}"
|
||||
|
||||
log = tempfile.NamedTemporaryFile(
|
||||
mode="w+", prefix="php-e2e-", suffix=".log", delete=False
|
||||
)
|
||||
# PHP_CLI_SERVER_WORKERS : le serveur intégré est mono-thread par défaut,
|
||||
# ce qui bloque quand le navigateur demande plusieurs PHP en parallèle.
|
||||
env = dict(os.environ, PHP_CLI_SERVER_WORKERS="4")
|
||||
proc = subprocess.Popen(
|
||||
["php", "-S", f"{SERVER_HOST}:{port}", "-t", str(PROJECT_ROOT)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Attendre que le serveur accepte des connexions
|
||||
deadline = time.monotonic() + STARTUP_TIMEOUT
|
||||
pret = False
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
log.seek(0)
|
||||
raise RuntimeError(
|
||||
"Le serveur PHP s'est arrêté au démarrage :\n" + log.read()
|
||||
)
|
||||
try:
|
||||
with socket.create_connection((SERVER_HOST, port), timeout=1):
|
||||
pret = True
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
|
||||
if not pret:
|
||||
proc.terminate()
|
||||
raise RuntimeError(
|
||||
f"Le serveur PHP ne répond pas après {STARTUP_TIMEOUT} s"
|
||||
)
|
||||
|
||||
# Pré-chauffe les caches (initCategories + appels API distants)
|
||||
try:
|
||||
with urllib.request.urlopen(url + "/index.php", timeout=WARMUP_TIMEOUT) as rep:
|
||||
if rep.status != 200:
|
||||
raise RuntimeError(f"index.php a répondu HTTP {rep.status}")
|
||||
except Exception:
|
||||
proc.terminate()
|
||||
log.seek(0)
|
||||
raise RuntimeError(
|
||||
"Impossible de charger la page d'accueil :\n" + log.read()
|
||||
)
|
||||
|
||||
yield url
|
||||
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
log.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def navigateur():
|
||||
"""Instance Chromium partagée pour toute la session de test."""
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
yield browser
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def page(navigateur):
|
||||
"""Nouvelle page vierge pour chaque test (contexte isolé)."""
|
||||
context = navigateur.new_context()
|
||||
context.set_default_timeout(PAGE_TIMEOUT_MS)
|
||||
pg = context.new_page()
|
||||
yield pg
|
||||
context.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def site_name():
|
||||
"""Valeur de la constante SITE_NAME de la configuration du site."""
|
||||
return lire_constante_config("SITE_NAME")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def peertube_url():
|
||||
"""Valeur de la constante PEERTUBE_URL de la configuration du site."""
|
||||
return lire_constante_config("PEERTUBE_URL")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests E2E de la page d'accueil (index.php)."""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_homepage_title(page, base_url, site_name):
|
||||
"""Le titre de la page correspond au SITE_NAME configuré."""
|
||||
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
|
||||
assert page.title() == site_name
|
||||
|
||||
|
||||
def test_homepage_csrf_meta_token(page, base_url):
|
||||
"""La balise meta csrf-token est présente au format 'timestamp:hmac'."""
|
||||
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
|
||||
token = page.locator('meta[name="csrf-token"]').get_attribute("content")
|
||||
assert token, "La balise meta csrf-token est absente ou vide"
|
||||
assert re.fullmatch(r"\d+:[0-9a-f]{64}", token), (
|
||||
f"Format de token CSRF inattendu : {token!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_homepage_main_sections(page, base_url):
|
||||
"""Les sections principales sont rendues côté serveur."""
|
||||
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
|
||||
|
||||
# Titres de sections vidéo (identifiants stables du markup)
|
||||
assert page.locator("#shorts-heading").inner_text() == "Shorts"
|
||||
assert page.locator("#recent-videos-heading").inner_text() == "Dernières vidéos"
|
||||
assert page.locator("#trending-videos-heading").inner_text() == "Tendances"
|
||||
|
||||
# Section du fil d'actualités Mastodon
|
||||
assert page.locator(".mastodon-section .mt-title").inner_text() != ""
|
||||
|
||||
# Au moins une grille de vidéos
|
||||
assert page.locator(".video-section").count() >= 3
|
||||
|
||||
|
||||
def test_homepage_view_more_buttons(page, base_url):
|
||||
"""Chaque section vidéo propose un bouton 'Voir plus'."""
|
||||
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
|
||||
|
||||
boutons = page.locator("button.view-more")
|
||||
# Sections 'Dernières vidéos' et 'Tendances' toujours présentes
|
||||
# (+ une par catégorie contenant des vidéos)
|
||||
assert boutons.count() >= 2
|
||||
for i in range(boutons.count()):
|
||||
# text_content : le texte DOM brut (inner_text subit text-transform: uppercase)
|
||||
assert boutons.nth(i).text_content() == "Voir plus"
|
||||
|
||||
|
||||
def test_homepage_video_cards(page, base_url):
|
||||
"""Des cartes vidéo remontent de l'API PeerTube (nécessite le réseau)."""
|
||||
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
|
||||
|
||||
cartes = page.locator(".video-card")
|
||||
if cartes.count() == 0:
|
||||
pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)")
|
||||
|
||||
# Chaque carte expose un identifiant vidéo exploitable
|
||||
for i in range(cartes.count()):
|
||||
assert cartes.nth(i).get_attribute("data-video-id")
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests E2E de l'endpoint AJAX ajax/load-more-videos.php.
|
||||
|
||||
L'endpoint exige trois protections cumulées (dans l'ordre) :
|
||||
1. l'en-tête X-Requested-With: XMLHttpRequest ;
|
||||
2. une origine (Origin, ou Referer à défaut) strictement identique au site ;
|
||||
3. un token CSRF stateless valide (champ POST csrf_token).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
ENDPOINT = "/ajax/load-more-videos.php"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def csrf_token(page, base_url):
|
||||
"""Récupère un token CSRF frais depuis la balise meta de l'accueil."""
|
||||
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
|
||||
token = page.locator('meta[name="csrf-token"]').get_attribute("content")
|
||||
assert token, "La balise meta csrf-token est absente de l'accueil"
|
||||
return token
|
||||
|
||||
|
||||
def _post(page, base_url, query="", token=None, ajax=True, origin=None):
|
||||
"""POST sur l'endpoint avec les protections activables une à une."""
|
||||
headers = {}
|
||||
if ajax:
|
||||
headers["X-Requested-With"] = "XMLHttpRequest"
|
||||
if origin is not None:
|
||||
headers["Origin"] = origin
|
||||
form = {}
|
||||
if token is not None:
|
||||
form["csrf_token"] = token
|
||||
return page.request.post(
|
||||
base_url + ENDPOINT + query, form=form, headers=headers
|
||||
)
|
||||
|
||||
|
||||
def test_load_more_rejects_non_ajax(page, base_url, csrf_token):
|
||||
"""Sans X-Requested-With, la requête est refusée (403)."""
|
||||
rep = _post(page, base_url, "?type=recent&page=1",
|
||||
token=csrf_token, ajax=False, origin=base_url)
|
||||
assert rep.status == 403
|
||||
assert rep.json() == {"error": "Accès non autorisé"}
|
||||
|
||||
|
||||
def test_load_more_rejects_bad_origin(page, base_url, csrf_token):
|
||||
"""Une origine étrangère est refusée (403)."""
|
||||
rep = _post(page, base_url, "?type=recent&page=1",
|
||||
token=csrf_token, origin="https://example.com")
|
||||
assert rep.status == 403
|
||||
assert rep.json() == {"error": "Origine non autorisée"}
|
||||
|
||||
|
||||
def test_load_more_rejects_bad_csrf(page, base_url):
|
||||
"""Un token CSRF forgé est refusé (403)."""
|
||||
rep = _post(page, base_url, "?type=recent&page=1",
|
||||
token="1234567890:tokenforge", origin=base_url)
|
||||
assert rep.status == 403
|
||||
assert rep.json() == {"error": "Token CSRF invalide"}
|
||||
|
||||
|
||||
def test_load_more_rejects_invalid_type(page, base_url, csrf_token):
|
||||
"""Un type de vidéos inconnu est refusé (400)."""
|
||||
rep = _post(page, base_url, "?type=inconnu&page=1",
|
||||
token=csrf_token, origin=base_url)
|
||||
assert rep.status == 400
|
||||
assert rep.json() == {"error": "Type de vidéos non valide"}
|
||||
|
||||
|
||||
def test_load_more_category_requires_id(page, base_url, csrf_token):
|
||||
"""Le type 'category' sans ID de catégorie est refusé (400)."""
|
||||
rep = _post(page, base_url, "?type=category&page=1",
|
||||
token=csrf_token, origin=base_url)
|
||||
assert rep.status == 400
|
||||
assert rep.json() == {"error": "ID de catégorie manquant ou invalide"}
|
||||
|
||||
|
||||
def test_load_more_recent_structure(page, base_url, csrf_token):
|
||||
"""Une requête valide renvoie la structure JSON attendue."""
|
||||
rep = _post(page, base_url, "?type=recent&page=1",
|
||||
token=csrf_token, origin=base_url)
|
||||
assert rep.status == 200
|
||||
|
||||
payload = rep.json()
|
||||
assert payload["success"] is True
|
||||
assert payload["page"] == 1
|
||||
assert isinstance(payload["hasMore"], bool)
|
||||
assert isinstance(payload["html"], str)
|
||||
|
||||
|
||||
def test_load_more_recent_returns_videos(page, base_url, csrf_token):
|
||||
"""Le HTML retourné contient des cartes vidéo (nécessite le réseau)."""
|
||||
rep = _post(page, base_url, "?type=recent&page=1",
|
||||
token=csrf_token, origin=base_url)
|
||||
assert rep.status == 200
|
||||
|
||||
payload = rep.json()
|
||||
if not payload["html"]:
|
||||
pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)")
|
||||
|
||||
assert 'class="video-card"' in payload["html"]
|
||||
assert "data-video-id=" in payload["html"]
|
||||
# L'endpoint n'a plus rien à servir bien au-delà du catalogue
|
||||
rep_fin = _post(page, base_url, "?type=recent&page=10000",
|
||||
token=csrf_token, origin=base_url)
|
||||
assert rep_fin.status == 200
|
||||
assert rep_fin.json()["hasMore"] is False
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests E2E de la page vidéo (video.php)."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _premier_video_id(page, base_url):
|
||||
"""Récupère l'identifiant de la première carte vidéo de l'accueil."""
|
||||
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
|
||||
ids = page.locator(".video-card").evaluate_all(
|
||||
"(els) => els.map(e => e.dataset.videoId).filter(Boolean)"
|
||||
)
|
||||
# Déduplique en conservant l'ordre (la même vidéo peut être en short et en grille)
|
||||
uniques = list(dict.fromkeys(ids))
|
||||
if not uniques:
|
||||
pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)")
|
||||
return uniques[0]
|
||||
|
||||
|
||||
def _jsonld_blocks(page):
|
||||
"""Parse tous les blocs <script type='application/ld+json'> de la page."""
|
||||
blocs = []
|
||||
for brut in page.locator('script[type="application/ld+json"]').all_text_contents():
|
||||
blocs.append(json.loads(brut))
|
||||
return blocs
|
||||
|
||||
|
||||
def test_video_page_jsonld_videoobject(page, base_url):
|
||||
"""La page vidéo expose des données structurées VideoObject valides."""
|
||||
video_id = _premier_video_id(page, base_url)
|
||||
page.goto(base_url + f"/video.php?id={video_id}", wait_until="domcontentloaded")
|
||||
|
||||
blocs = _jsonld_blocks(page)
|
||||
types = [b.get("@type") for b in blocs]
|
||||
assert "VideoObject" in types, f"Pas de VideoObject dans les JSON-LD : {types}"
|
||||
|
||||
video_ld = blocs[types.index("VideoObject")]
|
||||
assert video_ld.get("@context") == "https://schema.org"
|
||||
assert video_ld.get("name"), "Le nom du VideoObject est vide"
|
||||
assert video_ld.get("embedUrl", "").endswith(f"/videos/embed/{video_id}")
|
||||
assert video_ld.get("thumbnailUrl"), "thumbnailUrl manquant"
|
||||
assert video_ld.get("uploadDate"), "uploadDate manquant"
|
||||
assert video_ld.get("duration"), "duration manquante"
|
||||
|
||||
# Le fil d'Ariane est également publié
|
||||
assert "BreadcrumbList" in types
|
||||
|
||||
|
||||
def test_video_page_peertube_embed(page, base_url, peertube_url):
|
||||
"""Le lecteur embarque l'iframe PeerTube de la vidéo demandée."""
|
||||
video_id = _premier_video_id(page, base_url)
|
||||
page.goto(base_url + f"/video.php?id={video_id}", wait_until="domcontentloaded")
|
||||
|
||||
iframe = page.locator(".video-player iframe")
|
||||
assert iframe.count() == 1, "L'iframe du lecteur est absente"
|
||||
src = iframe.get_attribute("src")
|
||||
assert src.startswith(f"{peertube_url}/videos/embed/{video_id}"), (
|
||||
f"Source d'iframe inattendue : {src}"
|
||||
)
|
||||
|
||||
|
||||
def test_video_page_suggestions(page, base_url):
|
||||
"""La colonne de suggestions propose d'autres vidéos du site."""
|
||||
video_id = _premier_video_id(page, base_url)
|
||||
page.goto(base_url + f"/video.php?id={video_id}", wait_until="domcontentloaded")
|
||||
|
||||
suggestions = page.locator(".video-suggestions")
|
||||
assert suggestions.count() == 1, "La section de suggestions est absente"
|
||||
assert suggestions.locator("h2").inner_text() == "Vidéos suggérées"
|
||||
|
||||
cartes = suggestions.locator(".suggested-video")
|
||||
if cartes.count() == 0:
|
||||
pytest.skip("Aucune vidéo suggérée (API PeerTube indisponible ?)")
|
||||
|
||||
hrefs = cartes.locator("a.suggested-video-link").evaluate_all(
|
||||
"(els) => els.map(e => e.getAttribute('href'))"
|
||||
)
|
||||
for href in hrefs:
|
||||
assert href and href.startswith("video.php?id="), (
|
||||
f"Lien de suggestion inattendu : {href}"
|
||||
)
|
||||
# La vidéo courante ne doit pas se suggérer elle-même
|
||||
assert f"video.php?id={video_id}" not in hrefs
|
||||
|
||||
|
||||
def test_video_page_invalid_id_redirects(page, base_url):
|
||||
"""Un identifiant vidéo invalide redirige vers l'accueil (sans appel réseau)."""
|
||||
page.goto(base_url + "/video.php?id=pas-un-uuid-valide",
|
||||
wait_until="domcontentloaded")
|
||||
assert page.url.endswith("/index.php"), f"URL après redirection : {page.url}"
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Tests unitaires pour js/countdown.js (classe CountdownTimer).
|
||||
*
|
||||
* Le script est chargé dans un contexte vm avec un DOM simulé :
|
||||
* la date courante est figée (FakeDate) et setInterval est factice,
|
||||
* ce qui rend les calculs de temps restant déterministes.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const COUNTDOWN_PATH = path.join(__dirname, '..', '..', 'js', 'countdown.js');
|
||||
const COUNTDOWN_SOURCE = fs.readFileSync(COUNTDOWN_PATH, 'utf8');
|
||||
|
||||
// Date courante figée pour tous les tests
|
||||
const FIXED_NOW = new Date('2030-01-01T00:00:00Z').getTime();
|
||||
|
||||
function makeElements() {
|
||||
return {
|
||||
'countdown-days': { textContent: '' },
|
||||
'countdown-hours': { textContent: '' },
|
||||
'countdown-minutes': { textContent: '' },
|
||||
'countdown-seconds': { textContent: '' }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge countdown.js dans un contexte vm isolé et retourne la classe.
|
||||
*
|
||||
* @param {object|null} options.elements Éléments DOM simulés (null = absents)
|
||||
* @param {number} options.now Timestamp courant simulé
|
||||
*/
|
||||
function loadCountdown({ elements = null, now = FIXED_NOW } = {}) {
|
||||
class FakeDate extends Date {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) {
|
||||
super(now);
|
||||
} else {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
|
||||
static now() {
|
||||
return now;
|
||||
}
|
||||
}
|
||||
|
||||
const windowMock = {
|
||||
addEventListener() {},
|
||||
location: { href: '' }
|
||||
};
|
||||
|
||||
// Timers factices : le timer ne tourne jamais tout seul,
|
||||
// on enregistre simplement les appels pour vérification.
|
||||
const timers = { intervals: [], cleared: [] };
|
||||
let nextIntervalId = 1;
|
||||
|
||||
const sandbox = {
|
||||
document: {
|
||||
getElementById: (id) => (elements ? elements[id] || null : null),
|
||||
querySelectorAll: () => [],
|
||||
addEventListener() {}
|
||||
},
|
||||
window: windowMock,
|
||||
Date: FakeDate,
|
||||
setInterval: (fn, delay) => {
|
||||
const id = nextIntervalId++;
|
||||
timers.intervals.push({ id, fn, delay });
|
||||
return id;
|
||||
},
|
||||
clearInterval: (id) => {
|
||||
timers.cleared.push(id);
|
||||
},
|
||||
setTimeout: () => 0,
|
||||
console
|
||||
};
|
||||
|
||||
vm.createContext(sandbox);
|
||||
const CountdownTimer = vm.runInContext(COUNTDOWN_SOURCE + '\nCountdownTimer;', sandbox);
|
||||
|
||||
return { CountdownTimer, window: windowMock, timers };
|
||||
}
|
||||
|
||||
describe('CountdownTimer', () => {
|
||||
test('formatNumber complète les nombres sur deux chiffres', () => {
|
||||
const { CountdownTimer } = loadCountdown();
|
||||
|
||||
assert.strictEqual(CountdownTimer.prototype.formatNumber(0), '00');
|
||||
assert.strictEqual(CountdownTimer.prototype.formatNumber(5), '05');
|
||||
assert.strictEqual(CountdownTimer.prototype.formatNumber(42), '42');
|
||||
assert.strictEqual(CountdownTimer.prototype.formatNumber(123), '123');
|
||||
});
|
||||
|
||||
test('calcule correctement le temps restant (jours/heures/minutes/secondes)', () => {
|
||||
const distance =
|
||||
2 * 24 * 60 * 60 * 1000 + // 2 jours
|
||||
3 * 60 * 60 * 1000 + // 3 heures
|
||||
4 * 60 * 1000 + // 4 minutes
|
||||
5 * 1000; // 5 secondes
|
||||
|
||||
const elements = makeElements();
|
||||
const { CountdownTimer } = loadCountdown({ elements });
|
||||
const timer = new CountdownTimer(FIXED_NOW + distance);
|
||||
|
||||
try {
|
||||
assert.strictEqual(elements['countdown-days'].textContent, '02');
|
||||
assert.strictEqual(elements['countdown-hours'].textContent, '03');
|
||||
assert.strictEqual(elements['countdown-minutes'].textContent, '04');
|
||||
assert.strictEqual(elements['countdown-seconds'].textContent, '05');
|
||||
} finally {
|
||||
timer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('reporte les heures au-delà de 24h dans les jours', () => {
|
||||
const distance = 36 * 60 * 60 * 1000; // 36 heures = 1 jour + 12 heures
|
||||
|
||||
const elements = makeElements();
|
||||
const { CountdownTimer } = loadCountdown({ elements });
|
||||
const timer = new CountdownTimer(FIXED_NOW + distance);
|
||||
|
||||
try {
|
||||
assert.strictEqual(elements['countdown-days'].textContent, '01');
|
||||
assert.strictEqual(elements['countdown-hours'].textContent, '12');
|
||||
assert.strictEqual(elements['countdown-minutes'].textContent, '00');
|
||||
assert.strictEqual(elements['countdown-seconds'].textContent, '00');
|
||||
} finally {
|
||||
timer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('une date passée arrête le compte à rebours et redirige vers /', () => {
|
||||
const elements = makeElements();
|
||||
const { CountdownTimer, window } = loadCountdown({ elements });
|
||||
const timer = new CountdownTimer(FIXED_NOW - 1000);
|
||||
|
||||
try {
|
||||
// onComplete() redirige vers la page principale
|
||||
assert.strictEqual(window.location.href, '/');
|
||||
|
||||
// Les éléments ne sont pas mis à jour quand le compte est terminé
|
||||
assert.strictEqual(elements['countdown-days'].textContent, '');
|
||||
assert.strictEqual(elements['countdown-hours'].textContent, '');
|
||||
assert.strictEqual(elements['countdown-minutes'].textContent, '');
|
||||
assert.strictEqual(elements['countdown-seconds'].textContent, '');
|
||||
} finally {
|
||||
timer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('une distance nulle affiche zéro partout sans rediriger', () => {
|
||||
const elements = makeElements();
|
||||
const { CountdownTimer, window } = loadCountdown({ elements });
|
||||
const timer = new CountdownTimer(FIXED_NOW);
|
||||
|
||||
try {
|
||||
assert.strictEqual(elements['countdown-days'].textContent, '00');
|
||||
assert.strictEqual(elements['countdown-hours'].textContent, '00');
|
||||
assert.strictEqual(elements['countdown-minutes'].textContent, '00');
|
||||
assert.strictEqual(elements['countdown-seconds'].textContent, '00');
|
||||
assert.strictEqual(window.location.href, '');
|
||||
} finally {
|
||||
timer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('fonctionne sans éléments DOM présents', () => {
|
||||
const { CountdownTimer } = loadCountdown({ elements: null });
|
||||
const timer = new CountdownTimer(FIXED_NOW + 60 * 1000);
|
||||
|
||||
// Aucune erreur attendue malgré l'absence des éléments
|
||||
timer.stop();
|
||||
});
|
||||
|
||||
test('démarre un intervalle d\'une seconde et stop() le nettoie', () => {
|
||||
const { CountdownTimer, timers } = loadCountdown();
|
||||
const timer = new CountdownTimer(FIXED_NOW + 60 * 1000);
|
||||
|
||||
assert.strictEqual(timers.intervals.length, 1);
|
||||
assert.strictEqual(timers.intervals[0].delay, 1000);
|
||||
assert.strictEqual(timers.cleared.length, 0);
|
||||
|
||||
timer.stop();
|
||||
assert.deepStrictEqual(timers.cleared, [timer.interval]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Unit tests for js/pleroma-adapter.js.
|
||||
*
|
||||
* The adapter is an IIFE that wraps window.fetch. It is loaded in a vm
|
||||
* context with a mocked window object, so tests can call the wrapped
|
||||
* fetch and inspect how Pleroma API responses are mapped to the
|
||||
* Mastodon format expected by mastodon-timeline.umd.js.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const ADAPTER_PATH = path.join(__dirname, '..', '..', 'js', 'pleroma-adapter.js');
|
||||
const ADAPTER_SOURCE = fs.readFileSync(ADAPTER_PATH, 'utf8');
|
||||
|
||||
const TIMELINE_URL = 'https://pleroma.example/api/v1/timelines/public';
|
||||
const ACCOUNT_STATUSES_URL = 'https://pleroma.example/api/v1/accounts/42/statuses';
|
||||
|
||||
/**
|
||||
* Loads the adapter in an isolated vm context.
|
||||
*
|
||||
* @param {Function} handler Fake backend: receives the URL, returns a Response
|
||||
* @returns {{fetch: Function, warnings: string[]}} The wrapped fetch and logged warnings
|
||||
*/
|
||||
function loadAdapter(handler) {
|
||||
const warnings = [];
|
||||
const windowMock = {
|
||||
fetch: async (url) => handler(url)
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
window: windowMock,
|
||||
Response,
|
||||
console: {
|
||||
log() {},
|
||||
warn: (...args) => warnings.push(args.join(' ')),
|
||||
error() {}
|
||||
}
|
||||
};
|
||||
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(ADAPTER_SOURCE, sandbox);
|
||||
|
||||
return { fetch: windowMock.fetch, warnings };
|
||||
}
|
||||
|
||||
function jsonResponse(data, init = {}) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init
|
||||
});
|
||||
}
|
||||
|
||||
describe('Pleroma adapter', () => {
|
||||
test('adds default meta to timeline attachments missing meta', async () => {
|
||||
const posts = [
|
||||
{
|
||||
id: '1',
|
||||
content: 'hello',
|
||||
media_attachments: [
|
||||
{ id: 'a1', type: 'image', url: 'https://pleroma.example/img.png' }
|
||||
]
|
||||
},
|
||||
{ id: '2', content: 'no media' }
|
||||
];
|
||||
const { fetch } = loadAdapter(() => jsonResponse(posts));
|
||||
|
||||
const res = await fetch(TIMELINE_URL);
|
||||
const data = await res.json();
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(data.length, 2);
|
||||
|
||||
const attachment = data[0].media_attachments[0];
|
||||
assert.deepStrictEqual(attachment.meta.original, {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
aspect: 1280 / 720
|
||||
});
|
||||
assert.deepStrictEqual(attachment.meta.small, {
|
||||
width: 640,
|
||||
height: 360,
|
||||
aspect: 1280 / 720
|
||||
});
|
||||
// Other fields are preserved
|
||||
assert.strictEqual(attachment.url, 'https://pleroma.example/img.png');
|
||||
assert.strictEqual(data[0].content, 'hello');
|
||||
assert.strictEqual(data[1].content, 'no media');
|
||||
assert.strictEqual(data[1].media_attachments, undefined);
|
||||
});
|
||||
|
||||
test('uses 1920x1080 for video attachments (pleroma mime_type)', async () => {
|
||||
const posts = [
|
||||
{
|
||||
id: '9',
|
||||
media_attachments: [
|
||||
{ id: 'v1', pleroma: { mime_type: 'video/mp4' } }
|
||||
]
|
||||
}
|
||||
];
|
||||
const { fetch } = loadAdapter(() => jsonResponse(posts));
|
||||
|
||||
const res = await fetch(ACCOUNT_STATUSES_URL);
|
||||
const data = await res.json();
|
||||
|
||||
const meta = data[0].media_attachments[0].meta;
|
||||
assert.deepStrictEqual(meta.original, {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
aspect: 1920 / 1080
|
||||
});
|
||||
assert.deepStrictEqual(meta.small, {
|
||||
width: 960,
|
||||
height: 540,
|
||||
aspect: 1920 / 1080
|
||||
});
|
||||
});
|
||||
|
||||
test('uses 1200x800 for image attachments (pleroma mime_type)', async () => {
|
||||
const post = {
|
||||
id: '10',
|
||||
media_attachments: [
|
||||
{ id: 'i1', pleroma: { mime_type: 'image/jpeg' } }
|
||||
]
|
||||
};
|
||||
const { fetch } = loadAdapter(() => jsonResponse(post));
|
||||
|
||||
const res = await fetch(TIMELINE_URL);
|
||||
const data = await res.json();
|
||||
|
||||
const meta = data.media_attachments[0].meta;
|
||||
assert.deepStrictEqual(meta.original, {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
aspect: 1200 / 800
|
||||
});
|
||||
assert.deepStrictEqual(meta.small, {
|
||||
width: 600,
|
||||
height: 400,
|
||||
aspect: 1200 / 800
|
||||
});
|
||||
});
|
||||
|
||||
test('adapts a single (non-array) post object', async () => {
|
||||
const post = {
|
||||
id: 'single',
|
||||
media_attachments: [{ id: 's1' }]
|
||||
};
|
||||
const { fetch } = loadAdapter(() => jsonResponse(post));
|
||||
|
||||
const res = await fetch(TIMELINE_URL);
|
||||
const data = await res.json();
|
||||
|
||||
assert.strictEqual(Array.isArray(data), false);
|
||||
assert.strictEqual(data.id, 'single');
|
||||
assert.deepStrictEqual(data.media_attachments[0].meta.original, {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
aspect: 1280 / 720
|
||||
});
|
||||
});
|
||||
|
||||
test('leaves attachments with complete meta untouched', async () => {
|
||||
const completeMeta = {
|
||||
original: { width: 640, height: 480, aspect: 640 / 480 },
|
||||
small: { width: 320, height: 240, aspect: 640 / 480 }
|
||||
};
|
||||
const posts = [
|
||||
{ id: 'm1', media_attachments: [{ id: 'ok', meta: completeMeta }] }
|
||||
];
|
||||
const { fetch } = loadAdapter(() => jsonResponse(posts));
|
||||
|
||||
const res = await fetch(TIMELINE_URL);
|
||||
const data = await res.json();
|
||||
|
||||
assert.deepStrictEqual(data[0].media_attachments[0].meta, completeMeta);
|
||||
});
|
||||
|
||||
test('keeps existing partial meta.original and fills meta.small', async () => {
|
||||
const posts = [
|
||||
{
|
||||
id: 'p1',
|
||||
media_attachments: [
|
||||
{
|
||||
id: 'partial',
|
||||
meta: { original: { width: 640, height: 480, aspect: 640 / 480 } }
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
const { fetch } = loadAdapter(() => jsonResponse(posts));
|
||||
|
||||
const res = await fetch(TIMELINE_URL);
|
||||
const data = await res.json();
|
||||
|
||||
const meta = data[0].media_attachments[0].meta;
|
||||
assert.deepStrictEqual(meta.original, { width: 640, height: 480, aspect: 640 / 480 });
|
||||
// meta.small is created from the default dimensions
|
||||
assert.deepStrictEqual(meta.small, {
|
||||
width: 640,
|
||||
height: 360,
|
||||
aspect: 1280 / 720
|
||||
});
|
||||
});
|
||||
|
||||
test('adapts media attachments inside reblogs recursively', async () => {
|
||||
const posts = [
|
||||
{
|
||||
id: 'r1',
|
||||
reblog: {
|
||||
id: 'r2',
|
||||
media_attachments: [
|
||||
{ id: 'rb', pleroma: { mime_type: 'image/png' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
const { fetch } = loadAdapter(() => jsonResponse(posts));
|
||||
|
||||
const res = await fetch(TIMELINE_URL);
|
||||
const data = await res.json();
|
||||
|
||||
const meta = data[0].reblog.media_attachments[0].meta;
|
||||
assert.deepStrictEqual(meta.original, {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
aspect: 1200 / 800
|
||||
});
|
||||
});
|
||||
|
||||
test('does not mutate the original payload objects', async () => {
|
||||
const attachment = { id: 'orig' };
|
||||
const posts = [{ id: 'n1', media_attachments: [attachment] }];
|
||||
const { fetch } = loadAdapter(() => jsonResponse(posts));
|
||||
|
||||
const res = await fetch(TIMELINE_URL);
|
||||
await res.json();
|
||||
|
||||
assert.strictEqual(attachment.meta, undefined);
|
||||
assert.deepStrictEqual(posts[0].media_attachments, [attachment]);
|
||||
});
|
||||
|
||||
test('passes non-object array entries through unchanged', async () => {
|
||||
const posts = [null, 'not-a-post', { id: 'real', media_attachments: [{ id: 'x' }] }];
|
||||
const { fetch } = loadAdapter(() => jsonResponse(posts));
|
||||
|
||||
const res = await fetch(TIMELINE_URL);
|
||||
const data = await res.json();
|
||||
|
||||
assert.strictEqual(data[0], null);
|
||||
assert.strictEqual(data[1], 'not-a-post');
|
||||
assert.ok(data[2].media_attachments[0].meta);
|
||||
});
|
||||
|
||||
test('intercepts account statuses URLs only when they contain /statuses', async () => {
|
||||
const posts = [{ id: 'a', media_attachments: [{ id: 'x' }] }];
|
||||
const { fetch } = loadAdapter(() => jsonResponse(posts));
|
||||
|
||||
// /api/v1/accounts/42/statuses is adapted
|
||||
const resStatuses = await fetch(ACCOUNT_STATUSES_URL);
|
||||
const dataStatuses = await resStatuses.json();
|
||||
assert.ok(dataStatuses[0].media_attachments[0].meta);
|
||||
|
||||
// /api/v1/accounts/42 (no /statuses) is left as-is
|
||||
const resAccount = await fetch('https://pleroma.example/api/v1/accounts/42');
|
||||
const dataAccount = await resAccount.json();
|
||||
assert.strictEqual(dataAccount[0].media_attachments[0].meta, undefined);
|
||||
});
|
||||
|
||||
test('returns non-API responses untouched (same Response object)', async () => {
|
||||
let original;
|
||||
const { fetch } = loadAdapter(() => {
|
||||
original = jsonResponse({ ok: true });
|
||||
return original;
|
||||
});
|
||||
|
||||
const res = await fetch('https://example.com/about');
|
||||
|
||||
assert.strictEqual(res, original);
|
||||
assert.deepStrictEqual(await res.json(), { ok: true });
|
||||
});
|
||||
|
||||
test('falls back to the original response when JSON parsing fails', async () => {
|
||||
let original;
|
||||
const { fetch, warnings } = loadAdapter(() => {
|
||||
original = new Response('not-json', { status: 200 });
|
||||
return original;
|
||||
});
|
||||
|
||||
const res = await fetch(TIMELINE_URL);
|
||||
|
||||
assert.strictEqual(res, original);
|
||||
assert.strictEqual(await res.text(), 'not-json');
|
||||
assert.strictEqual(warnings.length, 1);
|
||||
});
|
||||
|
||||
test('preserves response status on adapted responses', async () => {
|
||||
const posts = [{ id: 's', media_attachments: [] }];
|
||||
const { fetch } = loadAdapter(() => jsonResponse(posts, { status: 201 }));
|
||||
|
||||
const res = await fetch(TIMELINE_URL);
|
||||
|
||||
assert.strictEqual(res.status, 201);
|
||||
assert.deepStrictEqual(await res.json(), posts);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Lanceur des tests unitaires JS.
|
||||
*
|
||||
* Utilise le runner natif de Node (node:test), aucune dépendance npm.
|
||||
* Usage : node tests/js/run.js
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const { run } = require('node:test');
|
||||
const { spec } = require('node:test/reporters');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const testDir = __dirname;
|
||||
const files = fs
|
||||
.readdirSync(testDir)
|
||||
.filter((name) => name.endsWith('-test.js'))
|
||||
.sort()
|
||||
.map((name) => path.join(testDir, name));
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error('Aucun fichier *-test.js trouvé dans ' + testDir);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stream = run({ files });
|
||||
|
||||
let failures = 0;
|
||||
stream.on('test:fail', () => {
|
||||
failures += 1;
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
stream.compose(spec).pipe(process.stdout);
|
||||
|
||||
stream.on('end', () => {
|
||||
if (failures > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/**
|
||||
* Bootstrap des tests unitaires PHP.
|
||||
*
|
||||
* Définit des constantes de test puis charge les includes du projet
|
||||
* SANS aucun appel réseau : PEERTUBE_URL pointe volontairement sur
|
||||
* 127.0.0.1, ce qui fait échouer isValidPeerTubeUrl() et court-circuite
|
||||
* callPeerTubeApiOriginal() (retour [] immédiat, jamais de cURL). De ce
|
||||
* fait, initCategories() — appelée au chargement de config.php — est
|
||||
* neutralisée et PEERTUBE_CATEGORIES vaut un tableau vide.
|
||||
*
|
||||
* Les constantes sont définies AVANT le chargement de config.php pour
|
||||
* primer sur les valeurs de config.default.php (protégées par !defined()).
|
||||
*/
|
||||
|
||||
define('APP_HOST_NAME', 'test.local');
|
||||
define('SITE_NAME', 'Site de test');
|
||||
define('CSRF_SECRET', 'secret-csrf-pour-tests-unitaires');
|
||||
define('PEERTUBE_URL', 'http://127.0.0.1'); // URL privée volontaire : bloque tout appel API réel
|
||||
define('CACHE_ENABLED', true);
|
||||
define('CACHE_DURATION', 3600);
|
||||
define('DEFAULT_TIMEZONE', 'UTC');
|
||||
|
||||
// Ne pas polluer la sortie des tests avec les error_log() de sécurité
|
||||
// (ex. "Invalid PeerTube URL", attendu avec l'URL factice ci-dessus)
|
||||
ini_set('error_log', sys_get_temp_dir() . '/annu-kute-tests-errors.log');
|
||||
|
||||
$includes_dir = dirname(__DIR__, 2) . '/includes';
|
||||
|
||||
require_once $includes_dir . '/security.php';
|
||||
require_once $includes_dir . '/simple-cache.php';
|
||||
require_once $includes_dir . '/lib/markdown.php';
|
||||
require_once $includes_dir . '/structured-data.php';
|
||||
require_once $includes_dir . '/config.php'; // fournit les fonctions de formatage
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests unitaires pour la classe SimpleAPICache (includes/simple-cache.php).
|
||||
*
|
||||
* Le répertoire de cache est redirigé vers un dossier temporaire via
|
||||
* réflexion afin de ne jamais toucher au cache réel (cache/api).
|
||||
*/
|
||||
|
||||
$cache = new SimpleAPICache();
|
||||
$cacheDirProperty = new ReflectionProperty(SimpleAPICache::class, 'cacheDir');
|
||||
$cacheDirProperty->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);
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests unitaires pour les fonctions de formatage de includes/config.php
|
||||
* (formatDuration, formatViewCount, formatDate, formatVideosData)
|
||||
* et truncateText de includes/structured-data.php
|
||||
*/
|
||||
|
||||
// --- formatDuration ---------------------------------------------------------
|
||||
|
||||
assertEquals('0:00', formatDuration(0), 'formatDuration(0) donne 0:00');
|
||||
assertEquals('0:59', formatDuration(59), 'formatDuration formate les secondes');
|
||||
assertEquals('1:05', formatDuration(65), 'formatDuration formate minutes:secondes avec zéro');
|
||||
assertEquals('1:00:00', formatDuration(3600), 'formatDuration passe au format heures');
|
||||
assertEquals('1:01:01', formatDuration(3661), 'formatDuration formate heures:minutes:secondes');
|
||||
|
||||
// --- formatViewCount --------------------------------------------------------
|
||||
|
||||
assertEquals(999, formatViewCount(999), 'formatViewCount laisse les valeurs < 1000 intactes');
|
||||
assertEquals('1K', formatViewCount(1000), 'formatViewCount abrège en K à partir de 1000');
|
||||
assertEquals('1.5K', formatViewCount(1500), 'formatViewCount arrondit les K à une décimale');
|
||||
assertEquals('1M', formatViewCount(1000000), 'formatViewCount abrège en M à partir de 1 million');
|
||||
assertEquals('2.5M', formatViewCount(2500000), 'formatViewCount arrondit les M à une décimale');
|
||||
|
||||
// --- formatDate -------------------------------------------------------------
|
||||
|
||||
assertEquals('Aujourd\'hui', formatDate(date('Y-m-d H:i:s')), 'formatDate affiche Aujourd\'hui');
|
||||
assertEquals('Hier', formatDate(date('Y-m-d H:i:s', time() - 86400)), 'formatDate affiche Hier');
|
||||
assertEquals(
|
||||
'Il y a 3 jours',
|
||||
formatDate(date('Y-m-d H:i:s', time() - 3 * 86400)),
|
||||
'formatDate affiche les jours sous une semaine'
|
||||
);
|
||||
assertEquals(
|
||||
'Il y a 1 semaine',
|
||||
formatDate(date('Y-m-d H:i:s', time() - 10 * 86400)),
|
||||
'formatDate affiche la semaine au singulier'
|
||||
);
|
||||
assertEquals(
|
||||
'Il y a 2 semaines',
|
||||
formatDate(date('Y-m-d H:i:s', time() - 20 * 86400)),
|
||||
'formatDate affiche les semaines au pluriel'
|
||||
);
|
||||
assertEquals(
|
||||
'Il y a 2 mois',
|
||||
formatDate(date('Y-m-d H:i:s', time() - 60 * 86400)),
|
||||
'formatDate affiche les mois'
|
||||
);
|
||||
assertEquals(
|
||||
'Il y a 1 an',
|
||||
formatDate(date('Y-m-d H:i:s', time() - 400 * 86400)),
|
||||
'formatDate affiche l\'année au singulier'
|
||||
);
|
||||
assertEquals(
|
||||
'Il y a 2 ans',
|
||||
formatDate(date('Y-m-d H:i:s', time() - 800 * 86400)),
|
||||
'formatDate affiche les années au pluriel'
|
||||
);
|
||||
|
||||
// --- formatVideosData -------------------------------------------------------
|
||||
|
||||
$rawVideos = [
|
||||
[
|
||||
'uuid' => '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'
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests unitaires pour markdown_to_html (includes/lib/markdown.php)
|
||||
*
|
||||
* Les sorties attendues reflètent le comportement actuel de la fonction,
|
||||
* y compris ses particularités (voir la note sur les listes à puces).
|
||||
*/
|
||||
|
||||
// --- Échappement XSS ---------------------------------------------------------
|
||||
|
||||
assertEquals(
|
||||
'<script>alert("x")</script>',
|
||||
markdown_to_html('<script>alert("x")</script>'),
|
||||
'markdown_to_html échappe le HTML brut'
|
||||
);
|
||||
assertNotContains(
|
||||
'<script>',
|
||||
markdown_to_html('<script>alert("x")</script>'),
|
||||
'markdown_to_html ne laisse passer aucune balise script'
|
||||
);
|
||||
|
||||
// --- Gras et italique --------------------------------------------------------
|
||||
|
||||
assertEquals(
|
||||
'<strong>gras</strong>',
|
||||
markdown_to_html('**gras**'),
|
||||
'markdown_to_html convertit **texte** en <strong>'
|
||||
);
|
||||
assertEquals(
|
||||
'<em>ital</em>',
|
||||
markdown_to_html('*ital*'),
|
||||
'markdown_to_html convertit *texte* en <em>'
|
||||
);
|
||||
|
||||
// --- Liens Markdown ----------------------------------------------------------
|
||||
|
||||
assertEquals(
|
||||
'<a href="https://exemple.com" target="_blank" rel="noopener noreferrer">exemple</a>',
|
||||
markdown_to_html('[exemple](https://exemple.com)'),
|
||||
'markdown_to_html convertit un lien Markdown avec attributs de sécurité'
|
||||
);
|
||||
assertEquals(
|
||||
'<a href="http://exemple.com" target="_blank" rel="noopener noreferrer">site</a>',
|
||||
markdown_to_html('[site](exemple.com)'),
|
||||
'markdown_to_html ajoute http:// aux liens sans protocole'
|
||||
);
|
||||
|
||||
// --- URLs brutes -------------------------------------------------------------
|
||||
|
||||
assertEquals(
|
||||
'Visite <a href="https://exemple.com/page" target="_blank" rel="noopener noreferrer">https://exemple.com/page</a> 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 <a href="http://o-k-i.net" target="_blank" rel="noopener noreferrer">o-k-i.net</a> 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 <li> déjà
|
||||
// produits par la passe des puces, d'où un double enveloppement <ul><ol>.
|
||||
// C'est le comportement actuel, verrouillé ici contre toute régression.
|
||||
assertEquals(
|
||||
"<ul><ol><li>a</li>\n<li>b</li></ol></ul>",
|
||||
markdown_to_html("- a\n- b"),
|
||||
'markdown_to_html convertit les listes à puces (double enveloppement actuel)'
|
||||
);
|
||||
assertEquals(
|
||||
"<ol><li>a</li>\n<li>b</li></ol>",
|
||||
markdown_to_html("1. a\n2. b"),
|
||||
'markdown_to_html convertit les listes numérotées en <ol>'
|
||||
);
|
||||
|
||||
// --- Retours à la ligne ------------------------------------------------------
|
||||
|
||||
assertEquals(
|
||||
"ligne1<br />\nligne2",
|
||||
markdown_to_html("ligne1\nligne2"),
|
||||
'markdown_to_html convertit les sauts de ligne en <br />'
|
||||
);
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
/**
|
||||
* Mini-lanceur de tests unitaires PHP (aucune dépendance externe).
|
||||
*
|
||||
* Usage : php tests/php/run.php
|
||||
*
|
||||
* Exécute tous les fichiers tests/php/*-test.php puis affiche un résumé.
|
||||
* Code de sortie : 0 si tous les tests passent, 1 sinon.
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
// --- Assertions -----------------------------------------------------------
|
||||
|
||||
$GLOBALS['tests_passed'] = 0;
|
||||
$GLOBALS['tests_failed'] = 0;
|
||||
$GLOBALS['tests_failures'] = [];
|
||||
|
||||
/**
|
||||
* Formate une valeur pour l'affichage dans les messages d'échec
|
||||
*/
|
||||
function test_export($value) {
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return var_export($value, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le résultat d'une assertion et affiche la ligne correspondante
|
||||
*/
|
||||
function test_record($ok, $label, $detail = '') {
|
||||
if ($ok) {
|
||||
$GLOBALS['tests_passed']++;
|
||||
echo " [OK] $label\n";
|
||||
} else {
|
||||
$GLOBALS['tests_failed']++;
|
||||
$GLOBALS['tests_failures'][] = $label;
|
||||
echo " [ÉCHEC] $label" . ($detail !== '' ? " — $detail" : '') . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
function assertEquals($expected, $actual, $label) {
|
||||
test_record(
|
||||
$expected == $actual,
|
||||
$label,
|
||||
'attendu ' . test_export($expected) . ', obtenu ' . test_export($actual)
|
||||
);
|
||||
}
|
||||
|
||||
function assertTrue($actual, $label) {
|
||||
test_record($actual === true, $label, 'attendu true, obtenu ' . test_export($actual));
|
||||
}
|
||||
|
||||
function assertFalse($actual, $label) {
|
||||
test_record($actual === false, $label, 'attendu false, obtenu ' . test_export($actual));
|
||||
}
|
||||
|
||||
function assertNull($actual, $label) {
|
||||
test_record($actual === null, $label, 'attendu null, obtenu ' . test_export($actual));
|
||||
}
|
||||
|
||||
function assertContains($needle, $haystack, $label) {
|
||||
test_record(
|
||||
strpos($haystack, $needle) !== false,
|
||||
$label,
|
||||
test_export($needle) . ' introuvable dans ' . test_export($haystack)
|
||||
);
|
||||
}
|
||||
|
||||
function assertNotContains($needle, $haystack, $label) {
|
||||
test_record(
|
||||
strpos($haystack, $needle) === false,
|
||||
$label,
|
||||
test_export($needle) . ' trouvé dans ' . test_export($haystack)
|
||||
);
|
||||
}
|
||||
|
||||
// --- Exécution des fichiers de test ---------------------------------------
|
||||
|
||||
$testFiles = glob(__DIR__ . '/*-test.php');
|
||||
sort($testFiles);
|
||||
|
||||
if (empty($testFiles)) {
|
||||
echo "Aucun fichier de test trouvé dans " . __DIR__ . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
foreach ($testFiles as $file) {
|
||||
echo "\n== " . basename($file) . " ==\n";
|
||||
try {
|
||||
require $file;
|
||||
} catch (Throwable $e) {
|
||||
test_record(false, basename($file) . ' : exception non interceptée — ' . $e->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);
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests unitaires pour includes/security.php
|
||||
* (et les validateurs d'URL de includes/config.php)
|
||||
*/
|
||||
|
||||
// --- validateVideoId --------------------------------------------------------
|
||||
|
||||
assertEquals(
|
||||
'9c5de94d-8e1a-4b3c-9d2e-1234567890ab',
|
||||
validateVideoId('9c5de94d-8e1a-4b3c-9d2e-1234567890ab'),
|
||||
'validateVideoId accepte un UUID valide'
|
||||
);
|
||||
assertEquals(
|
||||
'9C5DE94D-8E1A-4B3C-9D2E-1234567890AB',
|
||||
validateVideoId('9C5DE94D-8E1A-4B3C-9D2E-1234567890AB'),
|
||||
'validateVideoId accepte un UUID en majuscules'
|
||||
);
|
||||
assertEquals(
|
||||
'9c5de94d-8e1a-4b3c-9d2e-1234567890ab',
|
||||
validateVideoId(' 9c5de94d-8e1a-4b3c-9d2e-1234567890ab '),
|
||||
'validateVideoId nettoie les espaces autour de l\'UUID'
|
||||
);
|
||||
assertFalse(validateVideoId(''), 'validateVideoId refuse une chaîne vide');
|
||||
assertFalse(validateVideoId('pas-un-uuid'), 'validateVideoId refuse une chaîne quelconque');
|
||||
assertFalse(
|
||||
validateVideoId('9c5de94d-8e1a-4b3c-1234567890ab'),
|
||||
'validateVideoId refuse un UUID tronqué'
|
||||
);
|
||||
|
||||
// --- validateSearchQuery ----------------------------------------------------
|
||||
|
||||
assertEquals('indépendance', validateSearchQuery('indépendance'), 'validateSearchQuery conserve les accents');
|
||||
assertEquals(
|
||||
'script',
|
||||
validateSearchQuery('<script>'),
|
||||
'validateSearchQuery supprime les chevrons'
|
||||
);
|
||||
assertEquals(
|
||||
'recherche btest/b',
|
||||
validateSearchQuery('recherche <b>test</b>'),
|
||||
'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');
|
||||
Reference in New Issue
Block a user