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 '; + $result = $this->sanitizer->sanitize($html); + + $this->assertStringNotContainsString(' doit être supprimée. + */ + public function testObjectTagRemoved(): void + { + $html = ''; + $result = $this->sanitizer->sanitize($html); + + $this->assertStringNotContainsString(' doit être supprimée. + */ + public function testFormTagRemoved(): void + { + $html = '
'; + $result = $this->sanitizer->sanitize($html); + + $this->assertStringNotContainsString('assertStringNotContainsString('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 = '

Centré

'; + $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 = '

Texte

'; + $result = $this->sanitizer->sanitize($html); + + $this->assertStringNotContainsString('color', $result); + $this->assertStringNotContainsString('background', $result); + } +} diff --git a/tests/Shared/MigratorTest.php b/tests/Shared/MigratorTest.php new file mode 100644 index 0000000..277ddc9 --- /dev/null +++ b/tests/Shared/MigratorTest.php @@ -0,0 +1,212 @@ +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', '

Contenu

', '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', '

Contenu

', '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(); + } +} diff --git a/tests/Shared/SeederTest.php b/tests/Shared/SeederTest.php new file mode 100644 index 0000000..24124f6 --- /dev/null +++ b/tests/Shared/SeederTest.php @@ -0,0 +1,207 @@ + 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); + } +} diff --git a/tests/Shared/SessionManagerEdgeCasesTest.php b/tests/Shared/SessionManagerEdgeCasesTest.php new file mode 100644 index 0000000..41aef3a --- /dev/null +++ b/tests/Shared/SessionManagerEdgeCasesTest.php @@ -0,0 +1,40 @@ +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()); + } +} diff --git a/tests/Shared/SessionManagerTest.php b/tests/Shared/SessionManagerTest.php new file mode 100644 index 0000000..1608964 --- /dev/null +++ b/tests/Shared/SessionManagerTest.php @@ -0,0 +1,166 @@ +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); + } +} diff --git a/tests/Shared/SlugHelperTest.php b/tests/Shared/SlugHelperTest.php new file mode 100644 index 0000000..ea2e386 --- /dev/null +++ b/tests/Shared/SlugHelperTest.php @@ -0,0 +1,121 @@ +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')); + } +} diff --git a/tests/User/UserControllerTest.php b/tests/User/UserControllerTest.php new file mode 100644 index 0000000..36f960d --- /dev/null +++ b/tests/User/UserControllerTest.php @@ -0,0 +1,441 @@ +view = $this->makeTwigMock(); + $this->userService = $this->createMock(UserServiceInterface::class); + $this->flash = $this->createMock(FlashServiceInterface::class); + $this->sessionManager = $this->createMock(SessionManagerInterface::class); + + $this->controller = new UserController( + $this->view, + $this->userService, + $this->flash, + $this->sessionManager, + ); + } + + // ── index ──────────────────────────────────────────────────────── + + /** + * index() doit rendre la vue avec la liste des utilisateurs. + */ + public function testIndexRendersWithUserList(): void + { + $this->userService->method('findAll')->willReturn([]); + $this->sessionManager->method('getUserId')->willReturn(1); + + $this->view->expects($this->once()) + ->method('render') + ->with($this->anything(), 'admin/users/index.twig', $this->anything()) + ->willReturnArgument(0); + + $res = $this->controller->index($this->makeGet('/admin/users'), $this->makeResponse()); + + $this->assertStatus($res, 200); + } + + // ── showCreate ─────────────────────────────────────────────────── + + /** + * showCreate() doit rendre le formulaire de création. + */ + public function testShowCreateRendersForm(): void + { + $this->view->expects($this->once()) + ->method('render') + ->with($this->anything(), 'admin/users/form.twig', $this->anything()) + ->willReturnArgument(0); + + $res = $this->controller->showCreate($this->makeGet('/admin/users/create'), $this->makeResponse()); + + $this->assertStatus($res, 200); + } + + // ── create ─────────────────────────────────────────────────────── + + /** + * create() doit rediriger avec une erreur si les mots de passe ne correspondent pas. + */ + public function testCreateRedirectsWhenPasswordMismatch(): void + { + $this->flash->expects($this->once())->method('set') + ->with('user_error', 'Les mots de passe ne correspondent pas'); + + $req = $this->makePost('/admin/users/create', [ + 'username' => 'alice', + 'email' => 'alice@example.com', + 'password' => 'pass1', + 'password_confirm' => 'pass2', + ]); + $res = $this->controller->create($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/admin/users/create'); + } + + /** + * create() ne doit pas appeler userService si les mots de passe ne correspondent pas. + */ + public function testCreateDoesNotCallServiceOnMismatch(): void + { + $this->userService->expects($this->never())->method('createUser'); + $this->flash->method('set'); + + $req = $this->makePost('/admin/users/create', [ + 'username' => 'alice', + 'email' => 'alice@example.com', + 'password' => 'aaa', + 'password_confirm' => 'bbb', + ]); + $this->controller->create($req, $this->makeResponse()); + } + + /** + * create() doit rediriger avec une erreur si le nom d'utilisateur est déjà pris. + */ + public function testCreateRedirectsOnDuplicateUsername(): void + { + $this->userService->method('createUser') + ->willThrowException(new DuplicateUsernameException('alice')); + + $this->flash->expects($this->once())->method('set') + ->with('user_error', $this->stringContains("nom d'utilisateur est déjà pris")); + + $req = $this->makePost('/admin/users/create', [ + 'username' => 'alice', + 'email' => 'alice@example.com', + 'password' => 'password123', + 'password_confirm' => 'password123', + ]); + $res = $this->controller->create($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/admin/users/create'); + } + + /** + * create() doit rediriger avec une erreur si l'email est déjà utilisé. + */ + public function testCreateRedirectsOnDuplicateEmail(): void + { + $this->userService->method('createUser') + ->willThrowException(new DuplicateEmailException('alice@example.com')); + + $this->flash->expects($this->once())->method('set') + ->with('user_error', $this->stringContains('e-mail est déjà utilisée')); + + $req = $this->makePost('/admin/users/create', [ + 'username' => 'alice', + 'email' => 'alice@example.com', + 'password' => 'password123', + 'password_confirm' => 'password123', + ]); + $res = $this->controller->create($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/admin/users/create'); + } + + /** + * create() doit rediriger avec une erreur si le mot de passe est trop court. + */ + public function testCreateRedirectsOnWeakPassword(): void + { + $this->userService->method('createUser') + ->willThrowException(new WeakPasswordException()); + + $this->flash->expects($this->once())->method('set') + ->with('user_error', $this->stringContains('8 caractères')); + + $req = $this->makePost('/admin/users/create', [ + 'username' => 'alice', + 'email' => 'alice@example.com', + 'password' => 'short', + 'password_confirm' => 'short', + ]); + $res = $this->controller->create($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/admin/users/create'); + } + + /** + * create() doit flasher un succès et rediriger vers /admin/users en cas de succès. + */ + public function testCreateRedirectsToUsersListOnSuccess(): void + { + $this->userService->method('createUser')->willReturn($this->makeUser(99, 'alice', User::ROLE_USER)); + + $this->flash->expects($this->once())->method('set') + ->with('user_success', $this->stringContains('alice')); + + $req = $this->makePost('/admin/users/create', [ + 'username' => 'alice', + 'email' => 'alice@example.com', + 'password' => 'password123', + 'password_confirm' => 'password123', + ]); + $res = $this->controller->create($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/admin/users'); + } + + /** + * create() doit forcer le rôle 'user' si un rôle admin est soumis dans le formulaire. + */ + public function testCreateForcesRoleUserWhenAdminRoleSubmitted(): void + { + $this->userService->expects($this->once()) + ->method('createUser') + ->with('alice', 'alice@example.com', 'password123', User::ROLE_USER); + + $this->flash->method('set'); + + $req = $this->makePost('/admin/users/create', [ + 'username' => 'alice', + 'email' => 'alice@example.com', + 'password' => 'password123', + 'password_confirm' => 'password123', + 'role' => User::ROLE_ADMIN, // rôle injecté par l'attaquant + ]); + $this->controller->create($req, $this->makeResponse()); + } + + // ── updateRole ─────────────────────────────────────────────────── + + /** + * updateRole() doit rediriger avec une erreur si l'utilisateur est introuvable. + */ + public function testUpdateRoleRedirectsWhenUserNotFound(): void + { + $this->userService->method('findById')->willReturn(null); + + $this->flash->expects($this->once())->method('set') + ->with('user_error', 'Utilisateur introuvable'); + + $res = $this->controller->updateRole( + $this->makePost('/admin/users/role/99', ['role' => User::ROLE_EDITOR]), + $this->makeResponse(), + ['id' => '99'], + ); + + $this->assertRedirectTo($res, '/admin/users'); + } + + /** + * updateRole() doit rediriger avec une erreur si l'admin tente de changer son propre rôle. + */ + public function testUpdateRoleRedirectsWhenAdminTriesToChangeOwnRole(): void + { + $user = $this->makeUser(1, 'admin', User::ROLE_USER); + $this->userService->method('findById')->willReturn($user); + $this->sessionManager->method('getUserId')->willReturn(1); // même ID + + $this->flash->expects($this->once())->method('set') + ->with('user_error', $this->stringContains('propre rôle')); + + $res = $this->controller->updateRole( + $this->makePost('/admin/users/role/1', ['role' => User::ROLE_EDITOR]), + $this->makeResponse(), + ['id' => '1'], + ); + + $this->assertRedirectTo($res, '/admin/users'); + } + + /** + * updateRole() doit rediriger avec une erreur si l'utilisateur cible est déjà admin. + */ + public function testUpdateRoleRedirectsWhenTargetIsAdmin(): void + { + $user = $this->makeUser(2, 'superadmin', User::ROLE_ADMIN); + $this->userService->method('findById')->willReturn($user); + $this->sessionManager->method('getUserId')->willReturn(1); + + $this->flash->expects($this->once())->method('set') + ->with('user_error', $this->stringContains("administrateur ne peut pas être modifié")); + + $res = $this->controller->updateRole( + $this->makePost('/admin/users/role/2', ['role' => User::ROLE_EDITOR]), + $this->makeResponse(), + ['id' => '2'], + ); + + $this->assertRedirectTo($res, '/admin/users'); + } + + /** + * updateRole() doit rediriger avec une erreur si le rôle soumis est invalide. + */ + public function testUpdateRoleRedirectsOnInvalidRole(): void + { + $user = $this->makeUser(2, 'bob', User::ROLE_USER); + $this->userService->method('findById')->willReturn($user); + $this->sessionManager->method('getUserId')->willReturn(1); + + $this->flash->expects($this->once())->method('set') + ->with('user_error', 'Rôle invalide'); + + $res = $this->controller->updateRole( + $this->makePost('/admin/users/role/2', ['role' => 'superuser']), + $this->makeResponse(), + ['id' => '2'], + ); + + $this->assertRedirectTo($res, '/admin/users'); + } + + /** + * updateRole() doit appeler userService et rediriger avec succès. + */ + public function testUpdateRoleRedirectsWithSuccessFlash(): void + { + $user = $this->makeUser(2, 'bob', User::ROLE_USER); + $this->userService->method('findById')->willReturn($user); + $this->sessionManager->method('getUserId')->willReturn(1); + + $this->userService->expects($this->once())->method('updateRole')->with(2, User::ROLE_EDITOR); + $this->flash->expects($this->once())->method('set') + ->with('user_success', $this->stringContains('bob')); + + $res = $this->controller->updateRole( + $this->makePost('/admin/users/role/2', ['role' => User::ROLE_EDITOR]), + $this->makeResponse(), + ['id' => '2'], + ); + + $this->assertRedirectTo($res, '/admin/users'); + } + + // ── delete ─────────────────────────────────────────────────────── + + /** + * delete() doit rediriger avec une erreur si l'utilisateur est introuvable. + */ + public function testDeleteRedirectsWhenUserNotFound(): void + { + $this->userService->method('findById')->willReturn(null); + + $this->flash->expects($this->once())->method('set') + ->with('user_error', 'Utilisateur introuvable'); + + $res = $this->controller->delete( + $this->makePost('/admin/users/delete/99'), + $this->makeResponse(), + ['id' => '99'], + ); + + $this->assertRedirectTo($res, '/admin/users'); + } + + /** + * delete() doit rediriger avec une erreur si la cible est administrateur. + */ + public function testDeleteRedirectsWhenTargetIsAdmin(): void + { + $user = $this->makeUser(2, 'superadmin', User::ROLE_ADMIN); + $this->userService->method('findById')->willReturn($user); + $this->sessionManager->method('getUserId')->willReturn(1); + + $this->flash->expects($this->once())->method('set') + ->with('user_error', $this->stringContains('administrateur ne peut pas être supprimé')); + + $res = $this->controller->delete( + $this->makePost('/admin/users/delete/2'), + $this->makeResponse(), + ['id' => '2'], + ); + + $this->assertRedirectTo($res, '/admin/users'); + } + + /** + * delete() doit rediriger avec une erreur si l'admin tente de supprimer son propre compte. + */ + public function testDeleteRedirectsWhenAdminTriesToDeleteOwnAccount(): void + { + $user = $this->makeUser(1, 'alice', User::ROLE_USER); + $this->userService->method('findById')->willReturn($user); + $this->sessionManager->method('getUserId')->willReturn(1); // même ID + + $this->flash->expects($this->once())->method('set') + ->with('user_error', $this->stringContains('propre compte')); + + $res = $this->controller->delete( + $this->makePost('/admin/users/delete/1'), + $this->makeResponse(), + ['id' => '1'], + ); + + $this->assertRedirectTo($res, '/admin/users'); + } + + /** + * delete() doit appeler userService et rediriger avec succès. + */ + public function testDeleteRedirectsWithSuccessFlash(): void + { + $user = $this->makeUser(2, 'bob', User::ROLE_USER); + $this->userService->method('findById')->willReturn($user); + $this->sessionManager->method('getUserId')->willReturn(1); + + $this->userService->expects($this->once())->method('delete')->with(2); + $this->flash->expects($this->once())->method('set') + ->with('user_success', $this->stringContains('bob')); + + $res = $this->controller->delete( + $this->makePost('/admin/users/delete/2'), + $this->makeResponse(), + ['id' => '2'], + ); + + $this->assertRedirectTo($res, '/admin/users'); + } + + // ── Helpers ────────────────────────────────────────────────────── + + /** + * Crée un utilisateur de test avec les paramètres minimaux. + */ + private function makeUser(int $id, string $username, string $role): User + { + return new User($id, $username, "{$username}@example.com", password_hash('secret', PASSWORD_BCRYPT), $role); + } +} diff --git a/tests/User/UserRepositoryTest.php b/tests/User/UserRepositoryTest.php new file mode 100644 index 0000000..dbfaf72 --- /dev/null +++ b/tests/User/UserRepositoryTest.php @@ -0,0 +1,357 @@ + + */ + private array $rowAlice; + + /** + * Initialise le mock PDO, le dépôt et les données de test avant chaque test. + */ + protected function setUp(): void + { + $this->db = $this->createMock(PDO::class); + $this->repository = new UserRepository($this->db); + + $this->rowAlice = [ + 'id' => 1, + 'username' => 'alice', + 'email' => 'alice@example.com', + 'password_hash' => password_hash('secret', PASSWORD_BCRYPT), + 'role' => User::ROLE_USER, + 'created_at' => '2024-01-01 00:00:00', + ]; + } + + // ── Helpers ──────────────────────────────────────────────────── + + private function stmtForRead(array $rows = [], array|false $row = false): PDOStatement&MockObject + { + $stmt = $this->createMock(PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('fetchAll')->willReturn($rows); + $stmt->method('fetch')->willReturn($row); + + return $stmt; + } + + private function stmtForWrite(): PDOStatement&MockObject + { + $stmt = $this->createMock(PDOStatement::class); + $stmt->method('execute')->willReturn(true); + + return $stmt; + } + + + // ── findAll ──────────────────────────────────────────────────── + + /** + * findAll() doit retourner un tableau vide si aucun utilisateur n'existe. + */ + public function testFindAllReturnsEmptyArrayWhenNone(): void + { + $stmt = $this->stmtForRead([]); + $this->db->method('query')->willReturn($stmt); + + $this->assertSame([], $this->repository->findAll()); + } + + /** + * findAll() doit retourner un tableau d'instances User hydratées. + */ + public function testFindAllReturnsUserInstances(): void + { + $stmt = $this->stmtForRead([$this->rowAlice]); + $this->db->method('query')->willReturn($stmt); + + $result = $this->repository->findAll(); + + $this->assertCount(1, $result); + $this->assertInstanceOf(User::class, $result[0]); + $this->assertSame('alice', $result[0]->getUsername()); + } + + /** + * findAll() doit interroger la table 'users' avec un tri par created_at ASC. + */ + public function testFindAllQueriesWithAscendingOrder(): void + { + $stmt = $this->stmtForRead([]); + + $this->db->expects($this->once()) + ->method('query') + ->with($this->logicalAnd( + $this->stringContains('users'), + $this->stringContains('created_at ASC'), + )) + ->willReturn($stmt); + + $this->repository->findAll(); + } + + + // ── findById ─────────────────────────────────────────────────── + + /** + * findById() doit retourner null si aucun utilisateur ne correspond à cet identifiant. + */ + public function testFindByIdReturnsNullWhenMissing(): void + { + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertNull($this->repository->findById(99)); + } + + /** + * findById() doit retourner une instance User hydratée si l'utilisateur existe. + */ + public function testFindByIdReturnsUserWhenFound(): void + { + $stmt = $this->stmtForRead(row: $this->rowAlice); + $this->db->method('prepare')->willReturn($stmt); + + $result = $this->repository->findById(1); + + $this->assertInstanceOf(User::class, $result); + $this->assertSame(1, $result->getId()); + } + + /** + * findById() doit exécuter avec le bon identifiant. + */ + public function testFindByIdQueriesWithCorrectId(): void + { + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with([':id' => 42]); + + $this->repository->findById(42); + } + + + // ── findByUsername ───────────────────────────────────────────── + + /** + * findByUsername() doit retourner null si le nom d'utilisateur est introuvable. + */ + public function testFindByUsernameReturnsNullWhenMissing(): void + { + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertNull($this->repository->findByUsername('inconnu')); + } + + /** + * findByUsername() doit retourner une instance User si le nom est trouvé. + */ + public function testFindByUsernameReturnsUserWhenFound(): void + { + $stmt = $this->stmtForRead(row: $this->rowAlice); + $this->db->method('prepare')->willReturn($stmt); + + $result = $this->repository->findByUsername('alice'); + + $this->assertInstanceOf(User::class, $result); + $this->assertSame('alice', $result->getUsername()); + } + + /** + * findByUsername() doit exécuter avec le bon nom d'utilisateur. + */ + public function testFindByUsernameQueriesWithCorrectName(): void + { + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with([':username' => 'alice']); + + $this->repository->findByUsername('alice'); + } + + + // ── findByEmail ──────────────────────────────────────────────── + + /** + * findByEmail() doit retourner null si l'adresse e-mail est introuvable. + */ + public function testFindByEmailReturnsNullWhenMissing(): void + { + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertNull($this->repository->findByEmail('inconnu@example.com')); + } + + /** + * findByEmail() doit retourner une instance User si l'e-mail est trouvé. + */ + public function testFindByEmailReturnsUserWhenFound(): void + { + $stmt = $this->stmtForRead(row: $this->rowAlice); + $this->db->method('prepare')->willReturn($stmt); + + $result = $this->repository->findByEmail('alice@example.com'); + + $this->assertInstanceOf(User::class, $result); + $this->assertSame('alice@example.com', $result->getEmail()); + } + + /** + * findByEmail() doit exécuter avec la bonne adresse e-mail. + */ + public function testFindByEmailQueriesWithCorrectEmail(): void + { + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with([':email' => 'alice@example.com']); + + $this->repository->findByEmail('alice@example.com'); + } + + + // ── create ───────────────────────────────────────────────────── + + /** + * create() doit préparer un INSERT sur la table 'users' avec les bonnes données. + */ + public function testCreateCallsInsertWithCorrectData(): void + { + $user = User::fromArray($this->rowAlice); + $stmt = $this->stmtForWrite(); + + $this->db->method('prepare') + ->with($this->stringContains('INSERT INTO users')) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(function (array $data) use ($user): bool { + return $data[':username'] === $user->getUsername() + && $data[':email'] === $user->getEmail() + && $data[':password_hash'] === $user->getPasswordHash() + && $data[':role'] === $user->getRole() + && isset($data[':created_at']); + })); + + $this->db->method('lastInsertId')->willReturn('1'); + + $this->repository->create($user); + } + + /** + * create() doit retourner l'identifiant généré par la base de données. + */ + public function testCreateReturnsGeneratedId(): void + { + $user = User::fromArray($this->rowAlice); + $stmt = $this->stmtForWrite(); + $this->db->method('prepare')->willReturn($stmt); + $this->db->method('lastInsertId')->willReturn('42'); + + $this->assertSame(42, $this->repository->create($user)); + } + + + // ── updatePassword ───────────────────────────────────────────── + + /** + * updatePassword() doit préparer un UPDATE avec le nouveau hash et le bon identifiant. + */ + public function testUpdatePasswordCallsUpdateWithCorrectHash(): void + { + $newHash = password_hash('nouveaumdp', PASSWORD_BCRYPT); + $stmt = $this->stmtForWrite(); + + $this->db->method('prepare') + ->with($this->stringContains('UPDATE users')) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with([':password_hash' => $newHash, ':id' => 1]); + + $this->repository->updatePassword(1, $newHash); + } + + + // ── updateRole ───────────────────────────────────────────────── + + /** + * updateRole() doit préparer un UPDATE avec le bon rôle et le bon identifiant. + */ + public function testUpdateRoleCallsUpdateWithCorrectRole(): void + { + $stmt = $this->stmtForWrite(); + + $this->db->method('prepare') + ->with($this->stringContains('UPDATE users')) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with([':role' => User::ROLE_EDITOR, ':id' => 1]); + + $this->repository->updateRole(1, User::ROLE_EDITOR); + } + + + // ── delete ───────────────────────────────────────────────────── + + /** + * delete() doit préparer un DELETE avec le bon identifiant. + */ + public function testDeleteCallsDeleteWithCorrectId(): void + { + $stmt = $this->stmtForWrite(); + + $this->db->expects($this->once()) + ->method('prepare') + ->with($this->stringContains('DELETE FROM users')) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with([':id' => 7]); + + $this->repository->delete(7); + } +} diff --git a/tests/User/UserServiceTest.php b/tests/User/UserServiceTest.php new file mode 100644 index 0000000..3fa8254 --- /dev/null +++ b/tests/User/UserServiceTest.php @@ -0,0 +1,270 @@ +userRepository = $this->createMock(UserRepositoryInterface::class); + $this->service = new UserService($this->userRepository); + } + + + // ── createUser ───────────────────────────────────────────────── + + /** + * createUser() doit créer et retourner un utilisateur avec les bonnes données. + */ + public function testCreateUserWithValidData(): void + { + $this->userRepository->method('findByUsername')->willReturn(null); + $this->userRepository->method('findByEmail')->willReturn(null); + $this->userRepository->expects($this->once())->method('create'); + + $user = $this->service->createUser('Alice', 'alice@example.com', 'motdepasse1'); + + $this->assertSame('alice', $user->getUsername()); + $this->assertSame('alice@example.com', $user->getEmail()); + $this->assertSame(User::ROLE_USER, $user->getRole()); + } + + /** + * createUser() doit normaliser le nom d'utilisateur et l'email en minuscules. + */ + public function testCreateUserNormalizesToLowercase(): void + { + $this->userRepository->method('findByUsername')->willReturn(null); + $this->userRepository->method('findByEmail')->willReturn(null); + $this->userRepository->method('create'); + + $user = $this->service->createUser(' ALICE ', ' ALICE@EXAMPLE.COM ', 'motdepasse1'); + + $this->assertSame('alice', $user->getUsername()); + $this->assertSame('alice@example.com', $user->getEmail()); + } + + /** + * createUser() doit lever DuplicateUsernameException si le nom est déjà pris. + */ + public function testCreateUserDuplicateUsernameThrowsDuplicateUsernameException(): void + { + $existingUser = $this->makeUser('alice', 'alice@example.com'); + $this->userRepository->method('findByUsername')->willReturn($existingUser); + + $this->expectException(DuplicateUsernameException::class); + + $this->service->createUser('alice', 'autre@example.com', 'motdepasse1'); + } + + /** + * createUser() doit lever DuplicateEmailException si l'email est déjà utilisé. + */ + public function testCreateUserDuplicateEmailThrowsDuplicateEmailException(): void + { + $existingUser = $this->makeUser('bob', 'alice@example.com'); + $this->userRepository->method('findByUsername')->willReturn(null); + $this->userRepository->method('findByEmail')->willReturn($existingUser); + + $this->expectException(DuplicateEmailException::class); + + $this->service->createUser('newuser', 'alice@example.com', 'motdepasse1'); + } + + /** + * createUser() doit lever WeakPasswordException si le mot de passe est trop court. + */ + public function testCreateUserTooShortPasswordThrowsWeakPasswordException(): void + { + $this->userRepository->method('findByUsername')->willReturn(null); + $this->userRepository->method('findByEmail')->willReturn(null); + + $this->expectException(WeakPasswordException::class); + + $this->service->createUser('alice', 'alice@example.com', '1234567'); + } + + /** + * createUser() avec exactement 8 caractères de mot de passe doit réussir. + */ + public function testCreateUserMinimumPasswordLength(): void + { + $this->userRepository->method('findByUsername')->willReturn(null); + $this->userRepository->method('findByEmail')->willReturn(null); + $this->userRepository->method('create'); + + $user = $this->service->createUser('alice', 'alice@example.com', '12345678'); + + $this->assertInstanceOf(User::class, $user); + } + + /** + * createUser() doit stocker un hash bcrypt, jamais le mot de passe en clair. + */ + public function testCreateUserPasswordIsHashed(): void + { + $plainPassword = 'motdepasse1'; + + $this->userRepository->method('findByUsername')->willReturn(null); + $this->userRepository->method('findByEmail')->willReturn(null); + $this->userRepository->method('create'); + + $user = $this->service->createUser('alice', 'alice@example.com', $plainPassword); + + $this->assertNotSame($plainPassword, $user->getPasswordHash()); + $this->assertTrue(password_verify($plainPassword, $user->getPasswordHash())); + } + + /** + * createUser() doit attribuer le rôle passé en paramètre. + */ + public function testCreateUserWithEditorRole(): void + { + $this->userRepository->method('findByUsername')->willReturn(null); + $this->userRepository->method('findByEmail')->willReturn(null); + $this->userRepository->method('create'); + + $user = $this->service->createUser('alice', 'alice@example.com', 'motdepasse1', User::ROLE_EDITOR); + + $this->assertSame(User::ROLE_EDITOR, $user->getRole()); + } + + + // ── findAll ──────────────────────────────────────────────────── + + /** + * findAll() délègue au repository et retourne la liste. + */ + public function testFindAllDelegatesToRepository(): void + { + $users = [$this->makeUser('alice', 'alice@example.com')]; + $this->userRepository->method('findAll')->willReturn($users); + + $this->assertSame($users, $this->service->findAll()); + } + + + // ── findById ─────────────────────────────────────────────────── + + /** + * findById() retourne null si l'utilisateur est introuvable. + */ + public function testFindByIdReturnsNullWhenMissing(): void + { + $this->userRepository->method('findById')->willReturn(null); + + $this->assertNull($this->service->findById(99)); + } + + /** + * findById() retourne l'utilisateur trouvé. + */ + public function testFindByIdReturnsUser(): void + { + $user = $this->makeUser('alice', 'alice@example.com'); + $this->userRepository->method('findById')->with(1)->willReturn($user); + + $this->assertSame($user, $this->service->findById(1)); + } + + + // ── delete ───────────────────────────────────────────────────── + + /** + * delete() délègue la suppression au repository. + */ + public function testDeleteDelegatesToRepository(): void + { + $this->userRepository->method('findById')->with(5)->willReturn($this->makeUser('alice', 'alice@example.com')); + $this->userRepository->expects($this->once())->method('delete')->with(5); + + $this->service->delete(5); + } + + + // ── updateRole ───────────────────────────────────────────────── + + /** + * updateRole() doit déléguer au repository avec le rôle validé. + */ + public function testUpdateRoleDelegatesToRepository(): void + { + $this->userRepository->method('findById')->with(3)->willReturn($this->makeUser('alice', 'alice@example.com')); + $this->userRepository->expects($this->once()) + ->method('updateRole') + ->with(3, User::ROLE_EDITOR); + + $this->service->updateRole(3, User::ROLE_EDITOR); + } + + /** + * updateRole() doit lever InvalidArgumentException pour un rôle inconnu. + */ + public function testUpdateRoleThrowsOnInvalidRole(): void + { + $this->userRepository->expects($this->never())->method('updateRole'); + + $this->expectException(InvalidRoleException::class); + + $this->service->updateRole(1, 'superadmin'); + } + + /** + * updateRole() accepte les trois rôles valides sans lever d'exception. + */ + #[\PHPUnit\Framework\Attributes\DataProvider('validRolesProvider')] + public function testUpdateRoleAcceptsAllValidRoles(string $role): void + { + $this->userRepository->method('findById')->with(1)->willReturn($this->makeUser('alice', 'alice@example.com')); + $this->userRepository->expects($this->once())->method('updateRole')->with(1, $role); + + $this->service->updateRole(1, $role); + } + + /** + * @return array + */ + public static function validRolesProvider(): array + { + return [ + 'user' => [User::ROLE_USER], + 'editor' => [User::ROLE_EDITOR], + 'admin' => [User::ROLE_ADMIN], + ]; + } + + + // ── Helpers ──────────────────────────────────────────────────── + + /** + * Crée un utilisateur de test avec un hash bcrypt du mot de passe fourni. + */ + private function makeUser(string $username, string $email): User + { + return new User(1, $username, $email, password_hash('motdepasse1', PASSWORD_BCRYPT)); + } +} diff --git a/tests/User/UserTest.php b/tests/User/UserTest.php new file mode 100644 index 0000000..9310616 --- /dev/null +++ b/tests/User/UserTest.php @@ -0,0 +1,232 @@ +assertSame(1, $user->getId()); + $this->assertSame('alice', $user->getUsername()); + $this->assertSame('alice@example.com', $user->getEmail()); + $this->assertSame(User::ROLE_USER, $user->getRole()); + } + + /** + * Le rôle par défaut doit être 'user'. + */ + public function testDefaultRole(): void + { + $user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT)); + + $this->assertSame(User::ROLE_USER, $user->getRole()); + $this->assertFalse($user->isAdmin()); + $this->assertFalse($user->isEditor()); + } + + /** + * Un utilisateur avec le rôle 'admin' doit être reconnu comme administrateur. + */ + public function testAdminRole(): void + { + $user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_ADMIN); + + $this->assertTrue($user->isAdmin()); + $this->assertFalse($user->isEditor()); + } + + /** + * Un utilisateur avec le rôle 'editor' doit être reconnu comme éditeur. + */ + public function testEditorRole(): void + { + $user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_EDITOR); + + $this->assertFalse($user->isAdmin()); + $this->assertTrue($user->isEditor()); + } + + /** + * Une date de création explicite doit être conservée. + */ + public function testExplicitCreationDate(): void + { + $date = new DateTime('2024-01-15 10:00:00'); + $user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_USER, $date); + + $this->assertEquals($date, $user->getCreatedAt()); + } + + /** + * Sans date explicite, la date de création doit être définie à maintenant. + */ + public function testDefaultCreationDate(): void + { + $before = new DateTime(); + $user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT)); + $after = new DateTime(); + + $this->assertGreaterThanOrEqual($before, $user->getCreatedAt()); + $this->assertLessThanOrEqual($after, $user->getCreatedAt()); + } + + + // ── Validation — nom d'utilisateur ───────────────────────────── + + /** + * Un nom d'utilisateur de moins de 3 caractères doit lever une exception. + */ + public function testUsernameTooShort(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/3 caractères/'); + + new User(1, 'ab', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT)); + } + + /** + * Un nom d'utilisateur de plus de 50 caractères doit lever une exception. + */ + public function testUsernameTooLong(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/50 caractères/'); + + new User(1, str_repeat('a', 51), 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT)); + } + + /** + * Un nom d'utilisateur de exactement 3 caractères doit être accepté. + */ + public function testUsernameMinimumLength(): void + { + $user = new User(1, 'ali', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT)); + + $this->assertSame('ali', $user->getUsername()); + } + + /** + * Un nom d'utilisateur de exactement 50 caractères doit être accepté. + */ + public function testUsernameMaximumLength(): void + { + $username = str_repeat('a', 50); + $user = new User(1, $username, 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT)); + + $this->assertSame($username, $user->getUsername()); + } + + + // ── Validation — email ───────────────────────────────────────── + + /** + * Un email invalide doit lever une exception. + */ + public function testInvalidEmail(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/email/i'); + + new User(1, 'alice', 'pas-un-email', password_hash('secret', PASSWORD_BCRYPT)); + } + + /** + * Un email vide doit lever une exception. + */ + public function testEmptyEmail(): void + { + $this->expectException(InvalidArgumentException::class); + + new User(1, 'alice', '', password_hash('secret', PASSWORD_BCRYPT)); + } + + + // ── Validation — hash du mot de passe ────────────────────────── + + /** + * Un hash de mot de passe vide doit lever une exception. + */ + public function testEmptyPasswordHash(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/hash/i'); + + new User(1, 'alice', 'alice@example.com', ''); + } + + + // ── Validation — rôle ────────────────────────────────────────── + + /** + * Un rôle invalide doit lever une exception. + */ + public function testInvalidRole(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/rôle/i'); + + new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), 'superadmin'); + } + + + // ── Hydratation depuis un tableau ────────────────────────────── + + /** + * fromArray() doit hydrater correctement l'utilisateur depuis une ligne de base de données. + */ + public function testFromArray(): void + { + $hash = password_hash('secret', PASSWORD_BCRYPT); + + $user = User::fromArray([ + 'id' => 42, + 'username' => 'bob', + 'email' => 'bob@example.com', + 'password_hash' => $hash, + 'role' => 'editor', + 'created_at' => '2024-06-01 12:00:00', + ]); + + $this->assertSame(42, $user->getId()); + $this->assertSame('bob', $user->getUsername()); + $this->assertSame('bob@example.com', $user->getEmail()); + $this->assertSame($hash, $user->getPasswordHash()); + $this->assertSame('editor', $user->getRole()); + $this->assertTrue($user->isEditor()); + } + + /** + * fromArray() avec une date absente ne doit pas lever d'exception. + */ + public function testFromArrayWithoutDate(): void + { + $user = User::fromArray([ + 'id' => 1, + 'username' => 'alice', + 'email' => 'alice@example.com', + 'password_hash' => password_hash('secret', PASSWORD_BCRYPT), + ]); + + $this->assertInstanceOf(DateTime::class, $user->getCreatedAt()); + } +} diff --git a/views/admin/categories/index.twig b/views/admin/categories/index.twig new file mode 100644 index 0000000..d49e18a --- /dev/null +++ b/views/admin/categories/index.twig @@ -0,0 +1,72 @@ +{% extends "layout.twig" %} + +{% block title %}Tableau de bord – Catégories{% endblock %} + +{% block content %} +

Gestion des catégories

+ +{% include 'partials/_admin_nav.twig' %} + +{% if error %} +
{{ error }}
+{% endif %} + +{% if success %} +
{{ success }}
+{% endif %} + +
+

Ajouter une catégorie

+ +
+ + + + + + +
+ +

Le slug URL est généré automatiquement depuis le nom.

+
+ +{% if categories is not empty %} + + + + + + + + + + {% for category in categories %} + + + + + + {% endfor %} + +
NomSlugActions
{{ category.name }}{{ category.slug }} +
+
+ + + +
+
+
+{% else %} +

Aucune catégorie créée.

+{% endif %} +{% endblock %} diff --git a/views/admin/media/index.twig b/views/admin/media/index.twig new file mode 100644 index 0000000..006193f --- /dev/null +++ b/views/admin/media/index.twig @@ -0,0 +1,70 @@ +{% extends "layout.twig" %} + +{% block title %}Tableau de bord – Médias{% endblock %} + +{% block content %} +

Gestion des médias

+ +{% include 'partials/_admin_nav.twig' %} + +{% if error %} +
{{ error }}
+{% endif %} + +{% if success %} +
{{ success }}
+{% endif %} + +{% if media is not empty %} + + + + + + + + + + + {% for item in media %} + + + + + + + {% endfor %} + +
AperçuURLUploadé leActions
+
+ + + +
+
+
+ {{ item.url }} +
+ +
+
+
{{ item.createdAt|date("d/m/Y H:i") }} +
+
+ + + +
+
+
+{% else %} +

Aucun fichier uploadé.

+{% endif %} +{% endblock %} diff --git a/views/admin/posts/form.twig b/views/admin/posts/form.twig new file mode 100644 index 0000000..c902645 --- /dev/null +++ b/views/admin/posts/form.twig @@ -0,0 +1,139 @@ +{% extends "layout.twig" %} + +{% block title %} +{% if post is defined and post is not null and post.id > 0 %}Éditer l'article{% else %}Créer un article{% endif %} +{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block content %} +
+
+

+ {% if post is defined and post is not null and post.id > 0 %}Éditer l'article{% else %}Créer un article{% endif %} +

+
+ + {% include 'partials/_admin_nav.twig' %} + +
+ {% if error %} +
{{ error }}
+ {% endif %} + +
+ + + + {% if post is defined and post is not null %} +

+ +

+ {% endif %} + +

+ +

+ + {% if post is defined and post is not null and post.id > 0 %} +

+ + (URL actuelle : /article/{{ post.storedSlug }}) +

+ {% endif %} + +

+ +

+ +

+ +

+ +
+
+ +
+
+ Annuler +
+
+
+ + {% if post is defined and post is not null and post.id > 0 %} + + {% endif %} +
+
+{% endblock %} + +{% block scripts %} + + + + + + +{% endblock %} diff --git a/views/admin/posts/index.twig b/views/admin/posts/index.twig new file mode 100644 index 0000000..7de1574 --- /dev/null +++ b/views/admin/posts/index.twig @@ -0,0 +1,107 @@ +{% extends "layout.twig" %} + +{% block title %}Tableau de bord – Articles{% endblock %} + +{% block content %} +

Gestion des articles

+ +{% include 'partials/_admin_nav.twig' %} + +

+ + Ajouter un article +

+ + + +{% if searchQuery %} +

+ {% if posts is not empty %} + {{ posts|length }} résultat{{ posts|length > 1 ? 's' : '' }} pour « {{ searchQuery }} » + {% else %} + Aucun résultat pour « {{ searchQuery }} » + {% endif %} +

+{% endif %} + +{% if categories is not empty %} + +{% endif %} + +{% if error %} +
{{ error }}
+{% endif %} + +{% if success %} +
{{ success }}
+{% endif %} + +{% if posts is not empty %} + + + + + + + + + + + + + {% for post in posts %} + + + + + + + + + {% endfor %} + +
TitreCatégorieAuteurCréé leModifié leActions
{{ post.title }} + {% if post.categoryName %} + {{ post.categoryName }} + {% else %} + + {% endif %} + {{ post.authorUsername ?? 'inconnu' }}{{ post.createdAt|date("d/m/Y H:i") }}{{ post.updatedAt|date("d/m/Y H:i") }} +
+ Éditer + +
+ + + +
+
+
+{% else %} +

{% if searchQuery %}Aucun résultat pour « {{ searchQuery }} ».{% else %}Aucun article à gérer.{% endif %}

+{% endif %} +{% endblock %} diff --git a/views/admin/users/form.twig b/views/admin/users/form.twig new file mode 100644 index 0000000..5e2f59f --- /dev/null +++ b/views/admin/users/form.twig @@ -0,0 +1,74 @@ +{% extends "layout.twig" %} + +{% block title %}Tableau de bord – Créer un utilisateur{% endblock %} + +{% block content %} +

Créer un utilisateur

+ +{% include 'partials/_admin_nav.twig' %} + +
+
+ {% if error %} +
{{ error }}
+ {% endif %} + +
+ + + +

+ + Minimum 3 caractères +

+ +

+ +

+ +

+ + Minimum 8 caractères +

+ +

+ +

+ +

+ +

+ +
+
+ +
+
+ Annuler +
+
+
+
+
+{% endblock %} diff --git a/views/admin/users/index.twig b/views/admin/users/index.twig new file mode 100644 index 0000000..11841b9 --- /dev/null +++ b/views/admin/users/index.twig @@ -0,0 +1,95 @@ +{% extends "layout.twig" %} + +{% block title %}Tableau de bord – Utilisateurs{% endblock %} + +{% block content %} +

Gestion des utilisateurs

+ +{% include 'partials/_admin_nav.twig' %} + +

+ + Ajouter un utilisateur +

+ +{% if error %} +
{{ error }}
+{% endif %} + +{% if success %} +
{{ success }}
+{% endif %} + +{% if users is not empty %} + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + {% endfor %} + +
Nom d'utilisateurEmailRôleInscrit leModifier le rôleActions
+ {{ user.username }} + {% if user.id == currentUserId %} + (vous) + {% endif %} + {{ user.email }} + {% if user.isAdmin() %} + Admin + {% elseif user.isEditor() %} + Éditeur + {% else %} + Utilisateur + {% endif %} + {{ user.createdAt|date("d/m/Y") }} + {% if not user.isAdmin() and user.id != currentUserId %} +
+ + +
+ + +
+
+ {% else %} + + {% endif %} +
+ {% if not user.isAdmin() and user.id != currentUserId %} +
+
+ + + +
+
+ {% else %} + + {% endif %} +
+{% else %} +

Aucun utilisateur.

+{% endif %} +{% endblock %} diff --git a/views/emails/password-reset.twig b/views/emails/password-reset.twig new file mode 100644 index 0000000..6ee137f --- /dev/null +++ b/views/emails/password-reset.twig @@ -0,0 +1,82 @@ + + + + + + Réinitialisation de mot de passe + + + +
+ + +

Bonjour {{ username }},

+ +

Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :

+ +

+ Réinitialiser mon mot de passe +

+ +

Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :
{{ resetUrl }}

+ +

Ce lien est valable {{ ttlMinutes }} minutes. Passé ce délai, vous devrez faire une nouvelle demande.

+ +

Si vous n'êtes pas à l'origine de cette demande, ignorez simplement cet email. Votre mot de passe ne sera pas modifié.

+ + +
+ + diff --git a/views/layout.twig b/views/layout.twig new file mode 100644 index 0000000..738991c --- /dev/null +++ b/views/layout.twig @@ -0,0 +1,30 @@ + + + + + + + {% block title %}Slim Blog{% endblock %} + {% block meta %} + + {% endblock %} + + + {% block styles %}{% endblock %} + + + + + {% include 'partials/_header.twig' %} + +
+ {% block content %}{% endblock %} +
+ + {% include 'partials/_footer.twig' %} + + {% block scripts %}{% endblock %} + + + + diff --git a/views/pages/account/password-change.twig b/views/pages/account/password-change.twig new file mode 100644 index 0000000..b657be7 --- /dev/null +++ b/views/pages/account/password-change.twig @@ -0,0 +1,60 @@ +{% extends "layout.twig" %} + +{% block title %}Mon compte – Changer le mot de passe{% endblock %} + +{% block content %} +
+
+
+

Changer le mot de passe

+
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if success %} +
{{ success }}
+ {% endif %} + +
+ + + +

