From 8f7e61bda067b3cb16cd30ebffa94ce03a1bf9a4 Mon Sep 17 00:00:00 2001
From: julien
Date: Mon, 16 Mar 2026 01:47:07 +0100
Subject: [PATCH] first commit
---
.dockerignore | 61 +
.env.example | 45 +
.gitignore | 56 +
CONTRIBUTING.md | 116 +
LICENSE | 21 +
README.md | 177 +
assets/scss/abstracts/_mixins.scss | 13 +
assets/scss/abstracts/_variables.scss | 73 +
assets/scss/base/_reset.scss | 16 +
assets/scss/base/_typography.scss | 105 +
assets/scss/components/_alerts.scss | 24 +
assets/scss/components/_badges.scss | 38 +
assets/scss/components/_buttons.scss | 57 +
assets/scss/components/_card.scss | 182 +
assets/scss/components/_forms.scss | 200 +
assets/scss/components/_post.scss | 39 +
assets/scss/components/_upload.scss | 39 +
assets/scss/layout/_footer.scss | 13 +
assets/scss/layout/_header.scss | 79 +
assets/scss/main.scss | 31 +
assets/scss/pages/_admin.scss | 234 +
assets/scss/pages/_home.scss | 58 +
bin/provision.php | 19 +
composer.json | 30 +
composer.lock | 5908 +++++++++++++++++
config/container.php | 195 +
database/migrations/001_create_users.php | 19 +
database/migrations/002_create_categories.php | 23 +
database/migrations/003_create_posts.php | 31 +
database/migrations/004_create_media.php | 29 +
.../migrations/005_create_password_resets.php | 26 +
database/migrations/006_create_posts_fts.php | 68 +
.../migrations/007_create_login_attempts.php | 27 +
.../008_sync_posts_fts_when_users_change.php | 25 +
docker-compose.yml | 52 +
docker/nginx/default.conf | 54 +
docker/php/Dockerfile | 44 +
docker/php/entrypoint.sh | 42 +
docker/php/php.ini | 14 +
docs/ARCHITECTURE.md | 442 ++
docs/GUIDE.md | 1833 +++++
package-lock.json | 444 ++
package.json | 20 +
phpstan.neon | 6 +
phpunit.xml | 27 +
public/favicon.png | Bin 0 -> 801 bytes
public/index.php | 15 +
src/Auth/AccountController.php | 114 +
src/Auth/AuthController.php | 75 +
src/Auth/AuthService.php | 193 +
src/Auth/AuthServiceInterface.php | 84 +
.../Exception/InvalidResetTokenException.php | 16 +
src/Auth/LoginAttemptRepository.php | 116 +
src/Auth/LoginAttemptRepositoryInterface.php | 44 +
src/Auth/Middleware/AdminMiddleware.php | 50 +
src/Auth/Middleware/AuthMiddleware.php | 47 +
src/Auth/Middleware/EditorMiddleware.php | 50 +
src/Auth/PasswordResetController.php | 218 +
src/Auth/PasswordResetRepository.php | 74 +
src/Auth/PasswordResetRepositoryInterface.php | 21 +
src/Auth/PasswordResetService.php | 105 +
src/Auth/PasswordResetServiceInterface.php | 56 +
src/Category/Category.php | 91 +
src/Category/CategoryController.php | 114 +
src/Category/CategoryRepository.php | 134 +
src/Category/CategoryRepositoryInterface.php | 74 +
src/Category/CategoryService.php | 120 +
src/Category/CategoryServiceInterface.php | 64 +
src/Media/Exception/FileTooLargeException.php | 16 +
.../Exception/InvalidMimeTypeException.php | 15 +
src/Media/Exception/StorageException.php | 12 +
src/Media/Media.php | 130 +
src/Media/MediaController.php | 180 +
src/Media/MediaRepository.php | 139 +
src/Media/MediaRepositoryInterface.php | 65 +
src/Media/MediaService.php | 228 +
src/Media/MediaServiceInterface.php | 62 +
src/Post/Post.php | 252 +
src/Post/PostController.php | 379 ++
src/Post/PostExtension.php | 205 +
src/Post/PostRepository.php | 334 +
src/Post/PostRepositoryInterface.php | 113 +
src/Post/PostService.php | 252 +
src/Post/PostServiceInterface.php | 119 +
src/Post/RssController.php | 94 +
src/Shared/Bootstrap.php | 213 +
src/Shared/Config.php | 61 +
src/Shared/Database/Migrator.php | 137 +
src/Shared/Database/Provisioner.php | 37 +
src/Shared/Database/Seeder.php | 71 +
src/Shared/Exception/NotFoundException.php | 22 +
src/Shared/Extension/AppExtension.php | 36 +
src/Shared/Extension/CsrfExtension.php | 47 +
src/Shared/Extension/SessionExtension.php | 42 +
src/Shared/Html/HtmlPurifierFactory.php | 66 +
src/Shared/Html/HtmlSanitizer.php | 34 +
src/Shared/Html/HtmlSanitizerInterface.php | 19 +
src/Shared/Http/ClientIpResolver.php | 58 +
src/Shared/Http/FlashService.php | 45 +
src/Shared/Http/FlashServiceInterface.php | 31 +
src/Shared/Http/SessionManager.php | 112 +
src/Shared/Http/SessionManagerInterface.php | 56 +
src/Shared/Mail/MailService.php | 95 +
src/Shared/Mail/MailServiceInterface.php | 26 +
src/Shared/Routes.php | 125 +
src/Shared/Util/DateParser.php | 37 +
src/Shared/Util/SlugHelper.php | 47 +
.../Exception/DuplicateEmailException.php | 18 +
.../Exception/DuplicateUsernameException.php | 18 +
src/User/Exception/InvalidRoleException.php | 21 +
src/User/Exception/WeakPasswordException.php | 21 +
src/User/User.php | 191 +
src/User/UserController.php | 234 +
src/User/UserRepository.php | 150 +
src/User/UserRepositoryInterface.php | 80 +
src/User/UserService.php | 89 +
src/User/UserServiceInterface.php | 69 +
tests/Auth/AccountControllerTest.php | 207 +
tests/Auth/AuthControllerTest.php | 177 +
tests/Auth/AuthServiceRateLimitTest.php | 220 +
tests/Auth/AuthServiceTest.php | 244 +
tests/Auth/LoginAttemptRepositoryTest.php | 276 +
tests/Auth/MiddlewareTest.php | 109 +
tests/Auth/PasswordResetControllerTest.php | 409 ++
tests/Auth/PasswordResetRepositoryTest.php | 249 +
.../PasswordResetServiceIntegrationTest.php | 63 +
tests/Auth/PasswordResetServiceTest.php | 316 +
tests/Category/CategoryControllerTest.php | 198 +
tests/Category/CategoryModelTest.php | 48 +
tests/Category/CategoryRepositoryTest.php | 380 ++
tests/Category/CategoryServiceTest.php | 188 +
tests/ControllerTestCase.php | 144 +
tests/Media/MediaControllerTest.php | 311 +
tests/Media/MediaModelTest.php | 53 +
tests/Media/MediaRepositoryTest.php | 336 +
...diaServiceDuplicateAfterInsertRaceTest.php | 90 +
tests/Media/MediaServiceEdgeCasesTest.php | 45 +
tests/Media/MediaServiceInvalidMimeTest.php | 40 +
.../Media/MediaServiceInvalidTempPathTest.php | 33 +
tests/Media/MediaServiceTest.php | 256 +
.../PostConcurrentUpdateIntegrationTest.php | 66 +
tests/Post/PostControllerTest.php | 505 ++
tests/Post/PostExtensionTest.php | 77 +
.../PostFtsUsernameSyncIntegrationTest.php | 38 +
tests/Post/PostModelEdgeCasesTest.php | 29 +
tests/Post/PostModelTest.php | 98 +
tests/Post/PostRepositoryTest.php | 546 ++
tests/Post/PostServiceTest.php | 228 +
tests/Post/RssControllerTest.php | 182 +
tests/Shared/ClientIpResolverTest.php | 55 +
tests/Shared/ConfigTest.php | 53 +
tests/Shared/DateParserTest.php | 93 +
tests/Shared/ExtensionTest.php | 60 +
tests/Shared/FlashServiceConsumeTest.php | 26 +
tests/Shared/FlashServiceTest.php | 42 +
tests/Shared/HelperEdgeCasesTest.php | 24 +
tests/Shared/HtmlPurifierFactoryTest.php | 32 +
tests/Shared/HtmlSanitizerTest.php | 239 +
tests/Shared/MigratorTest.php | 212 +
tests/Shared/SeederTest.php | 207 +
tests/Shared/SessionManagerEdgeCasesTest.php | 40 +
tests/Shared/SessionManagerTest.php | 166 +
tests/Shared/SlugHelperTest.php | 121 +
tests/User/UserControllerTest.php | 441 ++
tests/User/UserRepositoryTest.php | 357 +
tests/User/UserServiceTest.php | 270 +
tests/User/UserTest.php | 232 +
views/admin/categories/index.twig | 72 +
views/admin/media/index.twig | 70 +
views/admin/posts/form.twig | 139 +
views/admin/posts/index.twig | 107 +
views/admin/users/form.twig | 74 +
views/admin/users/index.twig | 95 +
views/emails/password-reset.twig | 82 +
views/layout.twig | 30 +
views/pages/account/password-change.twig | 60 +
views/pages/auth/login.twig | 50 +
views/pages/auth/password-forgot.twig | 44 +
views/pages/auth/password-reset.twig | 46 +
views/pages/error.twig | 11 +
views/pages/home.twig | 94 +
views/pages/post/detail.twig | 48 +
views/partials/_admin_nav.twig | 10 +
views/partials/_footer.twig | 11 +
views/partials/_header.twig | 24 +
185 files changed, 27731 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 CONTRIBUTING.md
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 assets/scss/abstracts/_mixins.scss
create mode 100644 assets/scss/abstracts/_variables.scss
create mode 100644 assets/scss/base/_reset.scss
create mode 100644 assets/scss/base/_typography.scss
create mode 100644 assets/scss/components/_alerts.scss
create mode 100644 assets/scss/components/_badges.scss
create mode 100644 assets/scss/components/_buttons.scss
create mode 100644 assets/scss/components/_card.scss
create mode 100644 assets/scss/components/_forms.scss
create mode 100644 assets/scss/components/_post.scss
create mode 100644 assets/scss/components/_upload.scss
create mode 100644 assets/scss/layout/_footer.scss
create mode 100644 assets/scss/layout/_header.scss
create mode 100644 assets/scss/main.scss
create mode 100644 assets/scss/pages/_admin.scss
create mode 100644 assets/scss/pages/_home.scss
create mode 100644 bin/provision.php
create mode 100644 composer.json
create mode 100644 composer.lock
create mode 100644 config/container.php
create mode 100644 database/migrations/001_create_users.php
create mode 100644 database/migrations/002_create_categories.php
create mode 100644 database/migrations/003_create_posts.php
create mode 100644 database/migrations/004_create_media.php
create mode 100644 database/migrations/005_create_password_resets.php
create mode 100644 database/migrations/006_create_posts_fts.php
create mode 100644 database/migrations/007_create_login_attempts.php
create mode 100644 database/migrations/008_sync_posts_fts_when_users_change.php
create mode 100644 docker-compose.yml
create mode 100644 docker/nginx/default.conf
create mode 100644 docker/php/Dockerfile
create mode 100644 docker/php/entrypoint.sh
create mode 100644 docker/php/php.ini
create mode 100644 docs/ARCHITECTURE.md
create mode 100644 docs/GUIDE.md
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 phpstan.neon
create mode 100644 phpunit.xml
create mode 100644 public/favicon.png
create mode 100644 public/index.php
create mode 100644 src/Auth/AccountController.php
create mode 100644 src/Auth/AuthController.php
create mode 100644 src/Auth/AuthService.php
create mode 100644 src/Auth/AuthServiceInterface.php
create mode 100644 src/Auth/Exception/InvalidResetTokenException.php
create mode 100644 src/Auth/LoginAttemptRepository.php
create mode 100644 src/Auth/LoginAttemptRepositoryInterface.php
create mode 100644 src/Auth/Middleware/AdminMiddleware.php
create mode 100644 src/Auth/Middleware/AuthMiddleware.php
create mode 100644 src/Auth/Middleware/EditorMiddleware.php
create mode 100644 src/Auth/PasswordResetController.php
create mode 100644 src/Auth/PasswordResetRepository.php
create mode 100644 src/Auth/PasswordResetRepositoryInterface.php
create mode 100644 src/Auth/PasswordResetService.php
create mode 100644 src/Auth/PasswordResetServiceInterface.php
create mode 100644 src/Category/Category.php
create mode 100644 src/Category/CategoryController.php
create mode 100644 src/Category/CategoryRepository.php
create mode 100644 src/Category/CategoryRepositoryInterface.php
create mode 100644 src/Category/CategoryService.php
create mode 100644 src/Category/CategoryServiceInterface.php
create mode 100644 src/Media/Exception/FileTooLargeException.php
create mode 100644 src/Media/Exception/InvalidMimeTypeException.php
create mode 100644 src/Media/Exception/StorageException.php
create mode 100644 src/Media/Media.php
create mode 100644 src/Media/MediaController.php
create mode 100644 src/Media/MediaRepository.php
create mode 100644 src/Media/MediaRepositoryInterface.php
create mode 100644 src/Media/MediaService.php
create mode 100644 src/Media/MediaServiceInterface.php
create mode 100644 src/Post/Post.php
create mode 100644 src/Post/PostController.php
create mode 100644 src/Post/PostExtension.php
create mode 100644 src/Post/PostRepository.php
create mode 100644 src/Post/PostRepositoryInterface.php
create mode 100644 src/Post/PostService.php
create mode 100644 src/Post/PostServiceInterface.php
create mode 100644 src/Post/RssController.php
create mode 100644 src/Shared/Bootstrap.php
create mode 100644 src/Shared/Config.php
create mode 100644 src/Shared/Database/Migrator.php
create mode 100644 src/Shared/Database/Provisioner.php
create mode 100644 src/Shared/Database/Seeder.php
create mode 100644 src/Shared/Exception/NotFoundException.php
create mode 100644 src/Shared/Extension/AppExtension.php
create mode 100644 src/Shared/Extension/CsrfExtension.php
create mode 100644 src/Shared/Extension/SessionExtension.php
create mode 100644 src/Shared/Html/HtmlPurifierFactory.php
create mode 100644 src/Shared/Html/HtmlSanitizer.php
create mode 100644 src/Shared/Html/HtmlSanitizerInterface.php
create mode 100644 src/Shared/Http/ClientIpResolver.php
create mode 100644 src/Shared/Http/FlashService.php
create mode 100644 src/Shared/Http/FlashServiceInterface.php
create mode 100644 src/Shared/Http/SessionManager.php
create mode 100644 src/Shared/Http/SessionManagerInterface.php
create mode 100644 src/Shared/Mail/MailService.php
create mode 100644 src/Shared/Mail/MailServiceInterface.php
create mode 100644 src/Shared/Routes.php
create mode 100644 src/Shared/Util/DateParser.php
create mode 100644 src/Shared/Util/SlugHelper.php
create mode 100644 src/User/Exception/DuplicateEmailException.php
create mode 100644 src/User/Exception/DuplicateUsernameException.php
create mode 100644 src/User/Exception/InvalidRoleException.php
create mode 100644 src/User/Exception/WeakPasswordException.php
create mode 100644 src/User/User.php
create mode 100644 src/User/UserController.php
create mode 100644 src/User/UserRepository.php
create mode 100644 src/User/UserRepositoryInterface.php
create mode 100644 src/User/UserService.php
create mode 100644 src/User/UserServiceInterface.php
create mode 100644 tests/Auth/AccountControllerTest.php
create mode 100644 tests/Auth/AuthControllerTest.php
create mode 100644 tests/Auth/AuthServiceRateLimitTest.php
create mode 100644 tests/Auth/AuthServiceTest.php
create mode 100644 tests/Auth/LoginAttemptRepositoryTest.php
create mode 100644 tests/Auth/MiddlewareTest.php
create mode 100644 tests/Auth/PasswordResetControllerTest.php
create mode 100644 tests/Auth/PasswordResetRepositoryTest.php
create mode 100644 tests/Auth/PasswordResetServiceIntegrationTest.php
create mode 100644 tests/Auth/PasswordResetServiceTest.php
create mode 100644 tests/Category/CategoryControllerTest.php
create mode 100644 tests/Category/CategoryModelTest.php
create mode 100644 tests/Category/CategoryRepositoryTest.php
create mode 100644 tests/Category/CategoryServiceTest.php
create mode 100644 tests/ControllerTestCase.php
create mode 100644 tests/Media/MediaControllerTest.php
create mode 100644 tests/Media/MediaModelTest.php
create mode 100644 tests/Media/MediaRepositoryTest.php
create mode 100644 tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php
create mode 100644 tests/Media/MediaServiceEdgeCasesTest.php
create mode 100644 tests/Media/MediaServiceInvalidMimeTest.php
create mode 100644 tests/Media/MediaServiceInvalidTempPathTest.php
create mode 100644 tests/Media/MediaServiceTest.php
create mode 100644 tests/Post/PostConcurrentUpdateIntegrationTest.php
create mode 100644 tests/Post/PostControllerTest.php
create mode 100644 tests/Post/PostExtensionTest.php
create mode 100644 tests/Post/PostFtsUsernameSyncIntegrationTest.php
create mode 100644 tests/Post/PostModelEdgeCasesTest.php
create mode 100644 tests/Post/PostModelTest.php
create mode 100644 tests/Post/PostRepositoryTest.php
create mode 100644 tests/Post/PostServiceTest.php
create mode 100644 tests/Post/RssControllerTest.php
create mode 100644 tests/Shared/ClientIpResolverTest.php
create mode 100644 tests/Shared/ConfigTest.php
create mode 100644 tests/Shared/DateParserTest.php
create mode 100644 tests/Shared/ExtensionTest.php
create mode 100644 tests/Shared/FlashServiceConsumeTest.php
create mode 100644 tests/Shared/FlashServiceTest.php
create mode 100644 tests/Shared/HelperEdgeCasesTest.php
create mode 100644 tests/Shared/HtmlPurifierFactoryTest.php
create mode 100644 tests/Shared/HtmlSanitizerTest.php
create mode 100644 tests/Shared/MigratorTest.php
create mode 100644 tests/Shared/SeederTest.php
create mode 100644 tests/Shared/SessionManagerEdgeCasesTest.php
create mode 100644 tests/Shared/SessionManagerTest.php
create mode 100644 tests/Shared/SlugHelperTest.php
create mode 100644 tests/User/UserControllerTest.php
create mode 100644 tests/User/UserRepositoryTest.php
create mode 100644 tests/User/UserServiceTest.php
create mode 100644 tests/User/UserTest.php
create mode 100644 views/admin/categories/index.twig
create mode 100644 views/admin/media/index.twig
create mode 100644 views/admin/posts/form.twig
create mode 100644 views/admin/posts/index.twig
create mode 100644 views/admin/users/form.twig
create mode 100644 views/admin/users/index.twig
create mode 100644 views/emails/password-reset.twig
create mode 100644 views/layout.twig
create mode 100644 views/pages/account/password-change.twig
create mode 100644 views/pages/auth/login.twig
create mode 100644 views/pages/auth/password-forgot.twig
create mode 100644 views/pages/auth/password-reset.twig
create mode 100644 views/pages/error.twig
create mode 100644 views/pages/home.twig
create mode 100644 views/pages/post/detail.twig
create mode 100644 views/partials/_admin_nav.twig
create mode 100644 views/partials/_footer.twig
create mode 100644 views/partials/_header.twig
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..5aa552c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,61 @@
+# ============================================
+# Environnement & Configuration
+# ============================================
+.env
+
+# ============================================
+# Dépendances Composer
+# ============================================
+# Reconstruites dans l'image par composer install
+vendor/
+
+# ============================================
+# Dépendances NPM
+# ============================================
+# Reconstruites dans l'image par npm install
+node_modules/
+
+# ============================================
+# Assets générés
+# ============================================
+# Reconstruits dans l'image par npm run build
+public/assets/
+
+# ============================================
+# Données persistantes (bind mounts)
+# ============================================
+# Montés depuis l'hôte au démarrage — ne pas inclure dans l'image
+data/
+public/media/
+database/*.sqlite
+
+# ============================================
+# Cache & Logs
+# ============================================
+var/
+coverage/
+.php-cs-fixer.cache
+.phpstan/
+.phpunit.result.cache
+
+# ============================================
+# Tests & Documentation
+# ============================================
+tests/
+docs/
+
+# ============================================
+# Versioning
+# ============================================
+.git/
+
+# ============================================
+# IDE & OS
+# ============================================
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+Thumbs.db
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..e58c8eb
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,45 @@
+# =============================================================================
+# Général
+# =============================================================================
+
+# Environnement de l'application (development, test ou production)
+APP_ENV=development
+
+# Nom du blog (utilisé dans le flux RSS et les emails)
+APP_NAME="Slim Blog"
+
+# URL de base de l'application (utilisée pour les liens dans les emails et le flux RSS).
+# Développement : APP_URL=http://localhost:8080
+# Production : APP_URL=https://blog.exemple.com
+APP_URL=http://localhost:8080
+
+# Fuseau horaire
+TIMEZONE=Europe/Paris
+
+# =============================================================================
+# Administration
+# =============================================================================
+
+# Compte administrateur (créé automatiquement au premier démarrage)
+ADMIN_USERNAME=admin
+ADMIN_EMAIL=admin@example.com
+ADMIN_PASSWORD=changeme123
+
+# =============================================================================
+# Email
+# =============================================================================
+
+MAIL_HOST=smtp.example.com
+MAIL_PORT=587
+MAIL_USERNAME=noreply@example.com
+MAIL_PASSWORD=your_smtp_password
+MAIL_ENCRYPTION=tls
+MAIL_FROM=noreply@example.com
+MAIL_FROM_NAME="Slim Blog"
+
+# =============================================================================
+# Uploads
+# =============================================================================
+
+# Taille maximale en octets (doit être < upload_max_filesize dans docker/php/php.ini en production)
+UPLOAD_MAX_SIZE=5242880
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f7ea1d3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,56 @@
+# ============================================
+# Environnement & Configuration
+# ============================================
+.env
+
+# ============================================
+# Dépendances Composer
+# ============================================
+vendor/
+
+# ============================================
+# Dépendances NPM
+# ============================================
+node_modules/
+
+# ============================================
+# Assets générés
+# ============================================
+public/assets/
+
+# ============================================
+# Base de données
+# ============================================
+database/*.sqlite
+database/*.sqlite-shm
+database/*.sqlite-wal
+
+# ============================================
+# Cache & Logs
+# ============================================
+coverage/
+var/
+.php-cs-fixer.cache
+.phpstan/
+.phpunit.result.cache
+
+# ============================================
+# Media
+# ============================================
+public/media/
+
+# ============================================
+# Docker volumes
+# ============================================
+data/
+
+# ============================================
+# IDE & OS
+# ============================================
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+Thumbs.db
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..25a1b6e
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,116 @@
+# Contribuer au projet
+
+## Prérequis
+
+Les mêmes que pour le développement (voir [README](README.md)), plus :
+
+- PHP 8.1+ avec l'extension `dom` (requise par HTMLPurifier et PHPUnit)
+
+## Lancer les tests
+
+```bash
+composer install
+vendor/bin/phpunit
+```
+
+Avec rapport de couverture (nécessite Xdebug ou PCOV) :
+
+```bash
+vendor/bin/phpunit --coverage-text
+```
+
+## Analyse statique (PHPStan)
+
+```bash
+vendor/bin/phpstan analyse
+```
+
+Cette commande utilise `phpstan.neon` qui définit le niveau 8 et les chemins analysés. PDO est nativement connu de PHPStan — aucun stub supplémentaire n'est nécessaire.
+
+PHPStan est configuré au niveau 8, le plus strict. L'exécuter avant toute Pull Request garantit la cohérence des types et détecte les erreurs avant l'exécution.
+
+## Suite de tests
+
+Les tests sont dans `tests/`, organisés en miroir de `src/`.
+
+### `tests/Auth/`
+
+| Fichier | Classe testée | Ce qui est vérifié |
+|--------------------------------|---------------------------|---------------------|
+| `AuthServiceTest` | `AuthService` | `createUser()` (normalisation, unicité via exceptions métier, longueur mdp), `authenticate()`, `changePassword()`, `login/logout/isLoggedIn()` |
+| `AuthServiceRateLimitTest` | `AuthService` | `checkRateLimit()` (IP libre, verrouillée, expirée, minimum 1 minute, `deleteExpired()`), `recordFailure()` (constantes MAX_ATTEMPTS/LOCK_MINUTES), `resetRateLimit()` |
+| `LoginAttemptRepositoryTest` | `LoginAttemptRepository` | `findByIp()`, `recordFailure()` (INSERT vs UPDATE, compteur, seuil exact, fenêtre temporelle), `resetForIp()`, `deleteExpired()` |
+| `PasswordResetServiceTest` | `PasswordResetService` | `requestReset()` (email inconnu silencieux, invalidation, création, envoi, URL), `validateToken()` (inexistant, expiré, valide), `resetPassword()` (token invalide, mdp trop court via `WeakPasswordException`, mise à jour + consommation) |
+| `PasswordResetRepositoryTest` | `PasswordResetRepository` | `create()`, `findActiveByHash()` (filtre `used_at = null`), `invalidateByUserId()` et `markAsUsed()` (jamais de `delete`) |
+
+### `tests/User/`
+
+| Fichier | Classe testée | Ce qui est vérifié |
+|-----------------------|------------------|---------------------|
+| `UserTest` | `User` | Construction, validation (username, email, hash, rôle), limites min/max, `fromArray()` |
+| `UserRepositoryTest` | `UserRepository` | `findAll/ById/ByUsername/ByEmail()`, `create()`, `updatePassword()`, `delete()` |
+
+### `tests/Shared/`
+
+| Fichier | Classe testée | Ce qui est vérifié |
+|-----------------------|------------------|---------------------|
+| `SessionManagerTest` | `SessionManager` / `SessionManagerInterface` | `isAuthenticated()`, `getUserId()`, rôles, écriture `$_SESSION`, `destroy()` |
+| `HtmlSanitizerTest` | `HtmlSanitizer` | Balises autorisées conservées, XSS supprimé (`monde ' . str_repeat('x', 30) . '
';
+ $post = new Post(1, 'Titre', $html, 'titre');
+
+ $excerpt = $this->call('post_excerpt', $post, 20);
+
+ self::assertStringContainsString('Bonjour', $excerpt);
+ self::assertStringContainsString('', $excerpt);
+ self::assertStringNotContainsString('';
+ $result = $this->sanitizer->sanitize($html);
+
+ $this->assertStringNotContainsString('">XSS';
+ $result = $this->sanitizer->sanitize($html);
+
+ $this->assertStringNotContainsString('data:', $result);
+ }
+
+ /**
+ * La balise