first commit

This commit is contained in:
julien
2026-03-16 01:47:07 +01:00
commit 8f7e61bda0
185 changed files with 27731 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\ClientIpResolver;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
final class ClientIpResolverTest extends TestCase
{
public function testResolveReturnsDefaultWhenRemoteAddrMissing(): void
{
$request = (new ServerRequestFactory())->createServerRequest('GET', '/');
$resolver = new ClientIpResolver();
self::assertSame('0.0.0.0', $resolver->resolve($request));
}
public function testResolveReturnsRemoteAddrWhenProxyNotTrusted(): void
{
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
'REMOTE_ADDR' => '10.0.0.1',
'HTTP_X_FORWARDED_FOR' => '203.0.113.10',
]);
$resolver = new ClientIpResolver(['127.0.0.1']);
self::assertSame('10.0.0.1', $resolver->resolve($request));
}
public function testResolveReturnsForwardedIpWhenProxyTrusted(): void
{
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_X_FORWARDED_FOR' => '203.0.113.10, 198.51.100.12',
]);
$resolver = new ClientIpResolver(['127.0.0.1']);
self::assertSame('203.0.113.10', $resolver->resolve($request));
}
public function testResolveFallsBackToRemoteAddrWhenForwardedIpInvalid(): void
{
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_X_FORWARDED_FOR' => 'not-an-ip',
]);
$resolver = new ClientIpResolver(['*']);
self::assertSame('127.0.0.1', $resolver->resolve($request));
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Config;
use PHPUnit\Framework\TestCase;
final class ConfigTest extends TestCase
{
public function testGetTwigCacheReturnsFalseInDev(): void
{
self::assertFalse(Config::getTwigCache(true));
}
public function testGetTwigCacheReturnsCachePathOutsideDev(): void
{
$cachePath = Config::getTwigCache(false);
self::assertIsString($cachePath);
self::assertStringEndsWith('/var/cache/twig', $cachePath);
}
public function testGetDatabasePathCreatesDatabaseFileWhenMissing(): void
{
$dbFile = dirname(__DIR__, 2).'/database/app.sqlite';
$dbDir = dirname($dbFile);
$backup = $dbFile.'.bak-test';
if (file_exists($backup)) {
@unlink($backup);
}
if (file_exists($dbFile)) {
rename($dbFile, $backup);
}
@unlink($dbFile);
try {
$path = Config::getDatabasePath();
self::assertSame($dbFile, $path);
self::assertDirectoryExists($dbDir);
self::assertFileExists($dbFile);
} finally {
@unlink($dbFile);
if (file_exists($backup)) {
rename($backup, $dbFile);
}
}
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Util\DateParser;
use DateTime;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour DateParser::parse().
*
* Couvre la conversion de valeurs brutes de base de données en DateTime,
* ainsi que les cas silencieux (null, chaîne vide, valeur invalide).
*/
final class DateParserTest extends TestCase
{
// ── Valeurs valides ────────────────────────────────────────────
/**
* Une date au format SQLite standard doit produire un DateTime correct.
*/
public function testStandardSqliteDate(): void
{
$result = DateParser::parse('2024-06-15 10:30:00');
$this->assertInstanceOf(DateTime::class, $result);
$this->assertSame('2024-06-15', $result->format('Y-m-d'));
$this->assertSame('10:30:00', $result->format('H:i:s'));
}
/**
* Une date ISO 8601 doit être correctement parsée.
*/
public function testIso8601Date(): void
{
$result = DateParser::parse('2024-01-01T00:00:00');
$this->assertInstanceOf(DateTime::class, $result);
$this->assertSame('2024-01-01', $result->format('Y-m-d'));
}
/**
* Une date seule (sans heure) doit être parsée correctement.
*/
public function testDateOnly(): void
{
$result = DateParser::parse('2024-12-31');
$this->assertInstanceOf(DateTime::class, $result);
$this->assertSame('2024-12-31', $result->format('Y-m-d'));
}
// ── Valeurs silencieuses — doit retourner null sans exception ────
/**
* null doit retourner null.
*/
public function testNullReturnsNull(): void
{
$this->assertNull(DateParser::parse(null));
}
/**
* Une chaîne vide doit retourner null.
*/
public function testEmptyStringReturnsNull(): void
{
$this->assertNull(DateParser::parse(''));
}
/**
* Une valeur non parseable doit retourner null sans lever d'exception.
*/
public function testInvalidValueReturnsNull(): void
{
$this->assertNull(DateParser::parse('pas-une-date'));
}
/**
* Un entier doit être interprété comme un timestamp si valide,
* ou retourner null si la conversion échoue.
* Ici on vérifie simplement qu'aucune exception n'est levée.
*/
public function testIntegerThrowsNoException(): void
{
$result = DateParser::parse(0);
// Pas d'assertion sur la valeur — on vérifie juste la robustesse
$this->assertTrue($result === null || $result instanceof DateTime);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Extension\AppExtension;
use App\Shared\Extension\CsrfExtension;
use App\Shared\Extension\SessionExtension;
use PHPUnit\Framework\TestCase;
use Slim\Csrf\Guard;
use Slim\Psr7\Factory\ResponseFactory;
final class ExtensionTest extends TestCase
{
protected function setUp(): void
{
$_SESSION = [];
}
public function testAppExtensionExposesAppUrl(): void
{
$extension = new AppExtension('https://example.test');
self::assertSame(['app_url' => 'https://example.test'], $extension->getGlobals());
}
public function testSessionExtensionExposesSelectedSessionKeys(): void
{
$_SESSION = [
'user_id' => 12,
'username' => 'julien',
'role' => 'admin',
'flash' => ['notice' => 'x'],
];
$extension = new SessionExtension();
self::assertSame([
'session' => [
'user_id' => 12,
'username' => 'julien',
'role' => 'admin',
],
], $extension->getGlobals());
}
public function testCsrfExtensionExposesTokens(): void
{
$storage = [];
$guard = new Guard(new ResponseFactory(), storage: $storage);
$extension = new CsrfExtension($guard);
$globals = $extension->getGlobals();
self::assertArrayHasKey('csrf', $globals);
self::assertSame($guard->getTokenNameKey(), $globals['csrf']['keys']['name']);
self::assertSame($guard->getTokenValueKey(), $globals['csrf']['keys']['value']);
self::assertSame($guard->getTokenName(), $globals['csrf']['name']);
self::assertSame($guard->getTokenValue(), $globals['csrf']['value']);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\FlashService;
use PHPUnit\Framework\TestCase;
final class FlashServiceConsumeTest extends TestCase
{
protected function setUp(): void
{
$_SESSION = [];
}
public function testGetReturnsNullWhenMissingAndConsumesWhenPresent(): void
{
$flash = new FlashService();
self::assertNull($flash->get('missing'));
$flash->set('notice', 'Bonjour');
self::assertSame('Bonjour', $flash->get('notice'));
self::assertNull($flash->get('notice'));
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\FlashService;
use PHPUnit\Framework\TestCase;
final class FlashServiceTest extends TestCase
{
protected function setUp(): void
{
$_SESSION = [];
}
public function testSetAndGetConsumesFlashMessage(): void
{
$flash = new FlashService();
$flash->set('notice', 'Bonjour');
self::assertSame('Bonjour', $flash->get('notice'));
self::assertNull($flash->get('notice'));
}
public function testGetCastsNonStringValueAndRemovesIt(): void
{
$_SESSION['flash']['count'] = 123;
$flash = new FlashService();
self::assertSame('123', $flash->get('count'));
self::assertArrayNotHasKey('count', $_SESSION['flash']);
}
public function testGetReturnsNullWhenMissing(): void
{
$flash = new FlashService();
self::assertNull($flash->get('missing'));
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\ClientIpResolver;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
final class HelperEdgeCasesTest extends TestCase
{
public function testClientIpResolverFallsBackToRemoteAddr(): void
{
$resolver = new ClientIpResolver([]);
$request = new ServerRequestFactory()->createServerRequest(
'GET',
'/',
['REMOTE_ADDR' => '127.0.0.1']
);
self::assertSame('127.0.0.1', $resolver->resolve($request));
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Html\HtmlPurifierFactory;
use PHPUnit\Framework\TestCase;
final class HtmlPurifierFactoryTest extends TestCase
{
public function testCreateBuildsPurifierAndSanitizesDangerousHtml(): void
{
$cacheDir = sys_get_temp_dir().'/htmlpurifier-test-'.bin2hex(random_bytes(4));
try {
$purifier = HtmlPurifierFactory::create($cacheDir);
$result = $purifier->purify('<p style="text-align:center">ok</p><a href="javascript:alert(1)">x</a> https://example.test');
self::assertDirectoryExists($cacheDir);
self::assertStringContainsString('text-align:center', $result);
self::assertStringNotContainsString('javascript:', $result);
self::assertStringContainsString('https://example.test', $result);
} finally {
if (is_dir($cacheDir)) {
foreach (glob($cacheDir.'/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($cacheDir);
}
}
}
}

View File

@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Html\HtmlPurifierFactory;
use App\Shared\Html\HtmlSanitizer;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour HtmlSanitizer.
*
* Vérifie que HTMLPurifier supprime bien les contenus dangereux
* (XSS, balises non autorisées, schémas URI interdits) et conserve
* les balises légitimes produites par l'éditeur Trumbowyg.
*
* Ces tests utilisent une vraie instance HTMLPurifier (pas de mock)
* car c'est le comportement de purification lui-même qui est testé.
*/
final class HtmlSanitizerTest extends TestCase
{
private HtmlSanitizer $sanitizer;
/**
* Crée une instance réelle de HtmlSanitizer avant chaque test.
*/
protected function setUp(): void
{
$purifier = HtmlPurifierFactory::create(sys_get_temp_dir() . '/htmlpurifier_tests');
$this->sanitizer = new HtmlSanitizer($purifier);
}
// ── Balises autorisées ─────────────────────────────────────────
/**
* Les balises de texte courantes doivent être conservées.
*/
public function testTextTagsPreserved(): void
{
$html = '<p>Un <strong>texte</strong> avec <em>emphase</em> et <u>soulignement</u>.</p>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('<strong>texte</strong>', $result);
$this->assertStringContainsString('<em>emphase</em>', $result);
$this->assertStringContainsString('<u>soulignement</u>', $result);
}
/**
* Les titres h1 à h6 doivent être conservés.
*/
public function testHeadingsPreserved(): void
{
$html = '<h1>Titre 1</h1><h2>Titre 2</h2><h3>Titre 3</h3>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('<h1>', $result);
$this->assertStringContainsString('<h2>', $result);
$this->assertStringContainsString('<h3>', $result);
}
/**
* Les listes ordonnées et non ordonnées doivent être conservées.
*/
public function testListsPreserved(): void
{
$html = '<ul><li>Item 1</li><li>Item 2</li></ul><ol><li>A</li></ol>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('<ul>', $result);
$this->assertStringContainsString('<ol>', $result);
$this->assertStringContainsString('<li>', $result);
}
/**
* Les liens avec href http/https doivent être conservés.
*/
public function testHttpLinksPreserved(): void
{
$html = '<a href="https://example.com">Lien</a>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('href="https://example.com"', $result);
$this->assertStringContainsString('Lien', $result);
}
/**
* Les images avec src doivent être conservées.
*/
public function testImagesPreserved(): void
{
$html = '<img src="https://example.com/image.jpg" alt="Description">';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('<img', $result);
$this->assertStringContainsString('src="https://example.com/image.jpg"', $result);
}
/**
* Les blocs de code doivent être conservés.
*/
public function testPreTagPreserved(): void
{
$html = '<pre>code ici</pre>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('<pre>', $result);
}
// ── Balises et attributs dangereux — suppression XSS ───────────
/**
* Les balises <script> doivent être supprimées.
*/
public function testScriptTagRemoved(): void
{
$html = '<p>Texte</p><script>alert("xss")</script>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringNotContainsString('alert(', $result);
}
/**
* Les attributs onclick et autres handlers JavaScript doivent être supprimés.
*/
public function testJavascriptAttributesRemoved(): void
{
$html = '<p onclick="alert(1)" onmouseover="evil()">Texte</p>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('onclick', $result);
$this->assertStringNotContainsString('onmouseover', $result);
}
/**
* Les liens javascript: doivent être supprimés.
*/
public function testJavascriptLinkRemoved(): void
{
$html = '<a href="javascript:alert(1)">Cliquez</a>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('javascript:', $result);
}
/**
* Les liens data: doivent être supprimés.
*/
public function testDataLinkRemoved(): void
{
$html = '<a href="data:text/html,<script>alert(1)</script>">XSS</a>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('data:', $result);
}
/**
* La balise <iframe> doit être supprimée.
*/
public function testIframeTagRemoved(): void
{
$html = '<iframe src="https://evil.com"></iframe>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('<iframe', $result);
}
/**
* La balise <object> doit être supprimée.
*/
public function testObjectTagRemoved(): void
{
$html = '<object data="malware.swf"></object>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('<object', $result);
}
/**
* La balise <form> doit être supprimée.
*/
public function testFormTagRemoved(): void
{
$html = '<form action="/steal"><input type="password"></form>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('<form', $result);
$this->assertStringNotContainsString('<input', $result);
}
// ── Cas limites ────────────────────────────────────────────────
/**
* Une chaîne vide doit retourner une chaîne vide (ou quasi-vide).
*/
public function testEmptyStringReturnsEmptyOrBlank(): void
{
$result = $this->sanitizer->sanitize('');
$this->assertSame('', trim($result));
}
/**
* Du texte brut sans balises doit être conservé.
*/
public function testPlainTextWithoutTags(): void
{
$html = 'Bonjour le monde';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('Bonjour le monde', $result);
}
/**
* Les attributs CSS text-align doivent être conservés.
*/
public function testStyleTextAlignAttributePreserved(): void
{
$html = '<p style="text-align: center;">Centré</p>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringContainsString('text-align', $result);
}
/**
* Les propriétés CSS autres que text-align doivent être supprimées.
*/
public function testOtherCssPropertiesRemoved(): void
{
$html = '<p style="color: red; background: url(evil.php);">Texte</p>';
$result = $this->sanitizer->sanitize($html);
$this->assertStringNotContainsString('color', $result);
$this->assertStringNotContainsString('background', $result);
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Database\Migrator;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour Migrator.
*
* Vérifie que run() crée la table de suivi, exécute uniquement les migrations
* en attente (pas celles déjà appliquées), et appelle syncFtsIndex().
*
* Les fichiers de migration réels ne sont pas chargés : Migrator::run() est
* testé via une base SQLite en mémoire, ce qui est plus fiable qu'un mock
* de exec() et permet de vérifier l'état réel de la base après exécution.
*
* syncFtsIndex() requiert les tables posts, users et posts_fts — elles sont
* créées minimalement avant chaque test qui en a besoin.
*/
final class MigratorTest extends TestCase
{
private PDO $db;
protected function setUp(): void
{
$this->db = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
// strip_tags() doit être disponible comme fonction SQLite
// (enregistrée dans container.php en production)
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
}
// ── createMigrationTable ───────────────────────────────────────
/**
* run() doit créer la table 'migrations' si elle n'existe pas.
*/
public function testRunCreatesMigrationsTable(): void
{
$this->createMinimalSchema();
Migrator::run($this->db);
$stmt = $this->db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'");
$this->assertNotFalse($stmt->fetchColumn(), 'La table migrations doit exister après run()');
}
/**
* run() est idempotent : appeler run() deux fois ne génère pas d'erreur.
*/
public function testRunIsIdempotent(): void
{
$this->createMinimalSchema();
Migrator::run($this->db);
Migrator::run($this->db); // deuxième appel — ne doit pas planter
$this->addToAssertionCount(1);
}
// ── runPendingMigrations ───────────────────────────────────────
/**
* Une migration déjà enregistrée dans la table migrations
* ne doit pas être rejouée.
*/
public function testAlreadyAppliedMigrationIsSkipped(): void
{
$this->createMinimalSchema();
Migrator::run($this->db);
$before = $this->countMigrations();
// Simuler une migration future déjà appliquée (version fictive
// qui ne correspond à aucun fichier réel — ne génère pas de conflit UNIQUE)
$stmt = $this->db->prepare("INSERT INTO migrations (version, run_at) VALUES (:v, :r)");
$stmt->execute([':v' => '999_future_migration', ':r' => date('Y-m-d H:i:s')]);
Migrator::run($this->db);
$after = $this->countMigrations();
// Le nombre de migrations enregistrées ne doit pas avoir changé
$this->assertSame($before + 1, $after);
}
/**
* La table migrations doit contenir une entrée par migration exécutée.
*/
public function testRunRecordsMigrationsInTable(): void
{
$this->createMinimalSchema();
Migrator::run($this->db);
$count = $this->countMigrations();
// Le projet a au moins une migration (001_create_users)
$this->assertGreaterThan(0, $count, 'Au moins une migration doit être enregistrée');
}
// ── syncFtsIndex ───────────────────────────────────────────────
/**
* syncFtsIndex() doit insérer dans posts_fts les articles
* absents de l'index après run().
*/
public function testSyncFtsIndexInsertsUnindexedPosts(): void
{
// Exécuter les vraies migrations pour avoir le schéma complet
Migrator::run($this->db);
// Insérer un article directement en base (bypass des triggers FTS)
$this->db->exec("
INSERT INTO users (id, username, email, password_hash, role, created_at)
VALUES (1, 'alice', 'alice@example.com', 'hash', 'user', '2024-01-01 00:00:00')
");
$this->db->exec("
INSERT INTO posts (id, title, content, slug, author_id, created_at, updated_at)
VALUES (1, 'Test', '<p>Contenu</p>', 'test', 1, '2024-01-01 00:00:00', '2024-01-01 00:00:00')
");
// Supprimer l'entrée FTS pour simuler un article non indexé
$this->db->exec("DELETE FROM posts_fts WHERE rowid = 1");
// run() doit réindexer cet article via syncFtsIndex
Migrator::run($this->db);
$stmt = $this->db->query('SELECT rowid FROM posts_fts WHERE rowid = 1');
$this->assertNotFalse($stmt->fetchColumn(), "L'article doit être présent dans posts_fts après run()");
}
/**
* syncFtsIndex() ne doit pas créer de doublon pour un article
* déjà présent dans l'index.
*/
public function testSyncFtsIndexDoesNotDuplicateIndexedPosts(): void
{
Migrator::run($this->db);
// Insérer un article — le trigger FTS l'indexe automatiquement
$this->db->exec("
INSERT INTO users (id, username, email, password_hash, role, created_at)
VALUES (1, 'alice', 'alice@example.com', 'hash', 'user', '2024-01-01 00:00:00')
");
$this->db->exec("
INSERT INTO posts (id, title, content, slug, author_id, created_at, updated_at)
VALUES (1, 'Test', '<p>Contenu</p>', 'test', 1, '2024-01-01 00:00:00', '2024-01-01 00:00:00')
");
$before = (int) $this->db->query('SELECT COUNT(*) FROM posts_fts')->fetchColumn();
// Deuxième run() — ne doit pas dupliquer l'entrée FTS
Migrator::run($this->db);
$after = (int) $this->db->query('SELECT COUNT(*) FROM posts_fts')->fetchColumn();
$this->assertSame($before, $after);
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée le schéma minimal requis par run() quand les vraies migrations
* ne sont pas chargées (posts, users, posts_fts pour syncFtsIndex).
*
* Utilisé uniquement pour les tests qui ne veulent pas dépendre
* des fichiers de migration réels.
*/
private function createMinimalSchema(): void
{
$this->db->exec('
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT "user",
created_at DATETIME NOT NULL
)
');
$this->db->exec('
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT "",
slug TEXT NOT NULL UNIQUE,
author_id INTEGER,
category_id INTEGER,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)
');
$this->db->exec("
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts
USING fts5(title, content, author_username, content='posts', content_rowid='id')
");
}
private function countMigrations(): int
{
return (int) $this->db->query('SELECT COUNT(*) FROM migrations')->fetchColumn();
}
}

207
tests/Shared/SeederTest.php Normal file
View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Database\Seeder;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour Seeder.
*
* Vérifie que seed() insère le compte administrateur quand il est absent,
* et ne fait rien si le compte existe déjà (idempotence).
*
* PDO et PDOStatement sont mockés pour isoler le Seeder de la base de données.
* Les variables d'environnement sont définies dans setUp() et restaurées dans tearDown().
*/
final class SeederTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
/** @var array<string, string> Variables d'environnement sauvegardées avant chaque test */
private array $envBackup;
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->envBackup = [
'ADMIN_USERNAME' => $_ENV['ADMIN_USERNAME'] ?? '',
'ADMIN_EMAIL' => $_ENV['ADMIN_EMAIL'] ?? '',
'ADMIN_PASSWORD' => $_ENV['ADMIN_PASSWORD'] ?? '',
];
$_ENV['ADMIN_USERNAME'] = 'admin';
$_ENV['ADMIN_EMAIL'] = 'admin@example.com';
$_ENV['ADMIN_PASSWORD'] = 'secret1234';
}
protected function tearDown(): void
{
foreach ($this->envBackup as $key => $value) {
$_ENV[$key] = $value;
}
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un PDOStatement mock retournant $fetchColumnValue pour fetchColumn().
*/
private function stmtReturning(mixed $fetchColumnValue): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn($fetchColumnValue);
return $stmt;
}
private function stmtForWrite(): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
return $stmt;
}
// ── seed() — admin absent ──────────────────────────────────────
/**
* seed() doit insérer le compte admin quand aucun utilisateur
* portant ce nom d'utilisateur n'existe en base.
*/
public function testSeedInsertsAdminWhenAbsent(): void
{
$selectStmt = $this->stmtReturning(false);
$insertStmt = $this->stmtForWrite();
$this->db->expects($this->exactly(2))
->method('prepare')
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
$insertStmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data): bool {
return $data[':username'] === 'admin'
&& $data[':email'] === 'admin@example.com'
&& $data[':role'] === 'admin'
&& isset($data[':password_hash'], $data[':created_at'])
&& password_verify('secret1234', $data[':password_hash']);
}));
Seeder::seed($this->db);
}
/**
* seed() doit normaliser le nom d'utilisateur en minuscules
* et supprimer les espaces autour.
*/
public function testSeedNormalizesUsername(): void
{
$_ENV['ADMIN_USERNAME'] = ' ADMIN ';
$_ENV['ADMIN_EMAIL'] = ' ADMIN@EXAMPLE.COM ';
$selectStmt = $this->stmtReturning(false);
$insertStmt = $this->stmtForWrite();
$this->db->method('prepare')
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
$insertStmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data): bool {
return $data[':username'] === 'admin'
&& $data[':email'] === 'admin@example.com';
}));
Seeder::seed($this->db);
}
/**
* seed() doit stocker un hash bcrypt, jamais le mot de passe en clair.
*/
public function testSeedHashesPasswordBeforeInsert(): void
{
$selectStmt = $this->stmtReturning(false);
$insertStmt = $this->stmtForWrite();
$this->db->method('prepare')
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
$insertStmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data): bool {
// Le hash ne doit pas être le mot de passe brut
return $data[':password_hash'] !== 'secret1234'
// Et doit être vérifiable avec password_verify
&& password_verify('secret1234', $data[':password_hash']);
}));
Seeder::seed($this->db);
}
/**
* seed() doit renseigner created_at au format 'Y-m-d H:i:s'.
*/
public function testSeedSetsCreatedAt(): void
{
$selectStmt = $this->stmtReturning(false);
$insertStmt = $this->stmtForWrite();
$this->db->method('prepare')
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
$insertStmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data): bool {
return isset($data[':created_at'])
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $data[':created_at']);
}));
Seeder::seed($this->db);
}
// ── seed() — admin présent (idempotence) ───────────────────────
/**
* seed() ne doit pas exécuter d'INSERT si le compte admin existe déjà.
*/
public function testSeedDoesNotInsertWhenAdminExists(): void
{
// fetchColumn() retourne l'id existant — le compte est déjà là
$selectStmt = $this->stmtReturning('1');
$this->db->expects($this->once())
->method('prepare')
->willReturn($selectStmt);
// prepare() ne doit être appelé qu'une fois (SELECT uniquement, pas d'INSERT)
Seeder::seed($this->db);
}
/**
* seed() vérifie l'existence du compte via le nom d'utilisateur normalisé.
*/
public function testSeedChecksExistenceByNormalizedUsername(): void
{
$_ENV['ADMIN_USERNAME'] = ' Admin ';
$selectStmt = $this->stmtReturning('1');
$this->db->method('prepare')->willReturn($selectStmt);
$selectStmt->expects($this->once())
->method('execute')
->with([':username' => 'admin']);
Seeder::seed($this->db);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\SessionManager;
use PHPUnit\Framework\TestCase;
final class SessionManagerEdgeCasesTest extends TestCase
{
private SessionManager $manager;
protected function setUp(): void
{
$_SESSION = [];
$this->manager = new SessionManager();
}
protected function tearDown(): void
{
$_SESSION = [];
}
public function testGetUserIdReturnsNullForEmptyString(): void
{
$_SESSION['user_id'] = '';
self::assertNull($this->manager->getUserId());
self::assertFalse($this->manager->isAuthenticated());
}
public function testSetUserUsesDefaultRoleUser(): void
{
$this->manager->setUser(12, 'julien');
self::assertSame('user', $_SESSION['role']);
self::assertFalse($this->manager->isAdmin());
self::assertFalse($this->manager->isEditor());
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Http\SessionManager;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour SessionManager.
*
* Vérifie la lecture et l'écriture des données d'authentification
* dans $_SESSION, ainsi que la destruction de session.
*
* Note : session_start() n'est pas appelé dans ces tests — SessionManager
* manipule directement $_SESSION, ce qui fonctionne en CLI sans session active.
* session_regenerate_id() et session_destroy() sont gardés par un test
* session_status() === PHP_SESSION_ACTIVE dans SessionManager, ce qui les rend
* sans effet en contexte CLI et évite toute notice PHP.
*/
final class SessionManagerTest extends TestCase
{
private SessionManager $manager;
/**
* Réinitialise $_SESSION avant chaque test pour garantir l'isolation.
*/
protected function setUp(): void
{
$_SESSION = [];
$this->manager = new SessionManager();
}
/**
* Réinitialise $_SESSION après chaque test.
*/
protected function tearDown(): void
{
$_SESSION = [];
}
// ── isAuthenticated ────────────────────────────────────────────
/**
* Sans session active, isAuthenticated() doit retourner false.
*/
public function testIsAuthenticatedWithoutSession(): void
{
$this->assertFalse($this->manager->isAuthenticated());
}
/**
* Après setUser(), isAuthenticated() doit retourner true.
*/
public function testIsAuthenticatedAfterSetUser(): void
{
$this->manager->setUser(1, 'alice', 'user');
$this->assertTrue($this->manager->isAuthenticated());
}
// ── getUserId ──────────────────────────────────────────────────
/**
* Sans session active, getUserId() doit retourner null.
*/
public function testGetUserIdWithoutSession(): void
{
$this->assertNull($this->manager->getUserId());
}
/**
* Après setUser(), getUserId() doit retourner l'identifiant correct.
*/
public function testGetUserIdAfterSetUser(): void
{
$this->manager->setUser(42, 'alice', 'user');
$this->assertSame(42, $this->manager->getUserId());
}
// ── Rôles — isAdmin / isEditor ─────────────────────────────────
/**
* Un utilisateur avec le rôle 'admin' doit être reconnu comme administrateur.
*/
public function testIsAdminWithAdminRole(): void
{
$this->manager->setUser(1, 'alice', 'admin');
$this->assertTrue($this->manager->isAdmin());
$this->assertFalse($this->manager->isEditor());
}
/**
* Un utilisateur avec le rôle 'editor' doit être reconnu comme éditeur.
*/
public function testIsEditorWithEditorRole(): void
{
$this->manager->setUser(1, 'alice', 'editor');
$this->assertFalse($this->manager->isAdmin());
$this->assertTrue($this->manager->isEditor());
}
/**
* Un utilisateur avec le rôle 'user' ne doit être ni admin ni éditeur.
*/
public function testUserRoleIsNeitherAdminNorEditor(): void
{
$this->manager->setUser(1, 'alice', 'user');
$this->assertFalse($this->manager->isAdmin());
$this->assertFalse($this->manager->isEditor());
}
/**
* Sans session active, isAdmin() doit retourner false.
*/
public function testIsAdminWithoutSession(): void
{
$this->assertFalse($this->manager->isAdmin());
}
/**
* Sans session active, isEditor() doit retourner false.
*/
public function testIsEditorWithoutSession(): void
{
$this->assertFalse($this->manager->isEditor());
}
// ── Données en session ─────────────────────────────────────────
/**
* setUser() doit écrire le username et le rôle dans $_SESSION.
*/
public function testSetUserWritesToSession(): void
{
$this->manager->setUser(5, 'bob', 'editor');
$this->assertSame(5, $_SESSION['user_id']);
$this->assertSame('bob', $_SESSION['username']);
$this->assertSame('editor', $_SESSION['role']);
}
// ── destroy ────────────────────────────────────────────────────
/**
* Après destroy(), isAuthenticated() doit retourner false.
*/
public function testDestroyClearsSession(): void
{
$this->manager->setUser(1, 'alice', 'user');
$this->manager->destroy();
$this->assertFalse($this->manager->isAuthenticated());
$this->assertNull($this->manager->getUserId());
$this->assertEmpty($_SESSION);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Util\SlugHelper;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour SlugHelper::generate().
*
* Couvre la translittération ASCII, la normalisation en minuscules,
* le remplacement des caractères non alphanumériques, et les cas limites.
*/
final class SlugHelperTest extends TestCase
{
// ── Cas nominaux ───────────────────────────────────────────────
/**
* Une chaîne ASCII simple doit être mise en minuscules.
*/
public function testSimpleAsciiString(): void
{
$this->assertSame('hello-world', SlugHelper::generate('Hello World'));
}
/**
* Les caractères accentués doivent être translittérés en ASCII.
*/
public function testAccentedCharacters(): void
{
$this->assertSame('ete-en-foret', SlugHelper::generate('Été en forêt'));
}
/**
* La cédille et les caractères spéciaux courants doivent être translittérés.
*/
public function testCedillaAndSpecialCharacters(): void
{
$this->assertSame('ca-la', SlugHelper::generate('Ça & Là !'));
}
/**
* Les tirets multiples consécutifs doivent être fusionnés en un seul.
*/
public function testMultipleConsecutiveHyphens(): void
{
$this->assertSame('foo-bar', SlugHelper::generate('foo bar'));
}
/**
* Les tirets en début et fin de slug doivent être supprimés.
*/
public function testLeadingAndTrailingHyphen(): void
{
$this->assertSame('foo', SlugHelper::generate(' foo '));
}
/**
* Les chiffres doivent être conservés dans le slug.
*/
public function testDigitsPreserved(): void
{
$this->assertSame('article-2024', SlugHelper::generate('Article 2024'));
}
/**
* Les tirets déjà présents dans la chaîne doivent être conservés (fusionnés si doublons).
*/
public function testHyphensInSourceString(): void
{
$this->assertSame('mon-article', SlugHelper::generate('mon-article'));
}
/**
* Une chaîne entièrement en majuscules doit être passée en minuscules.
*/
public function testUppercaseString(): void
{
$this->assertSame('php-est-super', SlugHelper::generate('PHP EST SUPER'));
}
// ── Cas limites ────────────────────────────────────────────────
/**
* Une chaîne vide doit retourner une chaîne vide.
*/
public function testEmptyString(): void
{
$this->assertSame('', SlugHelper::generate(''));
}
/**
* Une chaîne composée uniquement d'espaces doit retourner une chaîne vide.
*/
public function testSpacesOnlyString(): void
{
$this->assertSame('', SlugHelper::generate(' '));
}
/**
* Une chaîne composée uniquement de caractères spéciaux sans équivalent ASCII
* doit retourner une chaîne vide.
*/
public function testCharactersWithoutAsciiEquivalent(): void
{
// Les caractères CJK n'ont pas d'équivalent ASCII//TRANSLIT
$result = SlugHelper::generate('日本語');
$this->assertSame('', $result);
}
/**
* Un slug déjà valide doit rester identique.
*/
public function testAlreadyValidSlug(): void
{
$this->assertSame('mon-slug-valide', SlugHelper::generate('mon-slug-valide'));
}
}