+ +

+ +

+ + Minimum 8 caractères +

+ +

+ +

+ +
+
+ +
+
+ Annuler +
+
+
+
+
+{% endblock %} diff --git a/views/pages/auth/login.twig b/views/pages/auth/login.twig new file mode 100644 index 0000000..49d9f4c --- /dev/null +++ b/views/pages/auth/login.twig @@ -0,0 +1,50 @@ +{% extends "layout.twig" %} + +{% block title %}Connexion – Slim Blog{% endblock %} + +{% block content %} +
+
+
+

Connexion

+
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if success %} +
{{ success }}
+ {% endif %} + +
+ + + +

+ +

+ +

+ +

+ +
+
+ +
+
+
+ + +
+
+{% endblock %} diff --git a/views/pages/auth/password-forgot.twig b/views/pages/auth/password-forgot.twig new file mode 100644 index 0000000..e6d1567 --- /dev/null +++ b/views/pages/auth/password-forgot.twig @@ -0,0 +1,44 @@ +{% extends "layout.twig" %} + +{% block title %}Mot de passe oublié – Slim Blog{% endblock %} + +{% block content %} +
+
+
+

Mot de passe oublié

+

Saisissez votre adresse email. Si elle est associée à un compte, vous recevrez un lien de réinitialisation.

