first commit
This commit is contained in:
55
tests/Shared/ClientIpResolverTest.php
Normal file
55
tests/Shared/ClientIpResolverTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
53
tests/Shared/ConfigTest.php
Normal file
53
tests/Shared/ConfigTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
tests/Shared/DateParserTest.php
Normal file
93
tests/Shared/DateParserTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
60
tests/Shared/ExtensionTest.php
Normal file
60
tests/Shared/ExtensionTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
26
tests/Shared/FlashServiceConsumeTest.php
Normal file
26
tests/Shared/FlashServiceConsumeTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
42
tests/Shared/FlashServiceTest.php
Normal file
42
tests/Shared/FlashServiceTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
24
tests/Shared/HelperEdgeCasesTest.php
Normal file
24
tests/Shared/HelperEdgeCasesTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
32
tests/Shared/HtmlPurifierFactoryTest.php
Normal file
32
tests/Shared/HtmlPurifierFactoryTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
239
tests/Shared/HtmlSanitizerTest.php
Normal file
239
tests/Shared/HtmlSanitizerTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
212
tests/Shared/MigratorTest.php
Normal file
212
tests/Shared/MigratorTest.php
Normal 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
207
tests/Shared/SeederTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
40
tests/Shared/SessionManagerEdgeCasesTest.php
Normal file
40
tests/Shared/SessionManagerEdgeCasesTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
166
tests/Shared/SessionManagerTest.php
Normal file
166
tests/Shared/SessionManagerTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
121
tests/Shared/SlugHelperTest.php
Normal file
121
tests/Shared/SlugHelperTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user