+
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if success %} +
{{ success }}
+ {% endif %} + +
+ + + +

+ +

+ +
+
+ +
+
+
+ + +
+
+{% endblock %} diff --git a/views/pages/auth/password-reset.twig b/views/pages/auth/password-reset.twig new file mode 100644 index 0000000..75cd206 --- /dev/null +++ b/views/pages/auth/password-reset.twig @@ -0,0 +1,46 @@ +{% extends "layout.twig" %} + +{% block title %}Réinitialisation du mot de passe – Slim Blog{% endblock %} + +{% block content %} +
+
+
+

Nouveau mot de passe

+
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ + + + +

+ + Minimum 8 caractères +

+ +

+ +

+ +
+
+ +
+
+
+
+
+{% endblock %} diff --git a/views/pages/error.twig b/views/pages/error.twig new file mode 100644 index 0000000..a94c421 --- /dev/null +++ b/views/pages/error.twig @@ -0,0 +1,11 @@ +{% extends "layout.twig" %} + +{% block title %}{{ status }} – Slim Blog{% endblock %} + +{% block content %} +
+

{{ status }}

+

{{ message }}

+

← Retour à l'accueil

+
+{% endblock %} diff --git a/views/pages/home.twig b/views/pages/home.twig new file mode 100644 index 0000000..3dd660f --- /dev/null +++ b/views/pages/home.twig @@ -0,0 +1,94 @@ +{% extends "layout.twig" %} + +{% block title %}Slim Blog{% endblock %} + +{% block meta %} + + + + + +{% endblock %} + +{% block content %} + + + +{% if searchQuery %} +

+ {% if posts is not empty %} + {{ posts|length }} résultat{{ posts|length > 1 ? 's' : '' }} pour « {{ searchQuery }} » + {% else %} + Aucun résultat pour « {{ searchQuery }} » + {% endif %} +

+{% endif %} + +{% if categories is not empty %} + +{% endif %} + +
+{% for post in posts %} + {% set thumb = post_thumbnail(post) %} + +{% else %} +

Aucun article publié{% if searchQuery %} pour « {{ searchQuery }} »{% elseif activeCategory %} dans cette catégorie{% endif %}.

+{% endfor %} +
+{% endblock %} diff --git a/views/pages/post/detail.twig b/views/pages/post/detail.twig new file mode 100644 index 0000000..37f8682 --- /dev/null +++ b/views/pages/post/detail.twig @@ -0,0 +1,48 @@ +{% extends "layout.twig" %} + +{% block title %}{{ post.title }} – Slim Blog{% endblock %} + +{% block meta %} +{% set excerpt = post_excerpt(post, 160) %} +{% set thumb = post_thumbnail(post) %} + + + + + +{% if thumb %} + +{% endif %} +{% endblock %} + +{% block content %} +
+

{{ post.title }}

+ + + + {% if post.updatedAt != post.createdAt %} +
+ Mis à jour le {{ post.updatedAt|date("d/m/Y à H:i") }} +
+ {% endif %} + +
+ {# Le contenu est déjà sanitisé par HtmlSanitizer via PostService #} + {{ post.content|raw }} +
+ +
+

+ ← Retour aux articles +

+
+{% endblock %} diff --git a/views/partials/_admin_nav.twig b/views/partials/_admin_nav.twig new file mode 100644 index 0000000..43c138e --- /dev/null +++ b/views/partials/_admin_nav.twig @@ -0,0 +1,10 @@ + diff --git a/views/partials/_footer.twig b/views/partials/_footer.twig new file mode 100644 index 0000000..9502a0b --- /dev/null +++ b/views/partials/_footer.twig @@ -0,0 +1,11 @@ +
+

+ © {{ "now"|date("Y") }} Slim Blog – Made with ❤️ by NETig – + CC BY-SA 4.0 +

+ +
\ No newline at end of file diff --git a/views/partials/_header.twig b/views/partials/_header.twig new file mode 100644 index 0000000..d73012b --- /dev/null +++ b/views/partials/_header.twig @@ -0,0 +1,24 @@ + \ No newline at end of file