From 41f8b3afb4f2f9020f9dbb9eb3b377db25bdb75c Mon Sep 17 00:00:00 2001 From: julien Date: Fri, 20 Mar 2026 22:13:41 +0100 Subject: [PATCH] first commit --- .editorconfig | 15 + .gitignore | 37 + .php-cs-fixer.dist.php | 36 + CONTRIBUTING.md | 110 + LICENSE | 21 + README.md | 107 + composer.json | 69 + composer.lock | 5892 +++++++++++++++++ config/modules.php | 5 + docs/ARCHITECTURE.md | 51 + docs/DEVELOPMENT.md | 95 + docs/MODULES.md | 35 + docs/PUBLIC_API.md | 44 + docs/README.md | 29 + phpstan.neon | 6 + phpunit.xml | 29 + .../AuditLogApplicationService.php | 54 + .../Application/AuditLogServiceInterface.php | 13 + src/AuditLog/AuditLogModule.php | 44 + src/AuditLog/Contracts/AuditEntryView.php | 19 + .../Contracts/AuditLogReaderInterface.php | 14 + .../Contracts/AuditLoggerInterface.php | 22 + src/AuditLog/Domain/Entity/AuditEntry.php | 84 + .../AuditLogRepositoryInterface.php | 15 + .../Infrastructure/PdoAuditLogRepository.php | 73 + src/AuditLog/Infrastructure/dependencies.php | 19 + .../Migrations/330_audit_log_schema.php | 24 + .../Application/AuthApplicationService.php | 116 + .../Application/AuthServiceInterface.php | 78 + .../Application/AuthSessionInterface.php | 31 + .../AuthorizationApplicationService.php | 37 + .../AuthorizationServiceInterface.php | 20 + .../Command/AdminDeleteUserCommand.php | 13 + .../Command/AdminUpdateUserRoleCommand.php | 14 + .../Command/AuthenticateUserCommand.php | 16 + .../Command/ChangePasswordCommand.php | 17 + .../Application/Command/CreateUserCommand.php | 20 + .../Command/RequestPasswordResetCommand.php | 16 + .../Command/ResetPasswordCommand.php | 16 + .../Command/UpdateUserRoleCommand.php | 16 + .../PasswordResetApplicationService.php | 42 + .../PasswordResetServiceInterface.php | 40 + .../Application/UseCase/AdminDeleteUser.php | 37 + .../UseCase/AdminUpdateUserRole.php | 42 + .../Application/UseCase/AuthenticateUser.php | 47 + .../Application/UseCase/ChangePassword.php | 44 + .../Application/UseCase/CreateUser.php | 57 + .../Application/UseCase/DeleteUser.php | 25 + .../UseCase/RequestPasswordReset.php | 111 + .../Application/UseCase/ResetPassword.php | 52 + .../Application/UseCase/UpdateUserRole.php | 32 + .../UseCase/ValidatePasswordResetToken.php | 38 + .../Application/UserApplicationService.php | 81 + .../Application/UserServiceInterface.php | 29 + src/Identity/Domain/Entity/User.php | 139 + .../CannotDeleteOwnAccountException.php | 13 + .../CannotModifyOwnRoleException.php | 13 + .../Exception/DuplicateEmailException.php | 19 + .../Exception/DuplicateUsernameException.php | 19 + .../Exception/InvalidResetTokenException.php | 17 + .../Domain/Exception/InvalidRoleException.php | 22 + ...rotectedAdministratorDeletionException.php | 13 + ...tectedAdministratorRoleChangeException.php | 13 + .../RoleAssignmentNotAllowedException.php | 18 + .../Exception/WeakPasswordException.php | 22 + .../Domain/Policy/LoginRateLimitPolicy.php | 36 + src/Identity/Domain/Policy/PasswordPolicy.php | 47 + .../Policy/PasswordResetTokenPolicy.php | 16 + src/Identity/Domain/Policy/Permission.php | 37 + .../Domain/Policy/RolePermissionMatrix.php | 37 + src/Identity/Domain/Policy/RolePolicy.php | 43 + .../LoginAttemptRepositoryInterface.php | 50 + .../PasswordResetRepositoryInterface.php | 38 + .../Repository/UserRepositoryInterface.php | 70 + src/Identity/IdentityModule.php | 62 + .../AdminProvisioningEnvironmentValidator.php | 35 + .../Infrastructure/AdminUserProvisioner.php | 47 + .../PdoLoginAttemptRepository.php | 96 + .../PdoPasswordResetRepository.php | 117 + .../Infrastructure/PdoUserRepository.php | 114 + .../Infrastructure/SessionAuthSession.php | 37 + src/Identity/Infrastructure/dependencies.php | 113 + .../Migrations/100_identity_schema.php | 42 + src/Identity/UI/Http/AccountController.php | 98 + src/Identity/UI/Http/AdminHomePath.php | 25 + src/Identity/UI/Http/AuthController.php | 114 + src/Identity/UI/Http/AuthRoutes.php | 33 + .../UI/Http/Middleware/AdminMiddleware.php | 50 + .../UI/Http/Middleware/AuthMiddleware.php | 80 + .../UI/Http/Middleware/EditorMiddleware.php | 50 + .../UI/Http/PasswordResetController.php | 174 + .../UI/Http/Request/ChangePasswordRequest.php | 43 + .../UI/Http/Request/CreateUserRequest.php | 44 + .../UI/Http/Request/ForgotPasswordRequest.php | 23 + src/Identity/UI/Http/Request/LoginRequest.php | 29 + .../UI/Http/Request/ResetPasswordRequest.php | 55 + .../UI/Http/Request/UpdateUserRoleRequest.php | 36 + src/Identity/UI/Http/UserController.php | 202 + src/Identity/UI/Http/UserRoutes.php | 28 + .../UI/Templates/account/password-change.twig | 35 + src/Identity/UI/Templates/admin/form.twig | 73 + src/Identity/UI/Templates/admin/index.twig | 84 + .../UI/Templates/emails/password-reset.twig | 82 + src/Identity/UI/Templates/login.twig | 43 + .../partials/_new_password_fields.twig | 16 + .../UI/Templates/partials/_role_badge.twig | 7 + .../UI/Templates/partials/_role_options.twig | 5 + .../UI/Templates/password-forgot.twig | 37 + src/Identity/UI/Templates/password-reset.twig | 29 + .../Application/HtmlSanitizerInterface.php | 20 + .../Infrastructure/HtmlPurifierFactory.php | 75 + .../Html/Infrastructure/HtmlSanitizer.php | 34 + .../Flash/FlashServiceInterface.php | 32 + .../Session/SessionManagerInterface.php | 62 + .../Infrastructure/Flash/FlashService.php | 48 + .../Request/ClientIpResolver.php | 27 + .../Infrastructure/Request/RequestContext.php | 128 + .../Infrastructure/Session/SessionManager.php | 88 + .../Http/Infrastructure/Twig/AppExtension.php | 35 + .../Infrastructure/Twig/CsrfExtension.php | 46 + .../Infrastructure/Twig/SessionExtension.php | 43 + src/Kernel/Http/UI/Templates/error.twig | 11 + src/Kernel/Http/UI/Templates/layout.twig | 32 + .../Http/UI/Templates/layout_picker.twig | 24 + .../Templates/partials/_admin_create_box.twig | 41 + .../partials/_admin_delete_form.twig | 7 + .../partials/_admin_form_actions.twig | 11 + .../UI/Templates/partials/_admin_nav.twig | 3 + .../partials/_admin_page_header.twig | 23 + .../Templates/partials/_auth_form_header.twig | 6 + .../Http/UI/Templates/partials/_badge.twig | 6 + .../UI/Templates/partials/_csrf_fields.twig | 2 + .../UI/Templates/partials/_empty_state.twig | 13 + .../Templates/partials/_flash_messages.twig | 7 + .../Http/UI/Templates/partials/_footer.twig | 5 + .../Http/UI/Templates/partials/_header.twig | 23 + .../UI/Templates/partials/_pagination.twig | 29 + .../Mail/Application/MailServiceInterface.php | 27 + .../Mail/Infrastructure/MailService.php | 96 + .../Application/PaginatedResult.php | 66 + .../Infrastructure/PaginationPresenter.php | 79 + .../TransactionManagerInterface.php | 20 + .../DatabaseNotProvisionedException.php | 10 + .../Infrastructure/DatabaseReadiness.php | 72 + .../Persistence/Infrastructure/Migrator.php | 135 + .../Infrastructure/PdoTransactionManager.php | 34 + .../Infrastructure/Provisioner.php | 52 + src/Kernel/Runtime/Bootstrap.php | 96 + src/Kernel/Runtime/DI/container.php | 12 + src/Kernel/Runtime/DI/dependencies.php | 105 + .../Runtime/Http/DefaultErrorHandler.php | 82 + .../Runtime/Http/ErrorHandlerConfigurator.php | 32 + .../Runtime/Http/HttpApplicationFactory.php | 38 + .../Runtime/Http/MiddlewareRegistrar.php | 89 + src/Kernel/Runtime/KernelModule.php | 49 + src/Kernel/Runtime/Module/ModuleInterface.php | 47 + src/Kernel/Runtime/Module/ModuleRegistry.php | 81 + .../ProvidesMigrationMaintenanceInterface.php | 16 + .../Module/ProvidesProvisioningInterface.php | 19 + .../Module/ProvidesSchemaInterface.php | 21 + src/Kernel/Runtime/Routing/Routes.php | 23 + src/Kernel/Runtime/RuntimePaths.php | 160 + .../EnsureRequiredPhpExtensionsCheck.php | 18 + .../Check/EnsureRuntimeDirectoriesCheck.php | 33 + .../Check/InfrastructureCheckInterface.php | 10 + .../Startup/Check/LoadEnvironmentCheck.php | 39 + .../Startup/InfrastructureBootstrapper.php | 80 + .../Support/Exception/NotFoundException.php | 23 + src/Kernel/Support/Util/DateParser.php | 36 + src/Kernel/Support/Util/SlugHelper.php | 48 + .../Application/Command/StoreMediaCommand.php | 18 + .../Application/MediaApplicationService.php | 129 + .../Application/MediaServiceInterface.php | 74 + src/Media/Application/UseCase/DeleteMedia.php | 33 + src/Media/Application/UseCase/StoreMedia.php | 58 + .../Contracts/MediaUsageReaderInterface.php | 28 + src/Media/Contracts/MediaUsageReference.php | 39 + src/Media/Domain/Entity/Media.php | 131 + .../Exception/FileTooLargeException.php | 17 + .../Exception/InvalidMimeTypeException.php | 16 + .../Domain/Exception/StorageException.php | 11 + .../Repository/MediaRepositoryInterface.php | 76 + .../Domain/Service/MediaStorageInterface.php | 38 + .../Domain/Service/UploadedMediaInterface.php | 29 + .../ValueObject/PreparedMediaUpload.php | 38 + .../Infrastructure/LocalMediaStorage.php | 198 + .../Infrastructure/NullMediaUsageReader.php | 44 + .../Infrastructure/PdoMediaRepository.php | 130 + src/Media/Infrastructure/dependencies.php | 36 + src/Media/MediaModule.php | 45 + src/Media/Migrations/300_media_schema.php | 23 + src/Media/UI/Http/MediaController.php | 205 + src/Media/UI/Http/PsrUploadedMedia.php | 33 + src/Media/UI/Http/Routes.php | 26 + src/Media/UI/Templates/admin/index.twig | 127 + .../NotificationApplicationService.php | 60 + .../NotificationServiceInterface.php | 13 + .../Contracts/NotificationDispatchView.php | 20 + .../NotificationHistoryReaderInterface.php | 14 + .../TransactionalEmailSenderInterface.php | 18 + .../Domain/Entity/NotificationDispatch.php | 131 + ...otificationDispatchRepositoryInterface.php | 15 + .../Domain/ValueObject/TransactionalEmail.php | 28 + .../PdoNotificationDispatchRepository.php | 56 + .../Infrastructure/dependencies.php | 19 + .../Migrations/340_notifications_schema.php | 26 + src/Notifications/NotificationsModule.php | 44 + .../SettingsApplicationService.php | 85 + .../Application/SettingsServiceInterface.php | 17 + .../Contracts/SettingsReaderInterface.php | 27 + .../Contracts/SettingsWriterInterface.php | 18 + src/Settings/Domain/Entity/Setting.php | 98 + .../Repository/SettingRepositoryInterface.php | 19 + .../Infrastructure/PdoSettingRepository.php | 71 + src/Settings/Infrastructure/dependencies.php | 19 + .../Migrations/320_settings_schema.php | 17 + src/Settings/SettingsModule.php | 44 + .../Command/CreateTaxonCommand.php | 13 + .../TaxonomyApplicationService.php | 65 + .../Application/TaxonomyServiceInterface.php | 31 + .../Application/UseCase/CreateTaxon.php | 40 + .../Application/UseCase/DeleteTaxon.php | 29 + .../Contracts/TaxonUsageCheckerInterface.php | 13 + src/Taxonomy/Contracts/TaxonView.php | 17 + .../Contracts/TaxonomyReaderInterface.php | 16 + src/Taxonomy/Domain/Entity/Taxon.php | 67 + .../Repository/TaxonRepositoryInterface.php | 31 + .../Domain/Service/TaxonSlugGenerator.php | 18 + .../Infrastructure/NullTaxonUsageChecker.php | 18 + .../Infrastructure/PdoTaxonRepository.php | 97 + .../Infrastructure/TaxonomyServiceReader.php | 45 + src/Taxonomy/Infrastructure/dependencies.php | 27 + .../Migrations/200_taxonomy_schema.php | 16 + src/Taxonomy/TaxonomyModule.php | 50 + .../UI/Http/Request/CreateTaxonRequest.php | 28 + src/Taxonomy/UI/Http/TaxonomyController.php | 113 + src/Taxonomy/UI/Http/TaxonomyRoutes.php | 26 + src/Taxonomy/UI/Templates/admin/index.twig | 66 + .../ApplicationServiceDoctrineTest.php | 45 + .../ApplicationServiceVocabularyTest.php | 91 + .../ApplicationWorkflowShapeTest.php | 93 + .../Architecture/HttpRuntimeBoundaryTest.php | 40 + .../InstantiationBoundaryTest.php | 74 + tests/Architecture/KernelStructureTest.php | 54 + tests/Architecture/LayerDependencyTest.php | 67 + .../ModuleBoundaryGovernanceTest.php | 112 + tests/Architecture/ModuleStructureTest.php | 34 + .../Support/ArchitectureTestCase.php | 338 + tests/Architecture/SupportGovernanceTest.php | 90 + tests/Architecture/WiringBoundaryTest.php | 27 + tests/AuditLog/AuditLogServiceTest.php | 39 + tests/ControllerTestBase.php | 96 + tests/Fixtures/Application/config/modules.php | 21 + .../Application/templates/Kernel/layout.twig | 1 + tests/Identity/AccountControllerTest.php | 209 + tests/Identity/AdminDeleteUserTest.php | 95 + tests/Identity/AdminHomePathTest.php | 40 + tests/Identity/AdminUpdateUserRoleTest.php | 112 + tests/Identity/AdminUserProvisionerTest.php | 222 + tests/Identity/AuthControllerTest.php | 179 + tests/Identity/AuthServiceRateLimitTest.php | 228 + tests/Identity/AuthServiceTest.php | 286 + tests/Identity/AuthorizationServiceTest.php | 36 + tests/Identity/LoginAttemptRepositoryTest.php | 286 + tests/Identity/MiddlewareTest.php | 110 + .../Identity/PasswordRequestHandlingTest.php | 85 + .../Identity/PasswordResetControllerTest.php | 435 ++ .../Identity/PasswordResetRepositoryTest.php | 264 + .../PasswordResetServiceIntegrationTest.php | 91 + tests/Identity/PasswordResetServiceTest.php | 341 + tests/Identity/UserControllerTest.php | 449 ++ tests/Identity/UserRepositoryTest.php | 356 + tests/Identity/UserServiceTest.php | 304 + tests/Identity/UserTest.php | 233 + tests/Kernel/BootstrapTest.php | 56 + tests/Kernel/ClientIpResolverCoverageTest.php | 36 + tests/Kernel/ClientIpResolverTest.php | 58 + .../Kernel/ContainerWiringIntegrationTest.php | 184 + tests/Kernel/DatabaseReadinessTest.php | 42 + tests/Kernel/DateParserTest.php | 94 + tests/Kernel/DefaultErrorHandlerTest.php | 126 + tests/Kernel/ErrorHandlerConfiguratorTest.php | 108 + tests/Kernel/ExtensionTest.php | 63 + tests/Kernel/FlashServiceConsumeTest.php | 29 + tests/Kernel/FlashServiceCoverageTest.php | 27 + tests/Kernel/FlashServiceTest.php | 45 + tests/Kernel/HelperEdgeCasesTest.php | 27 + tests/Kernel/HtmlPurifierFactoryTest.php | 36 + tests/Kernel/HtmlSanitizerTest.php | 252 + .../Kernel/InfrastructureBootstrapperTest.php | 155 + tests/Kernel/MailServiceTest.php | 72 + tests/Kernel/MigratorTest.php | 82 + tests/Kernel/ModuleRegistryTest.php | 87 + tests/Kernel/ModuleSchemaTest.php | 50 + tests/Kernel/NotFoundExceptionTest.php | 20 + tests/Kernel/ProvisionerTest.php | 79 + tests/Kernel/RequestContextTest.php | 55 + tests/Kernel/RoutesTest.php | 73 + tests/Kernel/RuntimePathsTest.php | 87 + tests/Kernel/SessionManagerCoverageTest.php | 33 + tests/Kernel/SessionManagerEdgeCasesTest.php | 43 + tests/Kernel/SessionManagerTest.php | 168 + tests/Kernel/SlugHelperTest.php | 122 + tests/Media/MediaControllerTest.php | 401 ++ tests/Media/MediaModelTest.php | 56 + tests/Media/MediaRepositoryTest.php | 335 + tests/Media/MediaSchemaIntegrationTest.php | 80 + ...diaServiceDuplicateAfterInsertRaceTest.php | 103 + tests/Media/MediaServiceEdgeCasesTest.php | 51 + tests/Media/MediaServiceInvalidMimeTest.php | 43 + .../Media/MediaServiceInvalidTempPathTest.php | 39 + tests/Media/MediaServiceTest.php | 348 + tests/Media/MediaUsageReferenceTest.php | 20 + .../NotificationDispatchTest.php | 29 + .../Notifications/NotificationServiceTest.php | 66 + tests/Settings/SettingTest.php | 45 + tests/Settings/SettingsServiceTest.php | 54 + tests/Taxonomy/TaxonModelTest.php | 51 + tests/Taxonomy/TaxonRepositoryTest.php | 336 + tests/Taxonomy/TaxonomyControllerTest.php | 201 + tests/Taxonomy/TaxonomyServiceReaderTest.php | 52 + tests/Taxonomy/TaxonomyServiceTest.php | 203 + tests/bootstrap.php | 12 + 323 files changed, 27222 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/modules.php create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/MODULES.md create mode 100644 docs/PUBLIC_API.md create mode 100644 docs/README.md create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 src/AuditLog/Application/AuditLogApplicationService.php create mode 100644 src/AuditLog/Application/AuditLogServiceInterface.php create mode 100644 src/AuditLog/AuditLogModule.php create mode 100644 src/AuditLog/Contracts/AuditEntryView.php create mode 100644 src/AuditLog/Contracts/AuditLogReaderInterface.php create mode 100644 src/AuditLog/Contracts/AuditLoggerInterface.php create mode 100644 src/AuditLog/Domain/Entity/AuditEntry.php create mode 100644 src/AuditLog/Domain/Repository/AuditLogRepositoryInterface.php create mode 100644 src/AuditLog/Infrastructure/PdoAuditLogRepository.php create mode 100644 src/AuditLog/Infrastructure/dependencies.php create mode 100644 src/AuditLog/Migrations/330_audit_log_schema.php create mode 100644 src/Identity/Application/AuthApplicationService.php create mode 100644 src/Identity/Application/AuthServiceInterface.php create mode 100644 src/Identity/Application/AuthSessionInterface.php create mode 100644 src/Identity/Application/AuthorizationApplicationService.php create mode 100644 src/Identity/Application/AuthorizationServiceInterface.php create mode 100644 src/Identity/Application/Command/AdminDeleteUserCommand.php create mode 100644 src/Identity/Application/Command/AdminUpdateUserRoleCommand.php create mode 100644 src/Identity/Application/Command/AuthenticateUserCommand.php create mode 100644 src/Identity/Application/Command/ChangePasswordCommand.php create mode 100644 src/Identity/Application/Command/CreateUserCommand.php create mode 100644 src/Identity/Application/Command/RequestPasswordResetCommand.php create mode 100644 src/Identity/Application/Command/ResetPasswordCommand.php create mode 100644 src/Identity/Application/Command/UpdateUserRoleCommand.php create mode 100644 src/Identity/Application/PasswordResetApplicationService.php create mode 100644 src/Identity/Application/PasswordResetServiceInterface.php create mode 100644 src/Identity/Application/UseCase/AdminDeleteUser.php create mode 100644 src/Identity/Application/UseCase/AdminUpdateUserRole.php create mode 100644 src/Identity/Application/UseCase/AuthenticateUser.php create mode 100644 src/Identity/Application/UseCase/ChangePassword.php create mode 100644 src/Identity/Application/UseCase/CreateUser.php create mode 100644 src/Identity/Application/UseCase/DeleteUser.php create mode 100644 src/Identity/Application/UseCase/RequestPasswordReset.php create mode 100644 src/Identity/Application/UseCase/ResetPassword.php create mode 100644 src/Identity/Application/UseCase/UpdateUserRole.php create mode 100644 src/Identity/Application/UseCase/ValidatePasswordResetToken.php create mode 100644 src/Identity/Application/UserApplicationService.php create mode 100644 src/Identity/Application/UserServiceInterface.php create mode 100644 src/Identity/Domain/Entity/User.php create mode 100644 src/Identity/Domain/Exception/CannotDeleteOwnAccountException.php create mode 100644 src/Identity/Domain/Exception/CannotModifyOwnRoleException.php create mode 100644 src/Identity/Domain/Exception/DuplicateEmailException.php create mode 100644 src/Identity/Domain/Exception/DuplicateUsernameException.php create mode 100644 src/Identity/Domain/Exception/InvalidResetTokenException.php create mode 100644 src/Identity/Domain/Exception/InvalidRoleException.php create mode 100644 src/Identity/Domain/Exception/ProtectedAdministratorDeletionException.php create mode 100644 src/Identity/Domain/Exception/ProtectedAdministratorRoleChangeException.php create mode 100644 src/Identity/Domain/Exception/RoleAssignmentNotAllowedException.php create mode 100644 src/Identity/Domain/Exception/WeakPasswordException.php create mode 100644 src/Identity/Domain/Policy/LoginRateLimitPolicy.php create mode 100644 src/Identity/Domain/Policy/PasswordPolicy.php create mode 100644 src/Identity/Domain/Policy/PasswordResetTokenPolicy.php create mode 100644 src/Identity/Domain/Policy/Permission.php create mode 100644 src/Identity/Domain/Policy/RolePermissionMatrix.php create mode 100644 src/Identity/Domain/Policy/RolePolicy.php create mode 100644 src/Identity/Domain/Repository/LoginAttemptRepositoryInterface.php create mode 100644 src/Identity/Domain/Repository/PasswordResetRepositoryInterface.php create mode 100644 src/Identity/Domain/Repository/UserRepositoryInterface.php create mode 100644 src/Identity/IdentityModule.php create mode 100644 src/Identity/Infrastructure/AdminProvisioningEnvironmentValidator.php create mode 100644 src/Identity/Infrastructure/AdminUserProvisioner.php create mode 100644 src/Identity/Infrastructure/PdoLoginAttemptRepository.php create mode 100644 src/Identity/Infrastructure/PdoPasswordResetRepository.php create mode 100644 src/Identity/Infrastructure/PdoUserRepository.php create mode 100644 src/Identity/Infrastructure/SessionAuthSession.php create mode 100644 src/Identity/Infrastructure/dependencies.php create mode 100644 src/Identity/Migrations/100_identity_schema.php create mode 100644 src/Identity/UI/Http/AccountController.php create mode 100644 src/Identity/UI/Http/AdminHomePath.php create mode 100644 src/Identity/UI/Http/AuthController.php create mode 100644 src/Identity/UI/Http/AuthRoutes.php create mode 100644 src/Identity/UI/Http/Middleware/AdminMiddleware.php create mode 100644 src/Identity/UI/Http/Middleware/AuthMiddleware.php create mode 100644 src/Identity/UI/Http/Middleware/EditorMiddleware.php create mode 100644 src/Identity/UI/Http/PasswordResetController.php create mode 100644 src/Identity/UI/Http/Request/ChangePasswordRequest.php create mode 100644 src/Identity/UI/Http/Request/CreateUserRequest.php create mode 100644 src/Identity/UI/Http/Request/ForgotPasswordRequest.php create mode 100644 src/Identity/UI/Http/Request/LoginRequest.php create mode 100644 src/Identity/UI/Http/Request/ResetPasswordRequest.php create mode 100644 src/Identity/UI/Http/Request/UpdateUserRoleRequest.php create mode 100644 src/Identity/UI/Http/UserController.php create mode 100644 src/Identity/UI/Http/UserRoutes.php create mode 100644 src/Identity/UI/Templates/account/password-change.twig create mode 100644 src/Identity/UI/Templates/admin/form.twig create mode 100644 src/Identity/UI/Templates/admin/index.twig create mode 100644 src/Identity/UI/Templates/emails/password-reset.twig create mode 100644 src/Identity/UI/Templates/login.twig create mode 100644 src/Identity/UI/Templates/partials/_new_password_fields.twig create mode 100644 src/Identity/UI/Templates/partials/_role_badge.twig create mode 100644 src/Identity/UI/Templates/partials/_role_options.twig create mode 100644 src/Identity/UI/Templates/password-forgot.twig create mode 100644 src/Identity/UI/Templates/password-reset.twig create mode 100644 src/Kernel/Html/Application/HtmlSanitizerInterface.php create mode 100644 src/Kernel/Html/Infrastructure/HtmlPurifierFactory.php create mode 100644 src/Kernel/Html/Infrastructure/HtmlSanitizer.php create mode 100644 src/Kernel/Http/Application/Flash/FlashServiceInterface.php create mode 100644 src/Kernel/Http/Application/Session/SessionManagerInterface.php create mode 100644 src/Kernel/Http/Infrastructure/Flash/FlashService.php create mode 100644 src/Kernel/Http/Infrastructure/Request/ClientIpResolver.php create mode 100644 src/Kernel/Http/Infrastructure/Request/RequestContext.php create mode 100644 src/Kernel/Http/Infrastructure/Session/SessionManager.php create mode 100644 src/Kernel/Http/Infrastructure/Twig/AppExtension.php create mode 100644 src/Kernel/Http/Infrastructure/Twig/CsrfExtension.php create mode 100644 src/Kernel/Http/Infrastructure/Twig/SessionExtension.php create mode 100644 src/Kernel/Http/UI/Templates/error.twig create mode 100644 src/Kernel/Http/UI/Templates/layout.twig create mode 100644 src/Kernel/Http/UI/Templates/layout_picker.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_admin_create_box.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_admin_delete_form.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_admin_form_actions.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_admin_nav.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_admin_page_header.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_auth_form_header.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_badge.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_csrf_fields.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_empty_state.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_flash_messages.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_footer.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_header.twig create mode 100644 src/Kernel/Http/UI/Templates/partials/_pagination.twig create mode 100644 src/Kernel/Mail/Application/MailServiceInterface.php create mode 100644 src/Kernel/Mail/Infrastructure/MailService.php create mode 100644 src/Kernel/Pagination/Application/PaginatedResult.php create mode 100644 src/Kernel/Pagination/Infrastructure/PaginationPresenter.php create mode 100644 src/Kernel/Persistence/Application/TransactionManagerInterface.php create mode 100644 src/Kernel/Persistence/Infrastructure/DatabaseNotProvisionedException.php create mode 100644 src/Kernel/Persistence/Infrastructure/DatabaseReadiness.php create mode 100644 src/Kernel/Persistence/Infrastructure/Migrator.php create mode 100644 src/Kernel/Persistence/Infrastructure/PdoTransactionManager.php create mode 100644 src/Kernel/Persistence/Infrastructure/Provisioner.php create mode 100644 src/Kernel/Runtime/Bootstrap.php create mode 100644 src/Kernel/Runtime/DI/container.php create mode 100644 src/Kernel/Runtime/DI/dependencies.php create mode 100644 src/Kernel/Runtime/Http/DefaultErrorHandler.php create mode 100644 src/Kernel/Runtime/Http/ErrorHandlerConfigurator.php create mode 100644 src/Kernel/Runtime/Http/HttpApplicationFactory.php create mode 100644 src/Kernel/Runtime/Http/MiddlewareRegistrar.php create mode 100644 src/Kernel/Runtime/KernelModule.php create mode 100644 src/Kernel/Runtime/Module/ModuleInterface.php create mode 100644 src/Kernel/Runtime/Module/ModuleRegistry.php create mode 100644 src/Kernel/Runtime/Module/ProvidesMigrationMaintenanceInterface.php create mode 100644 src/Kernel/Runtime/Module/ProvidesProvisioningInterface.php create mode 100644 src/Kernel/Runtime/Module/ProvidesSchemaInterface.php create mode 100644 src/Kernel/Runtime/Routing/Routes.php create mode 100644 src/Kernel/Runtime/RuntimePaths.php create mode 100644 src/Kernel/Runtime/Startup/Check/EnsureRequiredPhpExtensionsCheck.php create mode 100644 src/Kernel/Runtime/Startup/Check/EnsureRuntimeDirectoriesCheck.php create mode 100644 src/Kernel/Runtime/Startup/Check/InfrastructureCheckInterface.php create mode 100644 src/Kernel/Runtime/Startup/Check/LoadEnvironmentCheck.php create mode 100644 src/Kernel/Runtime/Startup/InfrastructureBootstrapper.php create mode 100644 src/Kernel/Support/Exception/NotFoundException.php create mode 100644 src/Kernel/Support/Util/DateParser.php create mode 100644 src/Kernel/Support/Util/SlugHelper.php create mode 100644 src/Media/Application/Command/StoreMediaCommand.php create mode 100644 src/Media/Application/MediaApplicationService.php create mode 100644 src/Media/Application/MediaServiceInterface.php create mode 100644 src/Media/Application/UseCase/DeleteMedia.php create mode 100644 src/Media/Application/UseCase/StoreMedia.php create mode 100644 src/Media/Contracts/MediaUsageReaderInterface.php create mode 100644 src/Media/Contracts/MediaUsageReference.php create mode 100644 src/Media/Domain/Entity/Media.php create mode 100644 src/Media/Domain/Exception/FileTooLargeException.php create mode 100644 src/Media/Domain/Exception/InvalidMimeTypeException.php create mode 100644 src/Media/Domain/Exception/StorageException.php create mode 100644 src/Media/Domain/Repository/MediaRepositoryInterface.php create mode 100644 src/Media/Domain/Service/MediaStorageInterface.php create mode 100644 src/Media/Domain/Service/UploadedMediaInterface.php create mode 100644 src/Media/Domain/ValueObject/PreparedMediaUpload.php create mode 100644 src/Media/Infrastructure/LocalMediaStorage.php create mode 100644 src/Media/Infrastructure/NullMediaUsageReader.php create mode 100644 src/Media/Infrastructure/PdoMediaRepository.php create mode 100644 src/Media/Infrastructure/dependencies.php create mode 100644 src/Media/MediaModule.php create mode 100644 src/Media/Migrations/300_media_schema.php create mode 100644 src/Media/UI/Http/MediaController.php create mode 100644 src/Media/UI/Http/PsrUploadedMedia.php create mode 100644 src/Media/UI/Http/Routes.php create mode 100644 src/Media/UI/Templates/admin/index.twig create mode 100644 src/Notifications/Application/NotificationApplicationService.php create mode 100644 src/Notifications/Application/NotificationServiceInterface.php create mode 100644 src/Notifications/Contracts/NotificationDispatchView.php create mode 100644 src/Notifications/Contracts/NotificationHistoryReaderInterface.php create mode 100644 src/Notifications/Contracts/TransactionalEmailSenderInterface.php create mode 100644 src/Notifications/Domain/Entity/NotificationDispatch.php create mode 100644 src/Notifications/Domain/Repository/NotificationDispatchRepositoryInterface.php create mode 100644 src/Notifications/Domain/ValueObject/TransactionalEmail.php create mode 100644 src/Notifications/Infrastructure/PdoNotificationDispatchRepository.php create mode 100644 src/Notifications/Infrastructure/dependencies.php create mode 100644 src/Notifications/Migrations/340_notifications_schema.php create mode 100644 src/Notifications/NotificationsModule.php create mode 100644 src/Settings/Application/SettingsApplicationService.php create mode 100644 src/Settings/Application/SettingsServiceInterface.php create mode 100644 src/Settings/Contracts/SettingsReaderInterface.php create mode 100644 src/Settings/Contracts/SettingsWriterInterface.php create mode 100644 src/Settings/Domain/Entity/Setting.php create mode 100644 src/Settings/Domain/Repository/SettingRepositoryInterface.php create mode 100644 src/Settings/Infrastructure/PdoSettingRepository.php create mode 100644 src/Settings/Infrastructure/dependencies.php create mode 100644 src/Settings/Migrations/320_settings_schema.php create mode 100644 src/Settings/SettingsModule.php create mode 100644 src/Taxonomy/Application/Command/CreateTaxonCommand.php create mode 100644 src/Taxonomy/Application/TaxonomyApplicationService.php create mode 100644 src/Taxonomy/Application/TaxonomyServiceInterface.php create mode 100644 src/Taxonomy/Application/UseCase/CreateTaxon.php create mode 100644 src/Taxonomy/Application/UseCase/DeleteTaxon.php create mode 100644 src/Taxonomy/Contracts/TaxonUsageCheckerInterface.php create mode 100644 src/Taxonomy/Contracts/TaxonView.php create mode 100644 src/Taxonomy/Contracts/TaxonomyReaderInterface.php create mode 100644 src/Taxonomy/Domain/Entity/Taxon.php create mode 100644 src/Taxonomy/Domain/Repository/TaxonRepositoryInterface.php create mode 100644 src/Taxonomy/Domain/Service/TaxonSlugGenerator.php create mode 100644 src/Taxonomy/Infrastructure/NullTaxonUsageChecker.php create mode 100644 src/Taxonomy/Infrastructure/PdoTaxonRepository.php create mode 100644 src/Taxonomy/Infrastructure/TaxonomyServiceReader.php create mode 100644 src/Taxonomy/Infrastructure/dependencies.php create mode 100644 src/Taxonomy/Migrations/200_taxonomy_schema.php create mode 100644 src/Taxonomy/TaxonomyModule.php create mode 100644 src/Taxonomy/UI/Http/Request/CreateTaxonRequest.php create mode 100644 src/Taxonomy/UI/Http/TaxonomyController.php create mode 100644 src/Taxonomy/UI/Http/TaxonomyRoutes.php create mode 100644 src/Taxonomy/UI/Templates/admin/index.twig create mode 100644 tests/Architecture/ApplicationServiceDoctrineTest.php create mode 100644 tests/Architecture/ApplicationServiceVocabularyTest.php create mode 100644 tests/Architecture/ApplicationWorkflowShapeTest.php create mode 100644 tests/Architecture/HttpRuntimeBoundaryTest.php create mode 100644 tests/Architecture/InstantiationBoundaryTest.php create mode 100644 tests/Architecture/KernelStructureTest.php create mode 100644 tests/Architecture/LayerDependencyTest.php create mode 100644 tests/Architecture/ModuleBoundaryGovernanceTest.php create mode 100644 tests/Architecture/ModuleStructureTest.php create mode 100644 tests/Architecture/Support/ArchitectureTestCase.php create mode 100644 tests/Architecture/SupportGovernanceTest.php create mode 100644 tests/Architecture/WiringBoundaryTest.php create mode 100644 tests/AuditLog/AuditLogServiceTest.php create mode 100644 tests/ControllerTestBase.php create mode 100644 tests/Fixtures/Application/config/modules.php create mode 100644 tests/Fixtures/Application/templates/Kernel/layout.twig create mode 100644 tests/Identity/AccountControllerTest.php create mode 100644 tests/Identity/AdminDeleteUserTest.php create mode 100644 tests/Identity/AdminHomePathTest.php create mode 100644 tests/Identity/AdminUpdateUserRoleTest.php create mode 100644 tests/Identity/AdminUserProvisionerTest.php create mode 100644 tests/Identity/AuthControllerTest.php create mode 100644 tests/Identity/AuthServiceRateLimitTest.php create mode 100644 tests/Identity/AuthServiceTest.php create mode 100644 tests/Identity/AuthorizationServiceTest.php create mode 100644 tests/Identity/LoginAttemptRepositoryTest.php create mode 100644 tests/Identity/MiddlewareTest.php create mode 100644 tests/Identity/PasswordRequestHandlingTest.php create mode 100644 tests/Identity/PasswordResetControllerTest.php create mode 100644 tests/Identity/PasswordResetRepositoryTest.php create mode 100644 tests/Identity/PasswordResetServiceIntegrationTest.php create mode 100644 tests/Identity/PasswordResetServiceTest.php create mode 100644 tests/Identity/UserControllerTest.php create mode 100644 tests/Identity/UserRepositoryTest.php create mode 100644 tests/Identity/UserServiceTest.php create mode 100644 tests/Identity/UserTest.php create mode 100644 tests/Kernel/BootstrapTest.php create mode 100644 tests/Kernel/ClientIpResolverCoverageTest.php create mode 100644 tests/Kernel/ClientIpResolverTest.php create mode 100644 tests/Kernel/ContainerWiringIntegrationTest.php create mode 100644 tests/Kernel/DatabaseReadinessTest.php create mode 100644 tests/Kernel/DateParserTest.php create mode 100644 tests/Kernel/DefaultErrorHandlerTest.php create mode 100644 tests/Kernel/ErrorHandlerConfiguratorTest.php create mode 100644 tests/Kernel/ExtensionTest.php create mode 100644 tests/Kernel/FlashServiceConsumeTest.php create mode 100644 tests/Kernel/FlashServiceCoverageTest.php create mode 100644 tests/Kernel/FlashServiceTest.php create mode 100644 tests/Kernel/HelperEdgeCasesTest.php create mode 100644 tests/Kernel/HtmlPurifierFactoryTest.php create mode 100644 tests/Kernel/HtmlSanitizerTest.php create mode 100644 tests/Kernel/InfrastructureBootstrapperTest.php create mode 100644 tests/Kernel/MailServiceTest.php create mode 100644 tests/Kernel/MigratorTest.php create mode 100644 tests/Kernel/ModuleRegistryTest.php create mode 100644 tests/Kernel/ModuleSchemaTest.php create mode 100644 tests/Kernel/NotFoundExceptionTest.php create mode 100644 tests/Kernel/ProvisionerTest.php create mode 100644 tests/Kernel/RequestContextTest.php create mode 100644 tests/Kernel/RoutesTest.php create mode 100644 tests/Kernel/RuntimePathsTest.php create mode 100644 tests/Kernel/SessionManagerCoverageTest.php create mode 100644 tests/Kernel/SessionManagerEdgeCasesTest.php create mode 100644 tests/Kernel/SessionManagerTest.php create mode 100644 tests/Kernel/SlugHelperTest.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/MediaSchemaIntegrationTest.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/Media/MediaUsageReferenceTest.php create mode 100644 tests/Notifications/NotificationDispatchTest.php create mode 100644 tests/Notifications/NotificationServiceTest.php create mode 100644 tests/Settings/SettingTest.php create mode 100644 tests/Settings/SettingsServiceTest.php create mode 100644 tests/Taxonomy/TaxonModelTest.php create mode 100644 tests/Taxonomy/TaxonRepositoryTest.php create mode 100644 tests/Taxonomy/TaxonomyControllerTest.php create mode 100644 tests/Taxonomy/TaxonomyServiceReaderTest.php create mode 100644 tests/Taxonomy/TaxonomyServiceTest.php create mode 100644 tests/bootstrap.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..749cc8a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[{package.json,package-lock.json}] +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4337c80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# ============================================ +# Environnement & Configuration +# ============================================ +.env + +# ============================================ +# Dépendances Composer +# ============================================ +vendor/ + +# ============================================ +# Base de données +# ============================================ +database/*.sqlite +database/*.sqlite-shm +database/*.sqlite-wal +database/.provision.lock + +# ============================================ +# Cache & Logs +# ============================================ +coverage/ +var/ +.php-cs-fixer.cache +.phpstan/ +.phpunit.result.cache + +# ============================================ +# IDE & OS +# ============================================ +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..e43d2d5 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,36 @@ + is_dir($directory))); + +$finder = PhpCsFixer\Finder::create() + ->in($directories) + ->name('*.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setFinder($finder) + ->setRules([ + '@PSR12' => true, + 'array_indentation' => true, + 'binary_operator_spaces' => ['default' => 'single_space'], + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try']], + 'class_attributes_separation' => ['elements' => ['method' => 'one', 'property' => 'one']], + 'concat_space' => ['spacing' => 'one'], + 'declare_strict_types' => true, + 'final_class' => false, + 'no_unused_imports' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'ordered_types' => ['sort_algorithm' => 'alpha'], + 'single_import_per_statement' => true, + 'single_line_empty_body' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']], + ]); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..95c6578 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,110 @@ +# Contribuer au projet + +Ce dépôt fournit un socle partagé consommé par plusieurs applications. Une contribution doit préserver la stabilité du package, la lisibilité de son organisation et la clarté de son API publique. + +## Avant de contribuer + +Lire au minimum : + +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) avant une évolution structurelle ; +- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) pour le workflow quotidien ; + +## Prérequis + +Les mêmes que pour le développement (voir [README.md](README.md)). + +## Vérifications attendues + +Lancer au minimum : + +```bash +composer test +composer stan +composer cs:check +``` + +Avec rapport de couverture (nécessite Xdebug ou PCOV) : + +```bash +vendor/bin/phpunit --coverage-text +``` + + +## Règles de contribution + +### Mocks + +Les services applicatifs utilisent les **interfaces** comme type des dépendances. Mocker l'interface plutôt que la classe concrète : + +```php +// Correct +$repo = $this->createMock(UserRepositoryInterface::class); + +// À éviter +$repo = $this->createMock(UserRepository::class); +``` + +Les tests de repository (`PdoUserRepository`, etc.) testent volontairement l'implémentation concrète avec un mock PDO. Ils doivent vérifier l'intention générale des requêtes et les valeurs retournées, sans figer inutilement les détails internes. + +### Exceptions métier + +Les erreurs métier doivent lever l'exception la plus spécifique disponible : + +| Situation | Exception | Namespace | +|---|---|---| +| Nom d'utilisateur déjà pris | `DuplicateUsernameException` | `Netig\Netslim\Identity\Domain\Exception` | +| Email déjà utilisé | `DuplicateEmailException` | `Netig\Netslim\Identity\Domain\Exception` | +| Mot de passe trop court | `WeakPasswordException` | `Netig\Netslim\Identity\Domain\Exception` | +| Entité introuvable en base | `NotFoundException` | `Netig\Netslim\Kernel\Support\Exception` | + +### Ajouter un test + +- **Nommage** : `test` + description camelCase +- **Structure** : Arrange / Act / Assert +- **Isolation** : dépendances mockées via leurs interfaces +- **PHPDoc** : une ligne décrivant le comportement attendu quand cela apporte une vraie valeur + +### Frontières à respecter + +- **DI stricte dans `Application/` et `UI/`** : ne pas instancier directement de use case, policy, repository concret ou implémentation infrastructure dans un service applicatif ou un controller ; +- **Controllers minces** : un controller traduit HTTP vers l'application, puis convertit le résultat en réponse ou flash ; +- **UI → service uniquement** : un controller, request object ou composant UI ne référence ni `Application/UseCase/`, ni `Application/Command/` ; +- **ApplicationService = façade** : garder les lectures simples et la pagination dans le service applicatif, déléguer les mutations et workflows sensibles à des use cases ; +- **UseCase = action explicite** : un use case doit rester `final readonly`, porter un verbe clair et exposer une seule méthode publique `handle(...)` ; +- **Command = entrée immuable** : une command est un DTO `final readonly` placé dans `Application/Command/` ; +- **Commentaires et PHPDoc** : documenter les shapes, invariants et choix de runtime utiles ; garder les anglicismes techniques déjà utilisés par le projet au lieu de les retraduire ; +- **Modules auto-déclaratifs** : chaque module déclare ses définitions DI, ses routes, ses namespaces Twig et ses extensions Twig via son `Module.php` ; +- **Découplage inter-domaines** : lorsqu'un domaine expose un port à un autre, éviter de faire remonter ses structures internes. + +### Checklist de review architecturale + +Avant de valider une PR backend, vérifier rapidement : + +- la `UI` parle bien au service applicatif, pas à un use case ; +- une mutation nouvelle ou sensible a bien été extraite en `UseCase` ; +- une `Command` a été introduite si elle clarifie l'entrée du workflow ; +- `Kernel/` n'a pas servi de raccourci pour ranger du code mono-domaine. + +## Placement du code + +Avant d'ajouter une classe dans `src/Kernel`, appliquer cette règle : **domain-first, kernel-last**. + +Un élément a sa place dans `Kernel` uniquement s'il est réellement transverse, technique, ou réutilisé par plusieurs domaines. Si son sens reste principalement lié à un domaine métier, il doit rester dans ce domaine. + +`Kernel/Support` est gelé par défaut : toute nouvelle entrée doit être justifiée comme réellement transverse, documentée dans `docs/ARCHITECTURE.md` et ajoutée explicitement à l'allow list des tests d'architecture. En cas d'hésitation, garder le code dans le domaine concerné. + +Le bootstrap et l'infrastructure runtime doivent aussi rester découpés en étapes simples. Les composants transverses doivent respecter la séparation `Runtime / Http / Persistence|Mail|Html|Pagination|Support` : démarrage et composition dans `Runtime`, web dans `Http`, briques techniques dans les sous-modules transverses dédiés. Si un fichier de démarrage commence à porter plusieurs responsabilités distinctes, préférer extraire un composant ciblé plutôt que d'empiler de la logique. + +## Formatage + +Appliquer automatiquement : + +```bash +composer cs:fix +``` + +Prévisualiser sans appliquer : + +```bash +composer cs:check +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a3fed6e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NETig + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..834d885 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# netslim-core + +`netslim-core` est le socle réutilisable de la plateforme Netslim. + +Il contient : +- le `Kernel` technique ; +- les modules partageables `Identity`, `Settings`, `AuditLog`, `Notifications`, `Taxonomy` et `Media` ; +- les tests d'architecture et de modules du socle. + +Ce dépôt est conçu pour être consommé par des projets applicatifs séparés via Composer. + +## Installation depuis le dépôt Git en HTTPS + +### Pendant le développement du core + +```json +{ + "repositories": [ + { + "type": "vcs", + "url": "https://git.netig.net/netig/netslim-core.git" + } + ], + "require": { + "netig/netslim-core": "^0.3@dev" + } +} +``` + +### Après la première release taguée + +```json +{ + "repositories": [ + { + "type": "vcs", + "url": "https://git.netig.net/netig/netslim-core.git" + } + ], + "require": { + "netig/netslim-core": "^0.1" + } +} +``` + +## Option locale pendant le développement + +Pour développer le core et une application consommatrice côte à côte, un `path` repository local reste pratique, mais ce n'est pas le mode de consommation par défaut : + +```json +{ + "repositories": [ + { + "type": "path", + "url": "../netslim-core" + } + ], + "require": { + "netig/netslim-core": "^0.3@dev" + } +} +``` + +## Ce que doit fournir une application consommatrice + +Le package ne porte pas d'application concrète. Un projet consommateur doit fournir au minimum : +- son propre `config/modules.php` ; +- son point d'entrée HTTP (`public/index.php`) ; +- ses templates applicatifs ; +- son pipeline d'assets. + +Si l'application active `Identity` et exécute le provisionnement initial, elle doit aussi définir `ADMIN_USERNAME`, `ADMIN_EMAIL` et `ADMIN_PASSWORD` dans son `.env`. Ces variables ne sont plus exigées par le bootstrap du noyau seul. + +Si l'application active `Notifications`, elle doit configurer `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_ENCRYPTION`, `MAIL_FROM` et `MAIL_FROM_NAME` pour permettre l'envoi effectif des emails transactionnels. + +Les templates du socle supposent en particulier : +- une feuille de styles servie sous `/assets/css/main.css` pour les layouts `@Kernel` ; +- si l'UI admin du module `Media` est utilisée, un script servi sous `/assets/js/media-admin.js` ; +- les caches, logs, médias et la base SQLite sont toujours résolus depuis le projet consommateur (`var/`, `public/media/`, `database/`), jamais depuis le package installé dans `vendor/` ; +- la destination du back-office peut être redéfinie via `ADMIN_HOME_PATH` si l'application consommatrice n'utilise pas `/admin`. + +## Surface publique + +Le socle expose principalement : +- `Netig\Netslim\Kernel\...` pour le runtime public ; +- les interfaces applicatives documentées des modules partagés (`Netig\Netslim\Identity\Application\*ServiceInterface`, `Netig\Netslim\Settings\Application\SettingsServiceInterface`, `Netig\Netslim\AuditLog\Application\AuditLogServiceInterface`, `Netig\Netslim\Notifications\Application\NotificationServiceInterface`, `Netig\Netslim\Taxonomy\Application\TaxonomyServiceInterface`, `Netig\Netslim\Media\Application\MediaServiceInterface`) ; +- `Netig\Netslim\Settings\Contracts\...` ; +- `Netig\Netslim\AuditLog\Contracts\...` ; +- `Netig\Netslim\Notifications\Contracts\...` ; +- `Netig\Netslim\Taxonomy\Contracts\...` ; +- `Netig\Netslim\Media\Contracts\...` ; +- les classes `*Module` des modules partagés. + +La frontière détaillée entre API publique et API interne est documentée dans [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md). + +## Vérifications locales + +```bash +composer install +composer qa +``` + +## Gouvernance du package + +- `docs/PUBLIC_API.md` définit la frontière supportée entre le package et les projets consommateurs ; + +> Quand `netslim-core` est installé via Composer, les chemins runtime détectent automatiquement la racine du projet consommateur pour les scripts CLI et les suites de tests qui n'appellent pas explicitement `Bootstrap::create()`. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e6a4251 --- /dev/null +++ b/composer.json @@ -0,0 +1,69 @@ +{ + "name": "netig/netslim-core", + "description": "Reusable kernel and shared modules for NETslim based applications.", + "license": "MIT", + "type": "library", + "require": { + "php": "^8.4", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-pdo": "*", + "ext-pdo_sqlite": "*", + "ext-session": "*", + "ext-simplexml": "*", + "slim/slim": "^4.13", + "slim/psr7": "^1.6", + "php-di/php-di": "^7.0", + "slim/twig-view": "^3.3", + "monolog/monolog": "^3.0", + "slim/csrf": "^1.3", + "ezyang/htmlpurifier": "^4.16", + "vlucas/phpdotenv": "^5.6", + "phpmailer/phpmailer": "^6.7" + }, + "require-dev": { + "phpunit/phpunit": "^13.0", + "phpstan/phpstan": "^1.10", + "friendsofphp/php-cs-fixer": "^3.50" + }, + "autoload": { + "psr-4": { + "Netig\\Netslim\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "test": "@php vendor/bin/phpunit", + "test:coverage": "@php -d xdebug.mode=coverage vendor/bin/phpunit --coverage-text", + "stan": "@php vendor/bin/phpstan analyse", + "cs:check": "@php vendor/bin/php-cs-fixer fix --dry-run --diff", + "cs:fix": "@php vendor/bin/php-cs-fixer fix", + "qa": [ + "@test", + "@stan", + "@cs:check" + ] + }, + "scripts-descriptions": { + "test": "Run PHPUnit test suite", + "test:coverage": "Run PHPUnit with text coverage output (requires Xdebug)", + "stan": "Run PHPStan static analysis", + "cs:check": "Check coding style with PHP CS Fixer", + "cs:fix": "Fix coding style issues with PHP CS Fixer", + "qa": "Run quality checks" + }, + "config": { + "sort-packages": true, + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..4f8205e --- /dev/null +++ b/composer.lock @@ -0,0 +1,5892 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "3f7a584723f1894e6186f1d8147bf58f", + "packages": [ + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, + { + "name": "fig/http-message-util", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message-util.git", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "suggest": { + "psr/http-message": "The package containing the PSR-7 interfaces" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fig\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Utility classes and constants for use with PSR-7 (psr/http-message)", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-message-util/issues", + "source": "https://github.com/php-fig/http-message-util/tree/1.1.5" + }, + "time": "2020-11-24T22:02:12+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.10", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-02-20T19:59:49+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nikic/fast-route", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/FastRoute.git", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|~5.7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "FastRoute\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov", + "email": "nikic@php.net" + } + ], + "description": "Fast request router for PHP", + "keywords": [ + "router", + "routing" + ], + "support": { + "issues": "https://github.com/nikic/FastRoute/issues", + "source": "https://github.com/nikic/FastRoute/tree/master" + }, + "time": "2018-02-13T20:26:39+00:00" + }, + { + "name": "php-di/invoker", + "version": "2.3.7", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/Invoker.git", + "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1", + "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "psr/container": "^1.0|^2.0" + }, + "require-dev": { + "athletic/athletic": "~0.1.8", + "mnapoli/hard-mode": "~0.3.0", + "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Invoker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Generic and extensible callable invoker", + "homepage": "https://github.com/PHP-DI/Invoker", + "keywords": [ + "callable", + "dependency", + "dependency-injection", + "injection", + "invoke", + "invoker" + ], + "support": { + "issues": "https://github.com/PHP-DI/Invoker/issues", + "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2025-08-30T10:22:22+00:00" + }, + { + "name": "php-di/php-di", + "version": "7.1.1", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/PHP-DI.git", + "reference": "f88054cc052e40dbe7b383c8817c19442d480352" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352", + "reference": "f88054cc052e40dbe7b383c8817c19442d480352", + "shasum": "" + }, + "require": { + "laravel/serializable-closure": "^1.0 || ^2.0", + "php": ">=8.0", + "php-di/invoker": "^2.0", + "psr/container": "^1.1 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "friendsofphp/proxy-manager-lts": "^1", + "mnapoli/phpunit-easymock": "^1.3", + "phpunit/phpunit": "^9.6 || ^10 || ^11", + "vimeo/psalm": "^5|^6" + }, + "suggest": { + "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "DI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The dependency injection container for humans", + "homepage": "https://php-di.org/", + "keywords": [ + "PSR-11", + "container", + "container-interop", + "dependency injection", + "di", + "ioc", + "psr11" + ], + "support": { + "issues": "https://github.com/PHP-DI/PHP-DI/issues", + "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/php-di/php-di", + "type": "tidelift" + } + ], + "time": "2025-08-16T11:10:48+00:00" + }, + { + "name": "phpmailer/phpmailer", + "version": "v6.12.0", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d1ac35d784bf9f5e61b424901d5a014967f15b12", + "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^9.3.5", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.7.2", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "support": { + "issues": "https://github.com/PHPMailer/PHPMailer/issues", + "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.12.0" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2025-10-15T16:49:08+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "slim/csrf", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/slimphp/Slim-Csrf.git", + "reference": "a476a61e38451e138c400f6b4ca96037f3c2dd39" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slimphp/Slim-Csrf/zipball/a476a61e38451e138c400f6b4ca96037f3c2dd39", + "reference": "a476a61e38451e138c400f6b4ca96037f3c2dd39", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "Slim\\Csrf\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Josh Lockhart", + "email": "hello@joshlockhart.com", + "homepage": "http://joshlockhart.com" + } + ], + "description": "Slim Framework 4 CSRF protection PSR-15 middleware", + "homepage": "https://www.slimframework.com", + "keywords": [ + "csrf", + "framework", + "middleware", + "slim" + ], + "support": { + "issues": "https://github.com/slimphp/Slim-Csrf/issues", + "source": "https://github.com/slimphp/Slim-Csrf/tree/1.5.1" + }, + "time": "2025-11-02T14:58:28+00:00" + }, + { + "name": "slim/psr7", + "version": "1.8.0", + "source": { + "type": "git", + "url": "https://github.com/slimphp/Slim-Psr7.git", + "reference": "76e7e3b1cdfd583e9035c4c966c08e01e45ce959" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slimphp/Slim-Psr7/zipball/76e7e3b1cdfd583e9035c4c966c08e01e45ce959", + "reference": "76e7e3b1cdfd583e9035c4c966c08e01e45ce959", + "shasum": "" + }, + "require": { + "fig/http-message-util": "^1.1.5", + "php": "^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.0 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "^1.0", + "psr/http-message-implementation": "^1.0 || ^2.0" + }, + "require-dev": { + "adriansuter/php-autoload-override": "^1.5|| ^2.0", + "ext-json": "*", + "http-interop/http-factory-tests": "^1.0 || ^2.0", + "php-http/psr7-integration-tests": "^1.5", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^9.6 || ^10", + "squizlabs/php_codesniffer": "^3.13" + }, + "type": "library", + "autoload": { + "psr-4": { + "Slim\\Psr7\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Josh Lockhart", + "email": "hello@joshlockhart.com", + "homepage": "https://joshlockhart.com" + }, + { + "name": "Andrew Smith", + "email": "a.smith@silentworks.co.uk", + "homepage": "https://silentworks.co.uk" + }, + { + "name": "Rob Allen", + "email": "rob@akrabat.com", + "homepage": "https://akrabat.com" + }, + { + "name": "Pierre Berube", + "email": "pierre@lgse.com", + "homepage": "https://www.lgse.com" + } + ], + "description": "Strict PSR-7 implementation", + "homepage": "https://www.slimframework.com", + "keywords": [ + "http", + "psr-7", + "psr7" + ], + "support": { + "issues": "https://github.com/slimphp/Slim-Psr7/issues", + "source": "https://github.com/slimphp/Slim-Psr7/tree/1.8.0" + }, + "time": "2025-11-02T17:51:19+00:00" + }, + { + "name": "slim/slim", + "version": "4.15.1", + "source": { + "type": "git", + "url": "https://github.com/slimphp/Slim.git", + "reference": "887893516557506f254d950425ce7f5387a26970" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slimphp/Slim/zipball/887893516557506f254d950425ce7f5387a26970", + "reference": "887893516557506f254d950425ce7f5387a26970", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nikic/fast-route": "^1.3", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/container": "^1.0 || ^2.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "adriansuter/php-autoload-override": "^1.4 || ^2", + "ext-simplexml": "*", + "guzzlehttp/psr7": "^2.6", + "httpsoft/http-message": "^1.1", + "httpsoft/http-server-request": "^1.1", + "laminas/laminas-diactoros": "^2.17 || ^3", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "phpspec/prophecy": "^1.19", + "phpspec/prophecy-phpunit": "^2.1", + "phpstan/phpstan": "^1 || ^2", + "phpunit/phpunit": "^9.6 || ^10 || ^11 || ^12", + "slim/http": "^1.3", + "slim/psr7": "^1.6", + "squizlabs/php_codesniffer": "^3.10", + "vimeo/psalm": "^5 || ^6" + }, + "suggest": { + "ext-simplexml": "Needed to support XML format in BodyParsingMiddleware", + "ext-xml": "Needed to support XML format in BodyParsingMiddleware", + "php-di/php-di": "PHP-DI is the recommended container library to be used with Slim", + "slim/psr7": "Slim PSR-7 implementation. See https://www.slimframework.com/docs/v4/start/installation.html for more information." + }, + "type": "library", + "autoload": { + "psr-4": { + "Slim\\": "Slim" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Josh Lockhart", + "email": "hello@joshlockhart.com", + "homepage": "https://joshlockhart.com" + }, + { + "name": "Andrew Smith", + "email": "a.smith@silentworks.co.uk", + "homepage": "https://silentworks.co.uk" + }, + { + "name": "Rob Allen", + "email": "rob@akrabat.com", + "homepage": "https://akrabat.com" + }, + { + "name": "Pierre Berube", + "email": "pierre@lgse.com", + "homepage": "https://www.lgse.com" + }, + { + "name": "Gabriel Manricks", + "email": "gmanricks@me.com", + "homepage": "http://gabrielmanricks.com" + } + ], + "description": "Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs", + "homepage": "https://www.slimframework.com", + "keywords": [ + "api", + "framework", + "micro", + "router" + ], + "support": { + "docs": "https://www.slimframework.com/docs/v4/", + "forum": "https://discourse.slimframework.com/", + "irc": "irc://irc.freenode.net:6667/slimphp", + "issues": "https://github.com/slimphp/Slim/issues", + "rss": "https://www.slimframework.com/blog/feed.rss", + "slack": "https://slimphp.slack.com/", + "source": "https://github.com/slimphp/Slim", + "wiki": "https://github.com/slimphp/Slim/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/slimphp", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/slim/slim", + "type": "tidelift" + } + ], + "time": "2025-11-21T12:23:44+00:00" + }, + { + "name": "slim/twig-view", + "version": "3.4.1", + "source": { + "type": "git", + "url": "https://github.com/slimphp/Twig-View.git", + "reference": "b4268d87d0e327feba5f88d32031e9123655b909" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slimphp/Twig-View/zipball/b4268d87d0e327feba5f88d32031e9123655b909", + "reference": "b4268d87d0e327feba5f88d32031e9123655b909", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/http-message": "^1.1 || ^2.0", + "slim/slim": "^4.12", + "symfony/polyfill-php81": "^1.29", + "twig/twig": "^3.11" + }, + "require-dev": { + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/phpstan": "^1.10.59", + "phpunit/phpunit": "^9.6 || ^10", + "psr/http-factory": "^1.0", + "squizlabs/php_codesniffer": "^3.9" + }, + "type": "library", + "autoload": { + "psr-4": { + "Slim\\Views\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Josh Lockhart", + "email": "hello@joshlockhart.com", + "homepage": "http://joshlockhart.com" + }, + { + "name": "Pierre Berube", + "email": "pierre@lgse.com", + "homepage": "http://www.lgse.com" + } + ], + "description": "Slim Framework 4 view helper built on top of the Twig 3 templating component", + "homepage": "https://www.slimframework.com", + "keywords": [ + "framework", + "slim", + "template", + "twig", + "view" + ], + "support": { + "issues": "https://github.com/slimphp/Twig-View/issues", + "source": "https://github.com/slimphp/Twig-View/tree/3.4.1" + }, + "time": "2024-09-26T05:42:02+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "twig/twig", + "version": "v3.24.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.24.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-03-17T21:31:11+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + } + ], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.94.2", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/7787ceff91365ba7d623ec410b8f429cdebb4f63", + "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.33", + "symfony/polyfill-php80": "^1.33", + "symfony/polyfill-php81": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.7.1", + "infection/infection": "^0.32.3", + "justinrainbow/json-schema": "^6.6.4", + "keradus/cli-executor": "^2.3", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.7", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.7", + "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.51", + "symfony/polyfill-php85": "^1.33", + "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.4", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.1" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.94.2" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-02-20T16:13:53+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "1.12.33", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-02-28T20:30:03+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "13.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "a8b58fde2f4fbc69a064e1f80ff917607cf7737c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/a8b58fde2f4fbc69a064e1f80ff917607cf7737c", + "reference": "a8b58fde2f4fbc69a064e1f80ff917607cf7737c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.4", + "phpunit/php-file-iterator": "^7.0", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.0", + "sebastian/lines-of-code": "^5.0", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/13.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-02-06T06:05:15+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:37:53+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "13.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "d57826e8921a534680c613924bfd921ded8047f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d57826e8921a534680c613924bfd921ded8047f4", + "reference": "d57826e8921a534680c613924bfd921ded8047f4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^13.0.1", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.0.0", + "sebastian/diff": "^8.0.0", + "sebastian/environment": "^9.0.0", + "sebastian/exporter": "^8.0.0", + "sebastian/global-state": "^9.0.0", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.0", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.0-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.0.5" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:40:03+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:39:44+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "29b232ddc29c2b114c0358c69b3084e7c3da0d58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/29b232ddc29c2b114c0358c69b3084e7c3da0d58", + "reference": "29b232ddc29c2b114c0358c69b3084e7c3da0d58", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^8.0", + "sebastian/exporter": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:40:39+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a2b6d09d7729ee87d605a439469f9dcc39be5ea3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2b6d09d7729ee87d605a439469f9dcc39be5ea3", + "reference": "a2b6d09d7729ee87d605a439469f9dcc39be5ea3", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:42:27+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "e26e9a944bd9d27b3a38a82fc2093d440951bfbe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/e26e9a944bd9d27b3a38a82fc2093d440951bfbe", + "reference": "e26e9a944bd9d27b3a38a82fc2093d440951bfbe", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-03-15T07:13:02+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "dc31f1f8e0186c8f0bb3e48fd4d51421d8905fea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/dc31f1f8e0186c8f0bb3e48fd4d51421d8905fea", + "reference": "dc31f1f8e0186c8f0bb3e48fd4d51421d8905fea", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:44:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:45:13+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", + "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:45:54+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:46:36+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:47:13+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:51:28+00:00" + }, + { + "name": "sebastian/type", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "42412224607bd3931241bbd17f38e0f972f5a916" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/42412224607bd3931241bbd17f38e0f972f5a916", + "reference": "42412224607bd3931241bbd17f38e0f972f5a916", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:09+00:00" + }, + { + "name": "sebastian/version", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:52+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/console", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4|^8.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T14:06:22+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T11:45:55+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-25T16:59:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-29T09:41:02+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T15:55:31+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:08:38+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/67df1914c6ccd2d7b52f70d40cf2aea02159d942", + "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-04T07:36:47+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-09T10:14:57+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.4", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-pdo": "*", + "ext-pdo_sqlite": "*", + "ext-session": "*", + "ext-simplexml": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/config/modules.php b/config/modules.php new file mode 100644 index 0000000..382917d --- /dev/null +++ b/config/modules.php @@ -0,0 +1,5 @@ + Quand `netslim-core` est installé via Composer, les chemins runtime détectent automatiquement la racine du projet consommateur pour les scripts CLI et les suites de tests qui n'appellent pas explicitement `Bootstrap::create()`. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..cc654ef --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,95 @@ +# Développement + +Ce document sert de guide quotidien pour travailler sur `netslim-core`. + +## Démarrage local + +Prérequis : PHP 8.4+, Composer, extensions `pdo`, `pdo_sqlite`, `fileinfo`, `gd`, `json`, `mbstring`, `session`, `dom`, `simplexml`. + +```bash +composer install +composer qa +``` + +## Ce que contient le dépôt + +- `Kernel/` : runtime, HTTP, persistence, mail, html, pagination, support +- `Identity/` : authentification, utilisateurs et autorisation transverse +- `Settings/` : paramètres applicatifs typés +- `AuditLog/` : journal d'audit partagé +- `Notifications/` : notifications et emails transactionnels +- `Taxonomy/` : taxons réutilisables +- `Media/` : médiathèque transverse + +Le dépôt ne contient pas d'application concrète : c'est le socle partagé. +Une application consommatrice fournit son propre `config/modules.php`, son point d'entrée HTTP, ses templates applicatifs et ses assets. +Les répertoires runtime (`var/`, `database/`, `public/media/`) appartiennent toujours au projet consommateur, même quand le core est installé sous `vendor/`. + +## Où placer une modification ? + +### Backend + +- `Domain/` : entités, règles métier, contrats métier +- `Application/` : cas d'usage, services applicatifs, commandes, ports +- `Infrastructure/` : implémentations concrètes, PDO, stockage, mail, bindings DI +- `UI/` : contrôleurs, request objects, templates, extensions Twig + +### Code transverse + +`Kernel/` reste limité au transverse et se découpe en sous-modules explicites : `Runtime/`, `Http/`, `Persistence/`, `Mail/`, `Html/`, `Pagination/` et `Support/`. +En cas d'hésitation, commencer dans le domaine concerné puis extraire vers `Kernel` à la deuxième vraie réutilisation. + +### Checklist avant d'ajouter quelque chose dans `Kernel/Support` + +Valider les trois points suivants : + +- ce code sert déjà à au moins deux domaines ou représente une primitive transverse évidente ; +- il ne dépend ni d'un domaine métier, ni de Slim, Twig, PSR-7 ou d'une implémentation d'infrastructure ; +- la PR met à jour à la fois la documentation et l'allow list des tests d'architecture. + +## Faire évoluer un domaine existant + +Ordre recommandé : + +1. adapter le contrat ou l'interface de service si nécessaire ; +2. décider si la modification reste une lecture simple ou mérite un `UseCase` + éventuelle `Command` ; +3. faire évoluer le repository ou le port technique ; +4. adapter le contrôleur et les vues sans court-circuiter le service applicatif ; +5. mettre à jour les tests et la documentation utile. + +## Ajouter un nouveau domaine + +Créer systématiquement : + +1. l'entité et les contrats du domaine ; +2. l'interface de service ; +3. le repository interface ; +4. l'implémentation `Infrastructure/` ; +5. le contrôleur et les templates ; +6. `Infrastructure/dependencies.php` ; +7. `UI/Http/Routes.php` ; +8. `Module.php` ; +9. les tests dans `tests//`. + +Pour les frontières et dépendances autorisées, se reporter à [ARCHITECTURE.md](ARCHITECTURE.md). + +## Vérifications avant push + +```bash +composer test +composer stan +composer cs:check +``` + +Raccourci utile : + +```bash +composer qa +``` + + +## Variables d'environnement et provisionnement + +Le bootstrap du noyau requiert seulement l'environnement technique commun (comme `APP_URL`). Les variables `ADMIN_*` sont nécessaires quand une application active `Identity` et exécute le provisionnement initial du compte administrateur. Les variables `MAIL_*` deviennent nécessaires uniquement si l'application active `Notifications` et envoie réellement des emails transactionnels. + +> Quand `netslim-core` est installé via Composer, les chemins runtime détectent automatiquement la racine du projet consommateur pour les scripts CLI et les suites de tests qui n'appellent pas explicitement `Bootstrap::create()`. diff --git a/docs/MODULES.md b/docs/MODULES.md new file mode 100644 index 0000000..7310ce8 --- /dev/null +++ b/docs/MODULES.md @@ -0,0 +1,35 @@ +# Modules fournis par netslim-core + +## Kernel +Socle technique : bootstrap, DI, routing, Twig, migrations, checks de démarrage. + +Les layouts `@Kernel` fournissent la structure HTML partagée, mais l'application consommatrice reste responsable de servir ses assets front (`/assets/css/main.css`). + +## Identity +Gestion des comptes, authentification, administration des utilisateurs et autorisation fine basée sur des permissions. + +## Settings +Paramètres applicatifs clé / valeur typés, utilisables par plusieurs projets sans recréer un mini back-office de configuration dans chaque application. + +## AuditLog +Journal d'audit transversal pour tracer les actions sensibles ou structurantes sur des ressources métier. + +## Notifications +Envoi et suivi des emails transactionnels. Le module s'appuie sur le service mail du noyau et conserve un historique des envois réussis ou échoués. + +Une application consommatrice qui active ce module doit fournir les variables d'environnement `MAIL_*` nécessaires au transport SMTP. + +## Taxonomy +Gestion de termes de taxonomie réutilisables. Les modules consommateurs gardent la propriété des relations d’usage. + +## Media +Gestion de la médiathèque. Les modules consommateurs utilisent les contrats publics pour exposer les usages des médias. + +L'UI admin de `Media` suppose qu'un projet consommateur serve un script applicatif sous `/assets/js/media-admin.js`. + +Le module `Identity` peut être intégré sans module éditorial : la redirection vers le back-office après connexion ou refus d'autorisation est pilotée par `ADMIN_HOME_PATH` (défaut : `/admin`). + + +## Frontière publique + +Les points d'intégration supportés pour une application consommatrice sont détaillés dans `docs/PUBLIC_API.md`. diff --git a/docs/PUBLIC_API.md b/docs/PUBLIC_API.md new file mode 100644 index 0000000..a1d0ae4 --- /dev/null +++ b/docs/PUBLIC_API.md @@ -0,0 +1,44 @@ +# API publique et API interne de netslim-core + +Ce document définit la frontière de support du package `netslim-core`. + +## API publique + +Les applications consommatrices peuvent dépendre des éléments suivants : + +- le namespace `Netig\Netslim\Kernel\Runtime\...` nécessaire au bootstrap, au runtime et à la découverte des modules ; +- les classes `*Module` exposées par les modules partagés (`IdentityModule`, `SettingsModule`, `AuditLogModule`, `NotificationsModule`, `TaxonomyModule`, `MediaModule`, `KernelModule`) ; +- les interfaces applicatives explicitement exposées par les modules (`Identity\Application\*ServiceInterface`, `Settings\Application\SettingsServiceInterface`, `AuditLog\Application\AuditLogServiceInterface`, `Notifications\Application\NotificationServiceInterface`, `Taxonomy\Application\TaxonomyServiceInterface`, `Media\Application\MediaServiceInterface`) ; +- les contrats publics sous `Netig\Netslim\*/Contracts/` ; +- les conventions documentées de `config/modules.php`, `public/index.php` et des chemins runtime résolus côté projet consommateur ; +- les layouts et partials Twig documentés sous `@Kernel/...` quand l'application choisit de les réutiliser. + +## API interne + +Les éléments suivants doivent être considérés comme des détails d'implémentation du package : + +- tout `Infrastructure/` (repositories PDO, stockage local, wiring DI, maintenance post-migration) ; +- tout `Application/UseCase/` et les services applicatifs non documentés comme contrats ; +- les entités de domaine utilisées en interne par les modules ; +- les vérifications de démarrage propres aux modules ; +- les templates Twig non documentés comme points d'intégration. + +Une application consommatrice ne devrait pas dépendre directement de ces éléments internes. + +## Règle pratique + +Quand un projet applicatif a besoin d'une capacité partagée, il doit préférer : + +1. un contrat public existant dans `Contracts/` ; +2. à défaut, une extension documentée du runtime ou d'un module ; +3. en dernier recours, une évolution du core qui ajoute un nouveau point d'intégration public. + +Éviter de se brancher directement sur des classes internes permet de garder le core versionnable et évolutif. + +## Stabilité attendue + +- l'API publique est la cible de compatibilité entre versions ; +- l'API interne peut évoluer à tout moment tant que le comportement public documenté reste cohérent ; +- tout nouveau point d'extension réutilisable doit être documenté ici ou dans `README.md` / `MODULES.md`. + +## Versionnement diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b7cbfad --- /dev/null +++ b/docs/README.md @@ -0,0 +1,29 @@ +# Documentation + +Ce dossier contient les documents de référence du socle `netslim-core`. + +## Par où commencer ? + +- **Je découvre le dépôt** → [../README.md](../README.md) +- **Je veux comprendre les frontières et dépendances** → [ARCHITECTURE.md](ARCHITECTURE.md) +- **Je veux connaître les conventions de module** → [MODULES.md](MODULES.md) +- **Je veux savoir ce qui est public ou interne** → [PUBLIC_API.md](PUBLIC_API.md) +- **Je travaille au quotidien sur le dépôt** → [DEVELOPMENT.md](DEVELOPMENT.md) +- **Je contribue au dépôt** → [../CONTRIBUTING.md](../CONTRIBUTING.md) + +## Si tu ne lis que trois documents + +1. [../README.md](../README.md) pour comprendre ce que contient le dépôt +2. [ARCHITECTURE.md](ARCHITECTURE.md) avant toute évolution structurelle +3. [PUBLIC_API.md](PUBLIC_API.md) avant d'utiliser le core comme dépendance applicative + +## Rôle de chaque document + +| Document | Rôle | +|---|---| +| [../README.md](../README.md) | Vue d'ensemble et mode d'installation du socle | +| [ARCHITECTURE.md](ARCHITECTURE.md) | Référence sur les modules, les couches et les dépendances autorisées | +| [MODULES.md](MODULES.md) | Charte de module et conventions de frontière | +| [PUBLIC_API.md](PUBLIC_API.md) | Délimitation de l'API publique et de l'API interne | +| [DEVELOPMENT.md](DEVELOPMENT.md) | Guide de travail quotidien, variables d'environnement et checklist avant push | +| [../CONTRIBUTING.md](../CONTRIBUTING.md) | Règles de contribution et attentes sur les tests | \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..d6a9353 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 8 + paths: + - src + excludePaths: + - src/Kernel/Runtime/Bootstrap.php diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..191c68f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,29 @@ + + + + + + tests + + + + + + src + + + src/Kernel/Runtime/Bootstrap.php + src/Kernel/Runtime/Routing/Routes.php + + + + diff --git a/src/AuditLog/Application/AuditLogApplicationService.php b/src/AuditLog/Application/AuditLogApplicationService.php new file mode 100644 index 0000000..b08d0e4 --- /dev/null +++ b/src/AuditLog/Application/AuditLogApplicationService.php @@ -0,0 +1,54 @@ +repository->create(new AuditEntry( + id: null, + action: $action, + resourceType: $resourceType, + resourceId: $resourceId, + actorUserId: $actorUserId, + context: $context, + createdAt: new \DateTimeImmutable(), + )); + } + + /** + * @return list + */ + public function listRecent(int $limit = 50, ?string $resourceType = null, ?string $resourceId = null): array + { + return array_map( + static fn (AuditEntry $entry): AuditEntryView => new AuditEntryView( + id: $entry->getId() ?? 0, + action: $entry->getAction(), + resourceType: $entry->getResourceType(), + resourceId: $entry->getResourceId(), + actorUserId: $entry->getActorUserId(), + context: $entry->getContext(), + createdAt: $entry->getCreatedAt()->format(DATE_ATOM), + ), + $this->repository->findRecent($limit, $resourceType, $resourceId), + ); + } +} diff --git a/src/AuditLog/Application/AuditLogServiceInterface.php b/src/AuditLog/Application/AuditLogServiceInterface.php new file mode 100644 index 0000000..133bd1c --- /dev/null +++ b/src/AuditLog/Application/AuditLogServiceInterface.php @@ -0,0 +1,13 @@ + $app */ + public function registerRoutes(App $app): void {} + + public function templateNamespaces(): array + { + return []; + } + + public function twigExtensions(): array + { + return []; + } + + public function migrationDirectories(): array + { + return [__DIR__ . '/Migrations']; + } + + public function requiredTables(): array + { + return ['audit_log']; + } +} diff --git a/src/AuditLog/Contracts/AuditEntryView.php b/src/AuditLog/Contracts/AuditEntryView.php new file mode 100644 index 0000000..2c84a98 --- /dev/null +++ b/src/AuditLog/Contracts/AuditEntryView.php @@ -0,0 +1,19 @@ + $context */ + public function __construct( + public int $id, + public string $action, + public string $resourceType, + public string $resourceId, + public ?int $actorUserId, + public array $context, + public string $createdAt, + ) {} +} diff --git a/src/AuditLog/Contracts/AuditLogReaderInterface.php b/src/AuditLog/Contracts/AuditLogReaderInterface.php new file mode 100644 index 0000000..031e04c --- /dev/null +++ b/src/AuditLog/Contracts/AuditLogReaderInterface.php @@ -0,0 +1,14 @@ + */ + public function listRecent(int $limit = 50, ?string $resourceType = null, ?string $resourceId = null): array; +} diff --git a/src/AuditLog/Contracts/AuditLoggerInterface.php b/src/AuditLog/Contracts/AuditLoggerInterface.php new file mode 100644 index 0000000..e24fdbe --- /dev/null +++ b/src/AuditLog/Contracts/AuditLoggerInterface.php @@ -0,0 +1,22 @@ + $context + */ + public function record( + string $action, + string $resourceType, + string $resourceId, + ?int $actorUserId = null, + array $context = [], + ): void; +} diff --git a/src/AuditLog/Domain/Entity/AuditEntry.php b/src/AuditLog/Domain/Entity/AuditEntry.php new file mode 100644 index 0000000..9901009 --- /dev/null +++ b/src/AuditLog/Domain/Entity/AuditEntry.php @@ -0,0 +1,84 @@ + $context + */ + public function __construct( + private readonly ?int $id, + private readonly string $action, + private readonly string $resourceType, + private readonly string $resourceId, + private readonly ?int $actorUserId, + private readonly array $context, + private readonly \DateTimeImmutable $createdAt, + ) { + if (trim($this->action) === '' || trim($this->resourceType) === '' || trim($this->resourceId) === '') { + throw new \InvalidArgumentException('Une entrée d’audit doit définir action, type de ressource et identifiant de ressource.'); + } + } + + /** + * @param array{id:int,action:string,resource_type:string,resource_id:string,actor_user_id:int|null,context_json:string|null,created_at:string} $row + */ + public static function fromRow(array $row): self + { + return new self( + id: (int) $row['id'], + action: $row['action'], + resourceType: $row['resource_type'], + resourceId: $row['resource_id'], + actorUserId: isset($row['actor_user_id']) ? (int) $row['actor_user_id'] : null, + context: $row['context_json'] !== null && $row['context_json'] !== '' + ? json_decode($row['context_json'], true, flags: JSON_THROW_ON_ERROR) + : [], + createdAt: new \DateTimeImmutable($row['created_at']), + ); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAction(): string + { + return $this->action; + } + + public function getResourceType(): string + { + return $this->resourceType; + } + + public function getResourceId(): string + { + return $this->resourceId; + } + + public function getActorUserId(): ?int + { + return $this->actorUserId; + } + + /** + * @return array + */ + public function getContext(): array + { + return $this->context; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/AuditLog/Domain/Repository/AuditLogRepositoryInterface.php b/src/AuditLog/Domain/Repository/AuditLogRepositoryInterface.php new file mode 100644 index 0000000..5e6bbec --- /dev/null +++ b/src/AuditLog/Domain/Repository/AuditLogRepositoryInterface.php @@ -0,0 +1,15 @@ + */ + public function findRecent(int $limit = 50, ?string $resourceType = null, ?string $resourceId = null): array; +} diff --git a/src/AuditLog/Infrastructure/PdoAuditLogRepository.php b/src/AuditLog/Infrastructure/PdoAuditLogRepository.php new file mode 100644 index 0000000..9d71e74 --- /dev/null +++ b/src/AuditLog/Infrastructure/PdoAuditLogRepository.php @@ -0,0 +1,73 @@ +db->prepare( + 'INSERT INTO audit_log (action, resource_type, resource_id, actor_user_id, context_json, created_at) + VALUES (:action, :resource_type, :resource_id, :actor_user_id, :context_json, :created_at)', + ); + + $stmt->execute([ + ':action' => $entry->getAction(), + ':resource_type' => $entry->getResourceType(), + ':resource_id' => $entry->getResourceId(), + ':actor_user_id' => $entry->getActorUserId(), + ':context_json' => $entry->getContext() === [] ? null : json_encode($entry->getContext(), JSON_THROW_ON_ERROR), + ':created_at' => $entry->getCreatedAt()->format('Y-m-d H:i:s'), + ]); + } + + /** + * @return list + */ + public function findRecent(int $limit = 50, ?string $resourceType = null, ?string $resourceId = null): array + { + $sql = 'SELECT id, action, resource_type, resource_id, actor_user_id, context_json, created_at FROM audit_log'; + $conditions = []; + $params = []; + + if ($resourceType !== null) { + $conditions[] = 'resource_type = :resource_type'; + $params[':resource_type'] = $resourceType; + } + + if ($resourceId !== null) { + $conditions[] = 'resource_id = :resource_id'; + $params[':resource_id'] = $resourceId; + } + + if ($conditions !== []) { + $sql .= ' WHERE ' . implode(' AND ', $conditions); + } + + $sql .= ' ORDER BY id DESC LIMIT :limit'; + $stmt = $this->db->prepare($sql); + + foreach ($params as $name => $value) { + $stmt->bindValue($name, $value); + } + + $stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT); + $stmt->execute(); + + /** @var list $rows */ + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + return array_map(static fn (array $row): AuditEntry => AuditEntry::fromRow($row), $rows); + } +} diff --git a/src/AuditLog/Infrastructure/dependencies.php b/src/AuditLog/Infrastructure/dependencies.php new file mode 100644 index 0000000..e3e3f98 --- /dev/null +++ b/src/AuditLog/Infrastructure/dependencies.php @@ -0,0 +1,19 @@ + autowire(AuditLogApplicationService::class), + AuditLoggerInterface::class => autowire(AuditLogApplicationService::class), + AuditLogReaderInterface::class => autowire(AuditLogApplicationService::class), + AuditLogRepositoryInterface::class => autowire(PdoAuditLogRepository::class), +]; diff --git a/src/AuditLog/Migrations/330_audit_log_schema.php b/src/AuditLog/Migrations/330_audit_log_schema.php new file mode 100644 index 0000000..d60b56b --- /dev/null +++ b/src/AuditLog/Migrations/330_audit_log_schema.php @@ -0,0 +1,24 @@ + " + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + actor_user_id INTEGER DEFAULT NULL REFERENCES users(id) ON DELETE SET NULL, + context_json TEXT DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type, resource_id); + CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_user_id); + ", + 'down' => " + DROP INDEX IF EXISTS idx_audit_log_actor; + DROP INDEX IF EXISTS idx_audit_log_resource; + DROP TABLE IF EXISTS audit_log; + ", +]; diff --git a/src/Identity/Application/AuthApplicationService.php b/src/Identity/Application/AuthApplicationService.php new file mode 100644 index 0000000..e454866 --- /dev/null +++ b/src/Identity/Application/AuthApplicationService.php @@ -0,0 +1,116 @@ +loginAttemptRepository->deleteExpired(); + + return $this->remainingMinutesForRow($this->loginAttemptRepository->findByIp($ip)); + } + + public function recordFailure(string $ip): void + { + $this->loginAttemptRepository->recordFailure($ip, $this->rateLimitPolicy->maxAttempts(), $this->rateLimitPolicy->lockMinutes()); + } + + public function resetRateLimit(string $ip): void + { + $this->loginAttemptRepository->resetForIp($ip); + } + + public function checkPasswordResetRateLimit(string $ip): int + { + $this->loginAttemptRepository->deleteExpired(); + + return $this->remainingMinutesForRow( + $this->loginAttemptRepository->findByScopeAndKey(self::PASSWORD_RESET_IP_SCOPE, $ip), + ); + } + + public function recordPasswordResetAttempt(string $ip): void + { + $this->loginAttemptRepository->recordFailureForScope( + self::PASSWORD_RESET_IP_SCOPE, + $ip, + $this->rateLimitPolicy->passwordResetMaxAttemptsPerIp(), + $this->rateLimitPolicy->passwordResetLockMinutesPerIp(), + ); + } + + public function authenticate(string $username, string $plainPassword): ?User + { + return $this->authenticateUser->handle(new AuthenticateUserCommand($username, $plainPassword)); + } + + public function changePassword(int $userId, string $currentPassword, string $newPassword): void + { + $this->changePassword->handle(new ChangePasswordCommand($userId, $currentPassword, $newPassword)); + } + + public function isLoggedIn(): bool + { + return $this->authSession->isAuthenticated(); + } + + public function login(User $user): void + { + $this->authSession->startForUser($user); + } + + public function logout(): void + { + $this->authSession->clear(); + } + + /** + * Convertit une ligne de verrouillage en nombre de minutes restantes. + * + * @param array{locked_until:string|null}|null $row + */ + private function remainingMinutesForRow(?array $row): int + { + if ($row === null || $row['locked_until'] === null) { + return 0; + } + + $lockedUntil = new \DateTime($row['locked_until']); + $now = new \DateTime(); + + if ($lockedUntil <= $now) { + return 0; + } + + $secondsLeft = $lockedUntil->getTimestamp() - $now->getTimestamp(); + + return max(1, (int) ceil($secondsLeft / 60)); + } +} diff --git a/src/Identity/Application/AuthServiceInterface.php b/src/Identity/Application/AuthServiceInterface.php new file mode 100644 index 0000000..a520bcc --- /dev/null +++ b/src/Identity/Application/AuthServiceInterface.php @@ -0,0 +1,78 @@ + permissions partagée par le core et + * constitue le point d'entrée recommandé pour les applications consommatrices. + */ +final class AuthorizationApplicationService implements AuthorizationServiceInterface +{ + public function __construct(private readonly RolePermissionMatrix $permissions) {} + + public function canRole(string $role, string $permission): bool + { + return $this->permissions->allows($role, $permission); + } + + public function canUser(User $user, string $permission): bool + { + return $this->permissions->allows($user->getRole(), $permission); + } + + /** + * @return list + */ + public function permissionsForRole(string $role): array + { + return $this->permissions->permissionsForRole($role); + } +} diff --git a/src/Identity/Application/AuthorizationServiceInterface.php b/src/Identity/Application/AuthorizationServiceInterface.php new file mode 100644 index 0000000..dffb9c6 --- /dev/null +++ b/src/Identity/Application/AuthorizationServiceInterface.php @@ -0,0 +1,20 @@ + */ + public function permissionsForRole(string $role): array; +} diff --git a/src/Identity/Application/Command/AdminDeleteUserCommand.php b/src/Identity/Application/Command/AdminDeleteUserCommand.php new file mode 100644 index 0000000..7940f8f --- /dev/null +++ b/src/Identity/Application/Command/AdminDeleteUserCommand.php @@ -0,0 +1,13 @@ +requestPasswordReset->handle(new RequestPasswordResetCommand($email, $baseUrl)); + } + + public function validateToken(string $tokenRaw): ?User + { + return $this->validatePasswordResetToken->handle($tokenRaw); + } + + public function resetPassword(string $tokenRaw, string $newPassword): void + { + $this->resetPassword->handle(new ResetPasswordCommand($tokenRaw, $newPassword)); + } +} diff --git a/src/Identity/Application/PasswordResetServiceInterface.php b/src/Identity/Application/PasswordResetServiceInterface.php new file mode 100644 index 0000000..c936766 --- /dev/null +++ b/src/Identity/Application/PasswordResetServiceInterface.php @@ -0,0 +1,40 @@ +userRepository->findById($command->targetUserId); + if ($user === null) { + throw new NotFoundException('Utilisateur', $command->targetUserId); + } + + if ($user->isAdmin()) { + throw new ProtectedAdministratorDeletionException(); + } + + if ($command->targetUserId === $command->actorUserId) { + throw new CannotDeleteOwnAccountException(); + } + + $this->userRepository->delete($command->targetUserId); + + return $user; + } +} diff --git a/src/Identity/Application/UseCase/AdminUpdateUserRole.php b/src/Identity/Application/UseCase/AdminUpdateUserRole.php new file mode 100644 index 0000000..053a518 --- /dev/null +++ b/src/Identity/Application/UseCase/AdminUpdateUserRole.php @@ -0,0 +1,42 @@ +userRepository->findById($command->targetUserId); + if ($user === null) { + throw new NotFoundException('Utilisateur', $command->targetUserId); + } + + if ($command->targetUserId === $command->actorUserId) { + throw new CannotModifyOwnRoleException(); + } + + if ($user->isAdmin()) { + throw new ProtectedAdministratorRoleChangeException(); + } + + $this->rolePolicy->assertAssignable($command->role); + $this->userRepository->updateRole($command->targetUserId, $command->role); + + return $user; + } +} diff --git a/src/Identity/Application/UseCase/AuthenticateUser.php b/src/Identity/Application/UseCase/AuthenticateUser.php new file mode 100644 index 0000000..67eb28a --- /dev/null +++ b/src/Identity/Application/UseCase/AuthenticateUser.php @@ -0,0 +1,47 @@ +userRepository->findByUsername(mb_strtolower(trim($command->username))); + + if ($user === null) { + return null; + } + + if (!password_verify($command->plainPassword, $user->getPasswordHash())) { + return null; + } + + if ($this->passwordPolicy->needsRehash($user->getPasswordHash())) { + $this->userRepository->updatePassword( + $user->getId(), + $this->passwordPolicy->hash($command->plainPassword), + false, + ); + } + + return $user; + } +} diff --git a/src/Identity/Application/UseCase/ChangePassword.php b/src/Identity/Application/UseCase/ChangePassword.php new file mode 100644 index 0000000..0f9edb3 --- /dev/null +++ b/src/Identity/Application/UseCase/ChangePassword.php @@ -0,0 +1,44 @@ +userRepository->findById($command->userId); + + if ($user === null) { + throw new NotFoundException('Utilisateur', $command->userId); + } + + if (!password_verify($command->currentPassword, $user->getPasswordHash())) { + throw new \InvalidArgumentException('Mot de passe actuel incorrect'); + } + + $this->passwordPolicy->assert($command->newPassword); + $this->userRepository->updatePassword( + $command->userId, + $this->passwordPolicy->hash($command->newPassword), + ); + } +} diff --git a/src/Identity/Application/UseCase/CreateUser.php b/src/Identity/Application/UseCase/CreateUser.php new file mode 100644 index 0000000..4b7e209 --- /dev/null +++ b/src/Identity/Application/UseCase/CreateUser.php @@ -0,0 +1,57 @@ +username)); + $email = mb_strtolower(trim($command->email)); + $plainPassword = $command->plainPassword; + + $this->rolePolicy->assertAssignable($command->role); + + if ($this->userRepository->findByUsername($username)) { + throw new DuplicateUsernameException($username); + } + + if ($this->userRepository->findByEmail($email)) { + throw new DuplicateEmailException($email); + } + + $this->passwordPolicy->assert($plainPassword); + + $passwordHash = $this->passwordPolicy->hash($plainPassword); + $user = new User(0, $username, $email, $passwordHash, $command->role); + + $this->userRepository->create($user); + + return $user; + } +} diff --git a/src/Identity/Application/UseCase/DeleteUser.php b/src/Identity/Application/UseCase/DeleteUser.php new file mode 100644 index 0000000..aab44ac --- /dev/null +++ b/src/Identity/Application/UseCase/DeleteUser.php @@ -0,0 +1,25 @@ +userRepository->findById($id) === null) { + throw new NotFoundException('Utilisateur', $id); + } + + $this->userRepository->delete($id); + } +} diff --git a/src/Identity/Application/UseCase/RequestPasswordReset.php b/src/Identity/Application/UseCase/RequestPasswordReset.php new file mode 100644 index 0000000..3860ef0 --- /dev/null +++ b/src/Identity/Application/UseCase/RequestPasswordReset.php @@ -0,0 +1,111 @@ +email)); + + if ($normalizedEmail === '') { + return; + } + + if ($this->isPasswordResetEmailCoolingDown($normalizedEmail)) { + return; + } + + $this->markPasswordResetEmailRequest($normalizedEmail); + + $user = $this->userRepository->findByEmail($normalizedEmail); + + if ($user === null) { + return; + } + + $this->passwordResetRepository->invalidateByUserId($user->getId()); + + $tokenRaw = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $tokenRaw); + $expiresAt = date('Y-m-d H:i:s', time() + $this->tokenPolicy->ttlMinutes() * 60); + + $this->passwordResetRepository->create($user->getId(), $tokenHash, $expiresAt); + + $resetUrl = rtrim($command->baseUrl, '/') . '/password/reset?token=' . $tokenRaw; + + $this->mailService->send( + to: $user->getEmail(), + subject: 'Réinitialisation de votre mot de passe', + template: '@Identity/emails/password-reset.twig', + context: [ + 'username' => $user->getUsername(), + 'resetUrl' => $resetUrl, + ], + ); + } + + /** + * Vérifie si un cooldown e-mail est déjà actif pour cette adresse. + */ + private function isPasswordResetEmailCoolingDown(string $email): bool + { + $this->loginAttemptRepository->deleteExpired(); + + $row = $this->loginAttemptRepository->findByScopeAndKey( + self::PASSWORD_RESET_EMAIL_SCOPE, + $this->emailRateLimitKey($email), + ); + + if ($row === null || $row['locked_until'] === null) { + return false; + } + + return new \DateTime($row['locked_until']) > new \DateTime(); + } + + /** + * Enregistre la demande pour activer le cooldown anti-spam par e-mail. + */ + private function markPasswordResetEmailRequest(string $email): void + { + $this->loginAttemptRepository->recordFailureForScope( + self::PASSWORD_RESET_EMAIL_SCOPE, + $this->emailRateLimitKey($email), + 1, + $this->rateLimitPolicy->passwordResetEmailCooldownMinutes(), + ); + } + + /** + * Produit une clé stable pour le cooldown e-mail sans exposer l'adresse brute. + */ + private function emailRateLimitKey(string $email): string + { + return hash('sha256', $email); + } +} diff --git a/src/Identity/Application/UseCase/ResetPassword.php b/src/Identity/Application/UseCase/ResetPassword.php new file mode 100644 index 0000000..ae8cf56 --- /dev/null +++ b/src/Identity/Application/UseCase/ResetPassword.php @@ -0,0 +1,52 @@ +passwordPolicy->assert($command->newPassword); + + $usedAt = date('Y-m-d H:i:s'); + $newHash = $this->passwordPolicy->hash($command->newPassword); + + $this->transactionManager->run(function () use ($command, $usedAt, $newHash): void { + $row = $this->passwordResetRepository->consumeActiveToken(hash('sha256', $command->tokenRaw), $usedAt); + + if ($row === null) { + throw new InvalidResetTokenException(); + } + + $user = $this->userRepository->findById((int) $row['user_id']); + + if ($user === null) { + throw new InvalidResetTokenException(); + } + + $this->userRepository->updatePassword($user->getId(), $newHash); + }); + } +} diff --git a/src/Identity/Application/UseCase/UpdateUserRole.php b/src/Identity/Application/UseCase/UpdateUserRole.php new file mode 100644 index 0000000..98e7ab4 --- /dev/null +++ b/src/Identity/Application/UseCase/UpdateUserRole.php @@ -0,0 +1,32 @@ +rolePolicy->assertAssignable($command->role); + + if ($this->userRepository->findById($command->id) === null) { + throw new NotFoundException('Utilisateur', $command->id); + } + + $this->userRepository->updateRole($command->id, $command->role); + } +} diff --git a/src/Identity/Application/UseCase/ValidatePasswordResetToken.php b/src/Identity/Application/UseCase/ValidatePasswordResetToken.php new file mode 100644 index 0000000..12b3c3c --- /dev/null +++ b/src/Identity/Application/UseCase/ValidatePasswordResetToken.php @@ -0,0 +1,38 @@ +passwordResetRepository->findActiveByHash(hash('sha256', $tokenRaw)); + + if ($row === null) { + return null; + } + + if (strtotime((string) $row['expires_at']) < time()) { + return null; + } + + return $this->userRepository->findById((int) $row['user_id']); + } +} diff --git a/src/Identity/Application/UserApplicationService.php b/src/Identity/Application/UserApplicationService.php new file mode 100644 index 0000000..cef0a52 --- /dev/null +++ b/src/Identity/Application/UserApplicationService.php @@ -0,0 +1,81 @@ +userRepository->findAll(); + } + + /** @return PaginatedResult */ + public function findPaginated(int $page, int $perPage): PaginatedResult + { + $page = max(1, $page); + $total = $this->userRepository->countAll(); + $offset = ($page - 1) * $perPage; + + return new PaginatedResult( + $this->userRepository->findPage($perPage, $offset), + $total, + $page, + $perPage, + ); + } + + public function findById(int $id): ?User + { + return $this->userRepository->findById($id); + } + + public function delete(int $id): void + { + $this->deleteUser->handle($id); + } + + public function deleteFromAdministration(int $actorUserId, int $targetUserId): User + { + return $this->adminDeleteUser->handle(new AdminDeleteUserCommand($actorUserId, $targetUserId)); + } + + public function updateRole(int $id, string $role): void + { + $this->updateUserRole->handle(new UpdateUserRoleCommand($id, $role)); + } + + public function updateRoleFromAdministration(int $actorUserId, int $targetUserId, string $role): User + { + return $this->adminUpdateUserRole->handle(new AdminUpdateUserRoleCommand($actorUserId, $targetUserId, $role)); + } + + public function create(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User + { + return $this->createUser->handle(new CreateUserCommand($username, $email, $plainPassword, $role)); + } +} diff --git a/src/Identity/Application/UserServiceInterface.php b/src/Identity/Application/UserServiceInterface.php new file mode 100644 index 0000000..3d693ed --- /dev/null +++ b/src/Identity/Application/UserServiceInterface.php @@ -0,0 +1,29 @@ + */ + public function findAll(): array; + + /** @return PaginatedResult */ + public function findPaginated(int $page, int $perPage): PaginatedResult; + + public function findById(int $id): ?User; + + public function delete(int $id): void; + + public function deleteFromAdministration(int $actorUserId, int $targetUserId): User; + + public function updateRole(int $id, string $role): void; + + public function updateRoleFromAdministration(int $actorUserId, int $targetUserId, string $role): User; + + public function create(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User; +} diff --git a/src/Identity/Domain/Entity/User.php b/src/Identity/Domain/Entity/User.php new file mode 100644 index 0000000..64668bf --- /dev/null +++ b/src/Identity/Domain/Entity/User.php @@ -0,0 +1,139 @@ +createdAt = $createdAt ?? new DateTime(); + $this->validate(); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + id: (int) ($data['id'] ?? 0), + username: (string) ($data['username'] ?? ''), + email: (string) ($data['email'] ?? ''), + passwordHash: (string) ($data['password_hash'] ?? ''), + role: (string) ($data['role'] ?? self::ROLE_USER), + createdAt: DateParser::parse($data['created_at'] ?? null), + sessionVersion: max(1, (int) ($data['session_version'] ?? 1)), + ); + } + + /** + * @return string[] + */ + public static function allRoles(): array + { + return [self::ROLE_USER, self::ROLE_EDITOR, self::ROLE_ADMIN]; + } + + /** + * @return string[] + */ + public static function assignableRoles(): array + { + return [self::ROLE_USER, self::ROLE_EDITOR]; + } + + public function getId(): int + { + return $this->id; + } + + public function getUsername(): string + { + return $this->username; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getPasswordHash(): string + { + return $this->passwordHash; + } + + public function getRole(): string + { + return $this->role; + } + + public function isAdmin(): bool + { + return $this->role === self::ROLE_ADMIN; + } + + public function isEditor(): bool + { + return $this->role === self::ROLE_EDITOR; + } + + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + public function getSessionVersion(): int + { + return $this->sessionVersion; + } + + private function validate(): void + { + if (mb_strlen($this->username) < 3) { + throw new \InvalidArgumentException("Le nom d'utilisateur doit contenir au moins 3 caractères"); + } + + if (mb_strlen($this->username) > 50) { + throw new \InvalidArgumentException("Le nom d'utilisateur ne peut pas dépasser 50 caractères"); + } + + if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException("L'email n'est pas valide"); + } + + if ($this->passwordHash === '') { + throw new \InvalidArgumentException('Le hash du mot de passe ne peut pas être vide'); + } + + if (!in_array($this->role, self::allRoles(), true)) { + throw new \InvalidArgumentException( + "Le rôle '{$this->role}' est invalide. Valeurs autorisées : " . implode(', ', self::allRoles()), + ); + } + + if ($this->sessionVersion < 1) { + throw new \InvalidArgumentException('La version de session doit être supérieure ou égale à 1'); + } + } +} diff --git a/src/Identity/Domain/Exception/CannotDeleteOwnAccountException.php b/src/Identity/Domain/Exception/CannotDeleteOwnAccountException.php new file mode 100644 index 0000000..91959b6 --- /dev/null +++ b/src/Identity/Domain/Exception/CannotDeleteOwnAccountException.php @@ -0,0 +1,13 @@ + 12]; + + public function minLength(): int + { + return self::MIN_LENGTH; + } + + /** + * @throws WeakPasswordException + */ + public function assert(string $plainPassword): void + { + if (mb_strlen($plainPassword) < self::MIN_LENGTH) { + throw new WeakPasswordException(self::MIN_LENGTH); + } + } + + public function hash(string $plainPassword): string + { + return password_hash($plainPassword, self::HASH_ALGO, self::HASH_OPTIONS); + } + + public function needsRehash(string $passwordHash): bool + { + return password_needs_rehash($passwordHash, self::HASH_ALGO, self::HASH_OPTIONS); + } +} diff --git a/src/Identity/Domain/Policy/PasswordResetTokenPolicy.php b/src/Identity/Domain/Policy/PasswordResetTokenPolicy.php new file mode 100644 index 0000000..88bd358 --- /dev/null +++ b/src/Identity/Domain/Policy/PasswordResetTokenPolicy.php @@ -0,0 +1,16 @@ + */ + public static function all(): array + { + return [ + self::PROFILE_MANAGE, + self::CONTENT_MANAGE, + self::CONTENT_PUBLISH, + self::MEDIA_MANAGE, + self::TAXONOMY_MANAGE, + self::SETTINGS_MANAGE, + self::USERS_MANAGE, + self::AUDIT_LOG_VIEW, + self::NOTIFICATIONS_SEND, + ]; + } +} diff --git a/src/Identity/Domain/Policy/RolePermissionMatrix.php b/src/Identity/Domain/Policy/RolePermissionMatrix.php new file mode 100644 index 0000000..7176e52 --- /dev/null +++ b/src/Identity/Domain/Policy/RolePermissionMatrix.php @@ -0,0 +1,37 @@ + permissions pour les projets consommateurs du core. + */ +final class RolePermissionMatrix +{ + /** @return list */ + public function permissionsForRole(string $role): array + { + return match ($role) { + User::ROLE_ADMIN => ['*'], + User::ROLE_EDITOR => [ + Permission::PROFILE_MANAGE, + Permission::CONTENT_MANAGE, + Permission::CONTENT_PUBLISH, + Permission::MEDIA_MANAGE, + Permission::TAXONOMY_MANAGE, + ], + User::ROLE_USER => [Permission::PROFILE_MANAGE], + default => [], + }; + } + + public function allows(string $role, string $permission): bool + { + $permissions = $this->permissionsForRole($role); + + return in_array('*', $permissions, true) || in_array($permission, $permissions, true); + } +} diff --git a/src/Identity/Domain/Policy/RolePolicy.php b/src/Identity/Domain/Policy/RolePolicy.php new file mode 100644 index 0000000..93ac99e --- /dev/null +++ b/src/Identity/Domain/Policy/RolePolicy.php @@ -0,0 +1,43 @@ +allRoles(), true)) { + throw new InvalidRoleException($role); + } + + if (!in_array($role, $this->assignableRoles(), true)) { + throw new RoleAssignmentNotAllowedException($role); + } + } +} diff --git a/src/Identity/Domain/Repository/LoginAttemptRepositoryInterface.php b/src/Identity/Domain/Repository/LoginAttemptRepositoryInterface.php new file mode 100644 index 0000000..03ccb62 --- /dev/null +++ b/src/Identity/Domain/Repository/LoginAttemptRepositoryInterface.php @@ -0,0 +1,50 @@ + + */ + public function findAll(): array; + + /** + * Retourne une page d'utilisateurs. + * + * @return list + */ + public function findPage(int $limit, int $offset): array; + + /** + * Retourne le nombre total d'utilisateurs. + */ + public function countAll(): int; + + /** + * Retourne un utilisateur par identifiant, ou `null` s'il n'existe pas. + */ + public function findById(int $id): ?User; + + /** + * Retourne un utilisateur par nom d'utilisateur, ou `null` s'il n'existe pas. + */ + public function findByUsername(string $username): ?User; + + /** + * Retourne un utilisateur par adresse e-mail, ou `null` s'il n'existe pas. + */ + public function findByEmail(string $email): ?User; + + /** + * Persiste un utilisateur et retourne son identifiant. + */ + public function create(User $user): int; + + /** + * Met à jour le hash du mot de passe d'un utilisateur. + * + * Si $invalidateSessions vaut true, la version de session est incrémentée + * afin d'invalider les sessions existantes. + */ + public function updatePassword(int $id, string $passwordHash, bool $invalidateSessions = true): void; + + /** + * Met à jour le rôle d'un utilisateur. + */ + public function updateRole(int $id, string $role): void; + + /** + * Supprime un utilisateur. + */ + public function delete(int $id): void; +} diff --git a/src/Identity/IdentityModule.php b/src/Identity/IdentityModule.php new file mode 100644 index 0000000..375549f --- /dev/null +++ b/src/Identity/IdentityModule.php @@ -0,0 +1,62 @@ + $app */ + public function registerRoutes(App $app): void + { + AuthRoutes::register($app); + UserRoutes::register($app); + } + + public function templateNamespaces(): array + { + return [ + 'Identity' => __DIR__ . '/UI/Templates', + ]; + } + + public function provision(\PDO $db): void + { + AdminUserProvisioner::seed($db); + } + + public function twigExtensions(): array + { + return []; + } + + public function migrationDirectories(): array + { + return [__DIR__ . '/Migrations']; + } + + public function requiredTables(): array + { + return ['users', 'password_resets', 'rate_limits']; + } +} diff --git a/src/Identity/Infrastructure/AdminProvisioningEnvironmentValidator.php b/src/Identity/Infrastructure/AdminProvisioningEnvironmentValidator.php new file mode 100644 index 0000000..bb57458 --- /dev/null +++ b/src/Identity/Infrastructure/AdminProvisioningEnvironmentValidator.php @@ -0,0 +1,35 @@ +prepare('SELECT id FROM users WHERE username = :username'); + $stmt->execute([':username' => $username]); + + if ($stmt->fetchColumn() !== false) { + return; + } + + $passwordPolicy->assert($password); + + $stmt = $db->prepare(' + INSERT INTO users (username, email, password_hash, role, session_version, created_at) + VALUES (:username, :email, :password_hash, :role, :session_version, :created_at) + '); + + $stmt->execute([ + ':username' => $username, + ':email' => $email, + ':password_hash' => $passwordPolicy->hash($password), + ':role' => 'admin', + ':session_version' => 1, + ':created_at' => date('Y-m-d H:i:s'), + ]); + } +} diff --git a/src/Identity/Infrastructure/PdoLoginAttemptRepository.php b/src/Identity/Infrastructure/PdoLoginAttemptRepository.php new file mode 100644 index 0000000..5898ddd --- /dev/null +++ b/src/Identity/Infrastructure/PdoLoginAttemptRepository.php @@ -0,0 +1,96 @@ +findByScopeAndKey(self::LOGIN_SCOPE, $ip); + } + + public function recordFailure(string $ip, int $maxAttempts, int $lockMinutes): void + { + $this->recordFailureForScope(self::LOGIN_SCOPE, $ip, $maxAttempts, $lockMinutes); + } + + public function resetForIp(string $ip): void + { + $this->resetForScope(self::LOGIN_SCOPE, $ip); + } + + public function findByScopeAndKey(string $scope, string $key): ?array + { + $stmt = $this->db->prepare('SELECT * FROM rate_limits WHERE scope = :scope AND rate_key = :rate_key'); + $stmt->execute([ + ':scope' => $scope, + ':rate_key' => $key, + ]); + + /** @var array{scope: string, rate_key: string, attempts: int, locked_until: string|null, updated_at: string}|false $row */ + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ?: null; + } + + /** + * Incrémente le compteur d'échecs pour un scope et une clé, puis positionne le verrouillage si le seuil est atteint. + */ + public function recordFailureForScope(string $scope, string $key, int $maxAttempts, int $lockMinutes): void + { + $now = (new \DateTime())->format('Y-m-d H:i:s'); + $lockUntil = (new \DateTime())->modify("+{$lockMinutes} minutes")->format('Y-m-d H:i:s'); + + $stmt = $this->db->prepare( + 'INSERT INTO rate_limits (scope, rate_key, attempts, locked_until, updated_at) + VALUES (:scope1, :rate_key1, 1, CASE WHEN 1 >= :max1 THEN :lock1 ELSE NULL END, :now1) + ON CONFLICT(scope, rate_key) DO UPDATE SET + attempts = rate_limits.attempts + 1, + locked_until = CASE WHEN rate_limits.attempts + 1 >= :max2 + THEN :lock2 + ELSE NULL END, + updated_at = :now2', + ); + + $stmt->execute([ + ':scope1' => $scope, + ':rate_key1' => $key, + ':max1' => $maxAttempts, + ':lock1' => $lockUntil, + ':now1' => $now, + ':max2' => $maxAttempts, + ':lock2' => $lockUntil, + ':now2' => $now, + ]); + } + + public function resetForScope(string $scope, string $key): void + { + $stmt = $this->db->prepare('DELETE FROM rate_limits WHERE scope = :scope AND rate_key = :rate_key'); + $stmt->execute([ + ':scope' => $scope, + ':rate_key' => $key, + ]); + } + + public function deleteExpired(): void + { + $now = (new \DateTime())->format('Y-m-d H:i:s'); + $stmt = $this->db->prepare( + 'DELETE FROM rate_limits WHERE locked_until IS NOT NULL AND locked_until < :now', + ); + $stmt->execute([':now' => $now]); + } +} diff --git a/src/Identity/Infrastructure/PdoPasswordResetRepository.php b/src/Identity/Infrastructure/PdoPasswordResetRepository.php new file mode 100644 index 0000000..9a4c8c1 --- /dev/null +++ b/src/Identity/Infrastructure/PdoPasswordResetRepository.php @@ -0,0 +1,117 @@ +db->prepare(' + INSERT INTO password_resets (user_id, token_hash, expires_at, created_at) + VALUES (:user_id, :token_hash, :expires_at, :created_at) + '); + + $stmt->execute([ + ':user_id' => $userId, + ':token_hash' => $tokenHash, + ':expires_at' => $expiresAt, + ':created_at' => date('Y-m-d H:i:s'), + ]); + } + + public function findActiveByHash(string $tokenHash): ?array + { + $stmt = $this->db->prepare( + 'SELECT * FROM password_resets WHERE token_hash = :token_hash AND used_at IS NULL', + ); + $stmt->execute([':token_hash' => $tokenHash]); + + /** @var array|false $row */ + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return is_array($row) ? $this->hydrateActiveRow($row) : null; + } + + public function invalidateByUserId(int $userId): void + { + $stmt = $this->db->prepare( + 'UPDATE password_resets SET used_at = :used_at WHERE user_id = :user_id AND used_at IS NULL', + ); + $stmt->execute([':used_at' => date('Y-m-d H:i:s'), ':user_id' => $userId]); + } + + public function consumeActiveToken(string $tokenHash, string $usedAt): ?array + { + $stmt = $this->db->prepare( + 'UPDATE password_resets + SET used_at = :used_at + WHERE token_hash = :token_hash + AND used_at IS NULL + AND expires_at >= :now + RETURNING *', + ); + + $stmt->execute([ + ':used_at' => $usedAt, + ':token_hash' => $tokenHash, + ':now' => $usedAt, + ]); + + /** @var array|false $row */ + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return is_array($row) ? $this->hydrateConsumedRow($row) : null; + } + + /** + * Normalise une ligne SQL complète vers la shape promise par findActiveByHash(). + * + * @param array $row + * @return array{id:int, user_id:int, token_hash:string, expires_at:string, used_at:string|null, created_at:string} + */ + private function hydrateActiveRow(array $row): array + { + return [ + 'id' => (int) $row['id'], + 'user_id' => (int) $row['user_id'], + 'token_hash' => (string) $row['token_hash'], + 'expires_at' => (string) $row['expires_at'], + 'used_at' => array_key_exists('used_at', $row) && $row['used_at'] !== null ? (string) $row['used_at'] : null, + 'created_at' => (string) $row['created_at'], + ]; + } + + /** + * Normalise la ligne renvoyée par RETURNING après consommation d'un token. + * + * @param array $row + * @return array{id:int, user_id:int, token_hash:string, used_at:string|null, expires_at?:string, created_at?:string} + */ + private function hydrateConsumedRow(array $row): array + { + /** @var array{id:int, user_id:int, token_hash:string, used_at:string|null, expires_at?:string, created_at?:string} $normalized */ + $normalized = [ + 'id' => (int) $row['id'], + 'user_id' => (int) $row['user_id'], + 'token_hash' => (string) $row['token_hash'], + 'used_at' => array_key_exists('used_at', $row) && $row['used_at'] !== null ? (string) $row['used_at'] : null, + ]; + + if (array_key_exists('expires_at', $row)) { + $normalized['expires_at'] = (string) $row['expires_at']; + } + + if (array_key_exists('created_at', $row)) { + $normalized['created_at'] = (string) $row['created_at']; + } + + return $normalized; + } +} diff --git a/src/Identity/Infrastructure/PdoUserRepository.php b/src/Identity/Infrastructure/PdoUserRepository.php new file mode 100644 index 0000000..307a4a9 --- /dev/null +++ b/src/Identity/Infrastructure/PdoUserRepository.php @@ -0,0 +1,114 @@ +db->query('SELECT * FROM users ORDER BY created_at ASC'); + if ($stmt === false) { + throw new \RuntimeException('La requête SELECT sur users a échoué.'); + } + + return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @return User[] */ + public function findPage(int $limit, int $offset): array + { + $stmt = $this->db->prepare('SELECT * FROM users ORDER BY created_at ASC LIMIT :limit OFFSET :offset'); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function countAll(): int + { + $stmt = $this->db->query('SELECT COUNT(*) FROM users'); + if ($stmt === false) { + throw new \RuntimeException('La requête COUNT sur users a échoué.'); + } + + return (int) ($stmt->fetchColumn() ?: 0); + } + + public function findById(int $id): ?User + { + $stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id'); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? User::fromArray($row) : null; + } + + public function findByUsername(string $username): ?User + { + $stmt = $this->db->prepare('SELECT * FROM users WHERE username = :username'); + $stmt->execute([':username' => $username]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? User::fromArray($row) : null; + } + + public function findByEmail(string $email): ?User + { + $stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email'); + $stmt->execute([':email' => $email]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? User::fromArray($row) : null; + } + + public function create(User $user): int + { + $stmt = $this->db->prepare( + 'INSERT INTO users (username, email, password_hash, role, session_version, created_at) + VALUES (:username, :email, :password_hash, :role, :session_version, :created_at)', + ); + + $stmt->execute([ + ':username' => $user->getUsername(), + ':email' => $user->getEmail(), + ':password_hash' => $user->getPasswordHash(), + ':role' => $user->getRole(), + ':session_version' => $user->getSessionVersion(), + ':created_at' => date('Y-m-d H:i:s'), + ]); + + return (int) $this->db->lastInsertId(); + } + + public function updatePassword(int $id, string $newHash, bool $invalidateSessions = true): void + { + $sql = $invalidateSessions + ? 'UPDATE users SET password_hash = :password_hash, session_version = session_version + 1 WHERE id = :id' + : 'UPDATE users SET password_hash = :password_hash WHERE id = :id'; + + $stmt = $this->db->prepare($sql); + $stmt->execute([':password_hash' => $newHash, ':id' => $id]); + } + + public function updateRole(int $id, string $role): void + { + $stmt = $this->db->prepare('UPDATE users SET role = :role WHERE id = :id'); + $stmt->execute([':role' => $role, ':id' => $id]); + } + + public function delete(int $id): void + { + $stmt = $this->db->prepare('DELETE FROM users WHERE id = :id'); + $stmt->execute([':id' => $id]); + } +} diff --git a/src/Identity/Infrastructure/SessionAuthSession.php b/src/Identity/Infrastructure/SessionAuthSession.php new file mode 100644 index 0000000..b3c7da9 --- /dev/null +++ b/src/Identity/Infrastructure/SessionAuthSession.php @@ -0,0 +1,37 @@ +sessionManager->isAuthenticated(); + } + + public function startForUser(User $user): void + { + $this->sessionManager->setUser( + $user->getId(), + $user->getUsername(), + $user->getRole(), + $user->getSessionVersion(), + ); + } + + public function clear(): void + { + $this->sessionManager->destroy(); + } +} diff --git a/src/Identity/Infrastructure/dependencies.php b/src/Identity/Infrastructure/dependencies.php new file mode 100644 index 0000000..cdd65ad --- /dev/null +++ b/src/Identity/Infrastructure/dependencies.php @@ -0,0 +1,113 @@ + autowire(SessionAuthSession::class), + AuthServiceInterface::class => autowire(AuthApplicationService::class), + AuthorizationServiceInterface::class => autowire(AuthorizationApplicationService::class), + LoginRateLimitPolicy::class => autowire(), + LoginAttemptRepositoryInterface::class => autowire(PdoLoginAttemptRepository::class), + PasswordResetRepositoryInterface::class => autowire(PdoPasswordResetRepository::class), + PasswordResetTokenPolicy::class => autowire(), + PasswordPolicy::class => autowire(), + PasswordResetServiceInterface::class => autowire(PasswordResetApplicationService::class), + UserServiceInterface::class => autowire(UserApplicationService::class), + UserRepositoryInterface::class => autowire(PdoUserRepository::class), + RolePolicy::class => autowire(), + RolePermissionMatrix::class => autowire(), + CreateUser::class => autowire(), + UpdateUserRole::class => autowire(), + DeleteUser::class => autowire(), + AuthMiddleware::class => factory(function ( + SessionManagerInterface $sessionManager, + UserRepositoryInterface $userRepository, + ): AuthMiddleware { + return new AuthMiddleware($sessionManager, $userRepository); + }), + AuthController::class => factory(function ( + Twig $twig, + AuthServiceInterface $authService, + FlashServiceInterface $flash, + ClientIpResolver $clientIpResolver, + LoggerInterface $logger, + ): AuthController { + return new AuthController( + $twig, + $authService, + $flash, + $clientIpResolver, + $logger, + ); + }), + AccountController::class => factory(function ( + Twig $twig, + AuthServiceInterface $authService, + FlashServiceInterface $flash, + SessionManagerInterface $sessionManager, + LoggerInterface $logger, + ): AccountController { + return new AccountController( + $twig, + $authService, + $flash, + $sessionManager, + $logger, + ); + }), + PasswordResetController::class => factory(function ( + Twig $twig, + PasswordResetServiceInterface $passwordResetService, + AuthServiceInterface $authService, + FlashServiceInterface $flash, + ClientIpResolver $clientIpResolver, + LoggerInterface $logger, + ): PasswordResetController { + return new PasswordResetController( + $twig, + $passwordResetService, + $authService, + $flash, + $clientIpResolver, + rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'), + $logger, + ); + }), +]; diff --git a/src/Identity/Migrations/100_identity_schema.php b/src/Identity/Migrations/100_identity_schema.php new file mode 100644 index 0000000..5bc9509 --- /dev/null +++ b/src/Identity/Migrations/100_identity_schema.php @@ -0,0 +1,42 @@ + " + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + session_version INTEGER NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS password_resets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + used_at DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS rate_limits ( + scope TEXT NOT NULL, + rate_key TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + locked_until TEXT DEFAULT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (scope, rate_key) + ); + CREATE INDEX IF NOT EXISTS idx_rate_limits_locked_until ON rate_limits(locked_until); + ", + 'down' => " + DROP INDEX IF EXISTS idx_rate_limits_locked_until; + DROP TABLE IF EXISTS rate_limits; + DROP TABLE IF EXISTS password_resets; + DROP TABLE IF EXISTS users; + ", +]; diff --git a/src/Identity/UI/Http/AccountController.php b/src/Identity/UI/Http/AccountController.php new file mode 100644 index 0000000..b65491c --- /dev/null +++ b/src/Identity/UI/Http/AccountController.php @@ -0,0 +1,98 @@ +getHeaderLine('Referer'); + $path = parse_url($referer, PHP_URL_PATH); + $path = is_string($path) ? $path : ''; + $backUrl = (str_starts_with($path, '/') && $path !== '/account/password') + ? $path + : AdminHomePath::resolve(); + + return $this->view->render($res, '@Identity/account/password-change.twig', [ + 'error' => $this->flash->get('password_error'), + 'success' => $this->flash->get('password_success'), + 'back_url' => $backUrl, + ]); + } + + /** + * Valide puis applique le changement de mot de passe pour l'utilisateur connecté. + */ + public function changePassword(Request $req, Response $res): Response + { + $changePasswordRequest = ChangePasswordRequest::fromRequest($req); + $userId = $this->sessionManager->getUserId() ?? 0; + + try { + $changePasswordRequest->ensureConfirmed(); + $this->authService->changePassword( + $userId, + $changePasswordRequest->currentPassword, + $changePasswordRequest->newPassword, + ); + $this->flash->set('password_success', 'Mot de passe modifié avec succès'); + } catch (WeakPasswordException $e) { + $this->flash->set('password_error', $e->getMessage()); + } catch (\InvalidArgumentException $e) { + $message = $e->getMessage(); + if ($message === 'Mot de passe actuel incorrect') { + $message = 'Le mot de passe actuel est incorrect'; + } + $this->flash->set('password_error', $message); + } catch (\Throwable $e) { + $incidentId = $this->logUnexpectedError($req, $e, [ + 'user_id' => $userId, + ]); + $this->flash->set('password_error', "Une erreur inattendue s\'est produite (réf. {$incidentId})"); + } + + return $res->withHeader('Location', '/account/password')->withStatus(302); + } + + /** + * @param array $context + */ + private function logUnexpectedError(Request $req, \Throwable $e, array $context = []): string + { + $incidentId = bin2hex(random_bytes(8)); + + $this->logger?->error('Account password change failed', $context + [ + 'incident_id' => $incidentId, + 'route' => (string) $req->getUri()->getPath(), + 'method' => $req->getMethod(), + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + 'exception' => $e, + ]); + + return $incidentId; + } +} diff --git a/src/Identity/UI/Http/AdminHomePath.php b/src/Identity/UI/Http/AdminHomePath.php new file mode 100644 index 0000000..c32a89a --- /dev/null +++ b/src/Identity/UI/Http/AdminHomePath.php @@ -0,0 +1,25 @@ +authService->isLoggedIn()) { + return $res->withHeader('Location', AdminHomePath::resolve())->withStatus(302); + } + + return $this->view->render($res, '@Identity/login.twig', [ + 'error' => $this->flash->get('login_error'), + 'success' => $this->flash->get('login_success'), + ]); + } + + /** + * Traite la soumission du formulaire de connexion et applique la limitation de débit. + */ + public function login(Request $req, Response $res): Response + { + $ip = $this->clientIpResolver->resolve($req); + + try { + $remainingMinutes = $this->authService->checkRateLimit($ip); + + if ($remainingMinutes > 0) { + $this->flash->set( + 'login_error', + "Trop de tentatives. Réessayez dans {$remainingMinutes} minute" + . ($remainingMinutes > 1 ? 's' : ''), + ); + + return $res->withHeader('Location', '/auth/login')->withStatus(302); + } + + $loginRequest = LoginRequest::fromRequest($req); + $user = $this->authService->authenticate($loginRequest->username, $loginRequest->password); + + if ($user === null) { + $this->authService->recordFailure($ip); + $this->flash->set('login_error', 'Identifiants invalides'); + + return $res->withHeader('Location', '/auth/login')->withStatus(302); + } + + $this->authService->resetRateLimit($ip); + $this->authService->login($user); + } catch (\Throwable $e) { + $incidentId = $this->logUnexpectedError($req, $e, [ + 'ip' => $ip, + ]); + $this->flash->set('login_error', "Une erreur inattendue s'est produite (réf. {$incidentId})"); + + return $res->withHeader('Location', '/auth/login')->withStatus(302); + } + + return $res->withHeader('Location', AdminHomePath::resolve())->withStatus(302); + } + + public function logout(Request $req, Response $res): Response + { + try { + $this->authService->logout(); + } catch (\Throwable $e) { + $this->logUnexpectedError($req, $e); + } + + return $res->withHeader('Location', '/')->withStatus(302); + } + + /** + * @param array $context + */ + private function logUnexpectedError(Request $req, \Throwable $e, array $context = []): string + { + $incidentId = bin2hex(random_bytes(8)); + + $this->logger?->error('Authentication flow failed', $context + [ + 'incident_id' => $incidentId, + 'route' => (string) $req->getUri()->getPath(), + 'method' => $req->getMethod(), + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + 'exception' => $e, + ]); + + return $incidentId; + } +} diff --git a/src/Identity/UI/Http/AuthRoutes.php b/src/Identity/UI/Http/AuthRoutes.php new file mode 100644 index 0000000..f1d44ec --- /dev/null +++ b/src/Identity/UI/Http/AuthRoutes.php @@ -0,0 +1,33 @@ + $app */ + public static function register(App $app): void + { + $app->get('/auth/login', [AuthController::class, 'showLogin']); + $app->post('/auth/login', [AuthController::class, 'login']); + $app->post('/auth/logout', [AuthController::class, 'logout']); + + $app->get('/password/forgot', [PasswordResetController::class, 'showForgot']); + $app->post('/password/forgot', [PasswordResetController::class, 'forgot']); + $app->get('/password/reset', [PasswordResetController::class, 'showReset']); + $app->post('/password/reset', [PasswordResetController::class, 'reset']); + + $app->group('/account', function ($group) { + $group->get('/password', [AccountController::class, 'showChangePassword']); + $group->post('/password', [AccountController::class, 'changePassword']); + })->add(AuthMiddleware::class); + } +} diff --git a/src/Identity/UI/Http/Middleware/AdminMiddleware.php b/src/Identity/UI/Http/Middleware/AdminMiddleware.php new file mode 100644 index 0000000..40a9cdc --- /dev/null +++ b/src/Identity/UI/Http/Middleware/AdminMiddleware.php @@ -0,0 +1,50 @@ +add($adminMiddleware)->add($authMiddleware) + */ +final class AdminMiddleware implements MiddlewareInterface +{ + /** + * @param SessionManagerInterface $sessionManager Gestionnaire de session (lecture du rôle admin) + */ + public function __construct(private readonly SessionManagerInterface $sessionManager) {} + + /** + * Vérifie le rôle admin avant de transmettre la requête au gestionnaire suivant. + * + * @param Request $request La requête HTTP entrante + * @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares + * + * @return Response Une redirection 302 vers la page d'accueil du back-office, ou la réponse normale + */ + public function process(Request $request, RequestHandler $handler): Response + { + if (!$this->sessionManager->isAdmin()) { + return (new SlimResponse()) + ->withHeader('Location', AdminHomePath::resolve()) + ->withStatus(302); + } + + return $handler->handle($request); + } +} diff --git a/src/Identity/UI/Http/Middleware/AuthMiddleware.php b/src/Identity/UI/Http/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..b49b14b --- /dev/null +++ b/src/Identity/UI/Http/Middleware/AuthMiddleware.php @@ -0,0 +1,80 @@ +sessionManager->isAuthenticated()) { + return $this->redirectToLogin(); + } + + if (!$this->isSessionStillValid()) { + $this->sessionManager->destroy(); + + return $this->redirectToLogin(); + } + + return $handler->handle($request); + } + + private function isSessionStillValid(): bool + { + if ($this->userRepository === null) { + return true; + } + + $userId = $this->sessionManager->getUserId(); + $sessionVersion = $this->sessionManager->getSessionVersion(); + + if ($userId === null || $sessionVersion === null) { + return false; + } + + $user = $this->userRepository->findById($userId); + + return $user !== null && $user->getSessionVersion() === $sessionVersion; + } + + private function redirectToLogin(): Response + { + return (new SlimResponse()) + ->withHeader('Location', '/auth/login') + ->withStatus(302); + } +} diff --git a/src/Identity/UI/Http/Middleware/EditorMiddleware.php b/src/Identity/UI/Http/Middleware/EditorMiddleware.php new file mode 100644 index 0000000..e84c8f5 --- /dev/null +++ b/src/Identity/UI/Http/Middleware/EditorMiddleware.php @@ -0,0 +1,50 @@ +add($editorMiddleware)->add($authMiddleware) + */ +final class EditorMiddleware implements MiddlewareInterface +{ + /** + * @param SessionManagerInterface $sessionManager Gestionnaire de session (lecture du rôle) + */ + public function __construct(private readonly SessionManagerInterface $sessionManager) {} + + /** + * Vérifie le rôle (editor ou admin) avant de transmettre la requête au gestionnaire suivant. + * + * @param Request $request La requête HTTP entrante + * @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares + * + * @return Response Une redirection 302 vers la page d'accueil du back-office, ou la réponse normale + */ + public function process(Request $request, RequestHandler $handler): Response + { + if (!$this->sessionManager->isAdmin() && !$this->sessionManager->isEditor()) { + return (new SlimResponse()) + ->withHeader('Location', AdminHomePath::resolve()) + ->withStatus(302); + } + + return $handler->handle($request); + } +} diff --git a/src/Identity/UI/Http/PasswordResetController.php b/src/Identity/UI/Http/PasswordResetController.php new file mode 100644 index 0000000..142e480 --- /dev/null +++ b/src/Identity/UI/Http/PasswordResetController.php @@ -0,0 +1,174 @@ +view->render($res, '@Identity/password-forgot.twig', [ + 'error' => $this->flash->get('reset_error'), + 'success' => $this->flash->get('reset_success'), + ]); + } + + public function forgot(Request $req, Response $res): Response + { + $ip = $this->clientIpResolver->resolve($req); + $remainingMinutes = $this->authService->checkPasswordResetRateLimit($ip); + + if ($remainingMinutes > 0) { + $this->flash->set( + 'reset_error', + "Trop de demandes. Veuillez réessayer dans {$remainingMinutes} minute" + . ($remainingMinutes > 1 ? 's' : ''), + ); + + return $res->withHeader('Location', '/password/forgot')->withStatus(302); + } + + $forgotPasswordRequest = ForgotPasswordRequest::fromRequest($req); + $this->authService->recordPasswordResetAttempt($ip); + + try { + $this->passwordResetService->requestReset($forgotPasswordRequest->email, $this->baseUrl); + } catch (\RuntimeException $e) { + $incidentId = $this->logUnexpectedError( + $req, + $e, + 'Password reset request failed', + [ + 'ip' => $ip, + 'email_hash' => $forgotPasswordRequest->email === '' ? null : hash('sha256', mb_strtolower(trim($forgotPasswordRequest->email))), + ], + ); + $this->flash->set('reset_error', "Une erreur est survenue. Veuillez réessayer (réf. {$incidentId})."); + + return $res->withHeader('Location', '/password/forgot')->withStatus(302); + } + + $this->flash->set( + 'reset_success', + 'Si cette adresse est associée à un compte, un email de réinitialisation a été envoyé', + ); + + return $res->withHeader('Location', '/password/forgot')->withStatus(302); + } + + public function showReset(Request $req, Response $res): Response + { + $token = trim((string) ($req->getQueryParams()['token'] ?? '')); + + if ($token === '') { + $this->flash->set('reset_error', 'Lien de réinitialisation manquant'); + + return $res->withHeader('Location', '/password/forgot')->withStatus(302); + } + + $user = $this->passwordResetService->validateToken($token); + + if ($user === null) { + $this->flash->set('reset_error', 'Ce lien est invalide ou a expiré. Veuillez faire une nouvelle demande.'); + + return $res->withHeader('Location', '/password/forgot')->withStatus(302); + } + + return $this->view->render($res, '@Identity/password-reset.twig', [ + 'token' => $token, + 'error' => $this->flash->get('reset_error'), + ]); + } + + /** + * Tente de consommer un jeton de réinitialisation et redirige avec le message adapté. + */ + public function reset(Request $req, Response $res): Response + { + $resetPasswordRequest = ResetPasswordRequest::fromRequest($req); + + try { + $resetPasswordRequest->ensureTokenPresent(); + $resetPasswordRequest->ensureConfirmed(); + $this->passwordResetService->resetPassword($resetPasswordRequest->token, $resetPasswordRequest->newPassword); + } catch (WeakPasswordException $e) { + $this->flash->set('reset_error', $e->getMessage()); + + return $res->withHeader('Location', '/password/reset?token=' . urlencode($resetPasswordRequest->token))->withStatus(302); + } catch (InvalidResetTokenException) { + $this->flash->set('reset_error', 'Ce lien de réinitialisation est invalide ou a expiré'); + + return $res->withHeader('Location', '/password/reset?token=' . urlencode($resetPasswordRequest->token))->withStatus(302); + } catch (\InvalidArgumentException $e) { + $this->flash->set('reset_error', $e->getMessage()); + + $location = $resetPasswordRequest->token === '' + ? '/password/forgot' + : '/password/reset?token=' . urlencode($resetPasswordRequest->token); + + return $res->withHeader('Location', $location)->withStatus(302); + } catch (\Throwable $e) { + $incidentId = $this->logUnexpectedError( + $req, + $e, + 'Password reset failed', + [ + 'token_present' => $resetPasswordRequest->token !== '', + ], + ); + $this->flash->set('reset_error', "Une erreur inattendue s\'est produite (réf. {$incidentId})"); + + return $res->withHeader('Location', '/password/reset?token=' . urlencode($resetPasswordRequest->token))->withStatus(302); + } + + $this->flash->set('login_success', 'Mot de passe réinitialisé avec succès. Vous pouvez vous connecter'); + + return $res->withHeader('Location', '/auth/login')->withStatus(302); + } + + /** + * @param array $context + */ + private function logUnexpectedError(Request $req, \Throwable $e, string $message, array $context = []): string + { + $incidentId = bin2hex(random_bytes(8)); + + $this->logger?->error($message, $context + [ + 'incident_id' => $incidentId, + 'route' => (string) $req->getUri()->getPath(), + 'method' => $req->getMethod(), + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + 'exception' => $e, + ]); + + return $incidentId; + } +} diff --git a/src/Identity/UI/Http/Request/ChangePasswordRequest.php b/src/Identity/UI/Http/Request/ChangePasswordRequest.php new file mode 100644 index 0000000..accc810 --- /dev/null +++ b/src/Identity/UI/Http/Request/ChangePasswordRequest.php @@ -0,0 +1,43 @@ + $data */ + $data = (array) $request->getParsedBody(); + + return new self( + currentPassword: (string) ($data['current_password'] ?? ''), + newPassword: (string) ($data['new_password'] ?? ''), + newPasswordConfirm: (string) ($data['new_password_confirm'] ?? ''), + ); + } + + /** + * Vérifie que la confirmation correspond bien au nouveau mot de passe demandé. + * + * @throws \InvalidArgumentException + */ + public function ensureConfirmed(): void + { + if ($this->newPassword !== $this->newPasswordConfirm) { + throw new \InvalidArgumentException('Les mots de passe ne correspondent pas'); + } + } +} diff --git a/src/Identity/UI/Http/Request/CreateUserRequest.php b/src/Identity/UI/Http/Request/CreateUserRequest.php new file mode 100644 index 0000000..cd02ef9 --- /dev/null +++ b/src/Identity/UI/Http/Request/CreateUserRequest.php @@ -0,0 +1,44 @@ + $data */ + $data = (array) $request->getParsedBody(); + $rawRole = trim((string) ($data['role'] ?? '')); + + return new self( + username: trim((string) ($data['username'] ?? '')), + email: trim((string) ($data['email'] ?? '')), + password: (string) ($data['password'] ?? ''), + passwordConfirm: (string) ($data['password_confirm'] ?? ''), + role: in_array($rawRole, $assignableRoles, true) ? $rawRole : 'user', + ); + } + + public function ensureConfirmed(): void + { + if ($this->password !== $this->passwordConfirm) { + throw new \InvalidArgumentException('Les mots de passe ne correspondent pas'); + } + } +} diff --git a/src/Identity/UI/Http/Request/ForgotPasswordRequest.php b/src/Identity/UI/Http/Request/ForgotPasswordRequest.php new file mode 100644 index 0000000..80ae6e3 --- /dev/null +++ b/src/Identity/UI/Http/Request/ForgotPasswordRequest.php @@ -0,0 +1,23 @@ + $data */ + $data = (array) $request->getParsedBody(); + + return new self(trim((string) ($data['email'] ?? ''))); + } +} diff --git a/src/Identity/UI/Http/Request/LoginRequest.php b/src/Identity/UI/Http/Request/LoginRequest.php new file mode 100644 index 0000000..26c8410 --- /dev/null +++ b/src/Identity/UI/Http/Request/LoginRequest.php @@ -0,0 +1,29 @@ + $data */ + $data = (array) $request->getParsedBody(); + + return new self( + username: trim((string) ($data['username'] ?? '')), + password: (string) ($data['password'] ?? ''), + ); + } +} diff --git a/src/Identity/UI/Http/Request/ResetPasswordRequest.php b/src/Identity/UI/Http/Request/ResetPasswordRequest.php new file mode 100644 index 0000000..fb2334f --- /dev/null +++ b/src/Identity/UI/Http/Request/ResetPasswordRequest.php @@ -0,0 +1,55 @@ + $data */ + $data = (array) $request->getParsedBody(); + + return new self( + token: trim((string) ($data['token'] ?? '')), + newPassword: (string) ($data['new_password'] ?? ''), + newPasswordConfirm: (string) ($data['new_password_confirm'] ?? ''), + ); + } + + /** + * Vérifie qu'un jeton de réinitialisation est bien présent dans la requête. + * + * @throws \InvalidArgumentException + */ + public function ensureTokenPresent(): void + { + if ($this->token === '') { + throw new \InvalidArgumentException('Lien de réinitialisation manquant'); + } + } + + /** + * Vérifie que le mot de passe saisi et sa confirmation correspondent. + * + * @throws \InvalidArgumentException + */ + public function ensureConfirmed(): void + { + if ($this->newPassword !== $this->newPasswordConfirm) { + throw new \InvalidArgumentException('Les mots de passe ne correspondent pas'); + } + } +} diff --git a/src/Identity/UI/Http/Request/UpdateUserRoleRequest.php b/src/Identity/UI/Http/Request/UpdateUserRoleRequest.php new file mode 100644 index 0000000..5268b6d --- /dev/null +++ b/src/Identity/UI/Http/Request/UpdateUserRoleRequest.php @@ -0,0 +1,36 @@ + $data */ + $data = (array) $request->getParsedBody(); + $rawRole = trim((string) ($data['role'] ?? '')); + + return new self( + role: in_array($rawRole, $assignableRoles, true) ? $rawRole : null, + ); + } + + public function requireRole(): string + { + if ($this->role === null) { + throw new \InvalidArgumentException('Rôle invalide'); + } + + return $this->role; + } +} diff --git a/src/Identity/UI/Http/UserController.php b/src/Identity/UI/Http/UserController.php new file mode 100644 index 0000000..0cc8771 --- /dev/null +++ b/src/Identity/UI/Http/UserController.php @@ -0,0 +1,202 @@ +rolePolicy = $rolePolicy; + } + + public function index(Request $req, Response $res): Response + { + $page = PaginationPresenter::resolvePage($req->getQueryParams()); + $paginated = $this->userService->findPaginated($page, self::PER_PAGE); + + return $this->view->render($res, '@Identity/admin/index.twig', [ + 'users' => $paginated->getItems(), + 'pagination' => PaginationPresenter::fromRequest($req, $paginated), + 'currentUserId' => $this->sessionManager->getUserId(), + 'assignableRoles' => $this->rolePolicy->assignableRoles(), + 'error' => $this->flash->get('user_error'), + 'success' => $this->flash->get('user_success'), + ]); + } + + public function showCreate(Request $req, Response $res): Response + { + return $this->view->render($res, '@Identity/admin/form.twig', [ + 'assignableRoles' => $this->rolePolicy->assignableRoles(), + 'error' => $this->flash->get('user_error'), + ]); + } + + public function create(Request $req, Response $res): Response + { + $createRequest = CreateUserRequest::fromRequest($req, $this->rolePolicy->assignableRoles()); + + try { + $createRequest->ensureConfirmed(); + $this->userService->create( + $createRequest->username, + $createRequest->email, + $createRequest->password, + $createRequest->role, + ); + $this->flash->set('user_success', "L'utilisateur « {$createRequest->username} » a été créé avec succès"); + + return $res->withHeader('Location', '/admin/users')->withStatus(302); + } catch (DuplicateUsernameException) { + $this->flash->set('user_error', "Ce nom d'utilisateur est déjà pris"); + } catch (DuplicateEmailException) { + $this->flash->set('user_error', 'Cette adresse e-mail est déjà utilisée'); + } catch (WeakPasswordException $e) { + $this->flash->set('user_error', $e->getMessage()); + } catch (\InvalidArgumentException|InvalidRoleException|RoleAssignmentNotAllowedException $e) { + $this->flash->set('user_error', $e->getMessage()); + } catch (\Throwable $e) { + $incidentId = $this->logUnexpectedError($req, $e, [ + 'actor_user_id' => $this->sessionManager->getUserId(), + 'target_username' => $createRequest->username, + 'target_email_hash' => hash('sha256', mb_strtolower(trim($createRequest->email))), + ]); + $this->flash->set('user_error', "Une erreur inattendue s'est produite (réf. {$incidentId})"); + } + + return $res->withHeader('Location', '/admin/users/create')->withStatus(302); + } + + /** @param array $args */ + public function updateRole(Request $req, Response $res, array $args): Response + { + $id = (int) $args['id']; + $user = $this->userService->findById($id); + + if ($user === null) { + $this->flash->set('user_error', 'Utilisateur introuvable'); + + return $res->withHeader('Location', '/admin/users')->withStatus(302); + } + + if ($id === $this->sessionManager->getUserId()) { + $this->flash->set('user_error', 'Vous ne pouvez pas modifier votre propre rôle'); + + return $res->withHeader('Location', '/admin/users')->withStatus(302); + } + + if ($user->isAdmin()) { + $this->flash->set('user_error', 'Le rôle d\'un administrateur ne peut pas être modifié depuis l\'interface'); + + return $res->withHeader('Location', '/admin/users')->withStatus(302); + } + + $updateUserRoleRequest = UpdateUserRoleRequest::fromRequest($req, $this->rolePolicy->assignableRoles()); + + try { + $this->userService->updateRole($id, $updateUserRoleRequest->requireRole()); + $this->flash->set('user_success', "Le rôle de « {$user->getUsername()} » a été mis à jour"); + } catch (\InvalidArgumentException|InvalidRoleException|RoleAssignmentNotAllowedException $e) { + $this->flash->set('user_error', $e->getMessage()); + } catch (\Throwable $e) { + $incidentId = $this->logUnexpectedError($req, $e, [ + 'actor_user_id' => $this->sessionManager->getUserId(), + 'target_user_id' => $id, + 'target_username' => $user->getUsername(), + ]); + $this->flash->set('user_error', "Une erreur inattendue s'est produite (réf. {$incidentId})"); + } + + return $res->withHeader('Location', '/admin/users')->withStatus(302); + } + + /** @param array $args */ + public function delete(Request $req, Response $res, array $args): Response + { + $id = (int) $args['id']; + $user = $this->userService->findById($id); + + if ($user === null) { + $this->flash->set('user_error', 'Utilisateur introuvable'); + + return $res->withHeader('Location', '/admin/users')->withStatus(302); + } + + if ($user->isAdmin()) { + $this->flash->set('user_error', 'Le compte administrateur ne peut pas être supprimé'); + + return $res->withHeader('Location', '/admin/users')->withStatus(302); + } + + if ($id === $this->sessionManager->getUserId()) { + $this->flash->set('user_error', 'Vous ne pouvez pas supprimer votre propre compte'); + + return $res->withHeader('Location', '/admin/users')->withStatus(302); + } + + try { + $this->userService->delete($id); + $this->flash->set('user_success', "L'utilisateur « {$user->getUsername()} » a été supprimé avec succès"); + } catch (\Throwable $e) { + $incidentId = $this->logUnexpectedError($req, $e, [ + 'actor_user_id' => $this->sessionManager->getUserId(), + 'target_user_id' => $id, + 'target_username' => $user->getUsername(), + ]); + $this->flash->set('user_error', "Une erreur inattendue s'est produite (réf. {$incidentId})"); + } + + return $res->withHeader('Location', '/admin/users')->withStatus(302); + } + + /** + * @param array $context + */ + private function logUnexpectedError(Request $req, \Throwable $e, array $context = []): string + { + $incidentId = bin2hex(random_bytes(8)); + + $this->logger?->error('User administration action failed', $context + [ + 'incident_id' => $incidentId, + 'route' => (string) $req->getUri()->getPath(), + 'method' => $req->getMethod(), + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + 'exception' => $e, + ]); + + return $incidentId; + } +} diff --git a/src/Identity/UI/Http/UserRoutes.php b/src/Identity/UI/Http/UserRoutes.php new file mode 100644 index 0000000..78be8f7 --- /dev/null +++ b/src/Identity/UI/Http/UserRoutes.php @@ -0,0 +1,28 @@ + $app */ + public static function register(App $app): void + { + $app->group('/admin/users', function ($group) { + $group->get('', [UserController::class, 'index']); + $group->get('/create', [UserController::class, 'showCreate']); + $group->post('/create', [UserController::class, 'create']); + $group->post('/role/{id}', [UserController::class, 'updateRole']); + $group->post('/delete/{id}', [UserController::class, 'delete']); + })->add(AdminMiddleware::class)->add(AuthMiddleware::class); + } +} diff --git a/src/Identity/UI/Templates/account/password-change.twig b/src/Identity/UI/Templates/account/password-change.twig new file mode 100644 index 0000000..c142806 --- /dev/null +++ b/src/Identity/UI/Templates/account/password-change.twig @@ -0,0 +1,35 @@ +{% extends "@Kernel/layout.twig" %} + +{% block title %}Mon compte – Changer le mot de passe{% endblock %} + +{% block content %} +
+
+ {% include '@Kernel/partials/_auth_form_header.twig' with { + title: 'Changer le mot de passe' + } %} + + {% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %} + +
+ {% include '@Kernel/partials/_csrf_fields.twig' %} + +

+ +

+ + {% include '@Identity/partials/_new_password_fields.twig' with { confirm_label: 'Confirmer le nouveau mot de passe' } %} + + {% include '@Kernel/partials/_admin_form_actions.twig' with { + primary_label: 'Mettre à jour', + secondary_href: back_url, + secondary_label: 'Annuler' + } %} +
+
+
+{% endblock %} diff --git a/src/Identity/UI/Templates/admin/form.twig b/src/Identity/UI/Templates/admin/form.twig new file mode 100644 index 0000000..3c8aefe --- /dev/null +++ b/src/Identity/UI/Templates/admin/form.twig @@ -0,0 +1,73 @@ +{% extends "@Kernel/layout.twig" %} + +{% block title %}Tableau de bord – Créer un utilisateur{% endblock %} + +{% block content %} +
+ {% include '@Kernel/partials/_admin_page_header.twig' with { + title: 'Créer un utilisateur', + secondary_action_href: '/admin/users', + secondary_action_label: 'Retour à la liste' + } %} + +
+ {% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %} + +
+ {% include '@Kernel/partials/_csrf_fields.twig' %} + +

+ + Minimum 3 caractères +

+ +

+ +

+ +

+ + Minimum 12 caractères +

+ +

+ +

+ +

+ + Le rôle administrateur reste réservé au provisionnement initial. +

+ + {% include '@Kernel/partials/_admin_form_actions.twig' with { + primary_label: "Créer l'utilisateur", + secondary_href: '/admin/users', + secondary_label: 'Annuler' + } %} +
+
+
+{% endblock %} diff --git a/src/Identity/UI/Templates/admin/index.twig b/src/Identity/UI/Templates/admin/index.twig new file mode 100644 index 0000000..070ea4e --- /dev/null +++ b/src/Identity/UI/Templates/admin/index.twig @@ -0,0 +1,84 @@ +{% extends "@Kernel/layout.twig" %} + +{% block title %}Tableau de bord – Utilisateurs{% endblock %} + +{% block content %} +{% include '@Kernel/partials/_admin_page_header.twig' with { + title: 'Gestion des utilisateurs', + primary_action_href: '/admin/users/create', + primary_action_label: '+ Ajouter un utilisateur' +} %} + +{% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %} + +{% 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 }} + {% include '@Identity/partials/_role_badge.twig' with { user: user } %} + {{ user.createdAt|date("d/m/Y") }} + {% if not user.isAdmin() and user.id != currentUserId %} +
+ {% include '@Kernel/partials/_csrf_fields.twig' %} +
+ + +
+
+ {% else %} + + {% endif %} +
+ {% if not user.isAdmin() and user.id != currentUserId %} +
+ {% include '@Kernel/partials/_admin_delete_form.twig' with { + action: '/admin/users/delete/' ~ user.id, + confirm: "Supprimer l'utilisateur « " ~ user.username ~ " » ?" + } %} +
+ {% else %} + + {% endif %} +
+ +{% include '@Kernel/partials/_pagination.twig' with { pagination: pagination } %} +{% else %} +{% include '@Kernel/partials/_empty_state.twig' with { + title: 'Aucun utilisateur', + message: 'Aucun utilisateur.', + action_href: '/admin/users/create', + action_label: 'Créer un utilisateur' +} %} +{% endif %} +{% endblock %} diff --git a/src/Identity/UI/Templates/emails/password-reset.twig b/src/Identity/UI/Templates/emails/password-reset.twig new file mode 100644 index 0000000..0d649d1 --- /dev/null +++ b/src/Identity/UI/Templates/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/src/Identity/UI/Templates/login.twig b/src/Identity/UI/Templates/login.twig new file mode 100644 index 0000000..d8133ca --- /dev/null +++ b/src/Identity/UI/Templates/login.twig @@ -0,0 +1,43 @@ +{% extends "@Kernel/layout.twig" %} + +{% block title %}Connexion – NETslim{% endblock %} + +{% block content %} +
+
+ {% include '@Kernel/partials/_auth_form_header.twig' with { + title: 'Connexion' + } %} + + {% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %} + +
+ {% include '@Kernel/partials/_csrf_fields.twig' %} + +

+ +

+ +

+ +

+ +
+
+ +
+
+
+ + +
+
+{% endblock %} diff --git a/src/Identity/UI/Templates/partials/_new_password_fields.twig b/src/Identity/UI/Templates/partials/_new_password_fields.twig new file mode 100644 index 0000000..2c78cf3 --- /dev/null +++ b/src/Identity/UI/Templates/partials/_new_password_fields.twig @@ -0,0 +1,16 @@ +

+ + Minimum 12 caractères +

+ +

+ +

diff --git a/src/Identity/UI/Templates/partials/_role_badge.twig b/src/Identity/UI/Templates/partials/_role_badge.twig new file mode 100644 index 0000000..4fbd959 --- /dev/null +++ b/src/Identity/UI/Templates/partials/_role_badge.twig @@ -0,0 +1,7 @@ +{% if user.isAdmin() %} + {% include '@Kernel/partials/_badge.twig' with { label: 'Admin', modifier: 'admin' } %} +{% elseif user.isEditor() %} + {% include '@Kernel/partials/_badge.twig' with { label: 'Éditeur', modifier: 'editor' } %} +{% else %} + {% include '@Kernel/partials/_badge.twig' with { label: 'Utilisateur', modifier: 'user' } %} +{% endif %} diff --git a/src/Identity/UI/Templates/partials/_role_options.twig b/src/Identity/UI/Templates/partials/_role_options.twig new file mode 100644 index 0000000..76d0a2c --- /dev/null +++ b/src/Identity/UI/Templates/partials/_role_options.twig @@ -0,0 +1,5 @@ +{% for role in assignableRoles %} + +{% endfor %} diff --git a/src/Identity/UI/Templates/password-forgot.twig b/src/Identity/UI/Templates/password-forgot.twig new file mode 100644 index 0000000..2388747 --- /dev/null +++ b/src/Identity/UI/Templates/password-forgot.twig @@ -0,0 +1,37 @@ +{% extends "@Kernel/layout.twig" %} + +{% block title %}Mot de passe oublié – NETslim{% endblock %} + +{% block content %} +
+
+ {% include '@Kernel/partials/_auth_form_header.twig' with { + title: 'Mot de passe oublié', + intro: 'Saisissez votre adresse email. Si elle est associée à un compte, vous recevrez un lien de réinitialisation.' + } %} + + {% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %} + +
+ {% include '@Kernel/partials/_csrf_fields.twig' %} + +

+ +

+ +
+
+ +
+
+
+ + +
+
+{% endblock %} diff --git a/src/Identity/UI/Templates/password-reset.twig b/src/Identity/UI/Templates/password-reset.twig new file mode 100644 index 0000000..3085b48 --- /dev/null +++ b/src/Identity/UI/Templates/password-reset.twig @@ -0,0 +1,29 @@ +{% extends "@Kernel/layout.twig" %} + +{% block title %}Réinitialisation du mot de passe – NETslim{% endblock %} + +{% block content %} +
+
+ {% include '@Kernel/partials/_auth_form_header.twig' with { + title: 'Nouveau mot de passe' + } %} + + {% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %} + +
+ {% include '@Kernel/partials/_csrf_fields.twig' %} + + + {% include '@Identity/partials/_new_password_fields.twig' with { + autofocus: true, + confirm_label: 'Confirmer le mot de passe' + } %} + + {% include '@Kernel/partials/_admin_form_actions.twig' with { + primary_label: 'Réinitialiser' + } %} +
+
+
+{% endblock %} diff --git a/src/Kernel/Html/Application/HtmlSanitizerInterface.php b/src/Kernel/Html/Application/HtmlSanitizerInterface.php new file mode 100644 index 0000000..81c74bf --- /dev/null +++ b/src/Kernel/Html/Application/HtmlSanitizerInterface.php @@ -0,0 +1,20 @@ +set( + 'HTML.Allowed', + 'p[style],br,strong,em,u,del,h1[style],h2[style],h3[style],h4[style],h5[style],h6[style],ul,ol,li[style],blockquote[style],pre,a[href|title],img[src|alt|width|height|data-media-id]', + ); + + $config->set('HTML.DefinitionID', 'netslim-post-content'); + $config->set('HTML.DefinitionRev', 2); + + // Autoriser uniquement la propriété CSS text-align (sécurité) + $config->set('CSS.AllowedProperties', ['text-align']); + + // Restriction des schémas URI autorisés dans href + $config->set('URI.AllowedSchemes', ['http' => true, 'https' => true, 'mailto' => true]); + + // Conversion automatique des URL nues en liens cliquables + $config->set('AutoFormat.Linkify', true); + + // Configuration du cache de définitions HTMLPurifier + $config->set('Cache.DefinitionImpl', 'Serializer'); + $config->set('Cache.SerializerPath', $cacheDir); + + if ($definition = $config->maybeGetRawHTMLDefinition()) { + $definition->addAttribute('img', 'data-media-id', 'Number'); + } + + return new HTMLPurifier($config); + } +} diff --git a/src/Kernel/Html/Infrastructure/HtmlSanitizer.php b/src/Kernel/Html/Infrastructure/HtmlSanitizer.php new file mode 100644 index 0000000..1053d6b --- /dev/null +++ b/src/Kernel/Html/Infrastructure/HtmlSanitizer.php @@ -0,0 +1,34 @@ +purifier->purify($html); + } +} diff --git a/src/Kernel/Http/Application/Flash/FlashServiceInterface.php b/src/Kernel/Http/Application/Flash/FlashServiceInterface.php new file mode 100644 index 0000000..81536ed --- /dev/null +++ b/src/Kernel/Http/Application/Flash/FlashServiceInterface.php @@ -0,0 +1,32 @@ +getServerParams(), $this->trustedProxies); + } +} diff --git a/src/Kernel/Http/Infrastructure/Request/RequestContext.php b/src/Kernel/Http/Infrastructure/Request/RequestContext.php new file mode 100644 index 0000000..d7d803a --- /dev/null +++ b/src/Kernel/Http/Infrastructure/Request/RequestContext.php @@ -0,0 +1,128 @@ + $serverParams + * @param string[] $trustedProxies + */ + public static function resolveClientIp(array $serverParams, array $trustedProxies = []): string + { + $remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? '')); + + if ($remoteAddr === '') { + return '0.0.0.0'; + } + + if (!self::isTrustedProxy($remoteAddr, $trustedProxies)) { + return $remoteAddr; + } + + $forwarded = self::firstForwardedValue($serverParams, 'HTTP_X_FORWARDED_FOR'); + + return $forwarded !== null && filter_var($forwarded, FILTER_VALIDATE_IP) + ? $forwarded + : $remoteAddr; + } + + /** + * @param array $serverParams + * @param string[] $trustedProxies + */ + public static function isHttps(array $serverParams, array $trustedProxies = []): bool + { + $https = strtolower(trim((string) ($serverParams['HTTPS'] ?? ''))); + + if ($https !== '' && $https !== 'off' && $https !== '0') { + return true; + } + + $scheme = strtolower(trim((string) ($serverParams['REQUEST_SCHEME'] ?? ''))); + if ($scheme === 'https') { + return true; + } + + if ((string) ($serverParams['SERVER_PORT'] ?? '') === '443') { + return true; + } + + $remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? '')); + if ($remoteAddr === '' || !self::isTrustedProxy($remoteAddr, $trustedProxies)) { + return false; + } + + $forwardedProto = self::firstForwardedValue($serverParams, 'HTTP_X_FORWARDED_PROTO'); + + return $forwardedProto === 'https'; + } + + /** + * @param array $environment + * @param array $serverParams + * @return string[] + */ + public static function trustedProxiesFromEnvironment(array $environment = [], array $serverParams = []): array + { + $candidates = [ + $environment['TRUSTED_PROXIES'] ?? null, + $serverParams['TRUSTED_PROXIES'] ?? null, + getenv('TRUSTED_PROXIES') ?: null, + ]; + + foreach ($candidates as $candidate) { + if ($candidate === null) { + continue; + } + + $raw = trim((string) $candidate); + if ($raw === '') { + continue; + } + + return array_values(array_filter(array_map( + static fn (string $value): string => trim($value), + explode(',', $raw), + ), static fn (string $value): bool => $value !== '')); + } + + return []; + } + + /** + * @param string[] $trustedProxies + */ + private static function isTrustedProxy(string $remoteAddr, array $trustedProxies): bool + { + foreach ($trustedProxies as $proxy) { + if ($proxy === '*' || $proxy === $remoteAddr) { + return true; + } + } + + return false; + } + + /** + * @param array $serverParams + */ + private static function firstForwardedValue(array $serverParams, string $header): ?string + { + $raw = trim((string) ($serverParams[$header] ?? '')); + + if ($raw === '') { + return null; + } + + return strtolower(trim(explode(',', $raw)[0])); + } +} diff --git a/src/Kernel/Http/Infrastructure/Session/SessionManager.php b/src/Kernel/Http/Infrastructure/Session/SessionManager.php new file mode 100644 index 0000000..956e273 --- /dev/null +++ b/src/Kernel/Http/Infrastructure/Session/SessionManager.php @@ -0,0 +1,88 @@ +getUserId() !== null; + } + + public function getSessionVersion(): ?int + { + return isset($_SESSION['session_version']) && $_SESSION['session_version'] !== '' + ? (int) $_SESSION['session_version'] + : null; + } + + public function isAdmin(): bool + { + return ($_SESSION['role'] ?? '') === 'admin'; + } + + public function isEditor(): bool + { + return ($_SESSION['role'] ?? '') === 'editor'; + } + + /** + * Vide la session courante, supprime le cookie HTTP associé puis détruit la session PHP. + */ + public function destroy(): void + { + $_SESSION = []; + + if (session_id() !== '') { + $sessionName = session_name(); + + if ($sessionName !== false) { + $trustedProxies = RequestContext::trustedProxiesFromEnvironment($_ENV, $_SERVER); + + setcookie($sessionName, '', [ + 'expires' => time() - 3600, + 'path' => '/', + 'secure' => RequestContext::isHttps($_SERVER, $trustedProxies), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + } + } + + if (session_status() === PHP_SESSION_ACTIVE) { + session_destroy(); + } + } +} diff --git a/src/Kernel/Http/Infrastructure/Twig/AppExtension.php b/src/Kernel/Http/Infrastructure/Twig/AppExtension.php new file mode 100644 index 0000000..d4c1473 --- /dev/null +++ b/src/Kernel/Http/Infrastructure/Twig/AppExtension.php @@ -0,0 +1,35 @@ + + */ +final class AppExtension extends AbstractExtension implements GlobalsInterface +{ + /** + * @param string $appUrl URL de base de l'application, sans slash final (depuis APP_URL dans .env) + */ + public function __construct(private readonly string $appUrl) {} + + /** + * Retourne les variables globales injectées dans tous les templates. + * + * @return array + */ + public function getGlobals(): array + { + return ['app_url' => $this->appUrl]; + } +} diff --git a/src/Kernel/Http/Infrastructure/Twig/CsrfExtension.php b/src/Kernel/Http/Infrastructure/Twig/CsrfExtension.php new file mode 100644 index 0000000..ee6c209 --- /dev/null +++ b/src/Kernel/Http/Infrastructure/Twig/CsrfExtension.php @@ -0,0 +1,46 @@ + + * + */ +final class CsrfExtension extends AbstractExtension implements GlobalsInterface +{ + /** + * @param Guard $csrf Instance du middleware CSRF de Slim + */ + public function __construct(private readonly Guard $csrf) {} + + /** + * Retourne les variables globales injectées dans tous les templates. + * + * @return array + */ + public function getGlobals(): array + { + return [ + 'csrf' => [ + 'keys' => [ + 'name' => $this->csrf->getTokenNameKey(), + 'value' => $this->csrf->getTokenValueKey(), + ], + 'name' => $this->csrf->getTokenName(), + 'value' => $this->csrf->getTokenValue(), + ], + ]; + } +} diff --git a/src/Kernel/Http/Infrastructure/Twig/SessionExtension.php b/src/Kernel/Http/Infrastructure/Twig/SessionExtension.php new file mode 100644 index 0000000..56fb749 --- /dev/null +++ b/src/Kernel/Http/Infrastructure/Twig/SessionExtension.php @@ -0,0 +1,43 @@ +> + */ + public function getGlobals(): array + { + return [ + 'session' => [ + 'user_id' => $_SESSION['user_id'] ?? null, + 'username' => $_SESSION['username'] ?? null, + 'role' => $_SESSION['role'] ?? null, + ], + ]; + } +} diff --git a/src/Kernel/Http/UI/Templates/error.twig b/src/Kernel/Http/UI/Templates/error.twig new file mode 100644 index 0000000..0e52daf --- /dev/null +++ b/src/Kernel/Http/UI/Templates/error.twig @@ -0,0 +1,11 @@ +{% extends "@Kernel/layout.twig" %} + +{% block title %}{{ status }} – NETslim{% endblock %} + +{% block content %} +
+

{{ status }}

+

{{ message }}

+

← Retour à l'accueil

+
+{% endblock %} diff --git a/src/Kernel/Http/UI/Templates/layout.twig b/src/Kernel/Http/UI/Templates/layout.twig new file mode 100644 index 0000000..c0ea93c --- /dev/null +++ b/src/Kernel/Http/UI/Templates/layout.twig @@ -0,0 +1,32 @@ + + + + + + + {% block title %}NETslim{% endblock %} + {% block meta %} + + {% endblock %} + + + {% block styles %}{% endblock %} + + + + + {% include '@Kernel/partials/_header.twig' %} + +
+
+ {% block content %}{% endblock %} +
+
+ + {% include '@Kernel/partials/_footer.twig' %} + + {% block scripts %}{% endblock %} + + + + diff --git a/src/Kernel/Http/UI/Templates/layout_picker.twig b/src/Kernel/Http/UI/Templates/layout_picker.twig new file mode 100644 index 0000000..420bacb --- /dev/null +++ b/src/Kernel/Http/UI/Templates/layout_picker.twig @@ -0,0 +1,24 @@ + + + + + + + {% block title %}NETslim{% endblock %} + {% block meta %} + + {% endblock %} + + + {% block styles %}{% endblock %} + + + +
+ {% block content %}{% endblock %} +
+ + {% block scripts %}{% endblock %} + + + diff --git a/src/Kernel/Http/UI/Templates/partials/_admin_create_box.twig b/src/Kernel/Http/UI/Templates/partials/_admin_create_box.twig new file mode 100644 index 0000000..3b7a79e --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_admin_create_box.twig @@ -0,0 +1,41 @@ +{# + Boîte d'ajout réutilisable dans l'admin. + + Usage (exemple) : + {% embed '@Kernel/partials/_admin_create_box.twig' with { + title: 'Ajouter un élément', + hint: 'Le contenu de la boîte est piloté par le module consommateur.', + form: { action: '/admin/items/create' } + } %} + {% block fields %}...{% endblock %} + {% block actions %}...{% endblock %} + {% endembed %} +#} + +{% set form = form|default({}) %} +{% set extra = form.extra|default({}) %} + +
+

{{ title }}

+ +
+ {% include '@Kernel/partials/_csrf_fields.twig' %} + + {% block fields %}{% endblock %} + +
+ {% block actions %}{% endblock %} +
+
+ + {% if hint is defined and hint %} +

{{ hint }}

+ {% endif %} +
diff --git a/src/Kernel/Http/UI/Templates/partials/_admin_delete_form.twig b/src/Kernel/Http/UI/Templates/partials/_admin_delete_form.twig new file mode 100644 index 0000000..cd8a8bb --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_admin_delete_form.twig @@ -0,0 +1,7 @@ +
+ {% include '@Kernel/partials/_csrf_fields.twig' %} + +
diff --git a/src/Kernel/Http/UI/Templates/partials/_admin_form_actions.twig b/src/Kernel/Http/UI/Templates/partials/_admin_form_actions.twig new file mode 100644 index 0000000..86991ed --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_admin_form_actions.twig @@ -0,0 +1,11 @@ +
+
+ +
+ + {% if secondary_href is defined and secondary_href and secondary_label is defined and secondary_label %} + + {% endif %} +
diff --git a/src/Kernel/Http/UI/Templates/partials/_admin_nav.twig b/src/Kernel/Http/UI/Templates/partials/_admin_nav.twig new file mode 100644 index 0000000..af5e5bd --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_admin_nav.twig @@ -0,0 +1,3 @@ + diff --git a/src/Kernel/Http/UI/Templates/partials/_admin_page_header.twig b/src/Kernel/Http/UI/Templates/partials/_admin_page_header.twig new file mode 100644 index 0000000..f761526 --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_admin_page_header.twig @@ -0,0 +1,23 @@ +
+
+

{{ title }}

+ {% if intro is defined and intro %} +

{{ intro }}

+ {% endif %} +
+ + {% if (primary_action_href is defined and primary_action_href and primary_action_label is defined and primary_action_label) + or (secondary_action_href is defined and secondary_action_href and secondary_action_label is defined and secondary_action_label) %} +
+ {% if secondary_action_href is defined and secondary_action_href and secondary_action_label is defined and secondary_action_label %} + {{ secondary_action_label }} + {% endif %} + + {% if primary_action_href is defined and primary_action_href and primary_action_label is defined and primary_action_label %} + {{ primary_action_label }} + {% endif %} +
+ {% endif %} +
+ +{% include '@Kernel/partials/_admin_nav.twig' %} diff --git a/src/Kernel/Http/UI/Templates/partials/_auth_form_header.twig b/src/Kernel/Http/UI/Templates/partials/_auth_form_header.twig new file mode 100644 index 0000000..ee11e1e --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_auth_form_header.twig @@ -0,0 +1,6 @@ +
+

{{ title }}

+ {% if intro is defined and intro %} +

{{ intro }}

+ {% endif %} +
diff --git a/src/Kernel/Http/UI/Templates/partials/_badge.twig b/src/Kernel/Http/UI/Templates/partials/_badge.twig new file mode 100644 index 0000000..02cb4fb --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_badge.twig @@ -0,0 +1,6 @@ +{% set tag = href is defined and href ? 'a' : 'span' %} +{% set classes = ['badge'] %} +{% if modifier is defined and modifier %} + {% set classes = classes|merge(['badge--' ~ modifier]) %} +{% endif %} +<{{ tag }}{% if href is defined and href %} href="{{ href }}"{% endif %} class="{{ classes|join(' ') }}">{{ label }} diff --git a/src/Kernel/Http/UI/Templates/partials/_csrf_fields.twig b/src/Kernel/Http/UI/Templates/partials/_csrf_fields.twig new file mode 100644 index 0000000..e17d421 --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_csrf_fields.twig @@ -0,0 +1,2 @@ + + diff --git a/src/Kernel/Http/UI/Templates/partials/_empty_state.twig b/src/Kernel/Http/UI/Templates/partials/_empty_state.twig new file mode 100644 index 0000000..526d784 --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_empty_state.twig @@ -0,0 +1,13 @@ +
+ {% if title is defined and title %} +

{{ title }}

+ {% endif %} + +

{{ message }}

+ + {% if action_href is defined and action_href and action_label is defined and action_label %} + + {% endif %} +
diff --git a/src/Kernel/Http/UI/Templates/partials/_flash_messages.twig b/src/Kernel/Http/UI/Templates/partials/_flash_messages.twig new file mode 100644 index 0000000..43d8e2d --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_flash_messages.twig @@ -0,0 +1,7 @@ +{% if error is defined and error %} + +{% endif %} + +{% if success is defined and success %} +
{{ success }}
+{% endif %} diff --git a/src/Kernel/Http/UI/Templates/partials/_footer.twig b/src/Kernel/Http/UI/Templates/partials/_footer.twig new file mode 100644 index 0000000..28952c4 --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_footer.twig @@ -0,0 +1,5 @@ +
+

+ © {{ "now"|date("Y") }} NETslim – socle modulaire pour applications web. +

+
diff --git a/src/Kernel/Http/UI/Templates/partials/_header.twig b/src/Kernel/Http/UI/Templates/partials/_header.twig new file mode 100644 index 0000000..2e6c2e0 --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_header.twig @@ -0,0 +1,23 @@ + diff --git a/src/Kernel/Http/UI/Templates/partials/_pagination.twig b/src/Kernel/Http/UI/Templates/partials/_pagination.twig new file mode 100644 index 0000000..0ff2fb0 --- /dev/null +++ b/src/Kernel/Http/UI/Templates/partials/_pagination.twig @@ -0,0 +1,29 @@ +{% if pagination.totalPages > 1 %} + +{% endif %} diff --git a/src/Kernel/Mail/Application/MailServiceInterface.php b/src/Kernel/Mail/Application/MailServiceInterface.php new file mode 100644 index 0000000..bb426c3 --- /dev/null +++ b/src/Kernel/Mail/Application/MailServiceInterface.php @@ -0,0 +1,27 @@ + $context Variables transmises au template + * + * @throws \RuntimeException Si l'envoi échoue + * @return void + */ + public function send(string $to, string $subject, string $template, array $context = []): void; +} diff --git a/src/Kernel/Mail/Infrastructure/MailService.php b/src/Kernel/Mail/Infrastructure/MailService.php new file mode 100644 index 0000000..feb8149 --- /dev/null +++ b/src/Kernel/Mail/Infrastructure/MailService.php @@ -0,0 +1,96 @@ + $context Variables transmises au template + * + * @throws \RuntimeException Si l'envoi échoue + * @return void + */ + public function send(string $to, string $subject, string $template, array $context = []): void + { + $html = $this->twig->getEnvironment()->render($template, $context); + $mail = $this->createMailer(); + + $mail->addAddress($to); + $mail->Subject = $subject; + $mail->Body = $html; + $mail->AltBody = strip_tags($html); + + try { + $mail->send(); + } catch (MailerException $e) { + throw new \RuntimeException("Échec de l'envoi de l'email : {$e->getMessage()}", 0, $e); + } + } + + /** + * Crée et configure une instance PHPMailer prête à l'envoi. + * + * @return PHPMailer L'instance configurée avec les paramètres SMTP injectés + */ + private function createMailer(): PHPMailer + { + $mail = new PHPMailer(true); + + $mail->isSMTP(); + $mail->Host = $this->host; + $mail->SMTPAuth = true; + $mail->Username = $this->username; + $mail->Password = $this->password; + $mail->SMTPSecure = $this->encryption === 'ssl' + ? PHPMailer::ENCRYPTION_SMTPS + : PHPMailer::ENCRYPTION_STARTTLS; + $mail->Port = $this->port; + $mail->CharSet = PHPMailer::CHARSET_UTF8; + $mail->setFrom($this->from, $this->fromName); + + return $mail; + } +} diff --git a/src/Kernel/Pagination/Application/PaginatedResult.php b/src/Kernel/Pagination/Application/PaginatedResult.php new file mode 100644 index 0000000..ae4b411 --- /dev/null +++ b/src/Kernel/Pagination/Application/PaginatedResult.php @@ -0,0 +1,66 @@ + */ + private readonly array $items; + + /** + * @param array $items + */ + public function __construct( + array $items, + private readonly int $total, + private readonly int $currentPage, + private readonly int $perPage, + ) { + $this->items = $items; + } + + /** + * @return array + */ + public function getItems(): array + { + return $this->items; + } + + public function getTotal(): int + { + return $this->total; + } + + public function getCurrentPage(): int + { + return $this->currentPage; + } + + public function getPerPage(): int + { + return $this->perPage; + } + + public function getTotalPages(): int + { + return max(1, (int) ceil($this->total / max(1, $this->perPage))); + } + + public function hasPreviousPage(): bool + { + return $this->currentPage > 1; + } + + public function hasNextPage(): bool + { + return $this->currentPage < $this->getTotalPages(); + } +} diff --git a/src/Kernel/Pagination/Infrastructure/PaginationPresenter.php b/src/Kernel/Pagination/Infrastructure/PaginationPresenter.php new file mode 100644 index 0000000..e0d23af --- /dev/null +++ b/src/Kernel/Pagination/Infrastructure/PaginationPresenter.php @@ -0,0 +1,79 @@ + $queryParams + */ + public static function resolvePage(array $queryParams): int + { + $rawPage = $queryParams['page'] ?? 1; + $page = filter_var($rawPage, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + + return $page === false ? 1 : (int) $page; + } + + /** + * @param PaginatedResult $result + * @return array + */ + public static function fromRequest(ServerRequestInterface $request, PaginatedResult $result): array + { + $totalPages = $result->getTotalPages(); + $currentPage = min($result->getCurrentPage(), $totalPages); + $baseQuery = $request->getQueryParams(); + unset($baseQuery['page']); + + $pages = []; + $start = max(1, $currentPage - 2); + $end = min($totalPages, $currentPage + 2); + + for ($page = $start; $page <= $end; ++$page) { + $pages[] = [ + 'number' => $page, + 'url' => self::buildPageUrl($request->getUri()->getPath(), $baseQuery, $page), + 'current' => $page === $currentPage, + ]; + } + + return [ + 'currentPage' => $currentPage, + 'perPage' => $result->getPerPage(), + 'total' => $result->getTotal(), + 'totalPages' => $totalPages, + 'hasPrevious' => $currentPage > 1, + 'hasNext' => $currentPage < $totalPages, + 'previousUrl' => $currentPage > 1 + ? self::buildPageUrl($request->getUri()->getPath(), $baseQuery, $currentPage - 1) + : null, + 'nextUrl' => $currentPage < $totalPages + ? self::buildPageUrl($request->getUri()->getPath(), $baseQuery, $currentPage + 1) + : null, + 'pages' => $pages, + ]; + } + + /** + * @param array $query + */ + private static function buildPageUrl(string $path, array $query, int $page): string + { + if ($page > 1) { + $query['page'] = $page; + } + + $queryString = http_build_query($query); + + return $queryString === '' ? $path : $path . '?' . $queryString; + } +} diff --git a/src/Kernel/Persistence/Application/TransactionManagerInterface.php b/src/Kernel/Persistence/Application/TransactionManagerInterface.php new file mode 100644 index 0000000..096e23d --- /dev/null +++ b/src/Kernel/Persistence/Application/TransactionManagerInterface.php @@ -0,0 +1,20 @@ + + */ + private static function requiredTables(): array + { + $tables = ['migrations']; + + foreach (ModuleRegistry::modules() as $module) { + if (!$module instanceof ProvidesSchemaInterface) { + continue; + } + + foreach ($module->requiredTables() as $table) { + $tables[] = $table; + } + } + + $tables = array_values(array_unique($tables)); + sort($tables); + + return $tables; + } + + /** + * @return list + */ + private static function existingTables(PDO $db): array + { + $stmt = $db->query("SELECT name FROM sqlite_master WHERE type = 'table'"); + $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_COLUMN) : []; + + return array_values(array_filter(array_map( + static fn (mixed $value): string => (string) $value, + $rows, + ))); + } +} diff --git a/src/Kernel/Persistence/Infrastructure/Migrator.php b/src/Kernel/Persistence/Infrastructure/Migrator.php new file mode 100644 index 0000000..074727a --- /dev/null +++ b/src/Kernel/Persistence/Infrastructure/Migrator.php @@ -0,0 +1,135 @@ + 'SQL...', 'down' => 'SQL...'] + * - Triés et exécutés par ordre alphanumérique croissant + * + * run() est idempotent et sûr à appeler à chaque démarrage applicatif : + * les migrations déjà appliquées ne sont jamais rejouées. + */ +final class Migrator +{ + /** + * Exécute les migrations en attente puis délègue les maintenances post-migration. + * + * Opération idempotente et sans effets de bord sur les données : + * sûre à appeler à chaque démarrage applicatif. + * + * Séquence : + * 1. Crée la table de suivi si absente + * 2. Joue les migrations en attente + * 3. Exécute les maintenances déclarées par les modules actifs + * + * @param PDO $db L'instance de connexion à la base de données + */ + public static function run(PDO $db): void + { + self::createMigrationTable($db); + self::runPendingMigrations($db); + self::runModuleMaintenance($db); + } + + /** + * Crée la table de suivi des migrations si elle n'existe pas. + * + * Cette table doit exister avant de pouvoir lire les migrations appliquées. + * + * @param PDO $db L'instance de connexion à la base de données + */ + private static function createMigrationTable(PDO $db): void + { + $db->exec(' + CREATE TABLE IF NOT EXISTS migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version TEXT NOT NULL UNIQUE, + run_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + '); + } + + /** + * Charge les fichiers de migration, filtre ceux déjà appliqués + * et exécute les migrations en attente dans l'ordre. + * + * @param PDO $db L'instance de connexion à la base de données + */ + private static function runPendingMigrations(PDO $db): void + { + $stmt = $db->query('SELECT version FROM migrations'); + $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_COLUMN) : []; + $applied = array_flip($rows); + + $files = self::migrationFiles(); + $insert = $db->prepare('INSERT INTO migrations (version, run_at) VALUES (:version, :run_at)'); + + foreach ($files as $file) { + $version = basename($file, '.php'); + + if (isset($applied[$version])) { + continue; + } + + $migration = require $file; + + $db->exec($migration['up']); + + $insert->execute([ + ':version' => $version, + ':run_at' => date('Y-m-d H:i:s'), + ]); + } + } + + /** + * @return list + */ + private static function migrationFiles(): array + { + $files = []; + + foreach (ModuleRegistry::modules() as $module) { + if (!$module instanceof ProvidesSchemaInterface) { + continue; + } + + foreach ($module->migrationDirectories() as $directory) { + $moduleFiles = glob(rtrim($directory, '/') . '/*.php') ?: []; + foreach ($moduleFiles as $file) { + $files[] = $file; + } + } + } + + usort($files, static fn (string $left, string $right): int => strcmp(basename($left), basename($right))); + + return array_values(array_unique($files)); + } + + private static function runModuleMaintenance(PDO $db): void + { + foreach (ModuleRegistry::modules() as $module) { + if (!$module instanceof ProvidesMigrationMaintenanceInterface) { + continue; + } + + $module->afterMigrations($db); + } + } +} diff --git a/src/Kernel/Persistence/Infrastructure/PdoTransactionManager.php b/src/Kernel/Persistence/Infrastructure/PdoTransactionManager.php new file mode 100644 index 0000000..4179250 --- /dev/null +++ b/src/Kernel/Persistence/Infrastructure/PdoTransactionManager.php @@ -0,0 +1,34 @@ +db->beginTransaction(); + + try { + $result = $operation(); + $this->db->commit(); + + return $result; + } catch (\Throwable $e) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + + throw $e; + } + } +} diff --git a/src/Kernel/Persistence/Infrastructure/Provisioner.php b/src/Kernel/Persistence/Infrastructure/Provisioner.php new file mode 100644 index 0000000..fc39fa8 --- /dev/null +++ b/src/Kernel/Persistence/Infrastructure/Provisioner.php @@ -0,0 +1,52 @@ +provision($db); + } + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } +} diff --git a/src/Kernel/Runtime/Bootstrap.php b/src/Kernel/Runtime/Bootstrap.php new file mode 100644 index 0000000..a722cac --- /dev/null +++ b/src/Kernel/Runtime/Bootstrap.php @@ -0,0 +1,96 @@ +initializeInfrastructure(); + + return $this->createHttpApp(); + } + + /** + * Prépare le container d'injection de dépendances et le met en cache local. + */ + public function initializeInfrastructure(): ContainerInterface + { + if ($this->container !== null) { + return $this->container; + } + + $this->container = $this->infrastructureBootstrapper->bootstrap(); + + return $this->container; + } + + /** + * Construit l'application Slim et y branche middlewares, routes et handlers. + */ + public function createHttpApp(): App + { + if ($this->app !== null) { + return $this->app; + } + + $this->app = $this->httpApplicationFactory->create($this->initializeInfrastructure()); + + return $this->app; + } + + /** + * Retourne le container déjà initialisé, ou le crée au besoin. + */ + public function getContainer(): ContainerInterface + { + return $this->initializeInfrastructure(); + } +} diff --git a/src/Kernel/Runtime/DI/container.php b/src/Kernel/Runtime/DI/container.php new file mode 100644 index 0000000..cf89351 --- /dev/null +++ b/src/Kernel/Runtime/DI/container.php @@ -0,0 +1,12 @@ +definitions()); +} + +return $definitions; diff --git a/src/Kernel/Runtime/DI/dependencies.php b/src/Kernel/Runtime/DI/dependencies.php new file mode 100644 index 0000000..a8c015c --- /dev/null +++ b/src/Kernel/Runtime/DI/dependencies.php @@ -0,0 +1,105 @@ + autowire(FlashService::class), + SessionManagerInterface::class => autowire(SessionManager::class), + HtmlSanitizerInterface::class => autowire(HtmlSanitizer::class), + TransactionManagerInterface::class => autowire(PdoTransactionManager::class), + + LoggerInterface::class => factory(function (): LoggerInterface { + $isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development'; + $logger = new Logger('netslim'); + $level = $isDev ? Level::Debug : Level::Warning; + + $logger->pushHandler(new StreamHandler(RuntimePaths::getLogFilePath(), $level)); + + return $logger; + }), + + PDO::class => factory(function (): PDO { + $pdo = new PDO('sqlite:' . RuntimePaths::getDatabasePath(), options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + $pdo->sqliteCreateFunction('strip_tags', 'strip_tags', 1); + $pdo->exec('PRAGMA journal_mode=WAL'); + $pdo->exec('PRAGMA busy_timeout=3000'); + $pdo->exec('PRAGMA synchronous=NORMAL'); + $pdo->exec('PRAGMA foreign_keys=ON'); + + return $pdo; + }), + + Twig::class => factory(function (): Twig { + $isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development'; + $kernelTemplatesPath = dirname(__DIR__, 2) . '/Http/UI/Templates'; + + $twig = Twig::create($kernelTemplatesPath, ['cache' => RuntimePaths::getTwigCache($isDev)]); + $loader = $twig->getEnvironment()->getLoader(); + + if ($loader instanceof FilesystemLoader) { + foreach (ModuleRegistry::modules() as $module) { + foreach ($module->templateNamespaces() as $namespace => $templatePath) { + $loader->addPath($templatePath, $namespace); + } + } + } + + return $twig; + }), + + \HTMLPurifier::class => factory(function (): \HTMLPurifier { + return HtmlPurifierFactory::create(RuntimePaths::getHtmlPurifierCachePath()); + }), + + MailServiceInterface::class => factory(function (Twig $twig): MailServiceInterface { + return new MailService( + twig: $twig, + host: $_ENV['MAIL_HOST'] ?? '', + port: (int) ($_ENV['MAIL_PORT'] ?? 587), + username: $_ENV['MAIL_USERNAME'] ?? '', + password: $_ENV['MAIL_PASSWORD'] ?? '', + encryption: strtolower($_ENV['MAIL_ENCRYPTION'] ?? 'tls'), + from: $_ENV['MAIL_FROM'] ?? '', + fromName: $_ENV['MAIL_FROM_NAME'] ?? 'NETslim', + ); + }), + + ClientIpResolver::class => factory(function (): ClientIpResolver { + $trusted = array_filter(array_map('trim', explode(',', (string) ($_ENV['TRUSTED_PROXIES'] ?? '')))); + + return new ClientIpResolver($trusted); + }), + + AppExtension::class => factory(function (): AppExtension { + return new AppExtension(rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/')); + }), +]; diff --git a/src/Kernel/Runtime/Http/DefaultErrorHandler.php b/src/Kernel/Runtime/Http/DefaultErrorHandler.php new file mode 100644 index 0000000..74acb9c --- /dev/null +++ b/src/Kernel/Runtime/Http/DefaultErrorHandler.php @@ -0,0 +1,82 @@ +isDevelopmentEnvironment + && !$exception instanceof DatabaseNotProvisionedException + && !$exception instanceof HttpException + ) { + throw $exception; + } + + $statusCode = $this->resolveStatusCode($exception); + $response = $this->responseFactory->createResponse($statusCode); + + return $this->twig->render($response, '@Kernel/error.twig', [ + 'status' => $statusCode, + 'message' => $this->resolveMessage($exception, $statusCode), + ]); + } + + private function resolveStatusCode(Throwable $exception): int + { + if ($exception instanceof DatabaseNotProvisionedException) { + return 503; + } + + if ($exception instanceof HttpException) { + return $exception->getCode() > 0 ? $exception->getCode() : 500; + } + + return 500; + } + + private function resolveMessage(Throwable $exception, int $statusCode): string + { + if ($exception instanceof DatabaseNotProvisionedException) { + return $exception->getMessage(); + } + + return match ($statusCode) { + 403 => 'Vous n\'avez pas accès à cette page.', + 404 => 'La page demandée est introuvable.', + default => 'Une erreur inattendue s\'est produite.', + }; + } +} diff --git a/src/Kernel/Runtime/Http/ErrorHandlerConfigurator.php b/src/Kernel/Runtime/Http/ErrorHandlerConfigurator.php new file mode 100644 index 0000000..9dd2698 --- /dev/null +++ b/src/Kernel/Runtime/Http/ErrorHandlerConfigurator.php @@ -0,0 +1,32 @@ + $app + */ + public function configure(App $app, ContainerInterface $container): void + { + $isDevelopmentEnvironment = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development'; + $logger = $container->get(LoggerInterface::class); + $errorMiddleware = $app->addErrorMiddleware($isDevelopmentEnvironment, true, true, $logger); + + $errorMiddleware->setDefaultErrorHandler(new DefaultErrorHandler( + responseFactory: $app->getResponseFactory(), + twig: $container->get(Twig::class), + isDevelopmentEnvironment: $isDevelopmentEnvironment, + )); + } +} diff --git a/src/Kernel/Runtime/Http/HttpApplicationFactory.php b/src/Kernel/Runtime/Http/HttpApplicationFactory.php new file mode 100644 index 0000000..0eb9890 --- /dev/null +++ b/src/Kernel/Runtime/Http/HttpApplicationFactory.php @@ -0,0 +1,38 @@ + + */ + public function create(ContainerInterface $container): App + { + /** @var App $app */ + $app = AppFactory::createFromContainer($container); + + $this->middlewareRegistrar->register($app, $container); + Routes::register($app); + $this->errorHandlerConfigurator->configure($app, $container); + + return $app; + } +} diff --git a/src/Kernel/Runtime/Http/MiddlewareRegistrar.php b/src/Kernel/Runtime/Http/MiddlewareRegistrar.php new file mode 100644 index 0000000..0333bef --- /dev/null +++ b/src/Kernel/Runtime/Http/MiddlewareRegistrar.php @@ -0,0 +1,89 @@ + $app + */ + public function register(App $app, ContainerInterface $container): void + { + $app->addBodyParsingMiddleware(); + + $twig = $container->get(Twig::class); + $this->configureTwig($app, $twig, $container); + $this->registerCsrfProtection($app, $twig); + $this->registerDatabaseReadinessCheck($app, $container); + } + + /** + * Charge les extensions Twig transverses puis celles exposées par chaque module. + * + * @param \Slim\App<\Psr\Container\ContainerInterface> $app + */ + private function configureTwig(App $app, Twig $twig, ContainerInterface $container): void + { + $twig->addExtension($container->get(AppExtension::class)); + $twig->addExtension($container->get(SessionExtension::class)); + + foreach (ModuleRegistry::modules() as $module) { + foreach ($module->twigExtensions() as $twigExtensionClass) { + $twig->addExtension($container->get($twigExtensionClass)); + } + } + + $app->add(TwigMiddleware::create($app, $twig)); + } + + /** + * Branche la protection CSRF Slim et expose ses tokens à Twig. + * + * @param \Slim\App<\Psr\Container\ContainerInterface> $app + */ + private function registerCsrfProtection(App $app, Twig $twig): void + { + $guard = new Guard($app->getResponseFactory()); + $guard->setPersistentTokenMode(true); + $twig->addExtension(new CsrfExtension($guard)); + + $app->add($guard); + } + + /** + * Ajoute un middleware de readiness SQLite pour rendre un message clair si le provisioning n'a pas été exécuté. + * + * @param \Slim\App<\Psr\Container\ContainerInterface> $app + */ + private function registerDatabaseReadinessCheck(App $app, ContainerInterface $container): void + { + $app->add(function ( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ) use ($container): ResponseInterface { + DatabaseReadiness::assertProvisioned($container->get(PDO::class)); + + return $handler->handle($request); + }); + } +} diff --git a/src/Kernel/Runtime/KernelModule.php b/src/Kernel/Runtime/KernelModule.php new file mode 100644 index 0000000..795ddb1 --- /dev/null +++ b/src/Kernel/Runtime/KernelModule.php @@ -0,0 +1,49 @@ +/templates/Kernel`. En l'absence d'application dédiée, le + * package utilise le fallback interne situé sous `src/Kernel/Http/UI/Templates`. + */ +final class KernelModule implements ModuleInterface +{ + public function definitions(): array + { + return require __DIR__ . '/DI/dependencies.php'; + } + + /** @param App $app */ + public function registerRoutes(App $app): void {} + + /** + * @return array + */ + public function templateNamespaces(): array + { + $applicationTemplates = RuntimePaths::getApplicationPath('templates/Kernel'); + + return [ + 'Kernel' => is_dir($applicationTemplates) + ? $applicationTemplates + : dirname(__DIR__) . '/Http/UI/Templates', + ]; + } + + /** + * @return list + */ + public function twigExtensions(): array + { + return []; + } +} diff --git a/src/Kernel/Runtime/Module/ModuleInterface.php b/src/Kernel/Runtime/Module/ModuleInterface.php new file mode 100644 index 0000000..fad4dac --- /dev/null +++ b/src/Kernel/Runtime/Module/ModuleInterface.php @@ -0,0 +1,47 @@ + + */ + public function definitions(): array; + + /** + * Enregistre les routes HTTP exposées par le module. + * + * @param App $app + */ + public function registerRoutes(App $app): void; + + /** + * Déclare les namespaces Twig ajoutés par le module. + * + * @return array + */ + public function templateNamespaces(): array; + + /** + * Déclare les extensions Twig exposées par le module. + * + * @return list + */ + public function twigExtensions(): array; +} diff --git a/src/Kernel/Runtime/Module/ModuleRegistry.php b/src/Kernel/Runtime/Module/ModuleRegistry.php new file mode 100644 index 0000000..94842e1 --- /dev/null +++ b/src/Kernel/Runtime/Module/ModuleRegistry.php @@ -0,0 +1,81 @@ +/config/modules.php`. + * Pour les projets simples qui ne distinguent pas `applicationRoot` et + * `projectRoot`, le même fichier est naturellement résolu sous `config/modules.php`. + */ +final class ModuleRegistry +{ + /** @var list|null */ + private static ?array $modules = null; + + /** + * @return list + */ + public static function modules(): array + { + if (self::$modules !== null) { + return self::$modules; + } + + $modules = []; + + foreach (self::moduleClassNames() as $moduleClass) { + if (!class_exists($moduleClass)) { + throw new \RuntimeException(sprintf('Module class "%s" not found in the active application manifest.', $moduleClass)); + } + + $module = new $moduleClass(); + + if (!$module instanceof ModuleInterface) { + throw new \RuntimeException(sprintf('Configured module "%s" must implement %s.', $moduleClass, ModuleInterface::class)); + } + + $modules[] = $module; + } + + self::$modules = $modules; + + return self::$modules; + } + + /** + * @return list> + */ + public static function moduleClassNames(): array + { + $manifestPath = RuntimePaths::getApplicationConfigPath('modules.php'); + + if (!is_file($manifestPath)) { + $manifestPath = RuntimePaths::getConfigPath('modules.php'); + } + + if (!is_file($manifestPath)) { + throw new \RuntimeException(sprintf('Module manifest not found: %s', $manifestPath)); + } + + $moduleClasses = require $manifestPath; + + if (!is_array($moduleClasses)) { + throw new \RuntimeException('The active module manifest must return an array of module class names.'); + } + + /** @var list> $moduleClasses */ + return array_values($moduleClasses); + } + + public static function reset(): void + { + self::$modules = null; + } +} diff --git a/src/Kernel/Runtime/Module/ProvidesMigrationMaintenanceInterface.php b/src/Kernel/Runtime/Module/ProvidesMigrationMaintenanceInterface.php new file mode 100644 index 0000000..922b0cd --- /dev/null +++ b/src/Kernel/Runtime/Module/ProvidesMigrationMaintenanceInterface.php @@ -0,0 +1,16 @@ + */ + public function migrationDirectories(): array; + + /** @return list */ + public function requiredTables(): array; +} diff --git a/src/Kernel/Runtime/Routing/Routes.php b/src/Kernel/Runtime/Routing/Routes.php new file mode 100644 index 0000000..2dd3468 --- /dev/null +++ b/src/Kernel/Runtime/Routing/Routes.php @@ -0,0 +1,23 @@ + $app */ + public static function register(App $app): void + { + foreach (ModuleRegistry::modules() as $module) { + $module->registerRoutes($app); + } + } +} diff --git a/src/Kernel/Runtime/RuntimePaths.php b/src/Kernel/Runtime/RuntimePaths.php new file mode 100644 index 0000000..6815a30 --- /dev/null +++ b/src/Kernel/Runtime/RuntimePaths.php @@ -0,0 +1,160 @@ +/vendor//. Dans ce cas, les + // chemins runtime persistants et le manifest de modules doivent viser + // le projet consommateur, pas le package installe dans vendor/. + if (basename(dirname($packageRoot, 2)) === 'vendor') { + return dirname($packageRoot, 3); + } + + return $packageRoot; + } + + public static function getApplicationRoot(): string + { + return self::$applicationRoot ?? self::getProjectRoot(); + } + + public static function getApplicationPath(string $relativePath = ''): string + { + $appRoot = self::getApplicationRoot(); + + return $relativePath === '' + ? $appRoot + : $appRoot . '/' . ltrim($relativePath, '/'); + } + + public static function getConfigPath(string $relativePath = ''): string + { + $configRoot = self::getProjectRoot() . '/config'; + + return $relativePath === '' + ? $configRoot + : $configRoot . '/' . ltrim($relativePath, '/'); + } + + public static function getApplicationConfigPath(string $relativePath = ''): string + { + $configRoot = self::getApplicationPath('config'); + + return $relativePath === '' + ? $configRoot + : $configRoot . '/' . ltrim($relativePath, '/'); + } + + /** + * Retourne le chemin du cache Twig, ou false si le cache est désactivé. + * + * Le cache Twig est désactivé en développement pour refléter immédiatement + * les modifications des templates. Le répertoire est préparé en amont par + * les checks runtime exécutés par InfrastructureBootstrapper. + * + * @param bool $isDev True si l'application est en mode développement. + * + * @return string|false Chemin absolu du répertoire de cache, ou false. + */ + public static function getTwigCache(bool $isDev): false|string + { + if ($isDev) { + return false; + } + + return self::getProjectRoot() . '/var/cache/twig'; + } + + /** + * Retourne le chemin absolu vers le cache HTMLPurifier du projet consommateur. + */ + public static function getHtmlPurifierCachePath(): string + { + return self::getProjectRoot() . '/var/cache/htmlpurifier'; + } + + /** + * Retourne le chemin absolu vers le fichier de log applicatif du projet consommateur. + */ + public static function getLogFilePath(): string + { + return self::getProjectRoot() . '/var/logs/app.log'; + } + + /** + * Retourne le chemin absolu vers le fichier SQLite principal. + * + * Le dossier et le fichier sont créés en fallback si cette méthode est appelée + * hors du bootstrap HTTP habituel (tests unitaires, scripts CLI, etc.). En + * production courante, le répertoire `database/` est déjà préparé par les + * checks runtime d'InfrastructureBootstrapper. + * + * @return string Chemin absolu vers `database/app.sqlite`. + */ + public static function getDatabasePath(): string + { + $path = self::getProjectRoot() . '/database/app.sqlite'; + $dir = dirname($path); + + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + + if (!file_exists($path)) { + @touch($path); + @chmod($path, 0664); + } + + return $path; + } +} diff --git a/src/Kernel/Runtime/Startup/Check/EnsureRequiredPhpExtensionsCheck.php b/src/Kernel/Runtime/Startup/Check/EnsureRequiredPhpExtensionsCheck.php new file mode 100644 index 0000000..570690a --- /dev/null +++ b/src/Kernel/Runtime/Startup/Check/EnsureRequiredPhpExtensionsCheck.php @@ -0,0 +1,18 @@ +path('var/cache/twig'), + $this->path('var/cache/htmlpurifier'), + $this->path('var/cache/di'), + $this->path('var/logs'), + $this->path('database'), + $this->path('public/media'), + ]; + + foreach ($directories as $directory) { + if (!is_dir($directory) && !@mkdir($directory, 0755, true)) { + throw new \RuntimeException("Impossible de créer le répertoire : {$directory}"); + } + } + } + + private function path(string $relativePath): string + { + return $this->rootDir . '/' . ltrim($relativePath, '/'); + } +} diff --git a/src/Kernel/Runtime/Startup/Check/InfrastructureCheckInterface.php b/src/Kernel/Runtime/Startup/Check/InfrastructureCheckInterface.php new file mode 100644 index 0000000..832e5c3 --- /dev/null +++ b/src/Kernel/Runtime/Startup/Check/InfrastructureCheckInterface.php @@ -0,0 +1,10 @@ +path('.env'); + + try { + $dotenv = Dotenv::createImmutable($this->rootDir); + $dotenv->load(); + } catch (InvalidPathException $exception) { + throw new \RuntimeException( + sprintf( + "Fichier .env introuvable a la racine du projet (%s). Copiez .env.example vers .env avant de demarrer l'application.", + $environmentPath, + ), + previous: $exception, + ); + } + + $dotenv->required(['APP_URL']); + date_default_timezone_set($_ENV['TIMEZONE'] ?? 'UTC'); + } + + private function path(string $relativePath): string + { + return $this->rootDir . '/' . ltrim($relativePath, '/'); + } +} diff --git a/src/Kernel/Runtime/Startup/InfrastructureBootstrapper.php b/src/Kernel/Runtime/Startup/InfrastructureBootstrapper.php new file mode 100644 index 0000000..1eadad3 --- /dev/null +++ b/src/Kernel/Runtime/Startup/InfrastructureBootstrapper.php @@ -0,0 +1,80 @@ +container !== null) { + return $this->container; + } + + foreach ($this->runtimeChecks() as $check) { + $check->execute(); + } + + $this->container = $this->buildContainer(); + + return $this->container; + } + + private function buildContainer(): ContainerInterface + { + $builder = new ContainerBuilder(); + $builder->addDefinitions($this->containerDefinitionsPath); + + if (!$this->isDevelopmentEnvironment()) { + $builder->enableCompilation($this->path('var/cache/di')); + } + + return $builder->build(); + } + + /** @return list */ + private function runtimeChecks(): array + { + return [ + new EnsureRuntimeDirectoriesCheck($this->rootDir), + new EnsureRequiredPhpExtensionsCheck(), + new LoadEnvironmentCheck($this->rootDir), + ]; + } + + private function isDevelopmentEnvironment(): bool + { + return strtolower($_ENV['APP_ENV'] ?? 'production') === 'development'; + } + + private function path(string $relativePath): string + { + return $this->rootDir . '/' . ltrim($relativePath, '/'); + } +} diff --git a/src/Kernel/Support/Exception/NotFoundException.php b/src/Kernel/Support/Exception/NotFoundException.php new file mode 100644 index 0000000..e567dbc --- /dev/null +++ b/src/Kernel/Support/Exception/NotFoundException.php @@ -0,0 +1,23 @@ +mediaRepository->findAll(); + } + + /** @return PaginatedResult */ + public function findPaginated(int $page, int $perPage): PaginatedResult + { + $page = max(1, $page); + $total = $this->mediaRepository->countAll(); + $offset = ($page - 1) * $perPage; + + return new PaginatedResult( + $this->mediaRepository->findPage($perPage, $offset), + $total, + $page, + $perPage, + ); + } + + /** @return Media[] */ + public function findByUserId(int $userId): array + { + return $this->mediaRepository->findByUserId($userId); + } + + /** @return PaginatedResult */ + public function findByUserIdPaginated(int $userId, int $page, int $perPage): PaginatedResult + { + $page = max(1, $page); + $total = $this->mediaRepository->countByUserId($userId); + $offset = ($page - 1) * $perPage; + + return new PaginatedResult( + $this->mediaRepository->findByUserPage($userId, $perPage, $offset), + $total, + $page, + $perPage, + ); + } + + public function findById(int $id): ?Media + { + return $this->mediaRepository->findById($id); + } + + public function store(UploadedMediaInterface $uploadedFile, int $userId): Media + { + return $this->storeMedia->handle(new StoreMediaCommand($uploadedFile, $userId)); + } + + /** @return array{count:int, references:list} */ + public function getUsageSummary(Media $media, int $sampleLimit = 5): array + { + return $this->getUsageSummaries([$media], $sampleLimit)[$media->getId()] ?? [ + 'count' => 0, + 'references' => [], + ]; + } + + /** + * Agrège les usages d'un lot de médias en une seule lecture inter-domaine. + * + * @param list $media + * @return array}> + */ + public function getUsageSummaries(array $media, int $sampleLimit = 5): array + { + if ($media === []) { + return []; + } + + $mediaIds = array_values(array_unique(array_map( + static fn (Media $item): int => $item->getId(), + $media, + ))); + + $counts = $this->mediaUsageReader->countUsagesByMediaIds($mediaIds); + $sampleUsages = $this->mediaUsageReader->findUsagesByMediaIds($mediaIds, $sampleLimit); + + $summaries = []; + foreach ($media as $item) { + $id = $item->getId(); + $summaries[$id] = [ + 'count' => $counts[$id] ?? 0, + 'references' => $sampleUsages[$id] ?? [], + ]; + } + + return $summaries; + } + + public function delete(Media $media): void + { + $this->deleteMedia->handle($media); + } +} diff --git a/src/Media/Application/MediaServiceInterface.php b/src/Media/Application/MediaServiceInterface.php new file mode 100644 index 0000000..c61f591 --- /dev/null +++ b/src/Media/Application/MediaServiceInterface.php @@ -0,0 +1,74 @@ + + */ + public function findAll(): array; + + /** + * Retourne une page de médias. + * + * @return PaginatedResult + */ + public function findPaginated(int $page, int $perPage): PaginatedResult; + + /** + * Retourne tous les médias déposés par un utilisateur. + * + * @return list + */ + public function findByUserId(int $userId): array; + + /** + * Retourne une page de médias pour un utilisateur donné. + * + * @return PaginatedResult + */ + public function findByUserIdPaginated(int $userId, int $page, int $perPage): PaginatedResult; + + /** + * Retourne un média par identifiant, ou `null` s'il n'existe pas. + */ + public function findById(int $id): ?Media; + + /** + * Stocke un média téléversé pour un utilisateur donné. + */ + public function store(UploadedMediaInterface $uploadedFile, int $userId): Media; + + /** + * Retourne le résumé d'usage d'un média. + * + * @return array{count:int, references:list} + */ + public function getUsageSummary(Media $media, int $sampleLimit = 5): array; + + /** + * Retourne les résumés d'usage pour une liste de médias. + * + * @param list $media Médias à inspecter. + * @return array}> Table indexée par identifiant de média. + */ + public function getUsageSummaries(array $media, int $sampleLimit = 5): array; + + /** + * Supprime définitivement un média. + */ + public function delete(Media $media): void; +} diff --git a/src/Media/Application/UseCase/DeleteMedia.php b/src/Media/Application/UseCase/DeleteMedia.php new file mode 100644 index 0000000..6ab1bf8 --- /dev/null +++ b/src/Media/Application/UseCase/DeleteMedia.php @@ -0,0 +1,33 @@ +mediaRepository->delete($media->getId()); + + if ($deletedRows < 1) { + return; + } + + // On privilégie l'absence d'enregistrement en base plutôt qu'une ligne pointant + // vers un fichier manquant : le cleanup disque intervient seulement après le delete SQL. + $this->mediaStorage->deleteStoredFile($media->getFilename()); + } +} diff --git a/src/Media/Application/UseCase/StoreMedia.php b/src/Media/Application/UseCase/StoreMedia.php new file mode 100644 index 0000000..60e4fa9 --- /dev/null +++ b/src/Media/Application/UseCase/StoreMedia.php @@ -0,0 +1,58 @@ +mediaStorage->prepareUpload($command->uploadedFile, $this->maxSize); + $hash = $preparedUpload->getHash(); + $existing = $this->mediaRepository->findByHashForUser($hash, $command->userId); + + if ($existing !== null) { + $this->mediaStorage->cleanupPreparedUpload($preparedUpload); + + return $existing; + } + + $filename = $this->mediaStorage->storePreparedUpload($command->uploadedFile, $preparedUpload); + $url = rtrim($this->uploadUrl, '/') . '/' . $filename; + $media = new Media(0, $filename, $url, $hash, $command->userId); + + try { + $id = $this->mediaRepository->create($media); + } catch (PDOException $e) { + // Une contrainte UNIQUE en base a gagné la course : on supprime le fichier + // fraîchement écrit puis on retourne le média désormais canonique si disponible. + $this->mediaStorage->deleteStoredFile($filename); + + $duplicate = $this->mediaRepository->findByHashForUser($hash, $command->userId); + if ($duplicate !== null) { + return $duplicate; + } + + throw $e; + } + + return new Media($id, $filename, $url, $hash, $command->userId); + } +} diff --git a/src/Media/Contracts/MediaUsageReaderInterface.php b/src/Media/Contracts/MediaUsageReaderInterface.php new file mode 100644 index 0000000..55fa306 --- /dev/null +++ b/src/Media/Contracts/MediaUsageReaderInterface.php @@ -0,0 +1,28 @@ + $mediaIds + * @return array + */ + public function countUsagesByMediaIds(array $mediaIds): array; + + /** @return list */ + public function findUsages(int $mediaId, int $limit = 5): array; + + /** + * @param list $mediaIds + * @return array> + */ + public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array; +} diff --git a/src/Media/Contracts/MediaUsageReference.php b/src/Media/Contracts/MediaUsageReference.php new file mode 100644 index 0000000..4b14731 --- /dev/null +++ b/src/Media/Contracts/MediaUsageReference.php @@ -0,0 +1,39 @@ +resourceId; + } + + public function getResourceId(): int + { + return $this->resourceId; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getEditPath(): string + { + return $this->path; + } + + public function getPath(): string + { + return $this->path; + } +} diff --git a/src/Media/Domain/Entity/Media.php b/src/Media/Domain/Entity/Media.php new file mode 100644 index 0000000..ccc3781 --- /dev/null +++ b/src/Media/Domain/Entity/Media.php @@ -0,0 +1,131 @@ +createdAt = $createdAt ?? new DateTime(); + } + + /** + * Crée une instance depuis un tableau associatif (ligne de base de données). + * + * @param array $data Données issues de la base de données + * + * @return self L'instance hydratée + */ + public static function fromArray(array $data): self + { + return new self( + id: (int) ($data['id'] ?? 0), + filename: (string) ($data['filename'] ?? ''), + url: (string) ($data['url'] ?? ''), + hash: (string) ($data['hash'] ?? ''), + userId: isset($data['user_id']) ? (int) $data['user_id'] : null, + createdAt: DateParser::parse($data['created_at'] ?? null), + ); + } + + /** + * Retourne l'identifiant du média. + * + * @return int L'identifiant en base (0 si non encore persisté) + */ + public function getId(): int + { + return $this->id; + } + + /** + * Retourne le nom de stockage du fichier sur disque. + * + * Ce nom est opaque et généré aléatoirement à l'upload. + * Il ne doit pas être affiché à l'utilisateur tel quel. + * + * @return string Le nom de fichier sur disque + */ + public function getFilename(): string + { + return $this->filename; + } + + /** + * Retourne l'URL publique d'accès au fichier. + * + * @return string L'URL publique (ex: "/media/a3f8c1d2_9f33.jpg") + */ + public function getUrl(): string + { + return $this->url; + } + + /** + * Retourne le hash SHA-256 du contenu binaire du fichier. + * + * Utilisé pour la détection des doublons à l'upload. + * + * @return string Le hash hexadécimal SHA-256 + */ + public function getHash(): string + { + return $this->hash; + } + + /** + * Retourne l'identifiant de l'auteur du média. + * + * @return int|null L'identifiant de l'auteur, ou null si le compte a été supprimé + */ + public function getUserId(): ?int + { + return $this->userId; + } + + /** + * Retourne la date d'upload du fichier. + * + * @return DateTime La date d'upload + */ + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } +} diff --git a/src/Media/Domain/Exception/FileTooLargeException.php b/src/Media/Domain/Exception/FileTooLargeException.php new file mode 100644 index 0000000..7d67889 --- /dev/null +++ b/src/Media/Domain/Exception/FileTooLargeException.php @@ -0,0 +1,17 @@ + + */ + public function findAll(): array; + + /** + * Retourne une page de médias. + * + * @return list + */ + public function findPage(int $limit, int $offset): array; + + /** + * Retourne le nombre total de médias. + */ + public function countAll(): int; + + /** + * Retourne tous les médias d'un utilisateur. + * + * @return list + */ + public function findByUserId(int $userId): array; + + /** + * Retourne une page de médias d'un utilisateur. + * + * @return list + */ + public function findByUserPage(int $userId, int $limit, int $offset): array; + + /** + * Retourne le nombre total de médias d'un utilisateur. + */ + public function countByUserId(int $userId): int; + + /** + * Retourne un média par identifiant, ou `null` s'il n'existe pas. + */ + public function findById(int $id): ?Media; + + /** + * Retourne un média par hash de contenu, ou `null` s'il n'existe pas. + */ + public function findByHash(string $hash): ?Media; + + /** + * Retourne un média par hash de contenu pour un utilisateur donné. + */ + public function findByHashForUser(string $hash, int $userId): ?Media; + + /** + * Persiste un média et retourne son identifiant. + */ + public function create(Media $media): int; + + /** + * Supprime un média et retourne le nombre de lignes affectées. + */ + public function delete(int $id): int; +} diff --git a/src/Media/Domain/Service/MediaStorageInterface.php b/src/Media/Domain/Service/MediaStorageInterface.php new file mode 100644 index 0000000..f05a032 --- /dev/null +++ b/src/Media/Domain/Service/MediaStorageInterface.php @@ -0,0 +1,38 @@ +temporaryPath; + } + + public function getHash(): string + { + return $this->hash; + } + + public function getExtension(): string + { + return $this->extension; + } + + public function shouldCopyFromTemporaryPath(): bool + { + return $this->copyFromTemporaryPath; + } +} diff --git a/src/Media/Infrastructure/LocalMediaStorage.php b/src/Media/Infrastructure/LocalMediaStorage.php new file mode 100644 index 0000000..aff5ead --- /dev/null +++ b/src/Media/Infrastructure/LocalMediaStorage.php @@ -0,0 +1,198 @@ + 'webp', + 'image/png' => 'webp', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + ]; + private const MIME_EXTENSIONS_FALLBACK = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + ]; + private const MAX_PIXELS = 40000000; + + public function __construct(private readonly string $uploadDir) {} + + /** + * Prépare un upload en validant le fichier, ses dimensions et son extension finale. + * + * @throws FileTooLargeException + * @throws InvalidMimeTypeException + * @throws StorageException + */ + public function prepareUpload(UploadedMediaInterface $uploadedFile, int $maxSize): PreparedMediaUpload + { + $size = $uploadedFile->getSize(); + + if (!is_int($size)) { + throw new StorageException('Impossible de déterminer la taille du fichier uploadé'); + } + + if ($size > $maxSize) { + throw new FileTooLargeException($maxSize); + } + + $tmpPath = $uploadedFile->getTemporaryPath(); + + if ($tmpPath === null || $tmpPath === '') { + throw new StorageException('Impossible de localiser le fichier temporaire uploadé'); + } + + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->file($tmpPath); + + if ($mime === false || !in_array($mime, self::ALLOWED_MIME_TYPES, true)) { + throw new InvalidMimeTypeException($mime === false ? 'unknown' : $mime); + } + + $this->assertReasonableDimensions($tmpPath); + + $copyFromTemporaryPath = false; + if (in_array($mime, self::WEBP_CONVERTIBLE, true)) { + $convertedPath = $this->convertToWebP($tmpPath); + if ($convertedPath !== null) { + $tmpPath = $convertedPath; + $copyFromTemporaryPath = true; + } + } + + $rawHash = hash_file('sha256', $tmpPath); + + if ($rawHash === false) { + if ($copyFromTemporaryPath) { + @unlink($tmpPath); + } + + throw new StorageException('Impossible de calculer le hash du fichier'); + } + + $extension = $copyFromTemporaryPath + ? self::MIME_EXTENSIONS[$mime] + : self::MIME_EXTENSIONS_FALLBACK[$mime]; + + return new PreparedMediaUpload($tmpPath, $rawHash, $extension, $copyFromTemporaryPath); + } + + public function storePreparedUpload(UploadedMediaInterface $uploadedFile, PreparedMediaUpload $preparedUpload): string + { + if (!is_dir($this->uploadDir) && !@mkdir($this->uploadDir, 0755, true)) { + throw new StorageException("Impossible de créer le répertoire d'upload"); + } + + $filename = uniqid('', true) . '_' . bin2hex(random_bytes(4)) . '.' . $preparedUpload->getExtension(); + $destPath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename; + + if ($preparedUpload->shouldCopyFromTemporaryPath()) { + if (!copy($preparedUpload->getTemporaryPath(), $destPath)) { + $this->cleanupPreparedUpload($preparedUpload); + + throw new StorageException('Impossible de déplacer le fichier converti'); + } + $this->cleanupPreparedUpload($preparedUpload); + + return $filename; + } + + $uploadedFile->moveTo($destPath); + + return $filename; + } + + public function cleanupPreparedUpload(PreparedMediaUpload $preparedUpload): void + { + if ($preparedUpload->shouldCopyFromTemporaryPath() && file_exists($preparedUpload->getTemporaryPath())) { + @unlink($preparedUpload->getTemporaryPath()); + } + } + + public function deleteStoredFile(string $filename): void + { + $filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename; + + if (file_exists($filePath)) { + @unlink($filePath); + } + } + + private function assertReasonableDimensions(string $path): void + { + $size = @getimagesize($path); + + if ($size === false) { + throw new StorageException('Impossible de lire les dimensions de l\'image'); + } + + [$width, $height] = $size; + + if ($width <= 0 || $height <= 0) { + throw new StorageException('Dimensions d\'image invalides'); + } + + if (($width * $height) > self::MAX_PIXELS) { + throw new StorageException('Image trop volumineuse en dimensions pour être traitée'); + } + } + + private function convertToWebP(string $sourcePath): ?string + { + if (!function_exists('imagecreatefromjpeg') || !function_exists('imagecreatefrompng') || !function_exists('imagewebp')) { + return null; + } + + $imageInfo = @getimagesize($sourcePath); + if ($imageInfo === false) { + return null; + } + + $mime = $imageInfo['mime']; + $src = match ($mime) { + 'image/jpeg' => @imagecreatefromjpeg($sourcePath), + 'image/png' => @imagecreatefrompng($sourcePath), + default => null, + }; + + if ($src === false || $src === null) { + return null; + } + + $tmpWebp = tempnam(sys_get_temp_dir(), 'media_webp_'); + if ($tmpWebp === false) { + imagedestroy($src); + + return null; + } + + $ok = @imagewebp($src, $tmpWebp, 82); + imagedestroy($src); + + if ($ok !== true) { + @unlink($tmpWebp); + + return null; + } + + return $tmpWebp; + } +} diff --git a/src/Media/Infrastructure/NullMediaUsageReader.php b/src/Media/Infrastructure/NullMediaUsageReader.php new file mode 100644 index 0000000..809f76d --- /dev/null +++ b/src/Media/Infrastructure/NullMediaUsageReader.php @@ -0,0 +1,44 @@ +db->query(self::SELECT . ' ORDER BY id DESC'); + if ($stmt === false) { + throw new \RuntimeException('La requête SELECT sur media a échoué.'); + } + + return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @return Media[] */ + public function findPage(int $limit, int $offset): array + { + $stmt = $this->db->prepare(self::SELECT . ' ORDER BY id DESC LIMIT :limit OFFSET :offset'); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function countAll(): int + { + $stmt = $this->db->query('SELECT COUNT(*) FROM media'); + if ($stmt === false) { + throw new \RuntimeException('La requête COUNT sur media a échoué.'); + } + + return (int) ($stmt->fetchColumn() ?: 0); + } + + /** @return Media[] */ + public function findByUserId(int $userId): array + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC'); + $stmt->execute([':user_id' => $userId]); + + return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @return Media[] */ + public function findByUserPage(int $userId, int $limit, int $offset): array + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC LIMIT :limit OFFSET :offset'); + $stmt->bindValue(':user_id', $userId, PDO::PARAM_INT); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function countByUserId(int $userId): int + { + $stmt = $this->db->prepare('SELECT COUNT(*) FROM media WHERE user_id = :user_id'); + $stmt->execute([':user_id' => $userId]); + + return (int) $stmt->fetchColumn(); + } + + public function findById(int $id): ?Media + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE id = :id'); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? Media::fromArray($row) : null; + } + + public function findByHash(string $hash): ?Media + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash ORDER BY id DESC LIMIT 1'); + $stmt->execute([':hash' => $hash]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? Media::fromArray($row) : null; + } + + public function findByHashForUser(string $hash, int $userId): ?Media + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash AND user_id = :user_id ORDER BY id DESC LIMIT 1'); + $stmt->execute([':hash' => $hash, ':user_id' => $userId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? Media::fromArray($row) : null; + } + + public function create(Media $media): int + { + $stmt = $this->db->prepare( + 'INSERT INTO media (filename, url, hash, user_id, created_at) + VALUES (:filename, :url, :hash, :user_id, :created_at)', + ); + + $stmt->execute([ + ':filename' => $media->getFilename(), + ':url' => $media->getUrl(), + ':hash' => $media->getHash(), + ':user_id' => $media->getUserId(), + ':created_at' => date('Y-m-d H:i:s'), + ]); + + return (int) $this->db->lastInsertId(); + } + + public function delete(int $id): int + { + $stmt = $this->db->prepare('DELETE FROM media WHERE id = :id'); + $stmt->execute([':id' => $id]); + + return $stmt->rowCount(); + } +} diff --git a/src/Media/Infrastructure/dependencies.php b/src/Media/Infrastructure/dependencies.php new file mode 100644 index 0000000..0243ba9 --- /dev/null +++ b/src/Media/Infrastructure/dependencies.php @@ -0,0 +1,36 @@ + autowire(PdoMediaRepository::class), + Netig\Netslim\Media\Contracts\MediaUsageReaderInterface::class => autowire(NullMediaUsageReader::class), + MediaStorageInterface::class => factory(function (): MediaStorageInterface { + return new LocalMediaStorage(RuntimePaths::getProjectRoot() . '/public/media'); + }), + StoreMedia::class => factory(function ( + MediaRepositoryInterface $mediaRepository, + MediaStorageInterface $mediaStorage, + ): StoreMedia { + return new StoreMedia( + mediaRepository: $mediaRepository, + mediaStorage: $mediaStorage, + uploadUrl: '/media', + maxSize: (int) ($_ENV['UPLOAD_MAX_SIZE'] ?? 5 * 1024 * 1024), + ); + }), + MediaServiceInterface::class => autowire(MediaApplicationService::class), +]; diff --git a/src/Media/MediaModule.php b/src/Media/MediaModule.php new file mode 100644 index 0000000..3e2f612 --- /dev/null +++ b/src/Media/MediaModule.php @@ -0,0 +1,45 @@ + $app */ + public function registerRoutes(App $app): void + { + Routes::register($app); + } + + public function templateNamespaces(): array + { + return ['Media' => __DIR__ . '/UI/Templates']; + } + + public function twigExtensions(): array + { + return []; + } + + public function migrationDirectories(): array + { + return [__DIR__ . '/Migrations']; + } + + public function requiredTables(): array + { + return ['media']; + } +} diff --git a/src/Media/Migrations/300_media_schema.php b/src/Media/Migrations/300_media_schema.php new file mode 100644 index 0000000..c202aca --- /dev/null +++ b/src/Media/Migrations/300_media_schema.php @@ -0,0 +1,23 @@ + " + CREATE TABLE IF NOT EXISTS media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + url TEXT NOT NULL, + hash TEXT NOT NULL, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_media_user_id ON media(user_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_media_hash_user_id ON media(hash, user_id); + ", + 'down' => " + DROP INDEX IF EXISTS idx_media_hash_user_id; + DROP INDEX IF EXISTS idx_media_user_id; + DROP TABLE IF EXISTS media; + ", +]; diff --git a/src/Media/UI/Http/MediaController.php b/src/Media/UI/Http/MediaController.php new file mode 100644 index 0000000..e393227 --- /dev/null +++ b/src/Media/UI/Http/MediaController.php @@ -0,0 +1,205 @@ +renderIndex($req, $res, false); + } + + public function picker(Request $req, Response $res): Response + { + return $this->renderIndex($req, $res, true); + } + + private function renderIndex(Request $req, Response $res, bool $pickerMode): Response + { + $isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor(); + $userId = $this->sessionManager->getUserId(); + $page = PaginationPresenter::resolvePage($req->getQueryParams()); + + $paginated = $isAdmin + ? $this->mediaService->findPaginated($page, self::PER_PAGE) + : $this->mediaService->findByUserIdPaginated((int) $userId, $page, self::PER_PAGE); + + $usageByMediaId = $paginated->getItems() === [] + ? [] + : $this->mediaService->getUsageSummaries($paginated->getItems(), 5); + + return $this->view->render($res, '@Media/admin/index.twig', [ + 'media' => $paginated->getItems(), + 'mediaUsage' => $usageByMediaId, + 'pagination' => PaginationPresenter::fromRequest($req, $paginated), + 'error' => $this->flash->get('media_error'), + 'success' => $this->flash->get('media_success'), + 'pickerMode' => $pickerMode, + ]); + } + + public function upload(Request $req, Response $res): Response + { + $files = $req->getUploadedFiles(); + $uploadedFile = $files['image'] ?? null; + + if ($uploadedFile === null || $uploadedFile->getError() !== UPLOAD_ERR_OK) { + return $this->jsonError($res, "Aucun fichier reçu ou erreur d'upload", 400); + } + + try { + $media = $this->mediaService->store(new PsrUploadedMedia($uploadedFile), $this->sessionManager->getUserId() ?? 0); + } catch (FileTooLargeException $e) { + return $this->jsonError($res, $e->getMessage(), 413); + } catch (InvalidMimeTypeException $e) { + return $this->jsonError($res, $e->getMessage(), 415); + } catch (StorageException $e) { + return $this->jsonError($res, $e->getMessage(), 500); + } catch (\Throwable $e) { + $incidentId = $this->logUnexpectedError($req, $e, [ + 'user_id' => $this->sessionManager->getUserId(), + ]); + + return $this->jsonError($res, "Une erreur inattendue s'est produite (réf. {$incidentId})", 500); + } + + return $this->jsonSuccess($res, $media); + } + + /** @param array $args */ + public function delete(Request $req, Response $res, array $args): Response + { + $id = (int) ($args['id'] ?? 0); + $media = $this->mediaService->findById($id); + + if ($media === null) { + $this->flash->set('media_error', 'Fichier introuvable'); + + return $res->withHeader('Location', '/admin/media')->withStatus(302); + } + + $userId = $this->sessionManager->getUserId(); + $isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor(); + + if (!$isAdmin && $media->getUserId() !== $userId) { + $this->flash->set('media_error', "Vous n'êtes pas autorisé à supprimer ce fichier"); + + return $res->withHeader('Location', '/admin/media')->withStatus(302); + } + + try { + $usage = self::normalizeUsageSummary($this->mediaService->getUsageSummary($media, 3)); + + if ($usage['count'] > 0) { + $titles = array_map( + static fn (MediaUsageReference $reference) => '« ' . $reference->getTitle() . ' »', + $usage['references'], + ); + $details = $titles === [] ? '' : ' Utilisé dans : ' . implode(', ', $titles) . '.'; + $this->flash->set( + 'media_error', + 'Ce média est encore référencé par ' . $usage['count'] . ' contenu(s) et ne peut pas être supprimé.' . $details, + ); + + return $res->withHeader('Location', '/admin/media')->withStatus(302); + } + + $this->mediaService->delete($media); + $this->flash->set('media_success', 'Fichier supprimé'); + } catch (\InvalidArgumentException $e) { + $this->flash->set('media_error', $e->getMessage()); + } catch (\Throwable $e) { + $incidentId = $this->logUnexpectedError($req, $e, [ + 'media_id' => $id, + 'user_id' => $userId, + 'media_owner_id' => $media->getUserId(), + ]); + $this->flash->set('media_error', "Une erreur inattendue s'est produite (réf. {$incidentId})"); + } + + return $res->withHeader('Location', '/admin/media')->withStatus(302); + } + + /** @param mixed $usage + * @return array{count:int, references:list} + */ + private static function normalizeUsageSummary(mixed $usage): array + { + if (!is_array($usage)) { + return ['count' => 0, 'references' => []]; + } + + $count = isset($usage['count']) && is_int($usage['count']) ? $usage['count'] : 0; + $references = isset($usage['references']) && is_array($usage['references']) + ? array_values(array_filter($usage['references'], static fn (mixed $reference): bool => $reference instanceof MediaUsageReference)) + : []; + + return [ + 'count' => $count, + 'references' => $references, + ]; + } + + private function jsonSuccess(Response $res, Media $media): Response + { + $res->getBody()->write(json_encode([ + 'success' => true, + 'file' => $media->getUrl(), + 'mediaId' => $media->getId(), + ], JSON_THROW_ON_ERROR)); + + return $res->withHeader('Content-Type', 'application/json')->withStatus(200); + } + + private function jsonError(Response $res, string $message, int $status): Response + { + $res->getBody()->write(json_encode(['error' => $message], JSON_THROW_ON_ERROR)); + + return $res->withHeader('Content-Type', 'application/json')->withStatus($status); + } + + /** + * @param array $context + */ + private function logUnexpectedError(Request $req, \Throwable $e, array $context = []): string + { + $incidentId = bin2hex(random_bytes(8)); + + $this->logger?->error('Media administration action failed', $context + [ + 'incident_id' => $incidentId, + 'route' => (string) $req->getUri()->getPath(), + 'method' => $req->getMethod(), + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + 'exception' => $e, + ]); + + return $incidentId; + } +} diff --git a/src/Media/UI/Http/PsrUploadedMedia.php b/src/Media/UI/Http/PsrUploadedMedia.php new file mode 100644 index 0000000..d1486ee --- /dev/null +++ b/src/Media/UI/Http/PsrUploadedMedia.php @@ -0,0 +1,33 @@ +uploadedFile->getSize(); + } + + public function getTemporaryPath(): ?string + { + $uri = $this->uploadedFile->getStream()->getMetadata('uri'); + + return is_string($uri) && $uri !== '' ? $uri : null; + } + + public function moveTo(string $targetPath): void + { + $this->uploadedFile->moveTo($targetPath); + } +} diff --git a/src/Media/UI/Http/Routes.php b/src/Media/UI/Http/Routes.php new file mode 100644 index 0000000..c9ded98 --- /dev/null +++ b/src/Media/UI/Http/Routes.php @@ -0,0 +1,26 @@ + $app */ + public static function register(App $app): void + { + $app->group('/admin', function ($group) { + $group->post('/media/upload', [MediaController::class, 'upload']); + $group->get('/media', [MediaController::class, 'index']); + $group->get('/media/picker', [MediaController::class, 'picker']); + $group->post('/media/delete/{id}', [MediaController::class, 'delete']); + })->add(AuthMiddleware::class); + } +} diff --git a/src/Media/UI/Templates/admin/index.twig b/src/Media/UI/Templates/admin/index.twig new file mode 100644 index 0000000..15e9fc9 --- /dev/null +++ b/src/Media/UI/Templates/admin/index.twig @@ -0,0 +1,127 @@ +{% extends pickerMode|default(false) ? "@Kernel/layout_picker.twig" : "@Kernel/layout.twig" %} + +{% block title %}Tableau de bord – Médias{% endblock %} + +{% block content %} +{% set isPicker = pickerMode|default(false) %} + +{% if isPicker %} +
+
+

Sélectionner un média

+

Choisissez un média à insérer dans l'éditeur. L'insertion conserve automatiquement le lien avec la médiathèque grâce à data-media-id.

+
+
+{% else %} +{% include '@Kernel/partials/_admin_page_header.twig' with { + title: 'Gestion des médias', +} %} +{% endif %} + +{% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %} + +
+{% embed '@Kernel/partials/_admin_create_box.twig' with { + title: 'Ajouter une image', + hint: 'Formats image uniquement. Après téléversement, la liste ci-dessous se met à jour.', + form: { + id: 'media-upload-form', + action: '/admin/media/upload', + enctype: 'multipart/form-data', + extra: { 'data-upload-url': '/admin/media/upload' } + } +} %} + {% block fields %} + + {% endblock %} + + {% block actions %} + + + {% endblock %} +{% endembed %} + +{% if media is not empty %} + + + + + + + + + + + + {% for item in media %} + {% set usage = mediaUsage[item.id] ?? {'count': 0, 'references': []} %} + + + + + + + + {% endfor %} + +
AperçuURLUsageUploadé leActions
+
+ + + +
+
+
+ {{ item.url }} +
+ {% if isPicker %} + + {% endif %} + + +
+
+
+ {% if usage.count > 0 %} + {{ usage.count }} contenu{{ usage.count > 1 ? 's' : '' }} + + {% else %} + Aucun + {% endif %} + {{ item.createdAt|date("d/m/Y H:i") }} +
+ {% include '@Kernel/partials/_admin_delete_form.twig' with { + action: '/admin/media/delete/' ~ item.id, + confirm: 'Supprimer ce fichier ?', + disabled: usage.count > 0, + disabled_title: "Supprimez ou remplacez d'abord l'image dans les contenus référencés" + } %} +
+
+ +{% include '@Kernel/partials/_pagination.twig' with { pagination: pagination } %} +{% else %} +{% include '@Kernel/partials/_empty_state.twig' with { + title: 'Aucun média', + message: 'Aucun fichier uploadé.' +} %} +{% endif %} +
+{% endblock %} + + +{% block scripts %} +{# Script applicatif attendu du côté projet consommateur. #} + +{% endblock %} diff --git a/src/Notifications/Application/NotificationApplicationService.php b/src/Notifications/Application/NotificationApplicationService.php new file mode 100644 index 0000000..9593d68 --- /dev/null +++ b/src/Notifications/Application/NotificationApplicationService.php @@ -0,0 +1,60 @@ +mailer->send($email->to, $email->subject, $email->template, $email->context); + $this->dispatches->create(NotificationDispatch::successful($email)); + } catch (\Throwable $exception) { + $this->dispatches->create(NotificationDispatch::failed($email, $exception->getMessage())); + + throw $exception; + } + } + + public function sendTemplate(string $to, string $subject, string $template, array $context = [], ?string $notificationKey = null): void + { + $this->send(new TransactionalEmail($to, $subject, $template, $context, $notificationKey)); + } + + /** + * @return list + */ + public function recent(int $limit = 50): array + { + return array_map( + static fn (NotificationDispatch $dispatch): NotificationDispatchView => new NotificationDispatchView( + id: $dispatch->getId() ?? 0, + recipient: $dispatch->getRecipient(), + subject: $dispatch->getSubject(), + template: $dispatch->getTemplate(), + status: $dispatch->getStatus(), + notificationKey: $dispatch->getNotificationKey(), + errorMessage: $dispatch->getErrorMessage(), + createdAt: $dispatch->getCreatedAt()->format(DATE_ATOM), + sentAt: $dispatch->getSentAt()?->format(DATE_ATOM), + ), + $this->dispatches->findRecent($limit), + ); + } +} diff --git a/src/Notifications/Application/NotificationServiceInterface.php b/src/Notifications/Application/NotificationServiceInterface.php new file mode 100644 index 0000000..265beb5 --- /dev/null +++ b/src/Notifications/Application/NotificationServiceInterface.php @@ -0,0 +1,13 @@ + */ + public function recent(int $limit = 50): array; +} diff --git a/src/Notifications/Contracts/TransactionalEmailSenderInterface.php b/src/Notifications/Contracts/TransactionalEmailSenderInterface.php new file mode 100644 index 0000000..f4c7827 --- /dev/null +++ b/src/Notifications/Contracts/TransactionalEmailSenderInterface.php @@ -0,0 +1,18 @@ + $context */ + public function sendTemplate(string $to, string $subject, string $template, array $context = [], ?string $notificationKey = null): void; +} diff --git a/src/Notifications/Domain/Entity/NotificationDispatch.php b/src/Notifications/Domain/Entity/NotificationDispatch.php new file mode 100644 index 0000000..75e84ac --- /dev/null +++ b/src/Notifications/Domain/Entity/NotificationDispatch.php @@ -0,0 +1,131 @@ +recipient) === '' || trim($this->subject) === '' || trim($this->template) === '') { + throw new \InvalidArgumentException('Un envoi de notification doit définir un destinataire, un sujet et un template.'); + } + + if (!in_array($this->status, [self::STATUS_SENT, self::STATUS_FAILED], true)) { + throw new \InvalidArgumentException(sprintf('Statut de notification non supporté : %s', $this->status)); + } + } + + public static function successful(TransactionalEmail $email): self + { + $now = new \DateTimeImmutable(); + + return new self( + id: null, + recipient: $email->to, + subject: $email->subject, + template: $email->template, + status: self::STATUS_SENT, + notificationKey: $email->notificationKey, + errorMessage: null, + createdAt: $now, + sentAt: $now, + ); + } + + public static function failed(TransactionalEmail $email, string $errorMessage): self + { + return new self( + id: null, + recipient: $email->to, + subject: $email->subject, + template: $email->template, + status: self::STATUS_FAILED, + notificationKey: $email->notificationKey, + errorMessage: $errorMessage, + createdAt: new \DateTimeImmutable(), + sentAt: null, + ); + } + + /** + * @param array{id:int,recipient:string,subject:string,template:string,status:string,notification_key:string|null,error_message:string|null,created_at:string,sent_at:string|null} $row + */ + public static function fromRow(array $row): self + { + return new self( + id: (int) $row['id'], + recipient: $row['recipient'], + subject: $row['subject'], + template: $row['template'], + status: $row['status'], + notificationKey: $row['notification_key'], + errorMessage: $row['error_message'], + createdAt: new \DateTimeImmutable($row['created_at']), + sentAt: $row['sent_at'] !== null ? new \DateTimeImmutable($row['sent_at']) : null, + ); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getRecipient(): string + { + return $this->recipient; + } + + public function getSubject(): string + { + return $this->subject; + } + + public function getTemplate(): string + { + return $this->template; + } + + public function getStatus(): string + { + return $this->status; + } + + public function getNotificationKey(): ?string + { + return $this->notificationKey; + } + + public function getErrorMessage(): ?string + { + return $this->errorMessage; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getSentAt(): ?\DateTimeImmutable + { + return $this->sentAt; + } +} diff --git a/src/Notifications/Domain/Repository/NotificationDispatchRepositoryInterface.php b/src/Notifications/Domain/Repository/NotificationDispatchRepositoryInterface.php new file mode 100644 index 0000000..3d61a90 --- /dev/null +++ b/src/Notifications/Domain/Repository/NotificationDispatchRepositoryInterface.php @@ -0,0 +1,15 @@ + */ + public function findRecent(int $limit = 50): array; +} diff --git a/src/Notifications/Domain/ValueObject/TransactionalEmail.php b/src/Notifications/Domain/ValueObject/TransactionalEmail.php new file mode 100644 index 0000000..c99fe12 --- /dev/null +++ b/src/Notifications/Domain/ValueObject/TransactionalEmail.php @@ -0,0 +1,28 @@ + $context */ + public function __construct( + public string $to, + public string $subject, + public string $template, + public array $context = [], + public ?string $notificationKey = null, + ) { + if (!filter_var($this->to, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Adresse destinataire invalide'); + } + + if (trim($this->subject) === '' || trim($this->template) === '') { + throw new \InvalidArgumentException('Sujet et template sont obligatoires pour un email transactionnel.'); + } + } +} diff --git a/src/Notifications/Infrastructure/PdoNotificationDispatchRepository.php b/src/Notifications/Infrastructure/PdoNotificationDispatchRepository.php new file mode 100644 index 0000000..df95fac --- /dev/null +++ b/src/Notifications/Infrastructure/PdoNotificationDispatchRepository.php @@ -0,0 +1,56 @@ +db->prepare( + 'INSERT INTO notification_dispatches (recipient, subject, template, status, notification_key, error_message, created_at, sent_at) + VALUES (:recipient, :subject, :template, :status, :notification_key, :error_message, :created_at, :sent_at)', + ); + + $stmt->execute([ + ':recipient' => $dispatch->getRecipient(), + ':subject' => $dispatch->getSubject(), + ':template' => $dispatch->getTemplate(), + ':status' => $dispatch->getStatus(), + ':notification_key' => $dispatch->getNotificationKey(), + ':error_message' => $dispatch->getErrorMessage(), + ':created_at' => $dispatch->getCreatedAt()->format('Y-m-d H:i:s'), + ':sent_at' => $dispatch->getSentAt()?->format('Y-m-d H:i:s'), + ]); + } + + /** + * @return list + */ + public function findRecent(int $limit = 50): array + { + $stmt = $this->db->prepare( + 'SELECT id, recipient, subject, template, status, notification_key, error_message, created_at, sent_at + FROM notification_dispatches + ORDER BY id DESC + LIMIT :limit', + ); + $stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT); + $stmt->execute(); + + /** @var list $rows */ + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + return array_map(static fn (array $row): NotificationDispatch => NotificationDispatch::fromRow($row), $rows); + } +} diff --git a/src/Notifications/Infrastructure/dependencies.php b/src/Notifications/Infrastructure/dependencies.php new file mode 100644 index 0000000..8a4b138 --- /dev/null +++ b/src/Notifications/Infrastructure/dependencies.php @@ -0,0 +1,19 @@ + autowire(NotificationApplicationService::class), + TransactionalEmailSenderInterface::class => autowire(NotificationApplicationService::class), + NotificationHistoryReaderInterface::class => autowire(NotificationApplicationService::class), + NotificationDispatchRepositoryInterface::class => autowire(PdoNotificationDispatchRepository::class), +]; diff --git a/src/Notifications/Migrations/340_notifications_schema.php b/src/Notifications/Migrations/340_notifications_schema.php new file mode 100644 index 0000000..e729d95 --- /dev/null +++ b/src/Notifications/Migrations/340_notifications_schema.php @@ -0,0 +1,26 @@ + " + CREATE TABLE IF NOT EXISTS notification_dispatches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient TEXT NOT NULL, + subject TEXT NOT NULL, + template TEXT NOT NULL, + status TEXT NOT NULL, + notification_key TEXT DEFAULT NULL, + error_message TEXT DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + sent_at DATETIME DEFAULT NULL + ); + CREATE INDEX IF NOT EXISTS idx_notification_dispatches_status ON notification_dispatches(status); + CREATE INDEX IF NOT EXISTS idx_notification_dispatches_key ON notification_dispatches(notification_key); + ", + 'down' => " + DROP INDEX IF EXISTS idx_notification_dispatches_key; + DROP INDEX IF EXISTS idx_notification_dispatches_status; + DROP TABLE IF EXISTS notification_dispatches; + ", +]; diff --git a/src/Notifications/NotificationsModule.php b/src/Notifications/NotificationsModule.php new file mode 100644 index 0000000..204a837 --- /dev/null +++ b/src/Notifications/NotificationsModule.php @@ -0,0 +1,44 @@ + $app */ + public function registerRoutes(App $app): void {} + + public function templateNamespaces(): array + { + return []; + } + + public function twigExtensions(): array + { + return []; + } + + public function migrationDirectories(): array + { + return [__DIR__ . '/Migrations']; + } + + public function requiredTables(): array + { + return ['notification_dispatches']; + } +} diff --git a/src/Settings/Application/SettingsApplicationService.php b/src/Settings/Application/SettingsApplicationService.php new file mode 100644 index 0000000..1b89597 --- /dev/null +++ b/src/Settings/Application/SettingsApplicationService.php @@ -0,0 +1,85 @@ +repository->findByKey($key) !== null; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->repository->findByKey($key)?->getValue() ?? $default; + } + + public function getString(string $key, string $default = ''): string + { + $value = $this->get($key, $default); + + return is_scalar($value) ? (string) $value : $default; + } + + public function getInt(string $key, int $default = 0): int + { + $value = $this->get($key, $default); + + return is_numeric($value) ? (int) $value : $default; + } + + public function getBool(string $key, bool $default = false): bool + { + $value = $this->get($key, $default); + + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + return filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $default; + } + + if (is_int($value)) { + return $value !== 0; + } + + return $default; + } + + /** + * @return array + */ + public function all(): array + { + $settings = []; + + foreach ($this->repository->findAll() as $setting) { + $settings[$setting->getKey()] = $setting->getValue(); + } + + ksort($settings); + + return $settings; + } + + public function set(string $key, mixed $value): void + { + $this->repository->save(new Setting($key, $value)); + } + + public function delete(string $key): void + { + $this->repository->delete($key); + } +} diff --git a/src/Settings/Application/SettingsServiceInterface.php b/src/Settings/Application/SettingsServiceInterface.php new file mode 100644 index 0000000..ae4b0d6 --- /dev/null +++ b/src/Settings/Application/SettingsServiceInterface.php @@ -0,0 +1,17 @@ + */ + public function all(): array; +} diff --git a/src/Settings/Contracts/SettingsWriterInterface.php b/src/Settings/Contracts/SettingsWriterInterface.php new file mode 100644 index 0000000..8ad785d --- /dev/null +++ b/src/Settings/Contracts/SettingsWriterInterface.php @@ -0,0 +1,18 @@ +key) === '') { + throw new \InvalidArgumentException('La clé de paramètre ne peut pas être vide'); + } + + $this->type = $type ?? self::inferType($this->value); + } + + /** + * @param array{setting_key:string, setting_value:string|null, value_type:string, updated_at?:string|null} $row + */ + public static function fromStorage(array $row): self + { + return new self( + key: $row['setting_key'], + value: self::decodeValue($row['setting_value'], $row['value_type']), + type: $row['value_type'], + updatedAt: isset($row['updated_at']) && $row['updated_at'] !== null + ? new \DateTimeImmutable($row['updated_at']) + : new \DateTimeImmutable(), + ); + } + + public function getKey(): string + { + return $this->key; + } + + public function getValue(): mixed + { + return $this->value; + } + + public function getType(): string + { + return $this->type; + } + + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + + public function toStorageValue(): ?string + { + return match ($this->type) { + 'null' => null, + 'bool' => $this->value ? '1' : '0', + 'int', 'float', 'string' => (string) $this->value, + 'json' => json_encode($this->value, JSON_THROW_ON_ERROR), + default => throw new \LogicException(sprintf('Type de paramètre non supporté : %s', $this->type)), + }; + } + + private static function inferType(mixed $value): string + { + return match (true) { + $value === null => 'null', + is_bool($value) => 'bool', + is_int($value) => 'int', + is_float($value) => 'float', + is_string($value) => 'string', + is_array($value) => 'json', + default => throw new \InvalidArgumentException('Type de paramètre non supporté. Utilisez null, bool, int, float, string ou array.'), + }; + } + + private static function decodeValue(?string $value, string $type): mixed + { + return match ($type) { + 'null' => null, + 'bool' => $value === '1', + 'int' => (int) $value, + 'float' => (float) $value, + 'string' => (string) $value, + 'json' => $value === null ? [] : json_decode($value, true, flags: JSON_THROW_ON_ERROR), + default => throw new \LogicException(sprintf('Type de paramètre stocké non supporté : %s', $type)), + }; + } +} diff --git a/src/Settings/Domain/Repository/SettingRepositoryInterface.php b/src/Settings/Domain/Repository/SettingRepositoryInterface.php new file mode 100644 index 0000000..1f371d1 --- /dev/null +++ b/src/Settings/Domain/Repository/SettingRepositoryInterface.php @@ -0,0 +1,19 @@ + */ + public function findAll(): array; + + public function save(Setting $setting): void; + + public function delete(string $key): void; +} diff --git a/src/Settings/Infrastructure/PdoSettingRepository.php b/src/Settings/Infrastructure/PdoSettingRepository.php new file mode 100644 index 0000000..1e9b143 --- /dev/null +++ b/src/Settings/Infrastructure/PdoSettingRepository.php @@ -0,0 +1,71 @@ +db->prepare('SELECT setting_key, setting_value, value_type, updated_at FROM settings WHERE setting_key = :key'); + $stmt->execute([':key' => $key]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!is_array($row)) { + return null; + } + + /** @var array{setting_key:string, setting_value:string|null, value_type:string, updated_at?:string|null} $row */ + return Setting::fromStorage($row); + } + + /** + * @return list + */ + public function findAll(): array + { + $stmt = $this->db->query('SELECT setting_key, setting_value, value_type, updated_at FROM settings ORDER BY setting_key ASC'); + if (!$stmt instanceof \PDOStatement) { + return []; + } + + /** @var list $rows */ + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + return array_map(static fn (array $row): Setting => Setting::fromStorage($row), $rows); + } + + public function save(Setting $setting): void + { + $stmt = $this->db->prepare( + 'INSERT INTO settings (setting_key, setting_value, value_type, updated_at) + VALUES (:key, :value, :type, :updated_at) + ON CONFLICT(setting_key) DO UPDATE SET + setting_value = excluded.setting_value, + value_type = excluded.value_type, + updated_at = excluded.updated_at', + ); + + $stmt->execute([ + ':key' => $setting->getKey(), + ':value' => $setting->toStorageValue(), + ':type' => $setting->getType(), + ':updated_at' => $setting->getUpdatedAt()->format('Y-m-d H:i:s'), + ]); + } + + public function delete(string $key): void + { + $stmt = $this->db->prepare('DELETE FROM settings WHERE setting_key = :key'); + $stmt->execute([':key' => $key]); + } +} diff --git a/src/Settings/Infrastructure/dependencies.php b/src/Settings/Infrastructure/dependencies.php new file mode 100644 index 0000000..9a47b6c --- /dev/null +++ b/src/Settings/Infrastructure/dependencies.php @@ -0,0 +1,19 @@ + autowire(SettingsApplicationService::class), + SettingsReaderInterface::class => autowire(SettingsApplicationService::class), + SettingsWriterInterface::class => autowire(SettingsApplicationService::class), + SettingRepositoryInterface::class => autowire(PdoSettingRepository::class), +]; diff --git a/src/Settings/Migrations/320_settings_schema.php b/src/Settings/Migrations/320_settings_schema.php new file mode 100644 index 0000000..d9e566b --- /dev/null +++ b/src/Settings/Migrations/320_settings_schema.php @@ -0,0 +1,17 @@ + " + CREATE TABLE IF NOT EXISTS settings ( + setting_key TEXT PRIMARY KEY, + setting_value TEXT DEFAULT NULL, + value_type TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + ", + 'down' => " + DROP TABLE IF EXISTS settings; + ", +]; diff --git a/src/Settings/SettingsModule.php b/src/Settings/SettingsModule.php new file mode 100644 index 0000000..6ebdbea --- /dev/null +++ b/src/Settings/SettingsModule.php @@ -0,0 +1,44 @@ + $app */ + public function registerRoutes(App $app): void {} + + public function templateNamespaces(): array + { + return []; + } + + public function twigExtensions(): array + { + return []; + } + + public function migrationDirectories(): array + { + return [__DIR__ . '/Migrations']; + } + + public function requiredTables(): array + { + return ['settings']; + } +} diff --git a/src/Taxonomy/Application/Command/CreateTaxonCommand.php b/src/Taxonomy/Application/Command/CreateTaxonCommand.php new file mode 100644 index 0000000..e3b4c52 --- /dev/null +++ b/src/Taxonomy/Application/Command/CreateTaxonCommand.php @@ -0,0 +1,13 @@ + */ + public function findAll(): array + { + return $this->taxonRepository->findAll(); + } + + /** @return PaginatedResult */ + public function findPaginated(int $page, int $perPage): PaginatedResult + { + $page = max(1, $page); + $total = $this->taxonRepository->countAll(); + $offset = ($page - 1) * $perPage; + + return new PaginatedResult( + $this->taxonRepository->findPage($perPage, $offset), + $total, + $page, + $perPage, + ); + } + + public function findById(int $id): ?Taxon + { + return $this->taxonRepository->findById($id); + } + + public function findBySlug(string $slug): ?Taxon + { + return $this->taxonRepository->findBySlug($slug); + } + + public function create(string $name): int + { + return $this->createTaxon->handle(new CreateTaxonCommand($name)); + } + + public function delete(Taxon $taxon): void + { + $this->deleteTaxon->handle($taxon); + } +} diff --git a/src/Taxonomy/Application/TaxonomyServiceInterface.php b/src/Taxonomy/Application/TaxonomyServiceInterface.php new file mode 100644 index 0000000..3adb02a --- /dev/null +++ b/src/Taxonomy/Application/TaxonomyServiceInterface.php @@ -0,0 +1,31 @@ + */ + public function findAll(): array; + + /** @return PaginatedResult */ + public function findPaginated(int $page, int $perPage): PaginatedResult; + + public function findById(int $id): ?Taxon; + + public function findBySlug(string $slug): ?Taxon; + + public function create(string $name): int; + + /** + * @throws \DomainException Si le terme ne peut pas être supprimé. + */ + public function delete(Taxon $taxon): void; +} diff --git a/src/Taxonomy/Application/UseCase/CreateTaxon.php b/src/Taxonomy/Application/UseCase/CreateTaxon.php new file mode 100644 index 0000000..13f6cc0 --- /dev/null +++ b/src/Taxonomy/Application/UseCase/CreateTaxon.php @@ -0,0 +1,40 @@ +name); + $slug = $this->slugGenerator->generate($name); + + if ($slug === '') { + throw new \InvalidArgumentException('Le nom fourni ne peut pas générer un slug URL valide'); + } + + if ($this->taxonRepository->nameExists($name)) { + throw new \InvalidArgumentException('Ce terme existe déjà'); + } + + return $this->taxonRepository->create(new Taxon(0, $name, $slug)); + } +} diff --git a/src/Taxonomy/Application/UseCase/DeleteTaxon.php b/src/Taxonomy/Application/UseCase/DeleteTaxon.php new file mode 100644 index 0000000..3a29221 --- /dev/null +++ b/src/Taxonomy/Application/UseCase/DeleteTaxon.php @@ -0,0 +1,29 @@ +taxonUsageChecker->isTaxonInUse($taxon->getId())) { + throw new \InvalidArgumentException("Le terme « {$taxon->getName()} » est encore utilisé et ne peut pas être supprimé"); + } + + $this->taxonRepository->delete($taxon->getId()); + } +} diff --git a/src/Taxonomy/Contracts/TaxonUsageCheckerInterface.php b/src/Taxonomy/Contracts/TaxonUsageCheckerInterface.php new file mode 100644 index 0000000..10e5fdb --- /dev/null +++ b/src/Taxonomy/Contracts/TaxonUsageCheckerInterface.php @@ -0,0 +1,13 @@ + */ + public function findAll(): array; + + public function findBySlug(string $slug): ?TaxonView; +} diff --git a/src/Taxonomy/Domain/Entity/Taxon.php b/src/Taxonomy/Domain/Entity/Taxon.php new file mode 100644 index 0000000..817754f --- /dev/null +++ b/src/Taxonomy/Domain/Entity/Taxon.php @@ -0,0 +1,67 @@ +validate(); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + id: (int) ($data['id'] ?? 0), + name: (string) ($data['name'] ?? ''), + slug: (string) ($data['slug'] ?? ''), + ); + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getSlug(): string + { + return $this->slug; + } + + private function validate(): void + { + if ($this->name === '') { + throw new \InvalidArgumentException('Le nom du terme ne peut pas être vide'); + } + + if (mb_strlen($this->name) > 100) { + throw new \InvalidArgumentException('Le nom du terme ne peut pas dépasser 100 caractères'); + } + } +} diff --git a/src/Taxonomy/Domain/Repository/TaxonRepositoryInterface.php b/src/Taxonomy/Domain/Repository/TaxonRepositoryInterface.php new file mode 100644 index 0000000..979f60d --- /dev/null +++ b/src/Taxonomy/Domain/Repository/TaxonRepositoryInterface.php @@ -0,0 +1,31 @@ + */ + public function findAll(): array; + + /** @return list */ + public function findPage(int $limit, int $offset): array; + + public function countAll(): int; + + public function findById(int $id): ?Taxon; + + public function findBySlug(string $slug): ?Taxon; + + public function create(Taxon $taxon): int; + + public function delete(int $id): int; + + public function nameExists(string $name): bool; +} diff --git a/src/Taxonomy/Domain/Service/TaxonSlugGenerator.php b/src/Taxonomy/Domain/Service/TaxonSlugGenerator.php new file mode 100644 index 0000000..aaba021 --- /dev/null +++ b/src/Taxonomy/Domain/Service/TaxonSlugGenerator.php @@ -0,0 +1,18 @@ + */ + public function findAll(): array + { + $stmt = $this->db->query('SELECT * FROM categories ORDER BY name ASC'); + if ($stmt === false) { + throw new \RuntimeException('La requête SELECT sur categories a échoué.'); + } + + return array_map( + static fn (array $row): Taxon => Taxon::fromArray($row), + $stmt->fetchAll(PDO::FETCH_ASSOC), + ); + } + + /** @return list */ + public function findPage(int $limit, int $offset): array + { + $stmt = $this->db->prepare('SELECT * FROM categories ORDER BY name ASC LIMIT :limit OFFSET :offset'); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return array_map( + static fn (array $row): Taxon => Taxon::fromArray($row), + $stmt->fetchAll(PDO::FETCH_ASSOC), + ); + } + + public function countAll(): int + { + $stmt = $this->db->query('SELECT COUNT(*) FROM categories'); + if ($stmt === false) { + throw new \RuntimeException('Le comptage des termes a échoué.'); + } + + return (int) $stmt->fetchColumn(); + } + + public function findById(int $id): ?Taxon + { + $stmt = $this->db->prepare('SELECT * FROM categories WHERE id = :id'); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? Taxon::fromArray($row) : null; + } + + public function findBySlug(string $slug): ?Taxon + { + $stmt = $this->db->prepare('SELECT * FROM categories WHERE slug = :slug'); + $stmt->execute([':slug' => $slug]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? Taxon::fromArray($row) : null; + } + + public function create(Taxon $taxon): int + { + $stmt = $this->db->prepare('INSERT INTO categories (name, slug) VALUES (:name, :slug)'); + $stmt->execute([ + ':name' => $taxon->getName(), + ':slug' => $taxon->getSlug(), + ]); + + return (int) $this->db->lastInsertId(); + } + + public function delete(int $id): int + { + $stmt = $this->db->prepare('DELETE FROM categories WHERE id = :id'); + $stmt->execute([':id' => $id]); + + return $stmt->rowCount(); + } + + public function nameExists(string $name): bool + { + $stmt = $this->db->prepare('SELECT COUNT(*) FROM categories WHERE name = :name'); + $stmt->execute([':name' => $name]); + + return (int) $stmt->fetchColumn() > 0; + } +} diff --git a/src/Taxonomy/Infrastructure/TaxonomyServiceReader.php b/src/Taxonomy/Infrastructure/TaxonomyServiceReader.php new file mode 100644 index 0000000..61c63b9 --- /dev/null +++ b/src/Taxonomy/Infrastructure/TaxonomyServiceReader.php @@ -0,0 +1,45 @@ + new TaxonView( + $taxon->getId(), + $taxon->getName(), + $taxon->getSlug(), + ), + $this->taxonomyService->findAll(), + ); + } + + public function findBySlug(string $slug): ?TaxonView + { + $taxon = $this->taxonomyService->findBySlug($slug); + + if ($taxon === null) { + return null; + } + + return new TaxonView( + $taxon->getId(), + $taxon->getName(), + $taxon->getSlug(), + ); + } +} diff --git a/src/Taxonomy/Infrastructure/dependencies.php b/src/Taxonomy/Infrastructure/dependencies.php new file mode 100644 index 0000000..0dc9eea --- /dev/null +++ b/src/Taxonomy/Infrastructure/dependencies.php @@ -0,0 +1,27 @@ + autowire(TaxonomyApplicationService::class), + TaxonomyReaderInterface::class => autowire(TaxonomyServiceReader::class), + TaxonRepositoryInterface::class => autowire(PdoTaxonRepository::class), + TaxonUsageCheckerInterface::class => autowire(NullTaxonUsageChecker::class), + TaxonSlugGenerator::class => autowire(), + CreateTaxon::class => autowire(), + DeleteTaxon::class => autowire(), +]; diff --git a/src/Taxonomy/Migrations/200_taxonomy_schema.php b/src/Taxonomy/Migrations/200_taxonomy_schema.php new file mode 100644 index 0000000..550dcf6 --- /dev/null +++ b/src/Taxonomy/Migrations/200_taxonomy_schema.php @@ -0,0 +1,16 @@ + " + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL + ); + ", + 'down' => " + DROP TABLE IF EXISTS categories; + ", +]; diff --git a/src/Taxonomy/TaxonomyModule.php b/src/Taxonomy/TaxonomyModule.php new file mode 100644 index 0000000..1a9cf91 --- /dev/null +++ b/src/Taxonomy/TaxonomyModule.php @@ -0,0 +1,50 @@ + $app */ + public function registerRoutes(App $app): void + { + TaxonomyRoutes::register($app); + } + + public function templateNamespaces(): array + { + return [ + 'Taxonomy' => __DIR__ . '/UI/Templates', + ]; + } + + public function twigExtensions(): array + { + return []; + } + + public function migrationDirectories(): array + { + return [__DIR__ . '/Migrations']; + } + + public function requiredTables(): array + { + return ['categories']; + } +} diff --git a/src/Taxonomy/UI/Http/Request/CreateTaxonRequest.php b/src/Taxonomy/UI/Http/Request/CreateTaxonRequest.php new file mode 100644 index 0000000..87312d4 --- /dev/null +++ b/src/Taxonomy/UI/Http/Request/CreateTaxonRequest.php @@ -0,0 +1,28 @@ + $data */ + $data = (array) $request->getParsedBody(); + + return new self((string) ($data['name'] ?? '')); + } + + public function trimmedName(): string + { + return trim($this->name); + } +} diff --git a/src/Taxonomy/UI/Http/TaxonomyController.php b/src/Taxonomy/UI/Http/TaxonomyController.php new file mode 100644 index 0000000..1b5c369 --- /dev/null +++ b/src/Taxonomy/UI/Http/TaxonomyController.php @@ -0,0 +1,113 @@ +getQueryParams()); + $paginated = $this->taxonomyService->findPaginated($page, self::PER_PAGE); + + return $this->view->render($res, '@Taxonomy/admin/index.twig', [ + 'taxons' => $paginated->getItems(), + 'pagination' => PaginationPresenter::fromRequest($req, $paginated), + 'error' => $this->flash->get('taxonomy_error'), + 'success' => $this->flash->get('taxonomy_success'), + ]); + } + + public function create(Request $req, Response $res): Response + { + $createTaxonRequest = CreateTaxonRequest::fromRequest($req); + + try { + $this->taxonomyService->create($createTaxonRequest->name); + $trimmed = $createTaxonRequest->trimmedName(); + $this->flash->set('taxonomy_success', "Le terme « {$trimmed} » a été créé avec succès"); + } catch (\InvalidArgumentException $e) { + $this->flash->set('taxonomy_error', $e->getMessage()); + } catch (\Throwable $e) { + $incidentId = $this->logUnexpectedError($req, $e, [ + 'taxon_name' => $createTaxonRequest->trimmedName(), + ]); + $this->flash->set('taxonomy_error', "Une erreur inattendue s'est produite (réf. {$incidentId})"); + } + + return $res->withHeader('Location', '/admin/categories')->withStatus(302); + } + + /** @param array $args */ + public function delete(Request $req, Response $res, array $args): Response + { + $id = (int) ($args['id'] ?? 0); + $taxon = $this->taxonomyService->findById($id); + + if ($taxon === null) { + $this->flash->set('taxonomy_error', 'Terme de taxonomie introuvable'); + + return $res->withHeader('Location', '/admin/categories')->withStatus(302); + } + + try { + $this->taxonomyService->delete($taxon); + $this->flash->set('taxonomy_success', "Le terme « {$taxon->getName()} » a été supprimé"); + } catch (\InvalidArgumentException $e) { + $this->flash->set('taxonomy_error', $e->getMessage()); + } catch (\Throwable $e) { + $incidentId = $this->logUnexpectedError($req, $e, [ + 'taxon_id' => $id, + 'taxon_name' => $taxon->getName(), + ]); + $this->flash->set('taxonomy_error', "Une erreur inattendue s'est produite (réf. {$incidentId})"); + } + + return $res->withHeader('Location', '/admin/categories')->withStatus(302); + } + + /** + * @param array $context + */ + private function logUnexpectedError(Request $req, \Throwable $e, array $context = []): string + { + $incidentId = bin2hex(random_bytes(8)); + + $this->logger?->error('Taxonomy administration action failed', $context + [ + 'incident_id' => $incidentId, + 'route' => (string) $req->getUri()->getPath(), + 'method' => $req->getMethod(), + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + 'exception' => $e, + ]); + + return $incidentId; + } +} diff --git a/src/Taxonomy/UI/Http/TaxonomyRoutes.php b/src/Taxonomy/UI/Http/TaxonomyRoutes.php new file mode 100644 index 0000000..4183861 --- /dev/null +++ b/src/Taxonomy/UI/Http/TaxonomyRoutes.php @@ -0,0 +1,26 @@ + $app */ + public static function register(App $app): void + { + $app->group('/admin/categories', function ($group) { + $group->get('', [TaxonomyController::class, 'index']); + $group->post('/create', [TaxonomyController::class, 'create']); + $group->post('/delete/{id}', [TaxonomyController::class, 'delete']); + })->add(EditorMiddleware::class)->add(AuthMiddleware::class); + } +} diff --git a/src/Taxonomy/UI/Templates/admin/index.twig b/src/Taxonomy/UI/Templates/admin/index.twig new file mode 100644 index 0000000..1880658 --- /dev/null +++ b/src/Taxonomy/UI/Templates/admin/index.twig @@ -0,0 +1,66 @@ +{% extends "@Kernel/layout.twig" %} + +{% block title %}Tableau de bord – Taxonomie{% endblock %} + +{% block content %} +{% include '@Kernel/partials/_admin_page_header.twig' with { + title: 'Gestion de la taxonomie' +} %} + +{% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %} + +{% embed '@Kernel/partials/_admin_create_box.twig' with { + title: 'Ajouter un terme', + hint: 'Le slug URL est généré automatiquement depuis le nom.', + form: { action: '/admin/categories/create' } +} %} + {% block fields %} + + {% endblock %} + + {% block actions %} + + {% endblock %} +{% endembed %} + +{% if taxons is not empty %} + + + + + + + + + + {% for taxon in taxons %} + + + + + + {% endfor %} + +
NomSlugActions
{{ taxon.name }}{{ taxon.slug }} +
+ {% include '@Kernel/partials/_admin_delete_form.twig' with { + action: '/admin/categories/delete/' ~ taxon.id, + confirm: 'Supprimer le terme « ' ~ taxon.name ~ ' » ? + +Cette action est impossible si ce terme est encore utilisé.' + } %} +
+
+ +{% include '@Kernel/partials/_pagination.twig' with { pagination: pagination } %} +{% else %} +{% include '@Kernel/partials/_empty_state.twig' with { + title: 'Aucun terme', + message: 'Aucun terme créé.' +} %} +{% endif %} +{% endblock %} diff --git a/tests/Architecture/ApplicationServiceDoctrineTest.php b/tests/Architecture/ApplicationServiceDoctrineTest.php new file mode 100644 index 0000000..fe9dde9 --- /dev/null +++ b/tests/Architecture/ApplicationServiceDoctrineTest.php @@ -0,0 +1,45 @@ +projectPath('src/Identity/Application/AuthApplicationService.php'); + $code = $this->normalizedCode($file); + + self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\AuthenticateUser', $code); + self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\ChangePassword', $code); + self::assertStringNotContainsString('Netig\Netslim\\Identity\\Domain\\Repository\\UserRepositoryInterface', $code); + self::assertStringNotContainsString('Netig\Netslim\\Identity\\Domain\\Policy\\PasswordPolicy', $code); + } + + public function testPasswordResetApplicationServiceDelegatesWorkflowToUseCases(): void + { + $file = $this->projectPath('src/Identity/Application/PasswordResetApplicationService.php'); + $code = $this->normalizedCode($file); + + self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\RequestPasswordReset', $code); + self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\ValidatePasswordResetToken', $code); + self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\ResetPassword', $code); + self::assertStringNotContainsString('Netig\Netslim\\Identity\\Domain\\Repository\\PasswordResetRepositoryInterface', $code); + self::assertStringNotContainsString('Netig\Netslim\\Kernel\\Mail\\Application\\MailServiceInterface', $code); + self::assertStringNotContainsString('Netig\Netslim\\Kernel\\Persistence\\Application\\TransactionManagerInterface', $code); + } + + public function testMediaApplicationServiceDelegatesMutationsToUseCases(): void + { + $file = $this->projectPath('src/Media/Application/MediaApplicationService.php'); + $code = $this->normalizedCode($file); + + self::assertStringContainsString('Netig\Netslim\\Media\\Application\\UseCase\\StoreMedia', $code); + self::assertStringContainsString('Netig\Netslim\\Media\\Application\\UseCase\\DeleteMedia', $code); + self::assertStringNotContainsString('Netig\Netslim\\Media\\Domain\\Service\\MediaStorageInterface', $code); + self::assertStringNotContainsString('PDOException', $code); + } +} diff --git a/tests/Architecture/ApplicationServiceVocabularyTest.php b/tests/Architecture/ApplicationServiceVocabularyTest.php new file mode 100644 index 0000000..a5b8939 --- /dev/null +++ b/tests/Architecture/ApplicationServiceVocabularyTest.php @@ -0,0 +1,91 @@ +}> + */ + public static function serviceInterfaces(): iterable + { + yield 'auth service vocabulary' => [ + AuthServiceInterface::class, + ['checkRateLimit', 'recordFailure', 'resetRateLimit', 'checkPasswordResetRateLimit', 'recordPasswordResetAttempt', 'authenticate', 'changePassword', 'isLoggedIn', 'login', 'logout'], + ]; + + yield 'password reset service vocabulary' => [ + PasswordResetServiceInterface::class, + ['requestReset', 'validateToken', 'resetPassword'], + ]; + + yield 'authorization service vocabulary' => [ + AuthorizationServiceInterface::class, + ['canRole', 'canUser', 'permissionsForRole'], + ]; + + yield 'settings service vocabulary' => [ + SettingsServiceInterface::class, + ['all', 'delete', 'get', 'getBool', 'getInt', 'getString', 'has', 'set'], + ]; + + yield 'audit log service vocabulary' => [ + AuditLogServiceInterface::class, + ['listRecent', 'record'], + ]; + + yield 'notification service vocabulary' => [ + NotificationServiceInterface::class, + ['recent', 'send', 'sendTemplate'], + ]; + + yield 'taxonomy service vocabulary' => [ + TaxonomyServiceInterface::class, + ['findAll', 'findPaginated', 'findById', 'findBySlug', 'create', 'delete'], + ]; + + yield 'media service vocabulary' => [ + MediaServiceInterface::class, + ['findAll', 'findPaginated', 'findByUserId', 'findByUserIdPaginated', 'findById', 'store', 'getUsageSummary', 'getUsageSummaries', 'delete'], + ]; + + + yield 'user service vocabulary' => [ + UserServiceInterface::class, + ['findAll', 'findPaginated', 'findById', 'delete', 'deleteFromAdministration', 'updateRole', 'updateRoleFromAdministration', 'create'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('serviceInterfaces')] + public function testApplicationServiceInterfacesExposeAnExplicitVocabulary(string $interface, array $expectedMethods): void + { + $reflection = new \ReflectionClass($interface); + $methods = array_values(array_map( + static fn (\ReflectionMethod $method): string => $method->getName(), + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + )); + sort($methods); + + $expected = $expectedMethods; + sort($expected); + + self::assertSame( + $expected, + $methods, + sprintf('%s must keep its agreed application vocabulary to stay aligned with the other modules.', $interface), + ); + } +} diff --git a/tests/Architecture/ApplicationWorkflowShapeTest.php b/tests/Architecture/ApplicationWorkflowShapeTest.php new file mode 100644 index 0000000..b9d635a --- /dev/null +++ b/tests/Architecture/ApplicationWorkflowShapeTest.php @@ -0,0 +1,93 @@ +phpFilesUnder('src/*/Application/UseCase'); + self::assertNotSame([], $files, 'Expected at least one use case file.'); + + foreach ($files as $file) { + $class = $this->reflectProjectClass($file); + $basename = pathinfo($file, PATHINFO_FILENAME); + + self::assertSame( + $basename, + $class->getShortName(), + $this->fileRuleMessage('Use cases must be named after their file', $file), + ); + self::assertTrue($class->isFinal(), $this->fileRuleMessage('Use cases must be final classes', $file)); + self::assertTrue($class->isReadOnly(), $this->fileRuleMessage('Use cases must be readonly classes', $file)); + + $publicMethods = array_values(array_map( + static fn (ReflectionMethod $method): string => $method->getName(), + array_filter( + $class->getMethods(ReflectionMethod::IS_PUBLIC), + static fn (ReflectionMethod $method): bool => $method->getDeclaringClass()->getName() === $class->getName() + && !$method->isConstructor() + && !$method->isDestructor(), + ), + )); + sort($publicMethods); + + self::assertSame( + ['handle'], + $publicMethods, + $this->fileRuleMessage('Use cases must expose a single public handle(...) entrypoint', $file), + ); + } + } + + public function testCommandsRemainImmutableInputDtos(): void + { + $files = $this->phpFilesUnder('src/*/Application/Command'); + self::assertNotSame([], $files, 'Expected at least one command file.'); + + foreach ($files as $file) { + $basename = pathinfo($file, PATHINFO_FILENAME); + $class = $this->reflectProjectClass($file); + + self::assertStringEndsWith('Command', $basename, $this->fileRuleMessage('Command files must end with Command', $file)); + self::assertSame( + $basename, + $class->getShortName(), + $this->fileRuleMessage('Commands must be named after their file', $file), + ); + self::assertTrue($class->isFinal(), $this->fileRuleMessage('Commands must be final classes', $file)); + self::assertTrue($class->isReadOnly(), $this->fileRuleMessage('Commands must be readonly classes', $file)); + } + } + + public function testUiLayerDoesNotReferenceUseCasesOrCommandsDirectly(): void + { + $files = $this->uiFiles(); + self::assertNotSame([], $files, 'Expected UI files to exist.'); + + $this->assertFilesDoNotReferenceFragments( + $files, + ['\\Application\\UseCase\\', '\\Application\\Command\\'], + 'UI layer must enter a domain through its application service, not through use cases or commands', + ); + } + + private function reflectProjectClass(string $file): ReflectionClass + { + $relativePath = $this->relativePath($file); + $className = 'Netig\Netslim\\' . str_replace(['/', '.php'], ['\\', ''], substr($relativePath, 4)); + + try { + return new ReflectionClass($className); + } catch (ReflectionException $exception) { + self::fail($this->fileRuleMessage('Unable to reflect class ' . $className . ': ' . $exception->getMessage(), $file)); + } + } +} diff --git a/tests/Architecture/HttpRuntimeBoundaryTest.php b/tests/Architecture/HttpRuntimeBoundaryTest.php new file mode 100644 index 0000000..e267178 --- /dev/null +++ b/tests/Architecture/HttpRuntimeBoundaryTest.php @@ -0,0 +1,40 @@ +projectPath('src/Kernel/Http/Application'); + self::assertDirectoryExists($path); + + $entries = array_values(array_filter(scandir($path) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true))); + sort($entries); + + self::assertSame([ + 'Flash', + 'Session', + ], $entries, 'Kernel/Http/Application should only expose transverse HTTP contracts.'); + } + + public function testKernelRuntimeHttpContainsOnlyApprovedAssemblyClasses(): void + { + $path = $this->projectPath('src/Kernel/Runtime/Http'); + self::assertDirectoryExists($path); + + $entries = array_values(array_filter(scandir($path) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true))); + sort($entries); + + self::assertSame([ + 'DefaultErrorHandler.php', + 'ErrorHandlerConfigurator.php', + 'HttpApplicationFactory.php', + 'MiddlewareRegistrar.php', + ], $entries, 'Kernel/Runtime/Http should contain only the approved HTTP runtime assembly classes.'); + } +} diff --git a/tests/Architecture/InstantiationBoundaryTest.php b/tests/Architecture/InstantiationBoundaryTest.php new file mode 100644 index 0000000..235880e --- /dev/null +++ b/tests/Architecture/InstantiationBoundaryTest.php @@ -0,0 +1,74 @@ +applicationServiceFiles() as $file) { + $violations = $this->collectInstantiationViolations( + $file, + self::USE_CASE_OR_POLICY_FRAGMENTS, + $this->isUseCaseOrPolicyClass(...), + ); + + $this->assertNoArchitectureViolations( + $violations, + $this->fileRuleMessage(self::MESSAGE_APPLICATION_SERVICE_DI_ONLY, $file), + ); + } + } + + public function testControllersDoNotInstantiateApplicationOrDomainDependenciesDirectly(): void + { + foreach ($this->uiControllerFiles() as $file) { + $violations = $this->collectInstantiationViolations( + $file, + self::APPLICATION_OR_DOMAIN_FRAGMENTS, + $this->isApplicationOrDomainClass(...), + ); + + $this->assertNoArchitectureViolations( + $violations, + $this->fileRuleMessage(self::MESSAGE_CONTROLLER_DI_ONLY, $file), + ); + } + } + + public function testUiLayerDoesNotInstantiateInfrastructureOrRepositoryImplementationsDirectly(): void + { + foreach ($this->uiFiles() as $file) { + $violations = $this->collectInstantiationViolations( + $file, + self::INFRASTRUCTURE_INSTANTIATION_FRAGMENTS, + $this->isInfrastructureOrRepositoryClass(...), + ); + + $this->assertNoArchitectureViolations( + $violations, + $this->fileRuleMessage(self::MESSAGE_UI_NO_INFRA_INSTANTIATION, $file), + ); + } + } + + public function testApplicationLayerDoesNotInstantiateInfrastructureOrRepositoryImplementationsDirectly(): void + { + foreach ($this->applicationFiles() as $file) { + $violations = $this->collectInstantiationViolations( + $file, + self::INFRASTRUCTURE_INSTANTIATION_FRAGMENTS, + $this->isInfrastructureOrRepositoryClass(...), + ); + + $this->assertNoArchitectureViolations( + $violations, + $this->fileRuleMessage(self::MESSAGE_APPLICATION_NO_INFRA_INSTANTIATION, $file), + ); + } + } +} diff --git a/tests/Architecture/KernelStructureTest.php b/tests/Architecture/KernelStructureTest.php new file mode 100644 index 0000000..8686701 --- /dev/null +++ b/tests/Architecture/KernelStructureTest.php @@ -0,0 +1,54 @@ +projectPath('src/Kernel'); + self::assertDirectoryExists($sharedPath); + + $entries = array_values(array_filter(scandir($sharedPath) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true))); + sort($entries); + + self::assertSame([ + 'Html', + 'Http', + 'Mail', + 'Pagination', + 'Persistence', + 'Runtime', + 'Support', + ], $entries, 'Kernel must expose only approved transverse modules at top level.'); + } + + public function testKernelPathsDoNotUseFeatureModuleNames(): void + { + $sharedPath = $this->projectPath('src/Kernel'); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($sharedPath, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iterator as $fileInfo) { + $relativePath = substr($fileInfo->getPathname(), strlen($sharedPath) + 1); + $pathSegments = preg_split('~/+~', $relativePath) ?: []; + + foreach ($pathSegments as $segment) { + $name = pathinfo($segment, PATHINFO_FILENAME); + + foreach (self::FEATURE_MODULES as $module) { + self::assertFalse( + str_starts_with($name, $module), + sprintf('Kernel should not host feature-specific files or directories: %s', 'src/Kernel/' . $relativePath), + ); + } + } + } + } +} diff --git a/tests/Architecture/LayerDependencyTest.php b/tests/Architecture/LayerDependencyTest.php new file mode 100644 index 0000000..ffb23a5 --- /dev/null +++ b/tests/Architecture/LayerDependencyTest.php @@ -0,0 +1,67 @@ +assertFilesDoNotReferenceFragments( + $this->domainFiles(), + array_merge( + self::APPLICATION_NAMESPACES, + self::INFRASTRUCTURE_NAMESPACES, + self::UI_NAMESPACES, + self::FRAMEWORK_NAMESPACE_FRAGMENTS, + self::HTTP_MESSAGE_DEPENDENCIES, + ), + self::MESSAGE_DOMAIN_FRAMEWORK_AGNOSTIC, + ); + } + + public function testApplicationLayerDoesNotDependOnUiInfrastructureOrHttpUploadedFiles(): void + { + $this->assertFilesDoNotReferenceFragments( + $this->applicationFiles(), + array_merge( + $this->applicationMustNotDependOnInfrastructureFragments(), + self::UI_NAMESPACES, + self::APPLICATION_RUNTIME_DETAIL_FRAGMENTS, + self::HTTP_MESSAGE_DEPENDENCIES, + self::FRAMEWORK_NAMESPACE_FRAGMENTS, + ), + self::MESSAGE_APPLICATION_PORTS_ONLY, + ); + } + + public function testApplicationLayerDoesNotDependOnInfrastructureImplementations(): void + { + $this->assertFilesDoNotReferenceFragments( + $this->applicationFiles(), + $this->applicationMustNotDependOnInfrastructureFragments(), + self::MESSAGE_APPLICATION_NO_INFRA_DEPENDENCY, + ); + } + + public function testUiLayerDoesNotDependOnInfrastructureImplementations(): void + { + $this->assertFilesDoNotReferenceFragments( + $this->uiFiles(), + $this->uiMustNotDependOnInfrastructureFragments(), + self::MESSAGE_UI_NO_INFRA_DEPENDENCY, + ); + } + + public function testInfrastructureLayerDoesNotDependOnUi(): void + { + $this->assertFilesDoNotReferenceFragments( + $this->nonWiringInfrastructureFiles(), + $this->infrastructureMustNotDependOnUiFragments(), + self::MESSAGE_INFRA_NO_UI_DEPENDENCY, + ); + } +} diff --git a/tests/Architecture/ModuleBoundaryGovernanceTest.php b/tests/Architecture/ModuleBoundaryGovernanceTest.php new file mode 100644 index 0000000..4f16988 --- /dev/null +++ b/tests/Architecture/ModuleBoundaryGovernanceTest.php @@ -0,0 +1,112 @@ +phpFilesUnder('src/Kernel') as $file) { + foreach ($this->importedClasses($file) as $import) { + $importsFeatureModule = false; + foreach ($featurePrefixes as $prefix) { + if (str_starts_with($import, $prefix)) { + $importsFeatureModule = true; + break; + } + } + + if (!$importsFeatureModule) { + continue; + } + + $violations[] = $this->relativePath($file) . ' -> ' . $import; + } + } + + $this->assertNoArchitectureViolations( + $violations, + 'Kernel must not import feature modules directly', + ); + } + + public function testApplicationDomainAndInfrastructureUseOnlyApprovedCrossModuleContracts(): void + { + $violations = []; + $allowedExternalImports = [ + 'Identity' => [], + 'Settings' => [], + 'AuditLog' => [], + 'Notifications' => [], + 'Taxonomy' => [], + 'Media' => [], + ]; + + foreach (self::FEATURE_MODULES as $module) { + $directories = [ + $this->projectPath('src/' . $module . '/Application'), + $this->projectPath('src/' . $module . '/Domain'), + $this->projectPath('src/' . $module . '/Infrastructure'), + ]; + + foreach ($directories as $directory) { + if (!is_dir($directory)) { + continue; + } + + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)); + foreach ($iterator as $fileInfo) { + if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'php') { + continue; + } + + $file = $fileInfo->getPathname(); + $imports = $this->importedClasses($file); + + foreach ($imports as $import) { + if (!str_starts_with($import, 'Netig\\Netslim\\')) { + continue; + } + + if (str_starts_with($import, 'Netig\\Netslim\\Kernel\\') || str_starts_with($import, 'Netig\\Netslim\\' . $module . '\\')) { + continue; + } + + $isAllowed = false; + foreach ($allowedExternalImports[$module] as $allowedPrefix) { + if (str_starts_with($import, $allowedPrefix)) { + $isAllowed = true; + break; + } + } + + if ($isAllowed) { + continue; + } + + $violations[] = $this->relativePath($file) . ' -> ' . $import; + } + } + } + } + + $this->assertNoArchitectureViolations( + $violations, + 'Application, Domain and Infrastructure layers must only cross module boundaries through approved public contracts', + ); + } +} diff --git a/tests/Architecture/ModuleStructureTest.php b/tests/Architecture/ModuleStructureTest.php new file mode 100644 index 0000000..4eeae79 --- /dev/null +++ b/tests/Architecture/ModuleStructureTest.php @@ -0,0 +1,34 @@ +modulePath($module); + self::assertDirectoryExists($modulePath, $this->moduleRuleMessage(self::MESSAGE_MODULE_DIRECTORY_MISSING, $module)); + + $entries = array_values(array_filter(scandir($modulePath) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true))); + sort($entries); + + self::assertSame($this->expectedModuleEntries($module), $entries, $this->moduleRuleMessage(self::MESSAGE_STANDARD_MODULE_STRUCTURE, $module)); + } + } + + public function testLegacyGlobalViewsDirectoryHasBeenRemoved(): void + { + self::assertDirectoryDoesNotExist($this->projectPath('views')); + } + + public function testFixtureApplicationExposesAModuleManifest(): void + { + self::assertDirectoryExists($this->projectPath('tests/Fixtures/Application/config')); + self::assertFileExists($this->projectPath('tests/Fixtures/Application/config/modules.php')); + } +} diff --git a/tests/Architecture/Support/ArchitectureTestCase.php b/tests/Architecture/Support/ArchitectureTestCase.php new file mode 100644 index 0000000..960162f --- /dev/null +++ b/tests/Architecture/Support/ArchitectureTestCase.php @@ -0,0 +1,338 @@ + */ + protected const FEATURE_MODULES = ['Identity', 'Settings', 'AuditLog', 'Notifications', 'Taxonomy', 'Media']; + + /** @var list */ + protected const APPLICATION_NAMESPACES = [ + 'Netig\\Netslim\\Identity\\Application\\', + 'Netig\\Netslim\\Settings\\Application\\', + 'Netig\\Netslim\\AuditLog\\Application\\', + 'Netig\\Netslim\\Notifications\\Application\\', + 'Netig\\Netslim\\Taxonomy\\Application\\', + 'Netig\\Netslim\\Media\\Application\\', + ]; + + /** @var list */ + protected const INFRASTRUCTURE_NAMESPACES = [ + 'Netig\\Netslim\\Identity\\Infrastructure\\', + 'Netig\\Netslim\\Settings\\Infrastructure\\', + 'Netig\\Netslim\\AuditLog\\Infrastructure\\', + 'Netig\\Netslim\\Notifications\\Infrastructure\\', + 'Netig\\Netslim\\Taxonomy\\Infrastructure\\', + 'Netig\\Netslim\\Media\\Infrastructure\\', + ]; + + /** @var list */ + protected const UI_NAMESPACES = [ + 'Netig\\Netslim\\Identity\\UI\\', + 'Netig\\Netslim\\Taxonomy\\UI\\', + 'Netig\\Netslim\\Media\\UI\\', + ]; + + /** @var list */ + protected const FRAMEWORK_NAMESPACE_FRAGMENTS = [ + 'Slim\\', + 'Twig\\', + ]; + + /** @var list */ + protected const HTTP_MESSAGE_DEPENDENCIES = [ + 'Psr\\Http\\Message\\ServerRequestInterface', + 'Psr\\Http\\Message\\ResponseInterface', + 'Psr\\Http\\Message\\UploadedFileInterface', + ]; + + /** @var list */ + protected const APPLICATION_RUNTIME_DETAIL_FRAGMENTS = [ + 'Netig\\Netslim\\Kernel\\Http\\Application\\Session\\SessionManagerInterface', + 'usePDO;', + 'newPDO(', + ]; + + /** @var list */ + protected const USE_CASE_OR_POLICY_FRAGMENTS = [ + '\\Application\\UseCase\\', + '\\Domain\\Policy\\', + ]; + + /** @var list */ + protected const APPLICATION_OR_DOMAIN_FRAGMENTS = [ + '\\Application\\', + '\\Domain\\', + ]; + + /** @var list */ + protected const INFRASTRUCTURE_INSTANTIATION_FRAGMENTS = [ + '\\Infrastructure\\', + ]; + + protected const MESSAGE_DOMAIN_FRAMEWORK_AGNOSTIC = 'Domain layer must stay framework-agnostic'; + protected const MESSAGE_APPLICATION_PORTS_ONLY = 'Application layer must depend on ports, not framework/runtime details'; + protected const MESSAGE_APPLICATION_NO_INFRA_DEPENDENCY = 'Application layer should depend on ports and injected services, not infrastructure implementations'; + protected const MESSAGE_UI_NO_INFRA_DEPENDENCY = 'UI layer should talk to interfaces and request adapters, not infrastructure implementations'; + protected const MESSAGE_INFRA_NO_UI_DEPENDENCY = 'Infrastructure implementation must not depend on UI classes'; + protected const MESSAGE_APPLICATION_SERVICE_DI_ONLY = 'Application services must rely on DI for use cases and policies, not instantiate them directly'; + protected const MESSAGE_CONTROLLER_DI_ONLY = 'Controllers must rely on DI and request adapters for application/domain dependencies, not instantiate them directly'; + protected const MESSAGE_UI_NO_INFRA_INSTANTIATION = 'UI layer must rely on DI and ports, not instantiate infrastructure or repository implementations directly'; + protected const MESSAGE_APPLICATION_NO_INFRA_INSTANTIATION = 'Application layer must rely on interfaces and injected collaborators, not instantiate infrastructure or repository implementations directly'; + protected const MESSAGE_WIRING_ONLY_UI_REFERENCE = 'Only infrastructure wiring files may reference UI classes'; + protected const MESSAGE_STANDARD_MODULE_STRUCTURE = '%s should only contain the standard modular structure'; + protected const MESSAGE_MODULE_DIRECTORY_MISSING = 'Module directory missing for %s'; + protected const MESSAGE_NO_ARCHITECTURE_VIOLATIONS = '%s'; + + /** @return list */ + protected function domainFiles(): array + { + return $this->phpFilesUnder('src/*/Domain'); + } + + /** @return list */ + protected function applicationFiles(): array + { + return $this->phpFilesUnder('src/*/Application'); + } + + /** @return list */ + protected function applicationServiceFiles(): array + { + return array_values(array_filter( + $this->applicationFiles(), + static fn (string $file): bool => str_ends_with($file, 'ApplicationService.php'), + )); + } + + /** @return list */ + protected function uiFiles(): array + { + return $this->phpFilesUnder('src/*/UI'); + } + + /** @return list */ + protected function uiControllerFiles(): array + { + return array_values(array_filter( + $this->phpFilesUnder('src/*/UI/Http'), + static fn (string $file): bool => str_ends_with($file, 'Controller.php'), + )); + } + + /** @return list */ + protected function infrastructureFiles(): array + { + return $this->phpFilesUnder('src/*/Infrastructure'); + } + + /** @return list */ + protected function nonWiringInfrastructureFiles(): array + { + return array_values(array_filter( + $this->infrastructureFiles(), + static fn (string $file): bool => basename($file) !== 'dependencies.php', + )); + } + + /** @return list */ + protected function phpFilesUnder(string $relativeDirectory): array + { + $directories = glob($this->projectPath($relativeDirectory), GLOB_ONLYDIR) ?: []; + $files = []; + + foreach ($directories as $directory) { + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)); + foreach ($iterator as $fileInfo) { + if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'php') { + continue; + } + + $files[] = $fileInfo->getPathname(); + } + } + + sort($files); + + return array_values(array_unique($files)); + } + + protected function projectPath(string $suffix = ''): string + { + $root = dirname(__DIR__, 3); + + return $suffix === '' ? $root : $root . '/' . $suffix; + } + + protected function relativePath(string $absolutePath): string + { + return substr($absolutePath, strlen($this->projectPath()) + 1); + } + + protected function modulePath(string $module): string + { + return $this->projectPath('src/' . $module); + } + + /** @return list */ + protected function expectedModuleEntries(string $module): array + { + $entries = match ($module) { + 'Taxonomy' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'Migrations', 'TaxonomyModule.php', 'UI'], + 'Media' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'MediaModule.php', 'Migrations', 'UI'], + 'Settings' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'Migrations', 'SettingsModule.php'], + 'AuditLog' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'AuditLogModule.php', 'Migrations'], + 'Notifications' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'Migrations', 'NotificationsModule.php'], + default => ['Application', 'Domain', 'Infrastructure', 'Migrations', 'UI', $module . 'Module.php'], + }; + sort($entries); + + return $entries; + } + + protected function normalizedCode(string $file): string + { + $code = file_get_contents($file); + self::assertNotFalse($code, 'Unable to read ' . $file); + + $normalized = ''; + foreach (token_get_all($code) as $token) { + if (is_array($token)) { + if (in_array($token[0], [T_COMMENT, T_DOC_COMMENT, T_WHITESPACE], true)) { + continue; + } + + $normalized .= $token[1]; + continue; + } + + $normalized .= $token; + } + + return $normalized; + } + + /** @param list $needles */ + protected function containsAny(string $haystack, array $needles): bool + { + foreach ($needles as $needle) { + if (str_contains($haystack, $needle)) { + return true; + } + } + + return false; + } + + /** + * @param list $files + * @param list $forbiddenFragments + */ + protected function assertFilesDoNotReferenceFragments(array $files, array $forbiddenFragments, string $message): void + { + foreach ($files as $file) { + $code = $this->normalizedCode($file); + + self::assertFalse( + $this->containsAny($code, $forbiddenFragments), + $this->fileRuleMessage($message, $file), + ); + } + } + + /** @return list */ + protected function importedClasses(string $file): array + { + $code = file_get_contents($file); + self::assertNotFalse($code, 'Unable to read ' . $file); + + preg_match_all('/^use\s+([^;]+);/m', $code, $matches); + + return array_values(array_unique(array_map('trim', $matches[1] ?? []))); + } + + protected function applicationMustNotDependOnInfrastructureFragments(): array + { + return array_filter(array_map( + static fn (string $namespace): string => str_replace('\\\\', '\\', $namespace), + self::INFRASTRUCTURE_NAMESPACES, + )); + } + + protected function uiMustNotDependOnInfrastructureFragments(): array + { + return $this->applicationMustNotDependOnInfrastructureFragments(); + } + + protected function infrastructureMustNotDependOnUiFragments(): array + { + return array_filter(array_map( + static fn (string $namespace): string => str_replace('\\\\', '\\', $namespace), + self::UI_NAMESPACES, + )); + } + + /** + * @param list $namespaceFragments + * @param callable(string): bool $classFilter + * @return list + */ + protected function collectInstantiationViolations(string $file, array $namespaceFragments, callable $classFilter): array + { + $code = $this->normalizedCode($file); + + if (!str_contains($code, 'new')) { + return []; + } + + $violations = []; + foreach ($this->importedClasses($file) as $import) { + if (!$classFilter($import) || !$this->containsAny($import, $namespaceFragments)) { + continue; + } + + $shortName = substr($import, (int) strrpos($import, '\\') + 1); + if (str_contains($code, 'new' . $shortName . '(') || str_contains($code, 'new\\' . $import . '(')) { + $violations[] = $this->relativePath($file) . ' instantiates ' . $import; + } + } + + return array_values(array_unique($violations)); + } + + protected function isUseCaseOrPolicyClass(string $class): bool + { + return str_contains($class, '\\Application\\UseCase\\') || str_contains($class, '\\Domain\\Policy\\'); + } + + protected function isApplicationOrDomainClass(string $class): bool + { + return str_contains($class, '\\Application\\') || str_contains($class, '\\Domain\\'); + } + + protected function isInfrastructureOrRepositoryClass(string $class): bool + { + return str_contains($class, '\\Infrastructure\\') || str_contains($class, '\\Repository\\'); + } + + protected function assertNoArchitectureViolations(array $violations, string $message): void + { + self::assertSame([], $violations, sprintf(self::MESSAGE_NO_ARCHITECTURE_VIOLATIONS, $message . ($violations !== [] ? "\n- " . implode("\n- ", $violations) : ''))); + } + + protected function fileRuleMessage(string $message, string $file): string + { + return sprintf('%s: %s', $message, $this->relativePath($file)); + } + + protected function moduleRuleMessage(string $message, string $module): string + { + return sprintf($message, $module); + } +} diff --git a/tests/Architecture/SupportGovernanceTest.php b/tests/Architecture/SupportGovernanceTest.php new file mode 100644 index 0000000..4ed9431 --- /dev/null +++ b/tests/Architecture/SupportGovernanceTest.php @@ -0,0 +1,90 @@ + */ + private const APPROVED_SUPPORT_TOP_LEVEL_ENTRIES = [ + 'Exception', + 'Util', + ]; + + /** @var list */ + private const APPROVED_SUPPORT_FILES = [ + 'Exception/NotFoundException.php', + 'Util/DateParser.php', + 'Util/SlugHelper.php', + ]; + + public function testSupportUsesOnlyApprovedTopLevelEntries(): void + { + $supportPath = $this->projectPath('src/Kernel/Support'); + self::assertDirectoryExists($supportPath); + + $entries = array_values(array_filter(scandir($supportPath) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true))); + sort($entries); + + self::assertSame( + self::APPROVED_SUPPORT_TOP_LEVEL_ENTRIES, + $entries, + 'Kernel/Support is frozen by default: only explicitly approved subdirectories may exist.', + ); + } + + public function testSupportContainsOnlyExplicitlyApprovedFiles(): void + { + $supportPath = $this->projectPath('src/Kernel/Support'); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($supportPath, \FilesystemIterator::SKIP_DOTS), + ); + + $files = []; + foreach ($iterator as $fileInfo) { + if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'php') { + continue; + } + + $files[] = substr($fileInfo->getPathname(), strlen($supportPath) + 1); + } + + sort($files); + + self::assertSame( + self::APPROVED_SUPPORT_FILES, + $files, + 'Any new file under Kernel/Support must be a deliberate architectural decision: update the allow list, tests and documentation together.', + ); + } + + public function testSupportStaysIndependentFromFeatureModulesAndFrameworks(): void + { + $this->assertFilesDoNotReferenceFragments( + $this->phpFilesUnder('src/Kernel/Support'), + array_merge( + self::APPLICATION_NAMESPACES, + self::INFRASTRUCTURE_NAMESPACES, + self::UI_NAMESPACES, + self::FRAMEWORK_NAMESPACE_FRAGMENTS, + self::HTTP_MESSAGE_DEPENDENCIES, + ), + 'Kernel/Support must stay pure, transverse and framework-agnostic', + ); + } + + public function testSupportClassesAreFinal(): void + { + foreach ($this->phpFilesUnder('src/Kernel/Support') as $file) { + $code = $this->normalizedCode($file); + + self::assertTrue( + str_contains($code, 'finalclass'), + $this->fileRuleMessage('Kernel/Support classes must be final to avoid becoming extension points by accident', $file), + ); + } + } +} diff --git a/tests/Architecture/WiringBoundaryTest.php b/tests/Architecture/WiringBoundaryTest.php new file mode 100644 index 0000000..ca461aa --- /dev/null +++ b/tests/Architecture/WiringBoundaryTest.php @@ -0,0 +1,27 @@ +infrastructureFiles() as $file) { + $code = $this->normalizedCode($file); + + if (!$this->containsAny($code, self::UI_NAMESPACES)) { + continue; + } + + self::assertSame( + 'dependencies.php', + basename($file), + $this->fileRuleMessage(self::MESSAGE_WIRING_ONLY_UI_REFERENCE, $file), + ); + } + } +} diff --git a/tests/AuditLog/AuditLogServiceTest.php b/tests/AuditLog/AuditLogServiceTest.php new file mode 100644 index 0000000..a892a00 --- /dev/null +++ b/tests/AuditLog/AuditLogServiceTest.php @@ -0,0 +1,39 @@ +db = new PDO('sqlite::memory:', options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $this->db->exec('CREATE TABLE audit_log (id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, actor_user_id INTEGER DEFAULT NULL, context_json TEXT DEFAULT NULL, created_at TEXT NOT NULL)'); + $this->service = new AuditLogApplicationService(new PdoAuditLogRepository($this->db)); + } + + public function testRecordsAndReadsAuditEntries(): void + { + $this->service->record('taxonomy.created', 'taxon', '12', 7, ['name' => 'PHP']); + $entries = $this->service->listRecent(); + + self::assertCount(1, $entries); + self::assertSame('taxonomy.created', $entries[0]->action); + self::assertSame('taxon', $entries[0]->resourceType); + self::assertSame('12', $entries[0]->resourceId); + self::assertSame(['name' => 'PHP'], $entries[0]->context); + } +} diff --git a/tests/ControllerTestBase.php b/tests/ControllerTestBase.php new file mode 100644 index 0000000..27d0967 --- /dev/null +++ b/tests/ControllerTestBase.php @@ -0,0 +1,96 @@ + $queryParams + * @param array $serverParams + */ + protected function makeGet(string $path, array $queryParams = [], array $serverParams = []): \Psr\Http\Message\ServerRequestInterface + { + $request = (new ServerRequestFactory())->createServerRequest('GET', $path, $serverParams); + + if ($queryParams !== []) { + $request = $request->withQueryParams($queryParams); + } + + return $request; + } + + /** + * @param array $parsedBody + * @param array $serverParams + */ + protected function makePost(string $path, array $parsedBody = [], array $serverParams = []): \Psr\Http\Message\ServerRequestInterface + { + $request = (new ServerRequestFactory())->createServerRequest('POST', $path, $serverParams); + + if ($parsedBody !== []) { + $request = $request->withParsedBody($parsedBody); + } + + return $request; + } + + protected function makeResponse(): ResponseInterface + { + return new Response(); + } + + /** + * @return Twig&\PHPUnit\Framework\MockObject\MockObject + */ + protected function makeTwigMock(): Twig + { + /** @var Twig&\PHPUnit\Framework\MockObject\MockObject $mock */ + $mock = $this->getMockBuilder(Twig::class) + ->disableOriginalConstructor() + ->onlyMethods(['render']) + ->getMock(); + + return $mock; + } + + protected function assertRedirectTo(ResponseInterface $response, string $path, int $status = 302): void + { + self::assertSame($status, $response->getStatusCode()); + self::assertSame($path, $response->getHeaderLine('Location')); + } + + protected function assertStatus(ResponseInterface $response, int $status): void + { + self::assertSame($status, $response->getStatusCode()); + } + + /** + * @param array $expectedSubset + */ + protected function assertJsonContains(ResponseInterface $response, array $expectedSubset): void + { + $body = (string) $response->getBody(); + /** @var mixed $decoded */ + $decoded = json_decode($body, true); + + self::assertIsArray($decoded, 'Response body is not valid JSON.'); + + foreach ($expectedSubset as $key => $value) { + self::assertArrayHasKey($key, $decoded); + self::assertSame($value, $decoded[$key]); + } + } + + protected function assertJsonContentType(ResponseInterface $response): void + { + self::assertStringContainsString('application/json', $response->getHeaderLine('Content-Type')); + } +} diff --git a/tests/Fixtures/Application/config/modules.php b/tests/Fixtures/Application/config/modules.php new file mode 100644 index 0000000..2439c18 --- /dev/null +++ b/tests/Fixtures/Application/config/modules.php @@ -0,0 +1,21 @@ +view = $this->makeTwigMock(); + $this->authService = $this->createMock(AuthServiceInterface::class); + $this->flash = $this->createMock(FlashServiceInterface::class); + $this->sessionManager = $this->createMock(SessionManagerInterface::class); + + $this->controller = new AccountController( + $this->view, + $this->authService, + $this->flash, + $this->sessionManager, + ); + } + + // ── showChangePassword ─────────────────────────────────────────── + + /** + * showChangePassword() doit rendre le formulaire de changement de mot de passe. + */ + public function testShowChangePasswordRendersForm(): void + { + $this->view->expects($this->once()) + ->method('render') + ->with($this->anything(), '@Identity/account/password-change.twig', $this->anything()) + ->willReturnArgument(0); + + $res = $this->controller->showChangePassword($this->makeGet('/account/password'), $this->makeResponse()); + + $this->assertStatus($res, 200); + } + + // ── changePassword ─────────────────────────────────────────────── + + /** + * changePassword() doit rediriger avec une erreur si les mots de passe ne correspondent pas. + */ + public function testChangePasswordRedirectsWhenPasswordMismatch(): void + { + $this->flash->expects($this->once())->method('set') + ->with('password_error', 'Les mots de passe ne correspondent pas'); + + $req = $this->makePost('/account/password', [ + 'current_password' => 'oldpass', + 'new_password' => 'newpass1', + 'new_password_confirm' => 'newpass2', + ]); + $res = $this->controller->changePassword($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/account/password'); + } + + /** + * changePassword() ne doit pas appeler authService si les mots de passe ne correspondent pas. + */ + public function testChangePasswordDoesNotCallServiceOnMismatch(): void + { + $this->authService->expects($this->never())->method('changePassword'); + + $req = $this->makePost('/account/password', [ + 'current_password' => 'old', + 'new_password' => 'aaa', + 'new_password_confirm' => 'bbb', + ]); + $this->controller->changePassword($req, $this->makeResponse()); + } + + /** + * changePassword() doit afficher une erreur si le nouveau mot de passe est trop court. + */ + public function testChangePasswordRedirectsOnWeakPassword(): void + { + $this->sessionManager->method('getUserId')->willReturn(1); + $this->authService->method('changePassword')->willThrowException(new WeakPasswordException()); + + $this->flash->expects($this->once())->method('set') + ->with('password_error', $this->stringContains('12 caractères')); + + $req = $this->makePost('/account/password', [ + 'current_password' => 'old', + 'new_password' => 'short', + 'new_password_confirm' => 'short', + ]); + $res = $this->controller->changePassword($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/account/password'); + } + + /** + * changePassword() doit afficher une erreur si le mot de passe actuel est incorrect. + */ + public function testChangePasswordRedirectsOnWrongCurrentPassword(): void + { + $this->sessionManager->method('getUserId')->willReturn(1); + $this->authService->method('changePassword') + ->willThrowException(new \InvalidArgumentException('Mot de passe actuel incorrect')); + + $this->flash->expects($this->once())->method('set') + ->with('password_error', 'Le mot de passe actuel est incorrect'); + + $req = $this->makePost('/account/password', [ + 'current_password' => 'wrong', + 'new_password' => 'newpassword', + 'new_password_confirm' => 'newpassword', + ]); + $res = $this->controller->changePassword($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/account/password'); + } + + /** + * changePassword() doit afficher une erreur générique en cas d'exception inattendue. + */ + public function testChangePasswordRedirectsOnUnexpectedError(): void + { + $this->sessionManager->method('getUserId')->willReturn(1); + $this->authService->method('changePassword') + ->willThrowException(new \RuntimeException('DB error')); + + $this->flash->expects($this->once())->method('set') + ->with('password_error', $this->stringContains('inattendue')); + + $req = $this->makePost('/account/password', [ + 'current_password' => 'old', + 'new_password' => 'newpassword', + 'new_password_confirm' => 'newpassword', + ]); + $res = $this->controller->changePassword($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/account/password'); + } + + /** + * changePassword() doit afficher un message de succès et rediriger en cas de succès. + */ + public function testChangePasswordRedirectsWithSuccessFlash(): void + { + $this->sessionManager->method('getUserId')->willReturn(1); + + $this->flash->expects($this->once())->method('set') + ->with('password_success', $this->stringContains('Mot de passe modifié')); + + $req = $this->makePost('/account/password', [ + 'current_password' => 'oldpass123', + 'new_password' => 'newpass123', + 'new_password_confirm' => 'newpass123', + ]); + $res = $this->controller->changePassword($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/account/password'); + } + + /** + * changePassword() doit utiliser 0 comme userId de repli si la session est vide. + */ + public function testChangePasswordUsesZeroAsUserIdFallback(): void + { + $this->sessionManager->method('getUserId')->willReturn(null); + + $this->authService->expects($this->once()) + ->method('changePassword') + ->with(0, $this->anything(), $this->anything()); + + $req = $this->makePost('/account/password', [ + 'current_password' => 'old', + 'new_password' => 'newpassword', + 'new_password_confirm' => 'newpassword', + ]); + $this->controller->changePassword($req, $this->makeResponse()); + } +} diff --git a/tests/Identity/AdminDeleteUserTest.php b/tests/Identity/AdminDeleteUserTest.php new file mode 100644 index 0000000..9036807 --- /dev/null +++ b/tests/Identity/AdminDeleteUserTest.php @@ -0,0 +1,95 @@ +userRepository = $this->createMock(UserRepositoryInterface::class); + $this->useCase = new AdminDeleteUser($this->userRepository); + } + + public function testHandleThrowsNotFoundWhenTargetUserDoesNotExist(): void + { + $this->userRepository->expects($this->once()) + ->method('findById') + ->with(42) + ->willReturn(null); + $this->userRepository->expects($this->never())->method('delete'); + + $this->expectException(NotFoundException::class); + + $this->useCase->handle(new AdminDeleteUserCommand(1, 42)); + } + + public function testHandleThrowsWhenTargetUserIsAdmin(): void + { + $admin = $this->makeUser(7, 'root', User::ROLE_ADMIN); + + $this->userRepository->expects($this->once()) + ->method('findById') + ->with(7) + ->willReturn($admin); + $this->userRepository->expects($this->never())->method('delete'); + + $this->expectException(ProtectedAdministratorDeletionException::class); + + $this->useCase->handle(new AdminDeleteUserCommand(1, 7)); + } + + public function testHandleThrowsWhenActorTriesToDeleteOwnAccount(): void + { + $user = $this->makeUser(5, 'alice', User::ROLE_EDITOR); + + $this->userRepository->expects($this->once()) + ->method('findById') + ->with(5) + ->willReturn($user); + $this->userRepository->expects($this->never())->method('delete'); + + $this->expectException(CannotDeleteOwnAccountException::class); + + $this->useCase->handle(new AdminDeleteUserCommand(5, 5)); + } + + public function testHandleDeletesUserAndReturnsTargetUserOnSuccess(): void + { + $user = $this->makeUser(9, 'charlie', User::ROLE_USER); + + $this->userRepository->expects($this->once()) + ->method('findById') + ->with(9) + ->willReturn($user); + $this->userRepository->expects($this->once()) + ->method('delete') + ->with(9); + + $result = $this->useCase->handle(new AdminDeleteUserCommand(1, 9)); + + $this->assertSame($user, $result); + } + + private function makeUser(int $id, string $username, string $role): User + { + return new User($id, $username, sprintf('%s@example.com', $username), 'hashed-password', $role); + } +} diff --git a/tests/Identity/AdminHomePathTest.php b/tests/Identity/AdminHomePathTest.php new file mode 100644 index 0000000..e354db0 --- /dev/null +++ b/tests/Identity/AdminHomePathTest.php @@ -0,0 +1,40 @@ +previous = $_ENV['ADMIN_HOME_PATH'] ?? null; + unset($_ENV['ADMIN_HOME_PATH']); + } + + protected function tearDown(): void + { + if ($this->previous === null) { + unset($_ENV['ADMIN_HOME_PATH']); + } else { + $_ENV['ADMIN_HOME_PATH'] = $this->previous; + } + } + + public function testResolveDefaultsToGenericAdminPath(): void + { + self::assertSame('/admin', AdminHomePath::resolve()); + } + + public function testResolveNormalizesConfiguredPath(): void + { + $_ENV['ADMIN_HOME_PATH'] = 'admin/posts'; + + self::assertSame('/admin/posts', AdminHomePath::resolve()); + } +} diff --git a/tests/Identity/AdminUpdateUserRoleTest.php b/tests/Identity/AdminUpdateUserRoleTest.php new file mode 100644 index 0000000..154e0fd --- /dev/null +++ b/tests/Identity/AdminUpdateUserRoleTest.php @@ -0,0 +1,112 @@ +userRepository = $this->createMock(UserRepositoryInterface::class); + $this->useCase = new AdminUpdateUserRole($this->userRepository, new RolePolicy()); + } + + public function testHandleThrowsNotFoundWhenTargetUserDoesNotExist(): void + { + $this->userRepository->expects($this->once()) + ->method('findById') + ->with(42) + ->willReturn(null); + $this->userRepository->expects($this->never())->method('updateRole'); + + $this->expectException(NotFoundException::class); + + $this->useCase->handle(new AdminUpdateUserRoleCommand(1, 42, User::ROLE_EDITOR)); + } + + public function testHandleThrowsWhenActorTriesToModifyOwnRole(): void + { + $user = $this->makeUser(5, 'alice', User::ROLE_USER); + + $this->userRepository->expects($this->once()) + ->method('findById') + ->with(5) + ->willReturn($user); + $this->userRepository->expects($this->never())->method('updateRole'); + + $this->expectException(CannotModifyOwnRoleException::class); + + $this->useCase->handle(new AdminUpdateUserRoleCommand(5, 5, User::ROLE_EDITOR)); + } + + public function testHandleThrowsWhenTargetUserIsAdmin(): void + { + $admin = $this->makeUser(7, 'root', User::ROLE_ADMIN); + + $this->userRepository->expects($this->once()) + ->method('findById') + ->with(7) + ->willReturn($admin); + $this->userRepository->expects($this->never())->method('updateRole'); + + $this->expectException(ProtectedAdministratorRoleChangeException::class); + + $this->useCase->handle(new AdminUpdateUserRoleCommand(1, 7, User::ROLE_EDITOR)); + } + + public function testHandleThrowsWhenRequestedRoleIsNotAssignable(): void + { + $user = $this->makeUser(8, 'bob', User::ROLE_USER); + + $this->userRepository->expects($this->once()) + ->method('findById') + ->with(8) + ->willReturn($user); + $this->userRepository->expects($this->never())->method('updateRole'); + + $this->expectException(RoleAssignmentNotAllowedException::class); + + $this->useCase->handle(new AdminUpdateUserRoleCommand(1, 8, User::ROLE_ADMIN)); + } + + public function testHandleUpdatesRoleAndReturnsTargetUserOnSuccess(): void + { + $user = $this->makeUser(9, 'charlie', User::ROLE_USER); + + $this->userRepository->expects($this->once()) + ->method('findById') + ->with(9) + ->willReturn($user); + $this->userRepository->expects($this->once()) + ->method('updateRole') + ->with(9, User::ROLE_EDITOR); + + $result = $this->useCase->handle(new AdminUpdateUserRoleCommand(1, 9, User::ROLE_EDITOR)); + + $this->assertSame($user, $result); + } + + private function makeUser(int $id, string $username, string $role): User + { + return new User($id, $username, sprintf('%s@example.com', $username), 'hashed-password', $role); + } +} diff --git a/tests/Identity/AdminUserProvisionerTest.php b/tests/Identity/AdminUserProvisionerTest.php new file mode 100644 index 0000000..4eae1b0 --- /dev/null +++ b/tests/Identity/AdminUserProvisionerTest.php @@ -0,0 +1,222 @@ + 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'] = 'secret123456'; + } + + 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): MockObject&PDOStatement + { + $stmt = $this->createMock(PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('fetchColumn')->willReturn($fetchColumnValue); + + return $stmt; + } + + private function stmtForWrite(): MockObject&PDOStatement + { + $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('secret123456', $data[':password_hash']); + })); + + AdminUserProvisioner::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'; + })); + + AdminUserProvisioner::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'] !== 'secret123456' + // Et doit être vérifiable avec password_verify + && password_verify('secret123456', $data[':password_hash']); + })); + + AdminUserProvisioner::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']); + })); + + AdminUserProvisioner::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) + AdminUserProvisioner::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']); + + AdminUserProvisioner::seed($this->db); + } + + public function testSeedRejectsTooShortAdminPassword(): void + { + $_ENV['ADMIN_PASSWORD'] = 'short123'; + + $this->db->expects($this->never()) + ->method('prepare'); + + $this->expectException(\Netig\Netslim\Identity\Domain\Exception\WeakPasswordException::class); + $this->expectExceptionMessage('au moins 12 caractères'); + + AdminUserProvisioner::seed($this->db); + } +} diff --git a/tests/Identity/AuthControllerTest.php b/tests/Identity/AuthControllerTest.php new file mode 100644 index 0000000..0d29407 --- /dev/null +++ b/tests/Identity/AuthControllerTest.php @@ -0,0 +1,179 @@ +view = $this->makeTwigMock(); + $this->authService = $this->createMock(AuthServiceInterface::class); + $this->flash = $this->createMock(FlashServiceInterface::class); + + $this->controller = new AuthController( + $this->view, + $this->authService, + $this->flash, + new ClientIpResolver(['*']), + ); + } + + // ── showLogin ──────────────────────────────────────────────────── + + /** + * showLogin() doit rediriger vers /admin si l'utilisateur est déjà connecté. + */ + public function testShowLoginRedirectsWhenAlreadyLoggedIn(): void + { + $this->authService->method('isLoggedIn')->willReturn(true); + + $res = $this->controller->showLogin($this->makeGet('/auth/login'), $this->makeResponse()); + + $this->assertRedirectTo($res, '/admin'); + } + + /** + * showLogin() doit rendre le formulaire de connexion si l'utilisateur n'est pas connecté. + */ + public function testShowLoginRendersFormWhenNotLoggedIn(): void + { + $this->authService->method('isLoggedIn')->willReturn(false); + + $this->view->expects($this->once()) + ->method('render') + ->with($this->anything(), '@Identity/login.twig', $this->anything()) + ->willReturnArgument(0); + + $res = $this->controller->showLogin($this->makeGet('/auth/login'), $this->makeResponse()); + + $this->assertStatus($res, 200); + } + + // ── login ──────────────────────────────────────────────────────── + + /** + * login() doit rediriger avec un message flash si l'IP est verrouillée. + */ + public function testLoginRedirectsWhenRateLimited(): void + { + $this->authService->method('checkRateLimit')->willReturn(5); + + $this->flash->expects($this->once())->method('set') + ->with('login_error', $this->stringContains('Trop de tentatives')); + + $req = $this->makePost('/auth/login', [], ['REMOTE_ADDR' => '10.0.0.1']); + $res = $this->controller->login($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/auth/login'); + } + + /** + * login() doit conjuguer correctement le singulier/pluriel dans le message de rate limit. + */ + public function testLoginRateLimitMessageIsSingularForOneMinute(): void + { + $this->authService->method('checkRateLimit')->willReturn(1); + + $this->flash->expects($this->once())->method('set') + ->with('login_error', $this->logicalNot($this->stringContains('minutes'))); + + $req = $this->makePost('/auth/login', [], ['REMOTE_ADDR' => '10.0.0.1']); + $this->controller->login($req, $this->makeResponse()); + } + + /** + * login() doit enregistrer l'échec et rediriger si les identifiants sont invalides. + */ + public function testLoginRecordsFailureOnInvalidCredentials(): void + { + $this->authService->method('checkRateLimit')->willReturn(0); + $this->authService->method('authenticate')->willReturn(null); + + $this->authService->expects($this->once())->method('recordFailure'); + $this->flash->expects($this->once())->method('set') + ->with('login_error', 'Identifiants invalides'); + + $req = $this->makePost('/auth/login', ['username' => 'alice', 'password' => 'wrong']); + $res = $this->controller->login($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/auth/login'); + } + + /** + * login() doit ouvrir la session et rediriger vers /admin en cas de succès. + */ + public function testLoginRedirectsToAdminOnSuccess(): void + { + $user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT)); + + $this->authService->method('checkRateLimit')->willReturn(0); + $this->authService->method('authenticate')->willReturn($user); + + $this->authService->expects($this->once())->method('resetRateLimit'); + $this->authService->expects($this->once())->method('login')->with($user); + + $req = $this->makePost('/auth/login', ['username' => 'alice', 'password' => 'secret']); + $res = $this->controller->login($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/admin'); + } + + /** + * login() doit utiliser '0.0.0.0' comme IP de repli si REMOTE_ADDR est absent. + */ + public function testLoginFallsBackToDefaultIpWhenRemoteAddrMissing(): void + { + $this->authService->method('checkRateLimit')->willReturn(0); + $this->authService->method('authenticate')->willReturn(null); + + $this->authService->expects($this->once()) + ->method('checkRateLimit') + ->with('0.0.0.0'); + + $req = $this->makePost('/auth/login'); + $this->controller->login($req, $this->makeResponse()); + } + + // ── logout ─────────────────────────────────────────────────────── + + /** + * logout() doit détruire la session et rediriger vers l'accueil. + */ + public function testLogoutDestroysSessionAndRedirects(): void + { + $this->authService->expects($this->once())->method('logout'); + + $res = $this->controller->logout($this->makePost('/auth/logout'), $this->makeResponse()); + + $this->assertRedirectTo($res, '/'); + } +} diff --git a/tests/Identity/AuthServiceRateLimitTest.php b/tests/Identity/AuthServiceRateLimitTest.php new file mode 100644 index 0000000..8ad836b --- /dev/null +++ b/tests/Identity/AuthServiceRateLimitTest.php @@ -0,0 +1,228 @@ +userRepository = $this->createMock(UserRepositoryInterface::class); + $this->authSession = $this->createMock(AuthSessionInterface::class); + $this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class); + + $this->service = new AuthApplicationService( + $this->authSession, + $this->loginAttemptRepository, + new LoginRateLimitPolicy(), + new AuthenticateUser($this->userRepository, new PasswordPolicy()), + new ChangePassword($this->userRepository, new PasswordPolicy()), + ); + } + + + // ── checkRateLimit ───────────────────────────────────────────── + + /** + * checkRateLimit() doit retourner 0 si l'IP n'a aucune entrée en base. + */ + public function testCheckRateLimitUnknownIpReturnsZero(): void + { + $this->loginAttemptRepository->method('deleteExpired'); + $this->loginAttemptRepository->method('findByIp')->willReturn(null); + + $result = $this->service->checkRateLimit('192.168.1.1'); + + $this->assertSame(0, $result); + } + + /** + * checkRateLimit() doit retourner 0 si l'IP a des tentatives + * mais n'est pas encore verrouillée (locked_until = null). + */ + public function testCheckRateLimitNoLockReturnsZero(): void + { + $this->loginAttemptRepository->method('deleteExpired'); + $this->loginAttemptRepository->method('findByIp')->willReturn([ + 'ip' => '192.168.1.1', + 'attempts' => 3, + 'locked_until' => null, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this->service->checkRateLimit('192.168.1.1'); + + $this->assertSame(0, $result); + } + + /** + * checkRateLimit() doit retourner le nombre de minutes restantes + * si l'IP est actuellement verrouillée. + */ + public function testCheckRateLimitLockedIpReturnsRemainingMinutes(): void + { + $lockedUntil = date('Y-m-d H:i:s', time() + 10 * 60); + + $this->loginAttemptRepository->method('deleteExpired'); + $this->loginAttemptRepository->method('findByIp')->willReturn([ + 'ip' => '192.168.1.1', + 'attempts' => 5, + 'locked_until' => $lockedUntil, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this->service->checkRateLimit('192.168.1.1'); + + $this->assertGreaterThan(0, $result); + $this->assertLessThanOrEqual(15, $result); + } + + /** + * checkRateLimit() doit retourner au minimum 1 minute + * même si le verrouillage expire dans quelques secondes. + */ + public function testCheckRateLimitReturnsAtLeastOneMinute(): void + { + $lockedUntil = date('Y-m-d H:i:s', time() + 30); + + $this->loginAttemptRepository->method('deleteExpired'); + $this->loginAttemptRepository->method('findByIp')->willReturn([ + 'ip' => '192.168.1.1', + 'attempts' => 5, + 'locked_until' => $lockedUntil, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this->service->checkRateLimit('192.168.1.1'); + + $this->assertSame(1, $result); + } + + /** + * checkRateLimit() doit retourner 0 si le verrouillage est expiré. + */ + public function testCheckRateLimitExpiredLockReturnsZero(): void + { + $lockedUntil = date('Y-m-d H:i:s', time() - 60); + + $this->loginAttemptRepository->method('deleteExpired'); + $this->loginAttemptRepository->method('findByIp')->willReturn([ + 'ip' => '192.168.1.1', + 'attempts' => 5, + 'locked_until' => $lockedUntil, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this->service->checkRateLimit('192.168.1.1'); + + $this->assertSame(0, $result); + } + + /** + * checkRateLimit() doit toujours appeler deleteExpired() avant la vérification. + */ + public function testCheckRateLimitCallsDeleteExpired(): void + { + $this->loginAttemptRepository + ->expects($this->once()) + ->method('deleteExpired'); + + $this->loginAttemptRepository->method('findByIp')->willReturn(null); + + $this->service->checkRateLimit('192.168.1.1'); + } + + + // ── recordFailure ────────────────────────────────────────────── + + /** + * recordFailure() doit déléguer avec MAX_ATTEMPTS = 5 et LOCK_MINUTES = 15. + */ + public function testRecordFailureDelegatesWithConstants(): void + { + $this->loginAttemptRepository + ->expects($this->once()) + ->method('recordFailure') + ->with('192.168.1.1', 5, 15); + + $this->service->recordFailure('192.168.1.1'); + } + + /** + * recordFailure() doit transmettre l'adresse IP exacte au dépôt. + */ + public function testRecordFailurePassesIpAddress(): void + { + $ip = '10.0.0.42'; + + $this->loginAttemptRepository + ->expects($this->once()) + ->method('recordFailure') + ->with($ip, $this->anything(), $this->anything()); + + $this->service->recordFailure($ip); + } + + + // ── resetRateLimit ───────────────────────────────────────────── + + /** + * resetRateLimit() doit appeler resetForIp() avec l'adresse IP fournie. + */ + public function testResetRateLimitCallsResetForIp(): void + { + $ip = '192.168.1.1'; + + $this->loginAttemptRepository + ->expects($this->once()) + ->method('resetForIp') + ->with($ip); + + $this->service->resetRateLimit($ip); + } + + /** + * resetRateLimit() ne doit pas appeler recordFailure() ni deleteExpired(). + */ + public function testResetRateLimitCallsNothingElse(): void + { + $this->loginAttemptRepository->expects($this->never())->method('recordFailure'); + $this->loginAttemptRepository->expects($this->never())->method('deleteExpired'); + $this->loginAttemptRepository->method('resetForIp'); + + $this->service->resetRateLimit('192.168.1.1'); + } +} diff --git a/tests/Identity/AuthServiceTest.php b/tests/Identity/AuthServiceTest.php new file mode 100644 index 0000000..be7c732 --- /dev/null +++ b/tests/Identity/AuthServiceTest.php @@ -0,0 +1,286 @@ +userRepository = $this->createMock(UserRepositoryInterface::class); + $this->authSession = $this->createMock(AuthSessionInterface::class); + $this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class); + + $this->service = new AuthApplicationService( + $this->authSession, + $this->loginAttemptRepository, + new LoginRateLimitPolicy(), + new AuthenticateUser($this->userRepository, new PasswordPolicy()), + new ChangePassword($this->userRepository, new PasswordPolicy()), + ); + } + + + // ── authenticate ─────────────────────────────────────────────── + + /** + * authenticate() doit retourner l'utilisateur si les identifiants sont corrects. + */ + public function testAuthenticateValidCredentials(): void + { + $password = 'motdepasse12'; + $user = $this->makeUser('alice', 'alice@example.com', $password); + + $this->userRepository->expects($this->once())->method('findByUsername')->with('alice')->willReturn($user); + + $result = $this->service->authenticate('alice', $password); + + $this->assertSame($user, $result); + } + + /** + * authenticate() doit normaliser le nom d'utilisateur en minuscules. + */ + public function testAuthenticateNormalizesUsername(): void + { + $password = 'motdepasse12'; + $user = $this->makeUser('alice', 'alice@example.com', $password); + + $this->userRepository->expects($this->once())->method('findByUsername')->with('alice')->willReturn($user); + + $result = $this->service->authenticate('ALICE', $password); + + $this->assertNotNull($result); + } + + /** + * authenticate() ne doit pas supprimer les espaces du mot de passe saisi. + */ + public function testAuthenticatePreservesPasswordWhitespace(): void + { + $password = ' motdepasse12 '; + $user = $this->makeUser('alice', 'alice@example.com', $password); + + $this->userRepository->expects($this->exactly(2))->method('findByUsername')->with('alice')->willReturn($user); + + $this->assertSame($user, $this->service->authenticate('alice', $password)); + $this->assertNull($this->service->authenticate('alice', 'motdepasse12')); + } + + /** + * authenticate() doit retourner null si l'utilisateur est introuvable. + */ + public function testAuthenticateUnknownUser(): void + { + $this->userRepository->method('findByUsername')->willReturn(null); + + $result = $this->service->authenticate('inconnu', 'motdepasse12'); + + $this->assertNull($result); + } + + /** + * authenticate() doit retourner null si le mot de passe est incorrect. + */ + public function testAuthenticateWrongPassword(): void + { + $user = $this->makeUser('alice', 'alice@example.com', 'bonmotdepasse'); + $this->userRepository->method('findByUsername')->willReturn($user); + + $result = $this->service->authenticate('alice', 'mauvaismdp'); + + $this->assertNull($result); + } + + + // ── changePassword ───────────────────────────────────────────── + + /** + * changePassword() doit mettre à jour le hash si les données sont valides. + */ + public function testChangePasswordWithValidData(): void + { + $password = 'ancienmdp12'; + $user = $this->makeUser('alice', 'alice@example.com', $password, 1); + + $this->userRepository->expects($this->once())->method('findById')->with(1)->willReturn($user); + $this->userRepository->expects($this->once())->method('updatePassword')->with(1); + + $this->service->changePassword(1, $password, 'nouveaumdp12'); + } + + /** + * changePassword() doit lever une exception si le mot de passe actuel est incorrect. + */ + public function testChangePasswordWrongCurrentPassword(): void + { + $user = $this->makeUser('alice', 'alice@example.com', 'bonmotdepasse', 1); + $this->userRepository->method('findById')->willReturn($user); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/actuel incorrect/'); + + $this->service->changePassword(1, 'mauvaismdp', 'nouveaumdp12'); + } + + /** + * changePassword() doit conserver exactement les espaces du nouveau mot de passe. + */ + public function testChangePasswordPreservesNewPasswordWhitespace(): void + { + $currentPassword = 'ancienmdp12'; + $newPassword = ' nouveaumdp12 '; + $user = $this->makeUser('alice', 'alice@example.com', $currentPassword, 1); + + $this->userRepository->expects($this->once())->method('findById')->with(1)->willReturn($user); + $this->userRepository->expects($this->once()) + ->method('updatePassword') + ->with(1, $this->callback(static function (string $hash) use ($newPassword): bool { + return password_verify($newPassword, $hash) + && !password_verify(trim($newPassword), $hash); + })); + + $this->service->changePassword(1, $currentPassword, $newPassword); + } + + /** + * changePassword() avec exactement 12 caractères doit réussir (frontière basse). + */ + public function testChangePasswordMinimumLengthNewPassword(): void + { + $password = 'ancienmdp12'; + $user = $this->makeUser('alice', 'alice@example.com', $password, 1); + $this->userRepository->method('findById')->willReturn($user); + $this->userRepository->expects($this->once())->method('updatePassword'); + + // Ne doit pas lever d'exception + $this->service->changePassword(1, $password, '123456789012'); + $this->addToAssertionCount(1); + } + + /** + * changePassword() doit lever WeakPasswordException si le nouveau mot de passe est trop court. + */ + public function testChangePasswordTooShortNewPasswordThrowsWeakPasswordException(): void + { + $password = 'ancienmdp12'; + $user = $this->makeUser('alice', 'alice@example.com', $password, 1); + $this->userRepository->method('findById')->willReturn($user); + + $this->expectException(WeakPasswordException::class); + + $this->service->changePassword(1, $password, '12345678901'); + } + + /** + * changePassword() doit lever NotFoundException si l'utilisateur est introuvable. + */ + public function testChangePasswordUnknownUserThrowsNotFoundException(): void + { + $this->userRepository->method('findById')->willReturn(null); + + $this->expectException(NotFoundException::class); + + $this->service->changePassword(99, 'ancienmdp12', 'nouveaumdp12'); + } + + + // ── login / logout / isLoggedIn ──────────────────────────────── + + /** + * login() doit appeler SessionManager::setUser() avec les bonnes données. + */ + public function testLoginCallsSetUser(): void + { + $user = $this->makeUser('alice', 'alice@example.com', 'secret', 7, User::ROLE_ADMIN); + + $this->authSession->expects($this->once()) + ->method('startForUser') + ->with($user); + + $this->service->login($user); + } + + /** + * logout() doit appeler SessionManager::destroy(). + */ + public function testLogoutCallsDestroy(): void + { + $this->authSession->expects($this->once())->method('clear'); + + $this->service->logout(); + } + + /** + * isLoggedIn() doit déléguer à SessionManager::isAuthenticated(). + */ + public function testIsLoggedInDelegatesToSessionManager(): void + { + $this->authSession->method('isAuthenticated')->willReturn(true); + + $this->assertTrue($this->service->isLoggedIn()); + } + + + // ── Helpers ──────────────────────────────────────────────────── + + /** + * Crée un utilisateur de test avec un hash bcrypt du mot de passe fourni. + * + * @param string $username Nom d'utilisateur + * @param string $email Adresse e-mail + * @param string $password Mot de passe en clair (haché en bcrypt) + * @param int $id Identifiant (défaut : 1) + * @param string $role Rôle (défaut : 'user') + */ + private function makeUser( + string $username, + string $email, + string $password = 'motdepasse12', + int $id = 1, + string $role = User::ROLE_USER, + ): User { + return new User( + $id, + $username, + $email, + password_hash($password, PASSWORD_BCRYPT), + $role, + ); + } +} diff --git a/tests/Identity/AuthorizationServiceTest.php b/tests/Identity/AuthorizationServiceTest.php new file mode 100644 index 0000000..0edc7ea --- /dev/null +++ b/tests/Identity/AuthorizationServiceTest.php @@ -0,0 +1,36 @@ +service = new AuthorizationApplicationService(new RolePermissionMatrix()); + } + + public function testEditorGetsFineGrainedPermissions(): void + { + $editor = new User(1, 'editor', 'editor@example.test', 'hash', User::ROLE_EDITOR); + + self::assertTrue($this->service->canUser($editor, Permission::CONTENT_PUBLISH)); + self::assertTrue($this->service->canUser($editor, Permission::MEDIA_MANAGE)); + self::assertFalse($this->service->canUser($editor, Permission::USERS_MANAGE)); + } + + public function testAdminHasWildcardPermissions(): void + { + self::assertTrue($this->service->canRole(User::ROLE_ADMIN, Permission::SETTINGS_MANAGE)); + self::assertContains('*', $this->service->permissionsForRole(User::ROLE_ADMIN)); + } +} diff --git a/tests/Identity/LoginAttemptRepositoryTest.php b/tests/Identity/LoginAttemptRepositoryTest.php new file mode 100644 index 0000000..20d14e9 --- /dev/null +++ b/tests/Identity/LoginAttemptRepositoryTest.php @@ -0,0 +1,286 @@ +db = $this->createMock(PDO::class); + $this->repository = new PdoLoginAttemptRepository($this->db); + } + + // ── Helper ───────────────────────────────────────────────────── + + private function stmtOk(): MockObject&PDOStatement + { + $stmt = $this->createMock(PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('fetch')->willReturn(false); + + return $stmt; + } + + + // ── findByIp ─────────────────────────────────────────────────── + + /** + * findByIp() doit retourner null si aucune entrée n'existe pour cette IP. + */ + public function testFindByIpReturnsNullWhenMissing(): void + { + $stmt = $this->createMock(PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('fetch')->willReturn(false); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertNull($this->repository->findByIp('192.168.1.1')); + } + + /** + * findByIp() doit retourner le tableau associatif si une entrée existe. + */ + public function testFindByIpReturnsRowWhenFound(): void + { + $row = [ + 'scope' => 'login_ip', + 'rate_key' => '192.168.1.1', + 'attempts' => 3, + 'locked_until' => null, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $stmt = $this->createMock(PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('fetch')->willReturn($row); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertSame($row, $this->repository->findByIp('192.168.1.1')); + } + + /** + * findByIp() exécute avec la bonne IP. + */ + public function testFindByIpQueriesWithCorrectIp(): void + { + $stmt = $this->stmtOk(); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with([':scope' => 'login_ip', ':rate_key' => '10.0.0.1']); + + $this->repository->findByIp('10.0.0.1'); + } + + + // ── recordFailure — UPSERT atomique ──────────────────────────── + + /** + * recordFailure() doit préparer une écriture sur rate_limits + * puis exécuter l'opération avec les bons paramètres métier. + */ + public function testRecordFailureUsesUpsertSql(): void + { + $stmt = $this->stmtOk(); + + $this->db->expects($this->once()) + ->method('prepare') + ->with($this->logicalAnd( + $this->stringContains('rate_limits'), + $this->stringContains('attempts'), + $this->stringContains('locked_until'), + )) + ->willReturn($stmt); + + $this->repository->recordFailure('192.168.1.1', 5, 15); + } + + /** + * recordFailure() doit passer la bonne IP au paramètre :ip. + */ + public function testRecordFailurePassesCorrectIp(): void + { + $ip = '10.0.0.42'; + $stmt = $this->stmtOk(); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(fn (array $p): bool => $p[':scope1'] === 'login_ip' && $p[':rate_key1'] === $ip)); + + $this->repository->recordFailure($ip, 5, 15); + } + + /** + * recordFailure() doit passer le seuil maxAttempts sous les deux alias :max1 et :max2. + * + * PDO interdit les paramètres nommés dupliqués dans une même requête ; + * le seuil est donc passé deux fois avec des noms distincts. + */ + public function testRecordFailurePassesMaxAttemptsUnderBothAliases(): void + { + $max = 7; + $stmt = $this->stmtOk(); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback( + fn (array $p): bool => + $p[':max1'] === $max && $p[':max2'] === $max, + )); + + $this->repository->recordFailure('192.168.1.1', $max, 15); + } + + /** + * recordFailure() doit calculer locked_until dans la fenêtre attendue. + * + * :lock1 et :lock2 doivent être égaux et correspondre à + * maintenant + lockMinutes (± 5 secondes de tolérance d'exécution). + */ + public function testRecordFailureLockedUntilIsInCorrectWindow(): void + { + $lockMinutes = 15; + $before = time() + $lockMinutes * 60 - 5; + $after = time() + $lockMinutes * 60 + 5; + + $stmt = $this->stmtOk(); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(function (array $p) use ($before, $after): bool { + foreach ([':lock1', ':lock2'] as $key) { + if (!isset($p[$key])) { + return false; + } + $ts = strtotime($p[$key]); + if ($ts < $before || $ts > $after) { + return false; + } + } + + return $p[':lock1'] === $p[':lock2']; + })); + + $this->repository->recordFailure('192.168.1.1', 5, $lockMinutes); + } + + /** + * recordFailure() doit horodater :now1 et :now2 dans la fenêtre de l'instant présent. + */ + public function testRecordFailureNowParamsAreCurrentTime(): void + { + $before = time() - 2; + $after = time() + 2; + + $stmt = $this->stmtOk(); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(function (array $p) use ($before, $after): bool { + foreach ([':now1', ':now2'] as $key) { + if (!isset($p[$key])) { + return false; + } + $ts = strtotime($p[$key]); + if ($ts < $before || $ts > $after) { + return false; + } + } + + return $p[':now1'] === $p[':now2']; + })); + + $this->repository->recordFailure('192.168.1.1', 5, 15); + } + + + // ── resetForIp ───────────────────────────────────────────────── + + /** + * resetForIp() doit préparer une suppression ciblant la bonne IP. + */ + public function testResetForIpCallsDeleteWithCorrectIp(): void + { + $ip = '10.0.0.42'; + $stmt = $this->stmtOk(); + + $this->db->expects($this->once()) + ->method('prepare') + ->with($this->logicalAnd( + $this->stringContains('rate_limits'), + $this->stringContains('DELETE'), + )) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with([':scope' => 'login_ip', ':rate_key' => $ip]); + + $this->repository->resetForIp($ip); + } + + + // ── deleteExpired ────────────────────────────────────────────── + + /** + * deleteExpired() doit préparer une suppression sur rate_limits + * et lier une date au format 'Y-m-d H:i:s' comme paramètre :now. + */ + public function testDeleteExpiredExecutesQueryWithCurrentTimestamp(): void + { + $stmt = $this->stmtOk(); + + $this->db->expects($this->once()) + ->method('prepare') + ->with($this->logicalAnd( + $this->stringContains('rate_limits'), + $this->stringContains('DELETE'), + )) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(function (array $params): bool { + return isset($params[':now']) + && (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $params[':now']); + })); + + $this->repository->deleteExpired(); + } +} diff --git a/tests/Identity/MiddlewareTest.php b/tests/Identity/MiddlewareTest.php new file mode 100644 index 0000000..332cad2 --- /dev/null +++ b/tests/Identity/MiddlewareTest.php @@ -0,0 +1,110 @@ +sessionManager = $this->createMock(SessionManagerInterface::class); + $this->request = (new ServerRequestFactory())->createServerRequest('GET', '/admin'); + } + + public function testAuthMiddlewareRedirectsGuests(): void + { + $this->sessionManager->method('isAuthenticated')->willReturn(false); + + $middleware = new AuthMiddleware($this->sessionManager); + $response = $middleware->process($this->request, $this->makeHandler()); + + self::assertSame(302, $response->getStatusCode()); + self::assertSame('/auth/login', $response->getHeaderLine('Location')); + } + + public function testAuthMiddlewareDelegatesWhenAuthenticated(): void + { + $this->sessionManager->method('isAuthenticated')->willReturn(true); + + $middleware = new AuthMiddleware($this->sessionManager); + $response = $middleware->process($this->request, $this->makeHandler(204)); + + self::assertSame(204, $response->getStatusCode()); + } + + public function testAdminMiddlewareRedirectsNonAdmins(): void + { + $this->sessionManager->method('isAdmin')->willReturn(false); + + $middleware = new AdminMiddleware($this->sessionManager); + $response = $middleware->process($this->request, $this->makeHandler()); + + self::assertSame(302, $response->getStatusCode()); + self::assertSame('/admin', $response->getHeaderLine('Location')); + } + + public function testAdminMiddlewareDelegatesForAdmins(): void + { + $this->sessionManager->method('isAdmin')->willReturn(true); + + $middleware = new AdminMiddleware($this->sessionManager); + $response = $middleware->process($this->request, $this->makeHandler(204)); + + self::assertSame(204, $response->getStatusCode()); + } + + public function testEditorMiddlewareRedirectsWhenNeitherAdminNorEditor(): void + { + $this->sessionManager->method('isAdmin')->willReturn(false); + $this->sessionManager->method('isEditor')->willReturn(false); + + $middleware = new EditorMiddleware($this->sessionManager); + $response = $middleware->process($this->request, $this->makeHandler()); + + self::assertSame(302, $response->getStatusCode()); + self::assertSame('/admin', $response->getHeaderLine('Location')); + } + + public function testEditorMiddlewareDelegatesForEditors(): void + { + $this->sessionManager->method('isAdmin')->willReturn(false); + $this->sessionManager->method('isEditor')->willReturn(true); + + $middleware = new EditorMiddleware($this->sessionManager); + $response = $middleware->process($this->request, $this->makeHandler(204)); + + self::assertSame(204, $response->getStatusCode()); + } + + private function makeHandler(int $status = 200): RequestHandlerInterface + { + return new class ($status) implements RequestHandlerInterface { + public function __construct(private readonly int $status) {} + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new Response($this->status); + } + }; + } +} diff --git a/tests/Identity/PasswordRequestHandlingTest.php b/tests/Identity/PasswordRequestHandlingTest.php new file mode 100644 index 0000000..8b7c29d --- /dev/null +++ b/tests/Identity/PasswordRequestHandlingTest.php @@ -0,0 +1,85 @@ +createServerRequest('POST', '/login') + ->withParsedBody([ + 'username' => ' alice ', + 'password' => ' secret ', + ]); + + $payload = LoginRequest::fromRequest($request); + + self::assertSame('alice', $payload->username); + self::assertSame(' secret ', $payload->password); + } + + public function testChangePasswordRequestPreservesPasswordWhitespace(): void + { + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/account/password') + ->withParsedBody([ + 'current_password' => ' current ', + 'new_password' => ' nextpass ', + 'new_password_confirm' => ' nextpass ', + ]); + + $payload = ChangePasswordRequest::fromRequest($request); + + self::assertSame(' current ', $payload->currentPassword); + self::assertSame(' nextpass ', $payload->newPassword); + self::assertSame(' nextpass ', $payload->newPasswordConfirm); + } + + public function testResetPasswordRequestPreservesPasswordWhitespace(): void + { + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/password/reset') + ->withParsedBody([ + 'token' => ' token ', + 'new_password' => ' nextpass ', + 'new_password_confirm' => ' nextpass ', + ]); + + $payload = ResetPasswordRequest::fromRequest($request); + + self::assertSame('token', $payload->token); + self::assertSame(' nextpass ', $payload->newPassword); + self::assertSame(' nextpass ', $payload->newPasswordConfirm); + } + + public function testCreateUserRequestPreservesPasswordWhitespace(): void + { + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/admin/users') + ->withParsedBody([ + 'username' => ' Alice ', + 'email' => ' alice@example.com ', + 'password' => ' secret123 ', + 'password_confirm' => ' secret123 ', + 'role' => 'editor', + ]); + + $payload = CreateUserRequest::fromRequest($request, ['user', 'editor']); + + self::assertSame('Alice', $payload->username); + self::assertSame('alice@example.com', $payload->email); + self::assertSame(' secret123 ', $payload->password); + self::assertSame(' secret123 ', $payload->passwordConfirm); + self::assertSame('editor', $payload->role); + } +} diff --git a/tests/Identity/PasswordResetControllerTest.php b/tests/Identity/PasswordResetControllerTest.php new file mode 100644 index 0000000..23157a7 --- /dev/null +++ b/tests/Identity/PasswordResetControllerTest.php @@ -0,0 +1,435 @@ +view = $this->makeTwigMock(); + $this->passwordResetService = $this->createMock(PasswordResetServiceInterface::class); + $this->authService = $this->createMock(AuthServiceInterface::class); + $this->flash = $this->createMock(FlashServiceInterface::class); + $this->clientIpResolver = new ClientIpResolver(['*']); + + // Par défaut : IP non verrouillée + $this->authService->method('checkPasswordResetRateLimit')->willReturn(0); + + $this->controller = new PasswordResetController( + $this->view, + $this->passwordResetService, + $this->authService, + $this->flash, + $this->clientIpResolver, + self::BASE_URL, + ); + } + + // ── showForgot ─────────────────────────────────────────────────── + + /** + * showForgot() doit rendre le formulaire de demande de réinitialisation. + */ + public function testShowForgotRendersForm(): void + { + $this->view->expects($this->once()) + ->method('render') + ->with($this->anything(), '@Identity/password-forgot.twig', $this->anything()) + ->willReturnArgument(0); + + $res = $this->controller->showForgot($this->makeGet('/password/forgot'), $this->makeResponse()); + + $this->assertStatus($res, 200); + } + + // ── forgot — rate limiting ──────────────────────────────────────── + + /** + * forgot() doit rediriger avec une erreur générique si l'IP est verrouillée. + * + * Le message ne mentionne pas l'email pour ne pas révéler qu'une demande + * pour cette adresse a déjà été traitée. + * + * Note : le mock setUp() est reconfiguré ici via un nouveau mock pour éviter + * le conflit avec le willReturn(0) global — en PHPUnit 11 la première + * configuration prend la main sur les suivantes pour le même matcher any(). + */ + public function testForgotRedirectsWhenRateLimited(): void + { + $authService = $this->createMock(AuthServiceInterface::class); + $authService->expects($this->once()) + ->method('checkPasswordResetRateLimit') + ->with('203.0.113.5') + ->willReturn(10); + + $controller = new PasswordResetController( + $this->view, + $this->passwordResetService, + $authService, + $this->flash, + $this->clientIpResolver, + self::BASE_URL, + ); + + $this->flash->expects($this->once())->method('set') + ->with('reset_error', $this->stringContains('Trop de demandes')); + + $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12', + ]); + $res = $controller->forgot($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/password/forgot'); + } + + /** + * forgot() ne doit pas appeler requestReset() si l'IP est verrouillée. + */ + public function testForgotDoesNotCallServiceWhenRateLimited(): void + { + $authService = $this->createMock(AuthServiceInterface::class); + $authService->expects($this->once()) + ->method('checkPasswordResetRateLimit') + ->with('203.0.113.5') + ->willReturn(5); + + $controller = new PasswordResetController( + $this->view, + $this->passwordResetService, + $authService, + $this->flash, + $this->clientIpResolver, + self::BASE_URL, + ); + + $this->passwordResetService->expects($this->never())->method('requestReset'); + + $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12', + ]); + $controller->forgot($req, $this->makeResponse()); + } + + /** + * forgot() doit enregistrer une tentative pour chaque demande, quel que soit le résultat. + * + * Réinitialiser le compteur uniquement en cas de succès constituerait un canal + * caché permettant de déduire si l'adresse email est enregistrée. + */ + public function testForgotAlwaysRecordsFailure(): void + { + $this->authService->expects($this->once()) + ->method('recordPasswordResetAttempt') + ->with('203.0.113.5'); + + $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12', + ]); + $this->controller->forgot($req, $this->makeResponse()); + } + + /** + * forgot() ne doit jamais réinitialiser le compteur de rate limit. + * + * Un reset sur succès révélerait l'existence du compte (canal caché). + */ + public function testForgotNeverResetsRateLimit(): void + { + $this->authService->expects($this->never())->method('resetRateLimit'); + + $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']); + $this->controller->forgot($req, $this->makeResponse()); + } + + // ── forgot — comportement nominal ──────────────────────────────── + + /** + * forgot() doit afficher un message de succès générique même si l'email est inconnu. + * + * Protection contre l'énumération des comptes : le comportement externe + * ne doit pas révéler si l'adresse est enregistrée. + */ + public function testForgotAlwaysShowsGenericSuccessMessage(): void + { + // requestReset() est void — aucun stub nécessaire, le mock ne lève pas d'exception par défaut + + $this->flash->expects($this->once())->method('set') + ->with('reset_success', $this->stringContains('Si cette adresse')); + + $req = $this->makePost('/password/forgot', ['email' => 'unknown@example.com']); + $res = $this->controller->forgot($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/password/forgot'); + } + + /** + * forgot() doit afficher une erreur et rediriger si l'envoi d'email échoue. + */ + public function testForgotRedirectsWithErrorOnMailFailure(): void + { + $this->passwordResetService->method('requestReset') + ->willThrowException(new \RuntimeException('SMTP error')); + + $this->flash->expects($this->once())->method('set') + ->with('reset_error', $this->stringContains('erreur est survenue')); + + $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']); + $res = $this->controller->forgot($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/password/forgot'); + } + + /** + * forgot() doit transmettre l'APP_URL au service lors de la demande. + */ + public function testForgotPassesBaseUrlToService(): void + { + $this->passwordResetService->expects($this->once()) + ->method('requestReset') + ->with($this->anything(), self::BASE_URL); + + $req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']); + $this->controller->forgot($req, $this->makeResponse()); + } + + // ── showReset ──────────────────────────────────────────────────── + + /** + * showReset() doit rediriger avec une erreur si le paramètre token est absent. + */ + public function testShowResetRedirectsWhenTokenMissing(): void + { + $this->flash->expects($this->once())->method('set') + ->with('reset_error', $this->stringContains('manquant')); + + $res = $this->controller->showReset($this->makeGet('/password/reset'), $this->makeResponse()); + + $this->assertRedirectTo($res, '/password/forgot'); + } + + /** + * showReset() doit rediriger avec une erreur si le token est invalide ou expiré. + */ + public function testShowResetRedirectsWhenTokenInvalid(): void + { + $this->passwordResetService->method('validateToken')->willReturn(null); + + $this->flash->expects($this->once())->method('set') + ->with('reset_error', $this->stringContains('invalide')); + + $req = $this->makeGet('/password/reset', ['token' => 'invalid-token']); + $res = $this->controller->showReset($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/password/forgot'); + } + + /** + * showReset() doit rendre le formulaire si le token est valide. + */ + public function testShowResetRendersFormWhenTokenValid(): void + { + $user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT)); + $this->passwordResetService->method('validateToken')->willReturn($user); + + $this->view->expects($this->once()) + ->method('render') + ->with($this->anything(), '@Identity/password-reset.twig', $this->anything()) + ->willReturnArgument(0); + + $req = $this->makeGet('/password/reset', ['token' => 'valid-token']); + $res = $this->controller->showReset($req, $this->makeResponse()); + + $this->assertStatus($res, 200); + } + + // ── reset ──────────────────────────────────────────────────────── + + /** + * reset() doit rediriger vers /password/forgot si le token est absent du corps. + */ + public function testResetRedirectsToForgotWhenTokenMissing(): void + { + $this->flash->expects($this->once())->method('set') + ->with('reset_error', $this->stringContains('manquant')); + + $req = $this->makePost('/password/reset', [ + 'token' => '', + 'new_password' => 'newpass123', + 'new_password_confirm' => 'newpass123', + ]); + $res = $this->controller->reset($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/password/forgot'); + } + + /** + * reset() doit rediriger avec une erreur si les mots de passe ne correspondent pas. + */ + public function testResetRedirectsWhenPasswordMismatch(): void + { + $this->flash->expects($this->once())->method('set') + ->with('reset_error', $this->stringContains('correspondent pas')); + + $req = $this->makePost('/password/reset', [ + 'token' => 'abc123', + 'new_password' => 'newpass1', + 'new_password_confirm' => 'newpass2', + ]); + $res = $this->controller->reset($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/password/reset?token=abc123'); + } + + /** + * reset() doit rediriger avec une erreur si le nouveau mot de passe est trop court. + */ + public function testResetRedirectsOnWeakPassword(): void + { + $this->passwordResetService->method('resetPassword') + ->willThrowException(new WeakPasswordException()); + + $this->flash->expects($this->once())->method('set') + ->with('reset_error', $this->stringContains('12 caractères')); + + $req = $this->makePost('/password/reset', [ + 'token' => 'abc123', + 'new_password' => 'short', + 'new_password_confirm' => 'short', + ]); + $res = $this->controller->reset($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/password/reset?token=abc123'); + } + + /** + * reset() doit rediriger avec une erreur si le token est invalide ou expiré. + */ + public function testResetRedirectsOnInvalidToken(): void + { + $this->passwordResetService->method('resetPassword') + ->willThrowException(new InvalidResetTokenException('Token invalide')); + + $this->flash->expects($this->once())->method('set') + ->with('reset_error', $this->stringContains('invalide')); + + $req = $this->makePost('/password/reset', [ + 'token' => 'expired-token', + 'new_password' => 'newpass123', + 'new_password_confirm' => 'newpass123', + ]); + $res = $this->controller->reset($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/password/reset?token=expired-token'); + } + + /** + * reset() doit rediriger avec une erreur générique en cas d'exception inattendue. + */ + public function testResetRedirectsOnUnexpectedError(): void + { + $this->passwordResetService->method('resetPassword') + ->willThrowException(new \RuntimeException('DB error')); + + $this->flash->expects($this->once())->method('set') + ->with('reset_error', $this->stringContains('inattendue')); + + $req = $this->makePost('/password/reset', [ + 'token' => 'abc123', + 'new_password' => 'newpass123', + 'new_password_confirm' => 'newpass123', + ]); + $res = $this->controller->reset($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/password/reset?token=abc123'); + } + + /** + * reset() doit flasher un succès et rediriger vers /auth/login en cas de succès. + */ + public function testResetRedirectsToLoginOnSuccess(): void + { + $this->flash->expects($this->once())->method('set') + ->with('login_success', $this->stringContains('réinitialisé')); + + $req = $this->makePost('/password/reset', [ + 'token' => 'valid-token', + 'new_password' => 'newpass123', + 'new_password_confirm' => 'newpass123', + ]); + $res = $this->controller->reset($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/auth/login'); + } + + /** + * reset() doit encoder correctement le token dans l'URL de redirection en cas d'erreur. + */ + public function testResetEncodesTokenInRedirectUrl(): void + { + $this->passwordResetService->method('resetPassword') + ->willThrowException(new WeakPasswordException()); + $this->flash->method('set'); + + $token = 'tok/en+with=special&chars'; + $req = $this->makePost('/password/reset', [ + 'token' => $token, + 'new_password' => 'x', + 'new_password_confirm' => 'x', + ]); + $res = $this->controller->reset($req, $this->makeResponse()); + + $this->assertSame( + '/password/reset?token=' . urlencode($token), + $res->getHeaderLine('Location'), + ); + } +} diff --git a/tests/Identity/PasswordResetRepositoryTest.php b/tests/Identity/PasswordResetRepositoryTest.php new file mode 100644 index 0000000..1575d80 --- /dev/null +++ b/tests/Identity/PasswordResetRepositoryTest.php @@ -0,0 +1,264 @@ +db = $this->createMock(PDO::class); + $this->repository = new PdoPasswordResetRepository($this->db); + } + + // ── Helper ───────────────────────────────────────────────────── + + private function stmtOk(array|false $fetchReturn = false): MockObject&PDOStatement + { + $stmt = $this->createMock(PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('fetch')->willReturn($fetchReturn); + + return $stmt; + } + + + // ── create ───────────────────────────────────────────────────── + + /** + * create() doit préparer un INSERT sur 'password_resets' + * avec le user_id, le token_hash et la date d'expiration fournis. + */ + public function testCreateCallsInsertWithCorrectData(): void + { + $userId = 1; + $tokenHash = hash('sha256', 'montokenbrut'); + $expiresAt = date('Y-m-d H:i:s', time() + 3600); + $stmt = $this->stmtOk(); + + $this->db->expects($this->once())->method('prepare') + ->with($this->stringContains('INSERT INTO password_resets')) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(function (array $data) use ($userId, $tokenHash, $expiresAt): bool { + return $data[':user_id'] === $userId + && $data[':token_hash'] === $tokenHash + && $data[':expires_at'] === $expiresAt + && isset($data[':created_at']); + })); + + $this->repository->create($userId, $tokenHash, $expiresAt); + } + + /** + * create() doit renseigner :created_at au format 'Y-m-d H:i:s'. + */ + public function testCreateSetsCreatedAt(): void + { + $stmt = $this->stmtOk(); + $this->db->expects($this->once())->method('prepare')->willReturn($stmt); + + $stmt->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']); + })); + + $this->repository->create(1, 'hash', date('Y-m-d H:i:s', time() + 3600)); + } + + + // ── findActiveByHash ─────────────────────────────────────────── + + /** + * findActiveByHash() doit retourner null si aucun token actif ne correspond au hash. + */ + public function testFindActiveByHashReturnsNullWhenMissing(): void + { + $stmt = $this->stmtOk(false); + $this->db->expects($this->once())->method('prepare')->willReturn($stmt); + + $this->assertNull($this->repository->findActiveByHash('hashquinaexistepas')); + } + + /** + * findActiveByHash() doit retourner la ligne si un token actif correspond au hash. + */ + public function testFindActiveByHashReturnsRowWhenFound(): void + { + $tokenHash = hash('sha256', 'montokenbrut'); + $row = [ + 'id' => 1, + 'user_id' => 42, + 'token_hash' => $tokenHash, + 'expires_at' => date('Y-m-d H:i:s', time() + 3600), + 'used_at' => null, + 'created_at' => date('Y-m-d H:i:s'), + ]; + + $stmt = $this->stmtOk($row); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertSame($row, $this->repository->findActiveByHash($tokenHash)); + } + + /** + * findActiveByHash() doit préparer une lecture sur password_resets + * puis lier le hash demandé. + */ + public function testFindActiveByHashFiltersOnNullUsedAt(): void + { + $tokenHash = hash('sha256', 'montokenbrut'); + $stmt = $this->stmtOk(false); + + $this->db->expects($this->once()) + ->method('prepare') + ->with($this->logicalAnd( + $this->stringContains('password_resets'), + $this->stringContains('token_hash'), + )) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with([':token_hash' => $tokenHash]); + + $this->repository->findActiveByHash($tokenHash); + } + + + // ── invalidateByUserId ───────────────────────────────────────── + + /** + * invalidateByUserId() doit préparer une invalidation logique + * en renseignant :used_at pour les tokens de l'utilisateur. + */ + public function testInvalidateByUserIdCallsUpdateWithUsedAt(): void + { + $userId = 42; + $stmt = $this->stmtOk(); + + $this->db->expects($this->once())->method('prepare') + ->with($this->logicalAnd( + $this->stringContains('password_resets'), + $this->stringContains('UPDATE'), + )) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(function (array $data) use ($userId): bool { + return $data[':user_id'] === $userId + && isset($data[':used_at']) + && (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $data[':used_at']); + })); + + $this->repository->invalidateByUserId($userId); + } + + /** + * invalidateByUserId() doit préparer une mise à jour ciblant + * les tokens actifs (used_at IS NULL) de password_resets pour + * l'utilisateur demandé. + */ + public function testInvalidateByUserIdFiltersOnActiveTokens(): void + { + $stmt = $this->stmtOk(); + + $this->db->expects($this->once()) + ->method('prepare') + ->with($this->logicalAnd( + $this->stringContains('password_resets'), + $this->stringContains('user_id'), + $this->stringContains('used_at IS NULL'), + )) + ->willReturn($stmt); + + $this->repository->invalidateByUserId(1); + } + + /** + * invalidateByUserId() ne doit pas utiliser DELETE — les tokens + * sont invalidés via used_at pour conserver la traçabilité. + */ + public function testInvalidateByUserIdNeverCallsDelete(): void + { + $stmt = $this->stmtOk(); + $this->db->expects($this->once())->method('prepare') + ->with($this->stringContains('UPDATE')) + ->willReturn($stmt); + + $this->db->expects($this->never())->method('exec'); + + $this->repository->invalidateByUserId(1); + } + + + + // ── consumeActiveToken ──────────────────────────────────────── + + /** + * consumeActiveToken() doit préparer une consommation atomique du token + * et retourner la ligne correspondante si elle existe. + */ + public function testConsumeActiveTokenUsesAtomicUpdateReturning(): void + { + $row = ['id' => 1, 'user_id' => 42, 'token_hash' => 'hash', 'used_at' => null]; + $stmt = $this->stmtOk($row); + + $this->db->expects($this->once()) + ->method('prepare') + ->with($this->logicalAnd( + $this->stringContains('password_resets'), + $this->stringContains('UPDATE'), + $this->stringContains('RETURNING'), + )) + ->willReturn($stmt); + + $result = $this->repository->consumeActiveToken('hash', '2024-01-01 00:00:00'); + + $this->assertSame($row, $result); + } + + /** + * consumeActiveToken() retourne null si aucun token actif ne correspond. + */ + public function testConsumeActiveTokenReturnsNullWhenNoRowMatches(): void + { + $stmt = $this->stmtOk(false); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertNull($this->repository->consumeActiveToken('missing', '2024-01-01 00:00:00')); + } +} diff --git a/tests/Identity/PasswordResetServiceIntegrationTest.php b/tests/Identity/PasswordResetServiceIntegrationTest.php new file mode 100644 index 0000000..c373013 --- /dev/null +++ b/tests/Identity/PasswordResetServiceIntegrationTest.php @@ -0,0 +1,91 @@ +db = new PDO('sqlite::memory:', options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1); + Migrator::run($this->db); + + $this->users = new PdoUserRepository($this->db); + $this->resets = new PdoPasswordResetRepository($this->db); + $mail = new class () implements MailServiceInterface { + public function send(string $to, string $subject, string $template, array $context = []): void {} + }; + + $this->service = new PasswordResetApplicationService( + new RequestPasswordReset( + $this->resets, + $this->users, + $mail, + new PasswordResetTokenPolicy(), + new PdoLoginAttemptRepository($this->db), + new LoginRateLimitPolicy(), + ), + new ValidatePasswordResetToken($this->resets, $this->users), + new ResetPassword( + $this->resets, + $this->users, + new PdoTransactionManager($this->db), + new PasswordPolicy(), + ), + ); + } + + public function testResetPasswordConsumesTokenOnlyOnceAndUpdatesPassword(): void + { + $userId = $this->users->create(new User(0, 'alice', 'alice@example.com', password_hash('ancienpass12', PASSWORD_BCRYPT))); + $tokenRaw = 'token-brut-integration'; + $tokenHash = hash('sha256', $tokenRaw); + $this->resets->create($userId, $tokenHash, date('Y-m-d H:i:s', time() + 3600)); + + $this->service->resetPassword($tokenRaw, 'nouveaupass12'); + + $user = $this->users->findById($userId); + self::assertNotNull($user); + self::assertTrue(password_verify('nouveaupass12', $user->getPasswordHash())); + + $row = $this->db->query("SELECT used_at FROM password_resets WHERE token_hash = '{$tokenHash}'")->fetch(); + self::assertIsArray($row); + self::assertNotEmpty($row['used_at']); + + $this->expectException(InvalidResetTokenException::class); + $this->service->resetPassword($tokenRaw, 'encoreplusfort1'); + } +} diff --git a/tests/Identity/PasswordResetServiceTest.php b/tests/Identity/PasswordResetServiceTest.php new file mode 100644 index 0000000..646c595 --- /dev/null +++ b/tests/Identity/PasswordResetServiceTest.php @@ -0,0 +1,341 @@ +resetRepository = $this->createMock(PasswordResetRepositoryInterface::class); + $this->userRepository = $this->createMock(UserRepositoryInterface::class); + $this->mailService = $this->createMock(MailServiceInterface::class); + $this->transactionManager = $this->createMock(TransactionManagerInterface::class); + $this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class); + + $this->service = new PasswordResetApplicationService( + new RequestPasswordReset( + $this->resetRepository, + $this->userRepository, + $this->mailService, + new PasswordResetTokenPolicy(), + $this->loginAttemptRepository, + new LoginRateLimitPolicy(), + ), + new ValidatePasswordResetToken($this->resetRepository, $this->userRepository), + new ResetPassword( + $this->resetRepository, + $this->userRepository, + $this->transactionManager, + new PasswordPolicy(), + ), + ); + } + + public function testRequestResetUnknownEmailReturnsSilently(): void + { + $this->userRepository->method('findByEmail')->willReturn(null); + $this->mailService->expects($this->never())->method('send'); + $this->resetRepository->expects($this->never())->method('create'); + + $this->service->requestReset('inconnu@example.com', 'https://app.exemple.com'); + } + + public function testRequestResetInvalidatesPreviousTokens(): void + { + $user = $this->makeUser(); + + $this->userRepository->method('findByEmail')->willReturn($user); + $this->resetRepository->expects($this->once()) + ->method('invalidateByUserId') + ->with($user->getId()); + $this->resetRepository->expects($this->once()) + ->method('create'); + $this->mailService->expects($this->once()) + ->method('send'); + + $this->service->requestReset('alice@example.com', 'https://app.exemple.com'); + } + + public function testRequestResetCreatesTokenInDatabase(): void + { + $user = $this->makeUser(); + + $this->userRepository->method('findByEmail')->willReturn($user); + $this->resetRepository->expects($this->once()) + ->method('invalidateByUserId'); + $this->resetRepository->expects($this->once()) + ->method('create') + ->with($user->getId(), $this->callback('is_string'), $this->callback('is_string')); + $this->mailService->expects($this->once()) + ->method('send'); + + $this->service->requestReset('alice@example.com', 'https://app.exemple.com'); + } + + public function testRequestResetSendsEmailWithCorrectAddress(): void + { + $user = $this->makeUser(); + + $this->userRepository->method('findByEmail')->willReturn($user); + $this->resetRepository->expects($this->once()) + ->method('invalidateByUserId'); + $this->resetRepository->expects($this->once()) + ->method('create'); + + $this->mailService->expects($this->once()) + ->method('send') + ->with( + 'alice@example.com', + $this->callback('is_string'), + '@Identity/emails/password-reset.twig', + $this->callback('is_array'), + ); + + $this->service->requestReset('alice@example.com', 'https://app.exemple.com'); + } + + public function testRequestResetUrlContainsToken(): void + { + $user = $this->makeUser(); + + $this->userRepository->method('findByEmail')->willReturn($user); + $this->resetRepository->expects($this->once()) + ->method('invalidateByUserId'); + $this->resetRepository->expects($this->once()) + ->method('create'); + + $this->mailService->expects($this->once()) + ->method('send') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->callback(function (array $context): bool { + return isset($context['resetUrl']) + && str_contains($context['resetUrl'], '/password/reset?token='); + }), + ); + + $this->service->requestReset('alice@example.com', 'https://app.exemple.com'); + } + + public function testValidateTokenMissingToken(): void + { + $this->resetRepository->expects($this->once()) + ->method('findActiveByHash') + ->willReturn(null); + + $result = $this->service->validateToken('tokeninexistant'); + + $this->assertNull($result); + } + + public function testValidateTokenExpiredToken(): void + { + $tokenRaw = 'montokenbrut'; + $tokenHash = hash('sha256', $tokenRaw); + + $this->resetRepository->expects($this->once()) + ->method('findActiveByHash') + ->with($tokenHash) + ->willReturn([ + 'user_id' => 1, + 'token_hash' => $tokenHash, + 'expires_at' => date('Y-m-d H:i:s', time() - 3600), + 'used_at' => null, + ]); + + $result = $this->service->validateToken($tokenRaw); + + $this->assertNull($result); + } + + public function testValidateTokenValidToken(): void + { + $user = $this->makeUser(); + $tokenRaw = 'montokenbrut'; + $tokenHash = hash('sha256', $tokenRaw); + + $this->resetRepository->expects($this->once()) + ->method('findActiveByHash') + ->with($tokenHash) + ->willReturn([ + 'user_id' => $user->getId(), + 'token_hash' => $tokenHash, + 'expires_at' => date('Y-m-d H:i:s', time() + 3600), + 'used_at' => null, + ]); + $this->userRepository->expects($this->once()) + ->method('findById') + ->with($user->getId()) + ->willReturn($user); + + $result = $this->service->validateToken($tokenRaw); + + $this->assertSame($user, $result); + } + + public function testValidateTokenDeletedUserReturnsNull(): void + { + $tokenRaw = 'token-valide-mais-user-supprime'; + $tokenHash = hash('sha256', $tokenRaw); + + $this->resetRepository->expects($this->once()) + ->method('findActiveByHash') + ->with($tokenHash) + ->willReturn([ + 'user_id' => 999, + 'expires_at' => date('Y-m-d H:i:s', time() + 3600), + 'used_at' => null, + ]); + $this->userRepository->expects($this->once()) + ->method('findById') + ->with(999) + ->willReturn(null); + + $result = $this->service->validateToken($tokenRaw); + + $this->assertNull($result); + } + + public function testResetPasswordInvalidToken(): void + { + $this->transactionManager->expects($this->once()) + ->method('run') + ->willReturnCallback(function (callable $operation): mixed { + return $operation(); + }); + $this->resetRepository->expects($this->once())->method('consumeActiveToken')->willReturn(null); + + $this->expectException(InvalidResetTokenException::class); + $this->expectExceptionMessageMatches('/invalide ou a expiré/'); + + $this->service->resetPassword('tokeninvalide', 'nouveaumdp12'); + } + + public function testResetPasswordTooShortPasswordThrowsWeakPasswordException(): void + { + $this->expectException(WeakPasswordException::class); + + $this->service->resetPassword('montokenbrut', '12345678901'); + } + + public function testResetPasswordPreservesWhitespace(): void + { + $user = $this->makeUser(); + $tokenRaw = 'montokenbrut'; + $tokenHash = hash('sha256', $tokenRaw); + $newPassword = ' nouveaumdp12 '; + + $this->transactionManager->expects($this->once()) + ->method('run') + ->willReturnCallback(function (callable $operation): mixed { + return $operation(); + }); + + $this->resetRepository->expects($this->once()) + ->method('consumeActiveToken') + ->with($tokenHash, $this->callback('is_string')) + ->willReturn([ + 'user_id' => $user->getId(), + 'token_hash' => $tokenHash, + 'expires_at' => date('Y-m-d H:i:s', time() + 3600), + 'used_at' => null, + ]); + $this->userRepository->expects($this->once()) + ->method('findById') + ->with($user->getId()) + ->willReturn($user); + + $this->userRepository->expects($this->once()) + ->method('updatePassword') + ->with($user->getId(), $this->callback(static function (string $hash) use ($newPassword): bool { + return password_verify($newPassword, $hash) + && !password_verify(trim($newPassword), $hash); + })); + + $this->service->resetPassword($tokenRaw, $newPassword); + } + + public function testResetPasswordUpdatesPasswordAndConsumesToken(): void + { + $user = $this->makeUser(); + $tokenRaw = 'montokenbrut'; + $tokenHash = hash('sha256', $tokenRaw); + + $this->transactionManager->expects($this->once()) + ->method('run') + ->willReturnCallback(function (callable $operation): mixed { + return $operation(); + }); + + $this->resetRepository->expects($this->once()) + ->method('consumeActiveToken') + ->with($tokenHash, $this->callback('is_string')) + ->willReturn([ + 'user_id' => $user->getId(), + 'token_hash' => $tokenHash, + 'expires_at' => date('Y-m-d H:i:s', time() + 3600), + 'used_at' => null, + ]); + $this->userRepository->expects($this->once()) + ->method('findById') + ->with($user->getId()) + ->willReturn($user); + + $this->userRepository->expects($this->once()) + ->method('updatePassword') + ->with($user->getId(), $this->callback('is_string')); + + $this->service->resetPassword($tokenRaw, 'nouveaumdp12'); + } + + private function makeUser(): User + { + return new User( + 1, + 'alice', + 'alice@example.com', + password_hash('motdepasse12', PASSWORD_BCRYPT), + ); + } +} diff --git a/tests/Identity/UserControllerTest.php b/tests/Identity/UserControllerTest.php new file mode 100644 index 0000000..3939193 --- /dev/null +++ b/tests/Identity/UserControllerTest.php @@ -0,0 +1,449 @@ +view = $this->makeTwigMock(); + $this->userService = $this->createMock(UserServiceInterface::class); + $this->flash = $this->createMock(FlashServiceInterface::class); + $this->sessionManager = $this->createMock(SessionManagerInterface::class); + $this->rolePolicy = new RolePolicy(); + + $this->controller = new UserController( + $this->view, + $this->userService, + $this->flash, + $this->sessionManager, + $this->rolePolicy, + ); + } + + // ── index ──────────────────────────────────────────────────────── + + /** + * index() doit rendre la vue avec la liste des utilisateurs. + */ + public function testIndexRendersWithUserList(): void + { + $this->userService->method('findPaginated')->willReturn(new PaginatedResult([], 0, 1, 20)); + $this->sessionManager->method('getUserId')->willReturn(1); + + $this->view->expects($this->once()) + ->method('render') + ->with($this->anything(), '@Identity/admin/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(), '@Identity/admin/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('create'); + $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('create') + ->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('create') + ->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('create') + ->willThrowException(new WeakPasswordException()); + + $this->flash->expects($this->once())->method('set') + ->with('user_error', $this->stringContains('12 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('create')->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('create') + ->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/Identity/UserRepositoryTest.php b/tests/Identity/UserRepositoryTest.php new file mode 100644 index 0000000..8cac30d --- /dev/null +++ b/tests/Identity/UserRepositoryTest.php @@ -0,0 +1,356 @@ + + */ + 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 PdoUserRepository($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): MockObject&PDOStatement + { + $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(): MockObject&PDOStatement + { + $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() interroge bien la table `users`. + */ + public function testFindAllRequestsUsersQuery(): void + { + $stmt = $this->stmtForRead([]); + + $this->db->expects($this->once()) + ->method('query') + ->with($this->stringContains('FROM users')) + ->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->expects($this->once())->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->expects($this->once())->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->expects($this->once())->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(fn (array $params): bool => in_array(42, $params, true))); + + $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($this->callback(fn (array $params): bool => in_array('alice', $params, true))); + + $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($this->callback(fn (array $params): bool => in_array('alice@example.com', $params, true))); + + $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->expects($this->once())->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->expects($this->once())->method('prepare') + ->with($this->stringContains('UPDATE users')) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(fn (array $params): bool => in_array($newHash, $params, true) && in_array(1, $params, true))); + + $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->expects($this->once())->method('prepare') + ->with($this->stringContains('UPDATE users')) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(fn (array $params): bool => in_array(User::ROLE_EDITOR, $params, true) && in_array(1, $params, true))); + + $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($this->callback(fn (array $params): bool => in_array(7, $params, true))); + + $this->repository->delete(7); + } +} diff --git a/tests/Identity/UserServiceTest.php b/tests/Identity/UserServiceTest.php new file mode 100644 index 0000000..1f6db1a --- /dev/null +++ b/tests/Identity/UserServiceTest.php @@ -0,0 +1,304 @@ +userRepository = $this->createMock(UserRepositoryInterface::class); + $rolePolicy = new RolePolicy(); + $passwordPolicy = new PasswordPolicy(); + $this->service = new UserApplicationService( + $this->userRepository, + new CreateUser($this->userRepository, $rolePolicy, $passwordPolicy), + new UpdateUserRole($this->userRepository, $rolePolicy), + new DeleteUser($this->userRepository), + new AdminUpdateUserRole($this->userRepository, $rolePolicy), + new AdminDeleteUser($this->userRepository), + ); + } + + + // ── create ───────────────────────────────────────────────── + + /** + * create() 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->create('Alice', 'alice@example.com', 'motdepasse12'); + + $this->assertSame('alice', $user->getUsername()); + $this->assertSame('alice@example.com', $user->getEmail()); + $this->assertSame(User::ROLE_USER, $user->getRole()); + } + + /** + * create() 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->create(' ALICE ', ' ALICE@EXAMPLE.COM ', 'motdepasse12'); + + $this->assertSame('alice', $user->getUsername()); + $this->assertSame('alice@example.com', $user->getEmail()); + } + + /** + * create() 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->create('alice', 'autre@example.com', 'motdepasse12'); + } + + /** + * create() 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->create('newuser', 'alice@example.com', 'motdepasse12'); + } + + /** + * create() 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->create('alice', 'alice@example.com', '12345678901'); + } + + /** + * create() avec exactement 12 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->create('alice', 'alice@example.com', '123456789012'); + + $this->assertInstanceOf(User::class, $user); + } + + /** + * create() doit stocker un hash bcrypt, jamais le mot de passe en clair. + */ + public function testCreateUserPasswordIsHashed(): void + { + $plainPassword = 'motdepasse12'; + + $this->userRepository->method('findByUsername')->willReturn(null); + $this->userRepository->method('findByEmail')->willReturn(null); + $this->userRepository->method('create'); + + $user = $this->service->create('alice', 'alice@example.com', $plainPassword); + + $this->assertNotSame($plainPassword, $user->getPasswordHash()); + $this->assertTrue(password_verify($plainPassword, $user->getPasswordHash())); + } + + /** + * create() doit conserver exactement les espaces du mot de passe saisi. + */ + public function testCreateUserPreservesPasswordWhitespace(): void + { + $plainPassword = ' motdepasse12 '; + + $this->userRepository->method('findByUsername')->willReturn(null); + $this->userRepository->method('findByEmail')->willReturn(null); + $this->userRepository->method('create'); + + $user = $this->service->create('alice', 'alice@example.com', $plainPassword); + + $this->assertTrue(password_verify($plainPassword, $user->getPasswordHash())); + $this->assertFalse(password_verify(trim($plainPassword), $user->getPasswordHash())); + } + + /** + * create() 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->create('alice', 'alice@example.com', 'motdepasse12', 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->expects($this->once())->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->expects($this->once())->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->expects($this->once())->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 uniquement les rôles attribuables depuis l'interface. + */ + #[\PHPUnit\Framework\Attributes\DataProvider('validRolesProvider')] + public function testUpdateRoleAcceptsAllValidRoles(string $role): void + { + $this->userRepository->expects($this->once())->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], + ]; + } + + + // ── 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('motdepasse12', PASSWORD_BCRYPT)); + } +} diff --git a/tests/Identity/UserTest.php b/tests/Identity/UserTest.php new file mode 100644 index 0000000..499db2c --- /dev/null +++ b/tests/Identity/UserTest.php @@ -0,0 +1,233 @@ +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/tests/Kernel/BootstrapTest.php b/tests/Kernel/BootstrapTest.php new file mode 100644 index 0000000..56927db --- /dev/null +++ b/tests/Kernel/BootstrapTest.php @@ -0,0 +1,56 @@ +createStub(ContainerInterface::class); + + $this->setPrivate($bootstrap, 'container', $container); + + self::assertSame($container, $bootstrap->initializeInfrastructure()); + self::assertSame($container, $bootstrap->getContainer()); + } + + public function testCreateHttpAppReturnsPreloadedApp(): void + { + $bootstrap = Bootstrap::create(); + $app = AppFactory::create(); + + $this->setPrivate($bootstrap, 'app', $app); + + self::assertSame($app, $bootstrap->createHttpApp()); + } + + public function testInitializeReturnsPreloadedApp(): void + { + $bootstrap = Bootstrap::create(); + $container = $this->createStub(ContainerInterface::class); + $app = AppFactory::create(); + + $this->setPrivate($bootstrap, 'container', $container); + $this->setPrivate($bootstrap, 'app', $app); + + self::assertSame($app, $bootstrap->initialize()); + } + + private function setPrivate(Bootstrap $bootstrap, string $property, mixed $value): void + { + $reflection = new ReflectionProperty($bootstrap, $property); + $reflection->setAccessible(true); + $reflection->setValue($bootstrap, $value); + } +} diff --git a/tests/Kernel/ClientIpResolverCoverageTest.php b/tests/Kernel/ClientIpResolverCoverageTest.php new file mode 100644 index 0000000..b66c504 --- /dev/null +++ b/tests/Kernel/ClientIpResolverCoverageTest.php @@ -0,0 +1,36 @@ +createServerRequest('GET', '/', [ + 'REMOTE_ADDR' => '127.0.0.1', + ]); + + $resolver = new ClientIpResolver(['127.0.0.1']); + + self::assertSame('127.0.0.1', $resolver->resolve($request)); + } + + public function testResolveTrimsForwardedIpWhenProxyWildcardIsTrusted(): void + { + $request = (new ServerRequestFactory())->createServerRequest('GET', '/', [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_X_FORWARDED_FOR' => ' 203.0.113.77 , 198.51.100.12', + ]); + + $resolver = new ClientIpResolver(['*']); + + self::assertSame('203.0.113.77', $resolver->resolve($request)); + } +} diff --git a/tests/Kernel/ClientIpResolverTest.php b/tests/Kernel/ClientIpResolverTest.php new file mode 100644 index 0000000..0a96982 --- /dev/null +++ b/tests/Kernel/ClientIpResolverTest.php @@ -0,0 +1,58 @@ +createServerRequest('GET', '/'); + $resolver = new ClientIpResolver(); + + self::assertSame('0.0.0.0', $resolver->resolve($request)); + } + + public function testResolveReturnsRemoteAddrWhenProxyNotTrusted(): void + { + $request = (new ServerRequestFactory())->createServerRequest('GET', '/', [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.10', + ]); + + $resolver = new ClientIpResolver(['127.0.0.1']); + + self::assertSame('10.0.0.1', $resolver->resolve($request)); + } + + public function testResolveReturnsForwardedIpWhenProxyTrusted(): void + { + $request = (new ServerRequestFactory())->createServerRequest('GET', '/', [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.10, 198.51.100.12', + ]); + + $resolver = new ClientIpResolver(['127.0.0.1']); + + self::assertSame('203.0.113.10', $resolver->resolve($request)); + } + + public function testResolveFallsBackToRemoteAddrWhenForwardedIpInvalid(): void + { + $request = (new ServerRequestFactory())->createServerRequest('GET', '/', [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED_FOR' => 'not-an-ip', + ]); + + $resolver = new ClientIpResolver(['*']); + + self::assertSame('127.0.0.1', $resolver->resolve($request)); + } +} diff --git a/tests/Kernel/ContainerWiringIntegrationTest.php b/tests/Kernel/ContainerWiringIntegrationTest.php new file mode 100644 index 0000000..3ee6b4a --- /dev/null +++ b/tests/Kernel/ContainerWiringIntegrationTest.php @@ -0,0 +1,184 @@ + 'development', + 'APP_URL' => 'http://localhost', + 'APP_NAME' => 'NETslim Test', + 'ADMIN_USERNAME' => 'admin', + 'ADMIN_EMAIL' => 'admin@example.test', + 'ADMIN_PASSWORD' => 'secret123456', + 'MAIL_HOST' => 'localhost', + 'MAIL_PORT' => '1025', + 'MAIL_USERNAME' => '', + 'MAIL_PASSWORD' => '', + 'MAIL_ENCRYPTION' => 'tls', + 'MAIL_FROM' => 'noreply@example.test', + 'MAIL_FROM_NAME' => 'NETslim Test', + 'TIMEZONE' => 'UTC', + ]; + + /** @var array */ + private array $envBackup = []; + + protected function setUp(): void + { + RuntimePaths::setApplicationRoot(dirname(__DIR__, 2) . '/tests/Fixtures/Application'); + ModuleRegistry::reset(); + foreach (self::ENV_DEFAULTS as $key => $value) { + $this->envBackup[$key] = $_ENV[$key] ?? null; + $_ENV[$key] = $value; + } + } + + protected function tearDown(): void + { + RuntimePaths::resetApplicationRoot(); + RuntimePaths::resetProjectRoot(); + ModuleRegistry::reset(); + foreach (self::ENV_DEFAULTS as $key => $_) { + $previous = $this->envBackup[$key] ?? null; + + if ($previous === null) { + unset($_ENV[$key]); + continue; + } + + $_ENV[$key] = $previous; + } + + parent::tearDown(); + } + + /** @return iterable */ + public static function containerServicesProvider(): iterable + { + yield 'auth service' => [AuthServiceInterface::class]; + yield 'password reset service' => [PasswordResetServiceInterface::class]; + yield 'authorization service' => [AuthorizationServiceInterface::class]; + yield 'settings service' => [SettingsServiceInterface::class]; + yield 'audit log service' => [AuditLogServiceInterface::class]; + yield 'notification service' => [NotificationServiceInterface::class]; + yield 'taxonomy service' => [TaxonomyServiceInterface::class]; + yield 'media service' => [MediaServiceInterface::class]; + yield 'taxonomy reader' => [TaxonomyReaderInterface::class]; + yield 'user service' => [UserServiceInterface::class]; + yield 'auth controller' => [AuthController::class]; + yield 'account controller' => [AccountController::class]; + yield 'password reset controller' => [PasswordResetController::class]; + yield 'taxonomy controller' => [TaxonomyController::class]; + yield 'media controller' => [MediaController::class]; + yield 'user controller' => [UserController::class]; + yield 'twig' => [Twig::class]; + yield 'app extension' => [AppExtension::class]; + } + + #[DataProvider('containerServicesProvider')] + public function testContainerResolvesExpectedServices(string $id): void + { + $container = $this->buildContainer(); + + $resolved = $container->get($id); + + self::assertInstanceOf($id, $resolved); + } + + public function testTwigFactoryRegistersModuleTemplateNamespaces(): void + { + $container = $this->buildContainer(); + $twig = $container->get(Twig::class); + $loader = $twig->getEnvironment()->getLoader(); + + self::assertInstanceOf(FilesystemLoader::class, $loader); + + foreach (ModuleRegistry::modules() as $module) { + foreach ($module->templateNamespaces() as $namespace => $templatePath) { + self::assertContains( + $templatePath, + $loader->getPaths($namespace), + sprintf('Le namespace Twig "%s" doit pointer vers "%s".', $namespace, $templatePath), + ); + } + } + } + + public function testMiddlewareRegistrationLoadsModuleTwigExtensions(): void + { + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + + session_id('netslim-container-wiring-test'); + session_start(); + + try { + $container = $this->buildContainer(); + AppFactory::setContainer($container); + $app = AppFactory::create(); + $registrar = new MiddlewareRegistrar(); + + $registrar->register($app, $container); + + $extensions = $container->get(Twig::class)->getEnvironment()->getExtensions(); + + $assertions = 0; + + foreach (ModuleRegistry::modules() as $module) { + foreach ($module->twigExtensions() as $twigExtensionClass) { + self::assertArrayHasKey( + $twigExtensionClass, + $extensions, + sprintf("L'extension Twig \"%s\" doit être chargée par MiddlewareRegistrar.", $twigExtensionClass), + ); + self::assertInstanceOf($twigExtensionClass, $extensions[$twigExtensionClass]); + ++$assertions; + } + } + self::assertGreaterThanOrEqual(0, $assertions); + } finally { + session_write_close(); + } + } + + private function buildContainer(): Container + { + $builder = new ContainerBuilder(); + $builder->useAutowiring(true); + $builder->addDefinitions(dirname(__DIR__, 2) . '/src/Kernel/Runtime/DI/container.php'); + + return $builder->build(); + } +} diff --git a/tests/Kernel/DatabaseReadinessTest.php b/tests/Kernel/DatabaseReadinessTest.php new file mode 100644 index 0000000..1512d68 --- /dev/null +++ b/tests/Kernel/DatabaseReadinessTest.php @@ -0,0 +1,42 @@ +exec('CREATE TABLE migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, version TEXT, run_at TEXT)'); + + $this->expectException(DatabaseNotProvisionedException::class); + $this->expectExceptionMessage('users'); + + DatabaseReadiness::assertProvisioned($db); + } + + public function testAssertProvisionedAcceptsCompleteCoreSchema(): void + { + $db = new PDO('sqlite::memory:'); + + $db->exec('CREATE TABLE migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, version TEXT, run_at TEXT)'); + $db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, email TEXT, password_hash TEXT, role TEXT, session_version INTEGER, created_at TEXT)'); + $db->exec('CREATE TABLE password_resets (id INTEGER PRIMARY KEY, user_id INTEGER, token_hash TEXT, expires_at TEXT, used_at TEXT, created_at TEXT)'); + $db->exec('CREATE TABLE rate_limits (scope TEXT, rate_key TEXT, attempts INTEGER, locked_until TEXT, updated_at TEXT)'); + $db->exec('CREATE TABLE settings (setting_key TEXT PRIMARY KEY, setting_value TEXT, value_type TEXT, updated_at TEXT)'); + $db->exec('CREATE TABLE audit_log (id INTEGER PRIMARY KEY, action TEXT, resource_type TEXT, resource_id TEXT, actor_user_id INTEGER, context_json TEXT, created_at TEXT)'); + $db->exec('CREATE TABLE notification_dispatches (id INTEGER PRIMARY KEY, recipient TEXT, subject TEXT, template TEXT, status TEXT, notification_key TEXT, error_message TEXT, created_at TEXT, sent_at TEXT)'); + $db->exec('CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT, slug TEXT)'); + $db->exec('CREATE TABLE media (id INTEGER PRIMARY KEY, filename TEXT, url TEXT, hash TEXT, user_id INTEGER, created_at TEXT)'); + + DatabaseReadiness::assertProvisioned($db); + $this->addToAssertionCount(1); + } +} diff --git a/tests/Kernel/DateParserTest.php b/tests/Kernel/DateParserTest.php new file mode 100644 index 0000000..33b4a54 --- /dev/null +++ b/tests/Kernel/DateParserTest.php @@ -0,0 +1,94 @@ +assertInstanceOf(DateTime::class, $result); + $this->assertSame('2024-06-15', $result->format('Y-m-d')); + $this->assertSame('10:30:00', $result->format('H:i:s')); + } + + /** + * Une date ISO 8601 doit être correctement parsée. + */ + public function testIso8601Date(): void + { + $result = DateParser::parse('2024-01-01T00:00:00'); + + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame('2024-01-01', $result->format('Y-m-d')); + } + + /** + * Une date seule (sans heure) doit être parsée correctement. + */ + public function testDateOnly(): void + { + $result = DateParser::parse('2024-12-31'); + + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame('2024-12-31', $result->format('Y-m-d')); + } + + + // ── Valeurs silencieuses — doit retourner null sans exception ──── + + /** + * null doit retourner null. + */ + public function testNullReturnsNull(): void + { + $this->assertNull(DateParser::parse(null)); + } + + /** + * Une chaîne vide doit retourner null. + */ + public function testEmptyStringReturnsNull(): void + { + $this->assertNull(DateParser::parse('')); + } + + /** + * Une valeur non parseable doit retourner null sans lever d'exception. + */ + public function testInvalidValueReturnsNull(): void + { + $this->assertNull(DateParser::parse('pas-une-date')); + } + + /** + * Un entier doit être interprété comme un timestamp si valide, + * ou retourner null si la conversion échoue. + * Ici on vérifie simplement qu'aucune exception n'est levée. + */ + public function testIntegerThrowsNoException(): void + { + $result = DateParser::parse(0); + // Pas d'assertion sur la valeur — on vérifie juste la robustesse + $this->assertTrue($result === null || $result instanceof DateTime); + } +} diff --git a/tests/Kernel/DefaultErrorHandlerTest.php b/tests/Kernel/DefaultErrorHandlerTest.php new file mode 100644 index 0000000..8ac38c7 --- /dev/null +++ b/tests/Kernel/DefaultErrorHandlerTest.php @@ -0,0 +1,126 @@ +responseFactory = $this->createMock(ResponseFactoryInterface::class); + $this->responseFactory + ->method('createResponse') + ->willReturnCallback(static fn (int $statusCode): Response => new Response($statusCode)); + + $this->twig = $this->createMock(Twig::class); + } + + public function testInvokeRendersFriendlyNotFoundPage(): void + { + $request = (new ServerRequestFactory())->createServerRequest('GET', '/missing'); + $exception = new HttpNotFoundException($request); + + $this->twig + ->expects(self::once()) + ->method('render') + ->willReturnCallback(function (Response $response, string $template, array $data): Response { + self::assertSame(404, $response->getStatusCode()); + self::assertSame('@Kernel/error.twig', $template); + self::assertSame(404, $data['status']); + self::assertSame('La page demandée est introuvable.', $data['message']); + + $response->getBody()->write('404: ' . $data['message']); + + return $response; + }); + + $handler = new DefaultErrorHandler($this->responseFactory, $this->twig, false); + $response = $handler($request, $exception, false, true, true); + + self::assertSame(404, $response->getStatusCode()); + self::assertStringContainsString('404: La page demandée est introuvable.', (string) $response->getBody()); + } + + public function testInvokeRendersDatabaseProvisioningErrorAs503(): void + { + $request = (new ServerRequestFactory())->createServerRequest('GET', '/'); + $exception = new DatabaseNotProvisionedException('La base de données doit être provisionnée.'); + + $this->twig + ->expects(self::once()) + ->method('render') + ->willReturnCallback(function (Response $response, string $template, array $data): Response { + self::assertSame(503, $response->getStatusCode()); + self::assertSame('@Kernel/error.twig', $template); + self::assertSame(503, $data['status']); + self::assertSame('La base de données doit être provisionnée.', $data['message']); + + $response->getBody()->write('503: ' . $data['message']); + + return $response; + }); + + $handler = new DefaultErrorHandler($this->responseFactory, $this->twig, false); + $response = $handler($request, $exception, false, true, true); + + self::assertSame(503, $response->getStatusCode()); + self::assertStringContainsString('503: La base de données doit être provisionnée.', (string) $response->getBody()); + } + + public function testInvokeDoesNotRethrowHttpExceptionsInDevelopment(): void + { + $request = (new ServerRequestFactory())->createServerRequest('GET', '/missing'); + $exception = new HttpNotFoundException($request); + + $this->twig + ->expects(self::once()) + ->method('render') + ->willReturnCallback(function (Response $response, string $template, array $data): Response { + self::assertSame(404, $response->getStatusCode()); + self::assertSame('@Kernel/error.twig', $template); + self::assertSame(404, $data['status']); + self::assertSame('La page demandée est introuvable.', $data['message']); + + $response->getBody()->write('404 dev: ' . $data['message']); + + return $response; + }); + + $handler = new DefaultErrorHandler($this->responseFactory, $this->twig, true); + $response = $handler($request, $exception, true, true, true); + + self::assertSame(404, $response->getStatusCode()); + self::assertStringContainsString('404 dev: La page demandée est introuvable.', (string) $response->getBody()); + } + + public function testInvokeRethrowsUnexpectedExceptionsInDevelopment(): void + { + $request = (new ServerRequestFactory())->createServerRequest('GET', '/boom'); + $exception = new \RuntimeException('boom'); + + $this->twig->expects(self::never())->method('render'); + + $handler = new DefaultErrorHandler($this->responseFactory, $this->twig, true); + + $this->expectExceptionObject($exception); + $handler($request, $exception, true, true, true); + } +} diff --git a/tests/Kernel/ErrorHandlerConfiguratorTest.php b/tests/Kernel/ErrorHandlerConfiguratorTest.php new file mode 100644 index 0000000..b58bd6a --- /dev/null +++ b/tests/Kernel/ErrorHandlerConfiguratorTest.php @@ -0,0 +1,108 @@ +originalAppEnv = $_ENV['APP_ENV'] ?? null; + $_ENV['APP_ENV'] = 'production'; + + $this->twig = $this->createMock(Twig::class); + $logger = $this->createStub(LoggerInterface::class); + + $this->container = $this->createMock(ContainerInterface::class); + $this->container + ->method('get') + ->willReturnMap([ + [LoggerInterface::class, $logger], + [Twig::class, $this->twig], + ]); + } + + protected function tearDown(): void + { + if ($this->originalAppEnv === null) { + unset($_ENV['APP_ENV']); + } else { + $_ENV['APP_ENV'] = $this->originalAppEnv; + } + } + + public function testConfigureRegistersHandlerThatRenders404Responses(): void + { + $app = AppFactory::create(); + + $this->twig + ->expects(self::once()) + ->method('render') + ->willReturnCallback(function ($response, string $template, array $data) { + self::assertSame('@Kernel/error.twig', $template); + self::assertSame(404, $data['status']); + self::assertSame('La page demandée est introuvable.', $data['message']); + + $response->getBody()->write('configured 404'); + + return $response; + }); + + (new ErrorHandlerConfigurator())->configure($app, $this->container); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/missing'); + $response = $app->handle($request); + + self::assertSame(404, $response->getStatusCode()); + self::assertStringContainsString('configured 404', (string) $response->getBody()); + } + + public function testConfigureRegistersHandlerThatRenders503Responses(): void + { + $app = AppFactory::create(); + $app->get('/db-check', function (): never { + throw new DatabaseNotProvisionedException('Provisionnement requis'); + }); + + $this->twig + ->expects(self::once()) + ->method('render') + ->willReturnCallback(function ($response, string $template, array $data) { + self::assertSame('@Kernel/error.twig', $template); + self::assertSame(503, $data['status']); + self::assertSame('Provisionnement requis', $data['message']); + + $response->getBody()->write('configured 503'); + + return $response; + }); + + (new ErrorHandlerConfigurator())->configure($app, $this->container); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/db-check'); + $response = $app->handle($request); + + self::assertSame(503, $response->getStatusCode()); + self::assertStringContainsString('configured 503', (string) $response->getBody()); + } +} diff --git a/tests/Kernel/ExtensionTest.php b/tests/Kernel/ExtensionTest.php new file mode 100644 index 0000000..89e397a --- /dev/null +++ b/tests/Kernel/ExtensionTest.php @@ -0,0 +1,63 @@ + 'https://example.test'], $extension->getGlobals()); + } + + public function testSessionExtensionExposesSelectedSessionKeys(): void + { + $_SESSION = [ + 'user_id' => 12, + 'username' => 'julien', + 'role' => 'admin', + 'flash' => ['notice' => 'x'], + ]; + + $extension = new SessionExtension(); + + self::assertSame([ + 'session' => [ + 'user_id' => 12, + 'username' => 'julien', + 'role' => 'admin', + ], + ], $extension->getGlobals()); + } + + public function testCsrfExtensionExposesTokens(): void + { + $storage = []; + $guard = new Guard(new ResponseFactory(), storage: $storage); + $extension = new CsrfExtension($guard); + $globals = $extension->getGlobals(); + + self::assertArrayHasKey('csrf', $globals); + self::assertSame($guard->getTokenNameKey(), $globals['csrf']['keys']['name']); + self::assertSame($guard->getTokenValueKey(), $globals['csrf']['keys']['value']); + self::assertSame($guard->getTokenName(), $globals['csrf']['name']); + self::assertSame($guard->getTokenValue(), $globals['csrf']['value']); + } +} diff --git a/tests/Kernel/FlashServiceConsumeTest.php b/tests/Kernel/FlashServiceConsumeTest.php new file mode 100644 index 0000000..e574f07 --- /dev/null +++ b/tests/Kernel/FlashServiceConsumeTest.php @@ -0,0 +1,29 @@ +get('missing')); + + $flash->set('notice', 'Bonjour'); + self::assertSame('Bonjour', $flash->get('notice')); + self::assertNull($flash->get('notice')); + } +} diff --git a/tests/Kernel/FlashServiceCoverageTest.php b/tests/Kernel/FlashServiceCoverageTest.php new file mode 100644 index 0000000..defd133 --- /dev/null +++ b/tests/Kernel/FlashServiceCoverageTest.php @@ -0,0 +1,27 @@ +get('flag')); + self::assertArrayNotHasKey('flag', $_SESSION['flash']); + } +} diff --git a/tests/Kernel/FlashServiceTest.php b/tests/Kernel/FlashServiceTest.php new file mode 100644 index 0000000..6940f19 --- /dev/null +++ b/tests/Kernel/FlashServiceTest.php @@ -0,0 +1,45 @@ +set('notice', 'Bonjour'); + + self::assertSame('Bonjour', $flash->get('notice')); + self::assertNull($flash->get('notice')); + } + + public function testGetCastsNonStringValueAndRemovesIt(): void + { + $_SESSION['flash']['count'] = 123; + + $flash = new FlashService(); + + self::assertSame('123', $flash->get('count')); + self::assertArrayNotHasKey('count', $_SESSION['flash']); + } + + public function testGetReturnsNullWhenMissing(): void + { + $flash = new FlashService(); + + self::assertNull($flash->get('missing')); + } +} diff --git a/tests/Kernel/HelperEdgeCasesTest.php b/tests/Kernel/HelperEdgeCasesTest.php new file mode 100644 index 0000000..f798311 --- /dev/null +++ b/tests/Kernel/HelperEdgeCasesTest.php @@ -0,0 +1,27 @@ +createServerRequest( + 'GET', + '/', + ['REMOTE_ADDR' => '127.0.0.1'], + ); + + self::assertSame('127.0.0.1', $resolver->resolve($request)); + } +} diff --git a/tests/Kernel/HtmlPurifierFactoryTest.php b/tests/Kernel/HtmlPurifierFactoryTest.php new file mode 100644 index 0000000..313a76c --- /dev/null +++ b/tests/Kernel/HtmlPurifierFactoryTest.php @@ -0,0 +1,36 @@ +purify('

ok

x https://example.test'); + + self::assertDirectoryExists($cacheDir); + self::assertStringContainsString('text-align:center', $result); + self::assertStringNotContainsString('javascript:', $result); + self::assertStringContainsString('https://example.test', $result); + self::assertStringContainsString('data-media-id="42"', $result); + } finally { + if (is_dir($cacheDir)) { + foreach (glob($cacheDir . '/*') ?: [] as $file) { + @unlink($file); + } + @rmdir($cacheDir); + } + } + } +} diff --git a/tests/Kernel/HtmlSanitizerTest.php b/tests/Kernel/HtmlSanitizerTest.php new file mode 100644 index 0000000..afda852 --- /dev/null +++ b/tests/Kernel/HtmlSanitizerTest.php @@ -0,0 +1,252 @@ +sanitizer = new HtmlSanitizer($purifier); + } + + + // ── Balises autorisées ───────────────────────────────────────── + + /** + * Les balises de texte courantes doivent être conservées. + */ + public function testTextTagsPreserved(): void + { + $html = '

Un texte avec emphase et soulignement.

'; + $result = $this->sanitizer->sanitize($html); + + $this->assertStringContainsString('texte', $result); + $this->assertStringContainsString('emphase', $result); + $this->assertStringContainsString('soulignement', $result); + } + + /** + * Les titres h1 à h6 doivent être conservés. + */ + public function testHeadingsPreserved(): void + { + $html = '

Titre 1

Titre 2

Titre 3

'; + $result = $this->sanitizer->sanitize($html); + + $this->assertStringContainsString('

', $result); + $this->assertStringContainsString('

', $result); + $this->assertStringContainsString('

', $result); + } + + /** + * Les listes ordonnées et non ordonnées doivent être conservées. + */ + public function testListsPreserved(): void + { + $html = '
  • Item 1
  • Item 2
  1. A
'; + $result = $this->sanitizer->sanitize($html); + + $this->assertStringContainsString('
    ', $result); + $this->assertStringContainsString('
      ', $result); + $this->assertStringContainsString('
    1. ', $result); + } + + /** + * Les liens avec href http/https doivent être conservés. + */ + public function testHttpLinksPreserved(): void + { + $html = 'Lien'; + $result = $this->sanitizer->sanitize($html); + + $this->assertStringContainsString('href="https://example.com"', $result); + $this->assertStringContainsString('Lien', $result); + } + + /** + * Les images avec src doivent être conservées. + */ + public function testImagesPreserved(): void + { + $html = 'Description'; + $result = $this->sanitizer->sanitize($html); + + $this->assertStringContainsString('assertStringContainsString('src="https://example.com/image.jpg"', $result); + } + + /** + * L'identifiant métier d'un média doit être conservé sur l'image. + */ + public function testImageDataMediaIdPreserved(): void + { + $html = 'Description'; + $result = $this->sanitizer->sanitize($html); + + $this->assertStringContainsString('data-media-id="42"', $result); + } + + /** + * Les blocs de code doivent être conservés. + */ + public function testPreTagPreserved(): void + { + $html = '
      code ici
      '; + $result = $this->sanitizer->sanitize($html); + + $this->assertStringContainsString('
      ', $result);
      +    }
      +
      +
      +    // ── Balises et attributs dangereux — suppression XSS ───────────
      +
      +    /**
      +     * Les balises ';
      +        $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/Kernel/InfrastructureBootstrapperTest.php b/tests/Kernel/InfrastructureBootstrapperTest.php new file mode 100644 index 0000000..4f84710 --- /dev/null +++ b/tests/Kernel/InfrastructureBootstrapperTest.php @@ -0,0 +1,155 @@ + */ + private array $envBackup = []; + + /** @var array */ + private array $putenvBackup = []; + + protected function setUp(): void + { + $this->workspace = sys_get_temp_dir() . '/netslim-bootstrapper-' . bin2hex(random_bytes(8)); + mkdir($this->workspace, 0777, true); + + foreach (['APP_ENV', 'APP_URL', 'TIMEZONE'] as $key) { + $this->envBackup[$key] = $_ENV[$key] ?? null; + $this->putenvBackup[$key] = getenv($key); + unset($_ENV[$key], $_SERVER[$key]); + putenv($key); + } + } + + protected function tearDown(): void + { + foreach ($this->envBackup as $key => $value) { + if ($value === null) { + unset($_ENV[$key], $_SERVER[$key]); + } else { + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + + $previous = $this->putenvBackup[$key] ?? false; + if ($previous === false) { + putenv($key); + } else { + putenv($key . '=' . $previous); + } + } + + $this->removeDirectory($this->workspace); + parent::tearDown(); + } + + public function testBootstrapCreatesRuntimeDirectoriesLoadsEnvironmentAndMemoizesContainer(): void + { + $this->writeEnvFile([ + 'APP_ENV=development', + 'APP_URL=http://localhost', + 'ADMIN_USERNAME=admin', + 'ADMIN_EMAIL=admin@example.test', + 'ADMIN_PASSWORD=secret123456', + 'TIMEZONE=Europe/Paris', + ]); + $definitionsPath = $this->writeDefinitionsFile(); + $bootstrapper = new InfrastructureBootstrapper($this->workspace, $definitionsPath); + + $container = $bootstrapper->bootstrap(); + $sameContainer = $bootstrapper->bootstrap(); + + self::assertInstanceOf(ContainerInterface::class, $container); + self::assertSame($container, $sameContainer); + self::assertSame('from-test-definitions', $container->get('bootstrap.flag')); + self::assertSame('Europe/Paris', date_default_timezone_get()); + + foreach ([ + 'var/cache/twig', + 'var/cache/htmlpurifier', + 'var/cache/di', + 'var/logs', + 'database', + 'public/media', + ] as $directory) { + self::assertDirectoryExists($this->workspace . '/' . $directory); + } + } + + public function testBootstrapFailsWhenEnvironmentFileIsMissing(): void + { + $bootstrapper = new InfrastructureBootstrapper($this->workspace, $this->writeDefinitionsFile()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Fichier .env introuvable'); + + $bootstrapper->bootstrap(); + } + + public function testBootstrapNoLongerRequiresIdentityProvisioningVariables(): void + { + $this->writeEnvFile([ + 'APP_ENV=production', + 'APP_URL=https://example.test', + ]); + $bootstrapper = new InfrastructureBootstrapper($this->workspace, $this->writeDefinitionsFile()); + + $container = $bootstrapper->bootstrap(); + + self::assertInstanceOf(ContainerInterface::class, $container); + } + + private function writeEnvFile(array $lines): void + { + file_put_contents($this->workspace . '/.env', implode(PHP_EOL, $lines) . PHP_EOL); + } + + private function writeDefinitionsFile(): string + { + $definitionsPath = $this->workspace . '/definitions.php'; + file_put_contents($definitionsPath, <<<'PHP' + 'from-test-definitions', +]; +PHP); + + return $definitionsPath; + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $fileInfo) { + if ($fileInfo->isDir()) { + rmdir($fileInfo->getPathname()); + continue; + } + + unlink($fileInfo->getPathname()); + } + + rmdir($directory); + } +} diff --git a/tests/Kernel/MailServiceTest.php b/tests/Kernel/MailServiceTest.php new file mode 100644 index 0000000..cba68d1 --- /dev/null +++ b/tests/Kernel/MailServiceTest.php @@ -0,0 +1,72 @@ +makeService('ssl', 465); + $mailer = $this->invokeCreateMailer($service); + + self::assertSame('smtp', $mailer->Mailer); + self::assertSame('smtp.example.test', $mailer->Host); + self::assertTrue($mailer->SMTPAuth); + self::assertSame('mailer-user', $mailer->Username); + self::assertSame('mailer-pass', $mailer->Password); + self::assertSame(PHPMailer::ENCRYPTION_SMTPS, $mailer->SMTPSecure); + self::assertSame(465, $mailer->Port); + self::assertSame(PHPMailer::CHARSET_UTF8, $mailer->CharSet); + self::assertSame('no-reply@example.test', $mailer->From); + self::assertSame('NETslim', $mailer->FromName); + } + + public function testCreateMailerUsesStartTlsWhenEncryptionIsNotSsl(): void + { + $service = $this->makeService('tls', 587); + $mailer = $this->invokeCreateMailer($service); + + self::assertSame(PHPMailer::ENCRYPTION_STARTTLS, $mailer->SMTPSecure); + self::assertSame(587, $mailer->Port); + } + + private function makeService(string $encryption, int $port): MailService + { + $twig = new Twig(new ArrayLoader([ + '@Test/emails/test.twig' => '

      Bonjour {{ name }}

      ', + ])); + + return new MailService( + $twig, + 'smtp.example.test', + $port, + 'mailer-user', + 'mailer-pass', + $encryption, + 'no-reply@example.test', + 'NETslim', + ); + } + + private function invokeCreateMailer(MailService $service): PHPMailer + { + $method = new ReflectionMethod($service, 'createMailer'); + $method->setAccessible(true); + + /** @var PHPMailer $mailer */ + $mailer = $method->invoke($service); + + return $mailer; + } +} diff --git a/tests/Kernel/MigratorTest.php b/tests/Kernel/MigratorTest.php new file mode 100644 index 0000000..88ca514 --- /dev/null +++ b/tests/Kernel/MigratorTest.php @@ -0,0 +1,82 @@ +db = new PDO('sqlite::memory:', options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } + + public function testRunCreatesMigrationsTable(): void + { + Migrator::run($this->db); + + $stmt = $this->db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'"); + self::assertNotFalse($stmt->fetchColumn(), 'La table migrations doit exister après run().'); + } + + public function testRunIsIdempotent(): void + { + Migrator::run($this->db); + Migrator::run($this->db); + + $this->addToAssertionCount(1); + } + + public function testAlreadyAppliedMigrationIsSkipped(): void + { + Migrator::run($this->db); + $before = $this->countMigrations(); + + $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(); + + self::assertSame($before + 1, $after); + } + + public function testRunRecordsModuleMigrationsInTable(): void + { + Migrator::run($this->db); + + self::assertGreaterThan(0, $this->countMigrations(), 'Au moins une migration du socle doit être enregistrée.'); + } + + public function testRunCreatesCoreModuleTables(): void + { + Migrator::run($this->db); + + foreach (['users', 'password_resets', 'rate_limits', 'settings', 'audit_log', 'notification_dispatches', 'categories', 'media'] as $table) { + $stmt = $this->db->query(sprintf("SELECT name FROM sqlite_master WHERE type='table' AND name='%s'", $table)); + self::assertNotFalse($stmt->fetchColumn(), sprintf('La table %s doit exister après run().', $table)); + } + } + + private function countMigrations(): int + { + return (int) $this->db->query('SELECT COUNT(*) FROM migrations')->fetchColumn(); + } +} diff --git a/tests/Kernel/ModuleRegistryTest.php b/tests/Kernel/ModuleRegistryTest.php new file mode 100644 index 0000000..66ab74b --- /dev/null +++ b/tests/Kernel/ModuleRegistryTest.php @@ -0,0 +1,87 @@ + $module::class, + ModuleRegistry::modules(), + ); + + self::assertSame([ + 'Netig\Netslim\Kernel\Runtime\KernelModule', + 'Netig\Netslim\Identity\IdentityModule', + 'Netig\Netslim\Settings\SettingsModule', + 'Netig\Netslim\AuditLog\AuditLogModule', + 'Netig\Netslim\Notifications\NotificationsModule', + 'Netig\Netslim\Taxonomy\TaxonomyModule', + 'Netig\Netslim\Media\MediaModule', + ], $moduleClasses); + } + + public function testModuleClassNamesExposeTheActiveApplicationComposition(): void + { + RuntimePaths::setApplicationRoot(dirname(__DIR__, 2) . '/tests/Fixtures/Application'); + + self::assertSame([ + 'Netig\Netslim\Kernel\Runtime\KernelModule', + 'Netig\Netslim\Identity\IdentityModule', + 'Netig\Netslim\Settings\SettingsModule', + 'Netig\Netslim\AuditLog\AuditLogModule', + 'Netig\Netslim\Notifications\NotificationsModule', + 'Netig\Netslim\Taxonomy\TaxonomyModule', + 'Netig\Netslim\Media\MediaModule', + ], ModuleRegistry::moduleClassNames()); + } + + public function testApplicationManifestIsResolvedFromTheActiveApplicationRoot(): void + { + RuntimePaths::setApplicationRoot(dirname(__DIR__, 2) . '/tests/Fixtures/Application'); + + self::assertFileExists(RuntimePaths::getApplicationConfigPath('modules.php')); + } + + public function testEveryModuleExposesResolvableMetadata(): void + { + RuntimePaths::setApplicationRoot(dirname(__DIR__, 2) . '/tests/Fixtures/Application'); + + foreach (ModuleRegistry::modules() as $module) { + self::assertNotSame([], $module->definitions(), $module::class . ' should expose DI definitions.'); + + foreach ($module->templateNamespaces() as $namespace => $templatePath) { + self::assertNotSame('', $namespace, $module::class . ' should declare a non-empty Twig namespace.'); + self::assertDirectoryExists($templatePath, $module::class . ' should point Twig to an existing template directory.'); + } + + foreach ($module->twigExtensions() as $twigExtensionClass) { + self::assertTrue(class_exists($twigExtensionClass), $module::class . ' should reference an existing Twig extension class.'); + } + } + } +} diff --git a/tests/Kernel/ModuleSchemaTest.php b/tests/Kernel/ModuleSchemaTest.php new file mode 100644 index 0000000..4cff1db --- /dev/null +++ b/tests/Kernel/ModuleSchemaTest.php @@ -0,0 +1,50 @@ + $module instanceof ProvidesSchemaInterface, + )); + + self::assertCount(6, $schemaModules); + + foreach ($schemaModules as $module) { + foreach ($module->migrationDirectories() as $directory) { + self::assertDirectoryExists($directory, $module::class . ' should expose an existing migration directory.'); + self::assertNotSame([], glob(rtrim($directory, '/') . '/*.php') ?: [], $module::class . ' should expose at least one migration file.'); + } + + self::assertNotSame([], $module->requiredTables(), $module::class . ' should expose owned tables for readiness checks.'); + } + } + + public function testLegacyGlobalMigrationsDirectoryIsNoLongerUsed(): void + { + self::assertSame([], glob(dirname(__DIR__, 2) . '/database/migrations/*.php') ?: []); + } +} diff --git a/tests/Kernel/NotFoundExceptionTest.php b/tests/Kernel/NotFoundExceptionTest.php new file mode 100644 index 0000000..01eb35b --- /dev/null +++ b/tests/Kernel/NotFoundExceptionTest.php @@ -0,0 +1,20 @@ +getMessage()); + } +} diff --git a/tests/Kernel/ProvisionerTest.php b/tests/Kernel/ProvisionerTest.php new file mode 100644 index 0000000..ffc0494 --- /dev/null +++ b/tests/Kernel/ProvisionerTest.php @@ -0,0 +1,79 @@ +db = new PDO('sqlite::memory:', options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1); + + $this->lockPath = dirname(__DIR__, 2) . '/database/.provision.lock'; + @unlink($this->lockPath); + + $this->envBackup = [ + 'ADMIN_USERNAME' => $_ENV['ADMIN_USERNAME'] ?? null, + 'ADMIN_EMAIL' => $_ENV['ADMIN_EMAIL'] ?? null, + 'ADMIN_PASSWORD' => $_ENV['ADMIN_PASSWORD'] ?? null, + ]; + + $_ENV['ADMIN_USERNAME'] = 'Admin'; + $_ENV['ADMIN_EMAIL'] = 'Admin@example.com'; + $_ENV['ADMIN_PASSWORD'] = 'secret123456'; + } + + protected function tearDown(): void + { + @unlink($this->lockPath); + + foreach ($this->envBackup as $key => $value) { + if ($value === null) { + unset($_ENV[$key]); + } else { + $_ENV[$key] = $value; + } + } + } + + public function testRunCreatesProvisionLockAndSeedsAdminUser(): void + { + Provisioner::run($this->db); + + self::assertFileExists($this->lockPath); + + $row = $this->db->query('SELECT username, email, role FROM users')->fetch(); + + self::assertIsArray($row); + self::assertSame('admin', $row['username']); + self::assertSame('admin@example.com', $row['email']); + self::assertSame('admin', $row['role']); + } + + public function testRunIsIdempotent(): void + { + Provisioner::run($this->db); + Provisioner::run($this->db); + + $count = (int) $this->db->query('SELECT COUNT(*) FROM users WHERE username = "admin"')->fetchColumn(); + + self::assertSame(1, $count); + } +} diff --git a/tests/Kernel/RequestContextTest.php b/tests/Kernel/RequestContextTest.php new file mode 100644 index 0000000..3348620 --- /dev/null +++ b/tests/Kernel/RequestContextTest.php @@ -0,0 +1,55 @@ + 'on', + ])); + } + + public function testIsHttpsReturnsTrueWhenTrustedProxyForwardsHttps(): void + { + self::assertTrue(RequestContext::isHttps([ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED_PROTO' => 'https, http', + ], ['127.0.0.1'])); + } + + public function testIsHttpsIgnoresForwardedProtoWhenProxyIsNotTrusted(): void + { + self::assertFalse(RequestContext::isHttps([ + 'REMOTE_ADDR' => '10.0.0.5', + 'HTTP_X_FORWARDED_PROTO' => 'https', + ], ['127.0.0.1'])); + } + + public function testTrustedProxiesFromEnvironmentTrimsValues(): void + { + self::assertSame(['127.0.0.1', '::1'], RequestContext::trustedProxiesFromEnvironment([ + 'TRUSTED_PROXIES' => ' 127.0.0.1 , ::1 ', + ])); + } + + public function testTrustedProxiesFromEnvironmentFallsBackToProcessEnvWhenDotenvValueIsBlank(): void + { + putenv('TRUSTED_PROXIES=*'); + + try { + self::assertSame(['*'], RequestContext::trustedProxiesFromEnvironment([ + 'TRUSTED_PROXIES' => '', + ])); + } finally { + putenv('TRUSTED_PROXIES'); + } + } +} diff --git a/tests/Kernel/RoutesTest.php b/tests/Kernel/RoutesTest.php new file mode 100644 index 0000000..c8f6d5c --- /dev/null +++ b/tests/Kernel/RoutesTest.php @@ -0,0 +1,73 @@ +getRouteCollector()->getRoutes() as $route) { + $pattern = $route->getPattern(); + $methods = array_values(array_diff($route->getMethods(), ['HEAD', 'OPTIONS'])); + + $actual[$pattern] ??= []; + $actual[$pattern] = array_values(array_unique(array_merge($actual[$pattern], $methods))); + sort($actual[$pattern]); + } + + ksort($actual); + + $expected = [ + '/account/password' => ['GET', 'POST'], + '/admin/categories' => ['GET'], + '/admin/categories/create' => ['POST'], + '/admin/categories/delete/{id}' => ['POST'], + '/admin/media' => ['GET'], + '/admin/media/delete/{id}' => ['POST'], + '/admin/media/picker' => ['GET'], + '/admin/media/upload' => ['POST'], + '/admin/users' => ['GET'], + '/admin/users/create' => ['GET', 'POST'], + '/admin/users/delete/{id}' => ['POST'], + '/admin/users/role/{id}' => ['POST'], + '/auth/login' => ['GET', 'POST'], + '/auth/logout' => ['POST'], + '/password/forgot' => ['GET', 'POST'], + '/password/reset' => ['GET', 'POST'], + ]; + + foreach ($expected as $pattern => $methods) { + sort($methods); + } + ksort($expected); + + self::assertSame($expected, $actual); + } +} diff --git a/tests/Kernel/RuntimePathsTest.php b/tests/Kernel/RuntimePathsTest.php new file mode 100644 index 0000000..996a18e --- /dev/null +++ b/tests/Kernel/RuntimePathsTest.php @@ -0,0 +1,87 @@ +manager = new SessionManager(); + } + + protected function tearDown(): void + { + $_SESSION = []; + } + + public function testGetUserIdCastsNumericStringToInteger(): void + { + $_SESSION['user_id'] = '42'; + + self::assertSame(42, $this->manager->getUserId()); + self::assertTrue($this->manager->isAuthenticated()); + } +} diff --git a/tests/Kernel/SessionManagerEdgeCasesTest.php b/tests/Kernel/SessionManagerEdgeCasesTest.php new file mode 100644 index 0000000..7ec11ad --- /dev/null +++ b/tests/Kernel/SessionManagerEdgeCasesTest.php @@ -0,0 +1,43 @@ +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/Kernel/SessionManagerTest.php b/tests/Kernel/SessionManagerTest.php new file mode 100644 index 0000000..e57660e --- /dev/null +++ b/tests/Kernel/SessionManagerTest.php @@ -0,0 +1,168 @@ +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/Kernel/SlugHelperTest.php b/tests/Kernel/SlugHelperTest.php new file mode 100644 index 0000000..e2453c1 --- /dev/null +++ b/tests/Kernel/SlugHelperTest.php @@ -0,0 +1,122 @@ +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('contenu-2024', SlugHelper::generate('Contenu 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-contenu', SlugHelper::generate('mon-contenu')); + } + + /** + * 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/Media/MediaControllerTest.php b/tests/Media/MediaControllerTest.php new file mode 100644 index 0000000..e075379 --- /dev/null +++ b/tests/Media/MediaControllerTest.php @@ -0,0 +1,401 @@ +view = $this->makeTwigMock(); + $this->mediaService = $this->createMock(MediaServiceInterface::class); + $this->flash = $this->createMock(FlashServiceInterface::class); + $this->sessionManager = $this->createMock(SessionManagerInterface::class); + + $this->controller = new MediaController( + $this->view, + $this->mediaService, + $this->flash, + $this->sessionManager, + ); + } + + // ── index ──────────────────────────────────────────────────────── + + /** + * index() doit appeler findAll() pour un admin et rendre la vue. + */ + public function testIndexShowsAllMediaForAdmin(): void + { + $this->sessionManager->method('isAdmin')->willReturn(true); + $this->sessionManager->method('isEditor')->willReturn(false); + + $this->mediaService->expects($this->once())->method('findPaginated')->with(1, 12)->willReturn(new PaginatedResult([], 0, 1, 12)); + $this->mediaService->expects($this->never())->method('findByUserIdPaginated'); + + $this->view->expects($this->once()) + ->method('render') + ->with($this->anything(), '@Media/admin/index.twig', $this->anything()) + ->willReturnArgument(0); + + $res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse()); + + $this->assertStatus($res, 200); + } + + /** + * index() doit appeler findAll() pour un éditeur. + */ + public function testIndexShowsAllMediaForEditor(): void + { + $this->sessionManager->method('isAdmin')->willReturn(false); + $this->sessionManager->method('isEditor')->willReturn(true); + + $this->mediaService->expects($this->once())->method('findPaginated')->with(1, 12)->willReturn(new PaginatedResult([], 0, 1, 12)); + $this->mediaService->expects($this->never())->method('findByUserIdPaginated'); + + $this->view->expects($this->once()) + ->method('render') + ->with($this->anything(), '@Media/admin/index.twig', $this->anything()) + ->willReturnArgument(0); + + $res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse()); + + $this->assertStatus($res, 200); + } + + /** + * index() doit appeler findByUserId() pour un utilisateur ordinaire. + */ + public function testIndexShowsOwnMediaForRegularUser(): void + { + $this->sessionManager->method('isAdmin')->willReturn(false); + $this->sessionManager->method('isEditor')->willReturn(false); + $this->sessionManager->method('getUserId')->willReturn(42); + + $this->mediaService->expects($this->once())->method('findByUserIdPaginated')->with(42, 1, 12)->willReturn(new PaginatedResult([], 0, 1, 12)); + $this->mediaService->expects($this->never())->method('findPaginated'); + + $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse()); + } + + /** + * index() doit exposer les résumés d'usage typés par identifiant de média. + */ + public function testIndexPassesMediaUsageSummaryToView(): void + { + $media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 42); + $usageSummary = [ + 'count' => 1, + 'references' => [new MediaUsageReference(8, 'Contenu lié', '/admin/content/edit/8')], + ]; + + $this->sessionManager->method('isAdmin')->willReturn(true); + $this->sessionManager->method('isEditor')->willReturn(false); + $this->mediaService->expects($this->once()) + ->method('findPaginated') + ->with(1, 12) + ->willReturn(new PaginatedResult([$media], 1, 1, 12)); + $this->mediaService->expects($this->once()) + ->method('getUsageSummaries') + ->with([$media], 5) + ->willReturn([$media->getId() => $usageSummary]); + + $this->view->expects($this->once()) + ->method('render') + ->with( + $this->anything(), + '@Media/admin/index.twig', + $this->callback(static function (array $data) use ($media, $usageSummary): bool { + return isset($data['mediaUsage'][$media->getId()]) + && $data['mediaUsage'][$media->getId()] === $usageSummary; + }), + ) + ->willReturnArgument(0); + + $res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse()); + + $this->assertStatus($res, 200); + } + + // ── upload ─────────────────────────────────────────────────────── + + /** + * upload() doit retourner 400 JSON si aucun fichier n'est dans la requête. + */ + public function testUploadReturns400WhenNoFilePresent(): void + { + $req = $this->makePost('/admin/media/upload'); + $res = $this->controller->upload($req, $this->makeResponse()); + + $this->assertStatus($res, 400); + $this->assertJsonContentType($res); + $this->assertJsonContains($res, ['error' => "Aucun fichier reçu ou erreur d'upload"]); + } + + /** + * upload() doit retourner 400 JSON si le fichier PSR-7 signale une erreur d'upload. + */ + public function testUploadReturns400WhenFileHasUploadError(): void + { + /** @var UploadedFileInterface&MockObject $file */ + $file = $this->createMock(UploadedFileInterface::class); + $file->method('getError')->willReturn(UPLOAD_ERR_INI_SIZE); + + $req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]); + $res = $this->controller->upload($req, $this->makeResponse()); + + $this->assertStatus($res, 400); + $this->assertJsonContains($res, ['error' => "Aucun fichier reçu ou erreur d'upload"]); + } + + /** + * upload() doit retourner 413 JSON si le fichier dépasse la taille autorisée. + */ + public function testUploadReturns413OnFileTooLarge(): void + { + $file = $this->makeValidUploadedFile(); + $this->mediaService->method('store') + ->willThrowException(new FileTooLargeException(2 * 1024 * 1024)); + + $req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]); + $res = $this->controller->upload($req, $this->makeResponse()); + + $this->assertStatus($res, 413); + $this->assertJsonContentType($res); + } + + /** + * upload() doit retourner 415 JSON si le type MIME n'est pas autorisé. + */ + public function testUploadReturns415OnInvalidMimeType(): void + { + $file = $this->makeValidUploadedFile(); + $this->mediaService->method('store') + ->willThrowException(new InvalidMimeTypeException('application/pdf')); + + $req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]); + $res = $this->controller->upload($req, $this->makeResponse()); + + $this->assertStatus($res, 415); + $this->assertJsonContentType($res); + } + + /** + * upload() doit retourner 500 JSON si une erreur de stockage survient. + */ + public function testUploadReturns500OnStorageException(): void + { + $file = $this->makeValidUploadedFile(); + $this->mediaService->method('store') + ->willThrowException(new StorageException('Disk full')); + + $req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]); + $res = $this->controller->upload($req, $this->makeResponse()); + + $this->assertStatus($res, 500); + $this->assertJsonContentType($res); + } + + /** + * upload() doit retourner 200 JSON avec l'URL du fichier en cas de succès. + */ + public function testUploadReturns200JsonWithUrlOnSuccess(): void + { + $file = $this->makeValidUploadedFile(); + $this->sessionManager->method('getUserId')->willReturn(1); + $this->mediaService->method('store')->willReturn(new Media(12, 'abc123.webp', '/media/abc123.webp', 'hash', 1)); + + $req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]); + $res = $this->controller->upload($req, $this->makeResponse()); + + $this->assertStatus($res, 200); + $this->assertJsonContentType($res); + $this->assertJsonContains($res, ['success' => true, 'file' => '/media/abc123.webp', 'mediaId' => 12]); + } + + // ── delete ─────────────────────────────────────────────────────── + + /** + * delete() doit flasher une erreur et rediriger si le média est introuvable. + */ + public function testDeleteRedirectsWithErrorWhenMediaNotFound(): void + { + $this->mediaService->method('findById')->willReturn(null); + + $this->flash->expects($this->once())->method('set') + ->with('media_error', 'Fichier introuvable'); + + $res = $this->controller->delete( + $this->makePost('/admin/media/delete/99'), + $this->makeResponse(), + ['id' => '99'], + ); + + $this->assertRedirectTo($res, '/admin/media'); + } + + /** + * delete() doit flasher une erreur si l'utilisateur n'est pas propriétaire du média. + */ + public function testDeleteRedirectsWithErrorWhenNotOwner(): void + { + $media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 10); + $this->mediaService->method('findById')->willReturn($media); + $this->sessionManager->method('isAdmin')->willReturn(false); + $this->sessionManager->method('isEditor')->willReturn(false); + $this->sessionManager->method('getUserId')->willReturn(99); // autre utilisateur + + $this->flash->expects($this->once())->method('set') + ->with('media_error', $this->stringContains('autorisé')); + + $res = $this->controller->delete( + $this->makePost('/admin/media/delete/5'), + $this->makeResponse(), + ['id' => '5'], + ); + + $this->assertRedirectTo($res, '/admin/media'); + } + + /** + * delete() doit supprimer le média et rediriger avec succès si l'utilisateur est propriétaire. + */ + public function testDeleteSucceedsForOwner(): void + { + $media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 42); + $this->mediaService->method('findById')->willReturn($media); + $this->sessionManager->method('isAdmin')->willReturn(false); + $this->sessionManager->method('isEditor')->willReturn(false); + $this->sessionManager->method('getUserId')->willReturn(42); + + $this->mediaService->expects($this->once())->method('delete')->with($media); + $this->flash->expects($this->once())->method('set') + ->with('media_success', 'Fichier supprimé'); + + $res = $this->controller->delete( + $this->makePost('/admin/media/delete/5'), + $this->makeResponse(), + ['id' => '5'], + ); + + $this->assertRedirectTo($res, '/admin/media'); + } + + /** + * delete() doit permettre la suppression à un admin même s'il n'est pas propriétaire. + */ + public function testDeleteSucceedsForAdmin(): void + { + $media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 10); + $this->mediaService->method('findById')->willReturn($media); + $this->sessionManager->method('isAdmin')->willReturn(true); + $this->sessionManager->method('isEditor')->willReturn(false); + $this->sessionManager->method('getUserId')->willReturn(1); // admin, pas propriétaire + + $this->mediaService->expects($this->once())->method('delete'); + + $res = $this->controller->delete( + $this->makePost('/admin/media/delete/5'), + $this->makeResponse(), + ['id' => '5'], + ); + + $this->assertRedirectTo($res, '/admin/media'); + } + + /** + * delete() doit bloquer la suppression si le média est encore référencé et lister les titres connus. + */ + public function testDeleteRedirectsWithDetailedErrorWhenMediaIsStillReferenced(): void + { + $media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 42); + $usageSummary = [ + 'count' => 2, + 'references' => [ + new MediaUsageReference(8, 'Contenu A', '/admin/content/edit/8'), + new MediaUsageReference(9, 'Contenu B', '/admin/content/edit/9'), + ], + ]; + + $this->mediaService->method('findById')->willReturn($media); + $this->mediaService->expects($this->once()) + ->method('getUsageSummary') + ->with($media, 3) + ->willReturn($usageSummary); + $this->mediaService->expects($this->never())->method('delete'); + $this->sessionManager->method('isAdmin')->willReturn(false); + $this->sessionManager->method('isEditor')->willReturn(false); + $this->sessionManager->method('getUserId')->willReturn(42); + + $this->flash->expects($this->once()) + ->method('set') + ->with( + 'media_error', + $this->callback(static fn (string $message): bool => str_contains($message, '2 contenu(s)') + && str_contains($message, '« Contenu A »') + && str_contains($message, '« Contenu B »')), + ); + + $res = $this->controller->delete( + $this->makePost('/admin/media/delete/5'), + $this->makeResponse(), + ['id' => '5'], + ); + + $this->assertRedirectTo($res, '/admin/media'); + } + + // ── Helpers ────────────────────────────────────────────────────── + + /** + * Crée un mock d'UploadedFileInterface sans erreur d'upload. + * + * @return UploadedFileInterface&MockObject + */ + private function makeValidUploadedFile(): UploadedFileInterface + { + $file = $this->createMock(UploadedFileInterface::class); + $file->method('getError')->willReturn(UPLOAD_ERR_OK); + + return $file; + } +} diff --git a/tests/Media/MediaModelTest.php b/tests/Media/MediaModelTest.php new file mode 100644 index 0000000..2cca910 --- /dev/null +++ b/tests/Media/MediaModelTest.php @@ -0,0 +1,56 @@ +getId()); + self::assertSame('file.webp', $media->getFilename()); + self::assertSame('/media/file.webp', $media->getUrl()); + self::assertSame('abc123', $media->getHash()); + self::assertSame(12, $media->getUserId()); + self::assertSame($createdAt, $media->getCreatedAt()); + } + + public function testFromArrayHydratesMedia(): void + { + $media = Media::fromArray([ + 'id' => '9', + 'filename' => 'stored.webp', + 'url' => '/media/stored.webp', + 'hash' => 'hash-9', + 'user_id' => '3', + 'created_at' => '2026-03-02 12:30:00', + ]); + + self::assertSame(9, $media->getId()); + self::assertSame('stored.webp', $media->getFilename()); + self::assertSame('/media/stored.webp', $media->getUrl()); + self::assertSame('hash-9', $media->getHash()); + self::assertSame(3, $media->getUserId()); + self::assertSame('2026-03-02 12:30:00', $media->getCreatedAt()->format('Y-m-d H:i:s')); + } + + public function testCreatedAtDefaultsToNowWhenMissing(): void + { + $before = new DateTime('-2 seconds'); + $media = new Media(1, 'f.webp', '/media/f.webp', 'h', null); + $after = new DateTime('+2 seconds'); + + self::assertGreaterThanOrEqual($before->getTimestamp(), $media->getCreatedAt()->getTimestamp()); + self::assertLessThanOrEqual($after->getTimestamp(), $media->getCreatedAt()->getTimestamp()); + } +} diff --git a/tests/Media/MediaRepositoryTest.php b/tests/Media/MediaRepositoryTest.php new file mode 100644 index 0000000..faa7905 --- /dev/null +++ b/tests/Media/MediaRepositoryTest.php @@ -0,0 +1,335 @@ + + */ + private array $rowImage; + + protected function setUp(): void + { + $this->db = $this->createMock(PDO::class); + $this->repository = new PdoMediaRepository($this->db); + + $this->rowImage = [ + 'id' => 1, + 'filename' => 'photo.webp', + 'url' => '/media/photo.webp', + 'hash' => str_repeat('a', 64), + 'user_id' => 2, + 'created_at' => '2024-06-01 10:00:00', + ]; + } + + // ── Helpers ──────────────────────────────────────────────────── + + private function stmtForRead(array $rows = [], array|false $row = false): MockObject&PDOStatement + { + $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(int $rowCount = 1): MockObject&PDOStatement + { + $stmt = $this->createMock(PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('rowCount')->willReturn($rowCount); + + return $stmt; + } + + + // ── findAll ──────────────────────────────────────────────────── + + /** + * findAll() retourne un tableau vide si aucun média n'existe. + */ + public function testFindAllReturnsEmptyArrayWhenNone(): void + { + $stmt = $this->stmtForRead([]); + $this->db->method('query')->willReturn($stmt); + + $this->assertSame([], $this->repository->findAll()); + } + + /** + * findAll() retourne des instances Media hydratées. + */ + public function testFindAllReturnsMediaInstances(): void + { + $stmt = $this->stmtForRead([$this->rowImage]); + $this->db->method('query')->willReturn($stmt); + + $result = $this->repository->findAll(); + + $this->assertCount(1, $result); + $this->assertInstanceOf(Media::class, $result[0]); + $this->assertSame('photo.webp', $result[0]->getFilename()); + $this->assertSame('/media/photo.webp', $result[0]->getUrl()); + } + + /** + * findAll() interroge bien la table `media`. + */ + public function testFindAllRequestsMediaQuery(): void + { + $stmt = $this->stmtForRead([]); + + $this->db->expects($this->once()) + ->method('query') + ->with($this->stringContains('FROM media')) + ->willReturn($stmt); + + $this->repository->findAll(); + } + + + // ── findByUserId ─────────────────────────────────────────────── + + /** + * findByUserId() retourne un tableau vide si l'utilisateur n'a aucun média. + */ + public function testFindByUserIdReturnsEmptyArrayWhenNone(): void + { + $stmt = $this->stmtForRead([]); + $this->db->expects($this->once())->method('prepare')->willReturn($stmt); + + $this->assertSame([], $this->repository->findByUserId(99)); + } + + /** + * findByUserId() retourne uniquement les médias de l'utilisateur donné. + */ + public function testFindByUserIdReturnsUserMedia(): void + { + $stmt = $this->stmtForRead([$this->rowImage]); + $this->db->method('prepare')->willReturn($stmt); + + $result = $this->repository->findByUserId(2); + + $this->assertCount(1, $result); + $this->assertSame(2, $result[0]->getUserId()); + } + + /** + * findByUserId() exécute avec le bon user_id. + */ + public function testFindByUserIdQueriesWithCorrectUserId(): void + { + $stmt = $this->stmtForRead([]); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(fn (array $params): bool => in_array(5, $params, true))); + + $this->repository->findByUserId(5); + } + + + // ── findById ─────────────────────────────────────────────────── + + /** + * findById() retourne null si le média est absent. + */ + public function testFindByIdReturnsNullWhenMissing(): void + { + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertNull($this->repository->findById(99)); + } + + /** + * findById() retourne une instance Media si le média existe. + */ + public function testFindByIdReturnsMediaWhenFound(): void + { + $stmt = $this->stmtForRead(row: $this->rowImage); + $this->db->method('prepare')->willReturn($stmt); + + $result = $this->repository->findById(1); + + $this->assertInstanceOf(Media::class, $result); + $this->assertSame(1, $result->getId()); + } + + /** + * findById() exécute 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($this->callback(fn (array $params): bool => in_array(8, $params, true))); + + $this->repository->findById(8); + } + + + // ── findByHash ───────────────────────────────────────────────── + + /** + * findByHash() retourne null si aucun média ne correspond au hash. + */ + public function testFindByHashReturnsNullWhenMissing(): void + { + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertNull($this->repository->findByHash(str_repeat('b', 64))); + } + + /** + * findByHash() retourne une instance Media si le hash existe (doublon détecté). + */ + public function testFindByHashReturnsDuplicateMedia(): void + { + $stmt = $this->stmtForRead(row: $this->rowImage); + $this->db->method('prepare')->willReturn($stmt); + + $result = $this->repository->findByHash(str_repeat('a', 64)); + + $this->assertInstanceOf(Media::class, $result); + $this->assertSame(str_repeat('a', 64), $result->getHash()); + } + + /** + * findByHash() exécute avec le bon hash. + */ + public function testFindByHashQueriesWithCorrectHash(): void + { + $hash = str_repeat('c', 64); + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(fn (array $params): bool => in_array($hash, $params, true))); + + $this->repository->findByHash($hash); + } + + + // ── create ───────────────────────────────────────────────────── + + /** + * create() prépare un INSERT avec les bonnes colonnes. + */ + public function testCreateCallsInsertWithCorrectData(): void + { + $media = Media::fromArray($this->rowImage); + $stmt = $this->stmtForWrite(); + + $this->db->expects($this->once())->method('prepare') + ->with($this->stringContains('INSERT INTO media')) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(function (array $data) use ($media): bool { + return $data[':filename'] === $media->getFilename() + && $data[':url'] === $media->getUrl() + && $data[':hash'] === $media->getHash() + && $data[':user_id'] === $media->getUserId() + && isset($data[':created_at']); + })); + + $this->db->method('lastInsertId')->willReturn('1'); + + $this->repository->create($media); + } + + /** + * create() retourne l'identifiant généré par la base de données. + */ + public function testCreateReturnsGeneratedId(): void + { + $media = Media::fromArray($this->rowImage); + $stmt = $this->stmtForWrite(); + $this->db->method('prepare')->willReturn($stmt); + $this->db->method('lastInsertId')->willReturn('15'); + + $this->assertSame(15, $this->repository->create($media)); + } + + + // ── delete ───────────────────────────────────────────────────── + + /** + * delete() prépare un DELETE avec le bon identifiant. + */ + public function testDeleteCallsDeleteWithCorrectId(): void + { + $stmt = $this->stmtForWrite(1); + + $this->db->expects($this->once()) + ->method('prepare') + ->with($this->stringContains('DELETE FROM media')) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(fn (array $params): bool => in_array(4, $params, true))); + + $this->repository->delete(4); + } + + /** + * delete() retourne le nombre de lignes supprimées. + */ + public function testDeleteReturnsDeletedRowCount(): void + { + $stmt = $this->stmtForWrite(1); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertSame(1, $this->repository->delete(4)); + } + + /** + * delete() retourne 0 si le média n'existait plus. + */ + public function testDeleteReturnsZeroWhenNotFound(): void + { + $stmt = $this->stmtForWrite(0); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertSame(0, $this->repository->delete(99)); + } +} diff --git a/tests/Media/MediaSchemaIntegrationTest.php b/tests/Media/MediaSchemaIntegrationTest.php new file mode 100644 index 0000000..1964307 --- /dev/null +++ b/tests/Media/MediaSchemaIntegrationTest.php @@ -0,0 +1,80 @@ +db = new PDO('sqlite::memory:', options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + $this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1); + Migrator::run($this->db); + + $this->db->exec("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'alice', 'alice@example.com', 'hash', 'user')"); + $this->db->exec("INSERT INTO users (id, username, email, password_hash, role) VALUES (2, 'bob', 'bob@example.com', 'hash', 'user')"); + } + + public function testMediaHashIsUniquePerUser(): void + { + $stmt = $this->db->prepare( + 'INSERT INTO media (filename, url, hash, user_id, created_at) VALUES (:filename, :url, :hash, :user_id, :created_at)', + ); + + $stmt->execute([ + ':filename' => 'first.webp', + ':url' => '/media/first.webp', + ':hash' => 'same-hash', + ':user_id' => 1, + ':created_at' => '2026-03-19 10:00:00', + ]); + + $this->expectException(PDOException::class); + + $stmt->execute([ + ':filename' => 'second.webp', + ':url' => '/media/second.webp', + ':hash' => 'same-hash', + ':user_id' => 1, + ':created_at' => '2026-03-19 10:01:00', + ]); + } + + public function testMediaHashCanBeSharedAcrossDifferentUsers(): void + { + $stmt = $this->db->prepare( + 'INSERT INTO media (filename, url, hash, user_id, created_at) VALUES (:filename, :url, :hash, :user_id, :created_at)', + ); + + $stmt->execute([ + ':filename' => 'alice.webp', + ':url' => '/media/alice.webp', + ':hash' => 'same-hash', + ':user_id' => 1, + ':created_at' => '2026-03-19 10:00:00', + ]); + $stmt->execute([ + ':filename' => 'bob.webp', + ':url' => '/media/bob.webp', + ':hash' => 'same-hash', + ':user_id' => 2, + ':created_at' => '2026-03-19 10:01:00', + ]); + + $count = (int) $this->db->query("SELECT COUNT(*) FROM media WHERE hash = 'same-hash'")->fetchColumn(); + + self::assertSame(2, $count); + } +} diff --git a/tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php b/tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php new file mode 100644 index 0000000..11643ee --- /dev/null +++ b/tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php @@ -0,0 +1,103 @@ +repository = $this->createMock(MediaRepositoryInterface::class); + $this->mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class); + $this->uploadDir = sys_get_temp_dir() . '/slim_media_race_' . uniqid('', true); + @mkdir($this->uploadDir, 0755, true); + + $storage = new LocalMediaStorage($this->uploadDir); + + $this->service = new MediaApplicationService( + $this->repository, + $this->mediaUsageReader, + new StoreMedia($this->repository, $storage, '/media', 5 * 1024 * 1024), + new DeleteMedia($this->repository, $storage), + ); + } + + protected function tearDown(): void + { + foreach (glob($this->uploadDir . '/*') ?: [] as $file) { + @unlink($file); + } + @rmdir($this->uploadDir); + } + + public function testReturnsDuplicateUrlWhenInsertRaceOccurs(): void + { + $tmpFile = $this->createMinimalGif(); + $hash = hash_file('sha256', $tmpFile); + self::assertNotFalse($hash); + + $duplicate = new Media(77, 'existing.gif', '/media/existing.gif', $hash, 1); + + $this->repository->expects($this->exactly(2)) + ->method('findByHashForUser') + ->with($hash, 1) + ->willReturnOnConsecutiveCalls(null, $duplicate); + + $this->repository->expects($this->once()) + ->method('create') + ->willThrowException(new PDOException('duplicate key')); + + $file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile)); + $media = $this->service->store($file, 1); + + self::assertSame('/media/existing.gif', $media->getUrl()); + self::assertSame(77, $media->getId()); + self::assertCount(0, glob($this->uploadDir . '/*') ?: []); + + @unlink($tmpFile); + } + + private function makeUploadedFileFromPath(string $path, int $size): UploadedMediaInterface + { + $file = $this->createMock(UploadedMediaInterface::class); + $file->method('getSize')->willReturn($size); + $file->method('getTemporaryPath')->willReturn($path); + $file->method('moveTo')->willReturnCallback(static function (string $dest) use ($path): void { + copy($path, $dest); + }); + + return $file; + } + + private function createMinimalGif(): string + { + $tmpFile = tempnam(sys_get_temp_dir(), 'slim_gif_'); + self::assertNotFalse($tmpFile); + file_put_contents($tmpFile, base64_decode('R0lGODdhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==')); + + return $tmpFile; + } +} diff --git a/tests/Media/MediaServiceEdgeCasesTest.php b/tests/Media/MediaServiceEdgeCasesTest.php new file mode 100644 index 0000000..130d4cc --- /dev/null +++ b/tests/Media/MediaServiceEdgeCasesTest.php @@ -0,0 +1,51 @@ +createMock(MediaRepositoryInterface::class); + $mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class); + + $file = $this->createMock(UploadedMediaInterface::class); + $file->method('getSize')->willReturn(null); + + $storage = new LocalMediaStorage('/tmp'); + $service = new MediaApplicationService($repo, $mediaUsageReader, new StoreMedia($repo, $storage, '/media', 1000), new DeleteMedia($repo, $storage)); + + $this->expectException(StorageException::class); + $service->store($file, 1); + } + + public function testRejectsWhenFileTooLarge(): void + { + $repo = $this->createMock(MediaRepositoryInterface::class); + $mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class); + + $file = $this->createMock(UploadedMediaInterface::class); + $file->method('getSize')->willReturn(999999); + $file->method('getTemporaryPath')->willReturn('/tmp/file'); + + $storage = new LocalMediaStorage('/tmp'); + $service = new MediaApplicationService($repo, $mediaUsageReader, new StoreMedia($repo, $storage, '/media', 100), new DeleteMedia($repo, $storage)); + + $this->expectException(FileTooLargeException::class); + $service->store($file, 1); + } +} diff --git a/tests/Media/MediaServiceInvalidMimeTest.php b/tests/Media/MediaServiceInvalidMimeTest.php new file mode 100644 index 0000000..92dc087 --- /dev/null +++ b/tests/Media/MediaServiceInvalidMimeTest.php @@ -0,0 +1,43 @@ +createMock(MediaRepositoryInterface::class); + $mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class); + + $tmpFile = tempnam(sys_get_temp_dir(), 'upload_'); + self::assertNotFalse($tmpFile); + file_put_contents($tmpFile, 'not an image'); + + $file = $this->createMock(UploadedMediaInterface::class); + $file->method('getSize')->willReturn(filesize($tmpFile)); + $file->method('getTemporaryPath')->willReturn($tmpFile); + + $storage = new LocalMediaStorage(sys_get_temp_dir()); + $service = new MediaApplicationService($repo, $mediaUsageReader, new StoreMedia($repo, $storage, '/media', 500000), new DeleteMedia($repo, $storage)); + + try { + $this->expectException(InvalidMimeTypeException::class); + $service->store($file, 1); + } finally { + @unlink($tmpFile); + } + } +} diff --git a/tests/Media/MediaServiceInvalidTempPathTest.php b/tests/Media/MediaServiceInvalidTempPathTest.php new file mode 100644 index 0000000..c707b28 --- /dev/null +++ b/tests/Media/MediaServiceInvalidTempPathTest.php @@ -0,0 +1,39 @@ +createMock(MediaRepositoryInterface::class); + + $file = $this->createMock(UploadedMediaInterface::class); + $file->method('getSize')->willReturn(128); + $file->method('getTemporaryPath')->willReturn(null); + + $mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class); + + $storage = new LocalMediaStorage(sys_get_temp_dir()); + $service = new MediaApplicationService($repository, $mediaUsageReader, new StoreMedia($repository, $storage, '/media', 500000), new DeleteMedia($repository, $storage)); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage('Impossible de localiser le fichier temporaire uploadé'); + + $service->store($file, 1); + } +} diff --git a/tests/Media/MediaServiceTest.php b/tests/Media/MediaServiceTest.php new file mode 100644 index 0000000..f463489 --- /dev/null +++ b/tests/Media/MediaServiceTest.php @@ -0,0 +1,348 @@ + maxSize + * - rejet si type MIME non autorisé + * - déduplication : retour de l'URL existante si hash déjà connu + * - stockage : fichier écrit sur disque, media créé en base + * - suppression : fichier supprimé du disque et entrée retirée de la base + */ +#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] +final class MediaServiceTest extends TestCase +{ + /** @var MediaRepositoryInterface&MockObject */ + private MediaRepositoryInterface $repository; + + private MediaUsageReaderInterface $mediaUsageReader; + + private string $uploadDir; + + private MediaApplicationService $service; + + protected function setUp(): void + { + $this->repository = $this->createMock(MediaRepositoryInterface::class); + $this->mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class); + $this->uploadDir = sys_get_temp_dir() . '/slim_media_test_' . uniqid(); + @mkdir($this->uploadDir, 0755, true); + + $storage = new LocalMediaStorage($this->uploadDir); + + $this->service = new MediaApplicationService( + mediaRepository: $this->repository, + mediaUsageReader: $this->mediaUsageReader, + storeMedia: new StoreMedia($this->repository, $storage, '/media', 5 * 1024 * 1024), + deleteMedia: new DeleteMedia($this->repository, $storage), + ); + } + + protected function tearDown(): void + { + // Nettoyage du répertoire temporaire + foreach (glob($this->uploadDir . '/*') ?: [] as $file) { + @unlink($file); + } + @rmdir($this->uploadDir); + } + + + // ── store — rejets ───────────────────────────────────────────── + + /** + * store() doit lever FileTooLargeException si la taille dépasse le maximum. + */ + public function testStoreThrowsFileTooLargeWhenOversized(): void + { + $file = $this->makeUploadedFile(size: 6 * 1024 * 1024); + + $this->expectException(FileTooLargeException::class); + + $this->service->store($file, 1); + } + + /** + * store() doit lever InvalidMimeTypeException pour un type MIME non autorisé. + */ + public function testStoreThrowsInvalidMimeType(): void + { + // Créer un vrai fichier texte — finfo retournera text/plain + $tmpFile = tempnam(sys_get_temp_dir(), 'slim_test_'); + file_put_contents($tmpFile, 'ceci est un fichier texte'); + + $file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile)); + + $this->expectException(InvalidMimeTypeException::class); + + try { + $this->service->store($file, 1); + } finally { + @unlink($tmpFile); + } + } + + + // ── store — déduplication ────────────────────────────────────── + + /** + * store() doit retourner l'URL existante sans créer de doublon si le hash est connu. + */ + public function testStoreReturnsDuplicateUrl(): void + { + $tmpFile = $this->createMinimalJpeg(); + + $existing = new Media(7, 'existing.jpg', '/media/existing.jpg', 'existing-hash', 1); + $this->repository + ->expects($this->once()) + ->method('findByHashForUser') + ->with($this->callback(static fn (mixed $value): bool => is_string($value) && $value !== ''), 1) + ->willReturn($existing); + $this->repository->expects($this->never())->method('create'); + + $file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile)); + $media = $this->service->store($file, 1); + + $this->assertSame('/media/existing.jpg', $media->getUrl()); + $this->assertSame(7, $media->getId()); + + @unlink($tmpFile); + } + + + // ── store — stockage nominal ─────────────────────────────────── + + /** + * store() doit créer le fichier sur disque et appeler repository::create(). + */ + public function testStoreWritesFileAndCreatesRecord(): void + { + $tmpFile = $this->createMinimalJpeg(); + + $this->repository->method('findByHashForUser')->willReturn(null); + $this->repository->expects($this->once())->method('create'); + + $file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile)); + $media = $this->service->store($file, 1); + + $this->assertStringStartsWith('/media/', $media->getUrl()); + + // Le fichier doit exister sur le disque + $filename = basename($media->getUrl()); + $this->assertFileExists($this->uploadDir . DIRECTORY_SEPARATOR . $filename); + + @unlink($tmpFile); + } + + + // ── delete ───────────────────────────────────────────────────── + + /** + * delete() doit supprimer le fichier physique et appeler repository::delete(). + */ + public function testDeleteRemovesFileAndRecord(): void + { + $filename = 'test_media.jpg'; + $filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename; + file_put_contents($filePath, 'fake image data'); + + $media = new Media(42, $filename, '/media/' . $filename, 'fakehash', 1); + + $this->repository->expects($this->once())->method('delete')->with(42)->willReturn(1); + + $this->service->delete($media); + + $this->assertFileDoesNotExist($filePath); + } + + /** + * delete() ne doit pas lever d'exception si le fichier physique n'existe plus. + */ + public function testDeleteIsSilentWhenFileMissing(): void + { + $media = new Media(99, 'inexistant.jpg', '/media/inexistant.jpg', 'hash', 1); + + $this->repository->expects($this->once())->method('delete')->with(99)->willReturn(1); + + // Ne doit pas lever d'exception + $this->service->delete($media); + $this->assertTrue(true); + } + + /** + * delete() doit conserver le fichier si la suppression en base echoue. + */ + public function testDeleteKeepsFileWhenRepositoryDeleteFails(): void + { + $filename = 'test_media_keep.jpg'; + $filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename; + file_put_contents($filePath, 'fake image data'); + + $media = new Media(43, $filename, '/media/' . $filename, 'fakehash', 1); + + $this->repository->expects($this->once()) + ->method('delete') + ->with(43) + ->willThrowException(new \RuntimeException('db delete failed')); + + $this->expectException(\RuntimeException::class); + + try { + $this->service->delete($media); + } finally { + $this->assertFileExists($filePath); + } + } + + /** + * delete() ne doit pas supprimer le fichier si la ligne base n'existe plus. + */ + public function testDeleteKeepsFileWhenRecordWasAlreadyMissing(): void + { + $filename = 'test_media_orphan.jpg'; + $filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename; + file_put_contents($filePath, 'fake image data'); + + $media = new Media(44, $filename, '/media/' . $filename, 'fakehash', 1); + + $this->repository->expects($this->once()) + ->method('delete') + ->with(44) + ->willReturn(0); + + $this->service->delete($media); + + $this->assertFileExists($filePath); + } + + + // ── Lectures déléguées ───────────────────────────────────────── + + /** + * findById() doit déléguer au repository et retourner null si absent. + */ + public function testFindByIdReturnsNullWhenMissing(): void + { + $this->repository->method('findById')->willReturn(null); + + $this->assertNull($this->service->findById(999)); + } + + /** + * findById() doit retourner le média trouvé. + */ + public function testFindByIdReturnsMedia(): void + { + $media = new Media(3, 'photo.jpg', '/media/photo.jpg', 'abc123', 1); + $this->repository->expects($this->once())->method('findById')->with(3)->willReturn($media); + + $this->assertSame($media, $this->service->findById(3)); + } + + /** + * getUsageSummary() doit déléguer au port de lecture des usages média. + */ + public function testGetUsageSummaryDelegatesToMediaUsageReader(): void + { + $media = new Media(3, 'photo.jpg', '/media/photo.jpg', 'abc123', 1); + $reference = new MediaUsageReference(12, 'Contenu lié', '/admin/content/edit/12'); + + $this->mediaUsageReader->expects($this->once())->method('countUsagesByMediaIds')->with([3])->willReturn([3 => 2]); + $this->mediaUsageReader->expects($this->once())->method('findUsagesByMediaIds')->with([3], 3)->willReturn([3 => [$reference]]); + + $summary = $this->service->getUsageSummary($media, 3); + + $this->assertSame(2, $summary['count']); + $this->assertSame([$reference], $summary['references']); + } + + public function testGetUsageSummariesDelegatesToMediaUsageReaderInBatch(): void + { + $mediaA = new Media(3, 'photo.jpg', '/media/photo.jpg', 'abc123', 1); + $mediaB = new Media(8, 'banner.jpg', '/media/banner.jpg', 'def456', 1); + $reference = new MediaUsageReference(12, 'Contenu lié', '/admin/content/edit/12'); + + $this->mediaUsageReader->expects($this->once())->method('countUsagesByMediaIds')->with([3, 8])->willReturn([ + 3 => 2, + 8 => 0, + ]); + $this->mediaUsageReader->expects($this->once())->method('findUsagesByMediaIds')->with([3, 8], 4)->willReturn([ + 3 => [$reference], + ]); + + $summaries = $this->service->getUsageSummaries([$mediaA, $mediaB], 4); + + $this->assertSame(2, $summaries[3]['count']); + $this->assertSame([$reference], $summaries[3]['references']); + $this->assertSame(0, $summaries[8]['count']); + $this->assertSame([], $summaries[8]['references']); + } + + + // ── Helpers ──────────────────────────────────────────────────── + + /** + * Crée un mock d'UploadedFile avec une taille donnée et un tmpPath bidon. + */ + private function makeUploadedFile(int $size): UploadedMediaInterface + { + $file = $this->createMock(UploadedMediaInterface::class); + $file->method('getSize')->willReturn($size); + $file->method('getTemporaryPath')->willReturn('/nonexistent/path'); + + return $file; + } + + /** + * Crée un mock d'UploadedFile pointant vers un fichier réel. + */ + private function makeUploadedFileFromPath(string $path, int $size): UploadedMediaInterface + { + $file = $this->createMock(UploadedMediaInterface::class); + $file->method('getSize')->willReturn($size); + $file->method('getTemporaryPath')->willReturn($path); + $file->method('moveTo')->willReturnCallback(function (string $dest) use ($path): void { + copy($path, $dest); + }); + + return $file; + } + + /** + * Crée un fichier JPEG valide via GD (1×1 pixel). + * Retourne le chemin du fichier temporaire. + */ + private function createMinimalJpeg(): string + { + $tmpFile = tempnam(sys_get_temp_dir(), 'slim_jpeg_') . '.jpg'; + + $img = imagecreatetruecolor(1, 1); + imagejpeg($img, $tmpFile); + imagedestroy($img); + + return $tmpFile; + } +} diff --git a/tests/Media/MediaUsageReferenceTest.php b/tests/Media/MediaUsageReferenceTest.php new file mode 100644 index 0000000..fd9726b --- /dev/null +++ b/tests/Media/MediaUsageReferenceTest.php @@ -0,0 +1,20 @@ +getId()); + self::assertSame('Contenu lié', $reference->getTitle()); + self::assertSame('/admin/content/edit/12', $reference->getEditPath()); + } +} diff --git a/tests/Notifications/NotificationDispatchTest.php b/tests/Notifications/NotificationDispatchTest.php new file mode 100644 index 0000000..6787779 --- /dev/null +++ b/tests/Notifications/NotificationDispatchTest.php @@ -0,0 +1,29 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Statut de notification non supporté'); + + NotificationDispatch::fromRow([ + 'id' => 1, + 'recipient' => 'user@example.test', + 'subject' => 'Sujet', + 'template' => '@Identity/emails/password-reset.twig', + 'status' => 'queued', + 'notification_key' => null, + 'error_message' => null, + 'created_at' => '2026-03-20 10:00:00', + 'sent_at' => null, + ]); + } +} diff --git a/tests/Notifications/NotificationServiceTest.php b/tests/Notifications/NotificationServiceTest.php new file mode 100644 index 0000000..474399a --- /dev/null +++ b/tests/Notifications/NotificationServiceTest.php @@ -0,0 +1,66 @@ +db = new PDO('sqlite::memory:', options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $this->db->exec('CREATE TABLE notification_dispatches (id INTEGER PRIMARY KEY AUTOINCREMENT, recipient TEXT NOT NULL, subject TEXT NOT NULL, template TEXT NOT NULL, status TEXT NOT NULL, notification_key TEXT DEFAULT NULL, error_message TEXT DEFAULT NULL, created_at TEXT NOT NULL, sent_at TEXT DEFAULT NULL)'); + $this->mailer = $this->createMock(MailServiceInterface::class); + $this->service = new NotificationApplicationService($this->mailer, new PdoNotificationDispatchRepository($this->db)); + } + + public function testSendTemplateRecordsSuccessfulDispatch(): void + { + $this->mailer->expects($this->once()) + ->method('send') + ->with('user@example.test', 'Sujet', '@Identity/emails/password-reset.twig', ['name' => 'Ada']); + + $this->service->sendTemplate('user@example.test', 'Sujet', '@Identity/emails/password-reset.twig', ['name' => 'Ada'], 'password-reset'); + + $history = $this->service->recent(); + self::assertCount(1, $history); + self::assertSame('sent', $history[0]->status); + self::assertSame('password-reset', $history[0]->notificationKey); + } + + public function testFailureIsRecordedAndRethrown(): void + { + $this->mailer->expects($this->once()) + ->method('send') + ->willThrowException(new \RuntimeException('SMTP indisponible')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('SMTP indisponible'); + + try { + $this->service->sendTemplate('user@example.test', 'Sujet', '@Identity/emails/password-reset.twig'); + } finally { + $history = $this->service->recent(); + self::assertCount(1, $history); + self::assertSame('failed', $history[0]->status); + self::assertSame('SMTP indisponible', $history[0]->errorMessage); + } + } +} diff --git a/tests/Settings/SettingTest.php b/tests/Settings/SettingTest.php new file mode 100644 index 0000000..dd26591 --- /dev/null +++ b/tests/Settings/SettingTest.php @@ -0,0 +1,45 @@ +getType()); + self::assertSame('1', $setting->toStorageValue()); + + $restored = Setting::fromStorage([ + 'setting_key' => 'site.enabled', + 'setting_value' => '1', + 'value_type' => 'bool', + ]); + + self::assertTrue($restored->getValue()); + } + + public function testRejectsEmptyKey(): void + { + $this->expectException(\InvalidArgumentException::class); + new Setting('', 'value'); + } + + public function testRejectsUnsupportedStoredType(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Type de paramètre stocké non supporté'); + + Setting::fromStorage([ + 'setting_key' => 'site.mode', + 'setting_value' => 'legacy', + 'value_type' => 'legacy-type', + ]); + } +} diff --git a/tests/Settings/SettingsServiceTest.php b/tests/Settings/SettingsServiceTest.php new file mode 100644 index 0000000..2da7273 --- /dev/null +++ b/tests/Settings/SettingsServiceTest.php @@ -0,0 +1,54 @@ +db = new PDO('sqlite::memory:', options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $this->db->exec('CREATE TABLE settings (setting_key TEXT PRIMARY KEY, setting_value TEXT DEFAULT NULL, value_type TEXT NOT NULL, updated_at TEXT NOT NULL)'); + $this->service = new SettingsApplicationService(new PdoSettingRepository($this->db)); + } + + public function testStoresAndReadsTypedValues(): void + { + $this->service->set('site.name', 'Netslim'); + $this->service->set('posts.per_page', 12); + $this->service->set('site.enabled', true); + + self::assertSame('Netslim', $this->service->getString('site.name')); + self::assertSame(12, $this->service->getInt('posts.per_page')); + self::assertTrue($this->service->getBool('site.enabled')); + self::assertSame([ + 'posts.per_page' => 12, + 'site.enabled' => true, + 'site.name' => 'Netslim', + ], $this->service->all()); + } + + public function testDeleteRemovesSetting(): void + { + $this->service->set('site.name', 'Netslim'); + self::assertTrue($this->service->has('site.name')); + + $this->service->delete('site.name'); + + self::assertFalse($this->service->has('site.name')); + self::assertSame('fallback', $this->service->getString('site.name', 'fallback')); + } +} diff --git a/tests/Taxonomy/TaxonModelTest.php b/tests/Taxonomy/TaxonModelTest.php new file mode 100644 index 0000000..623e268 --- /dev/null +++ b/tests/Taxonomy/TaxonModelTest.php @@ -0,0 +1,51 @@ +getId()); + self::assertSame('PHP', $taxon->getName()); + self::assertSame('php', $taxon->getSlug()); + } + + public function testFromArrayHydratesTaxon(): void + { + $taxon = Taxon::fromArray([ + 'id' => '6', + 'name' => 'Tests', + 'slug' => 'tests', + ]); + + self::assertSame(6, $taxon->getId()); + self::assertSame('Tests', $taxon->getName()); + self::assertSame('tests', $taxon->getSlug()); + } + + public function testValidationRejectsEmptyName(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Le nom du terme ne peut pas être vide'); + + new Taxon(1, '', 'slug'); + } + + public function testValidationRejectsTooLongName(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Le nom du terme ne peut pas dépasser 100 caractères'); + + new Taxon(1, str_repeat('a', 101), 'slug'); + } +} diff --git a/tests/Taxonomy/TaxonRepositoryTest.php b/tests/Taxonomy/TaxonRepositoryTest.php new file mode 100644 index 0000000..121a5c0 --- /dev/null +++ b/tests/Taxonomy/TaxonRepositoryTest.php @@ -0,0 +1,336 @@ + + */ + private array $rowPhp; + + protected function setUp(): void + { + $this->db = $this->createMock(PDO::class); + $this->repository = new PdoTaxonRepository($this->db); + + $this->rowPhp = [ + 'id' => 1, + 'name' => 'PHP', + 'slug' => 'php', + ]; + } + + // ── Helpers ──────────────────────────────────────────────────── + + private function stmtForRead(array $rows = [], array|false $row = false): MockObject&PDOStatement + { + $stmt = $this->createMock(PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('fetchAll')->willReturn($rows); + $stmt->method('fetch')->willReturn($row); + + return $stmt; + } + + private function stmtForScalar(mixed $value): MockObject&PDOStatement + { + $stmt = $this->createMock(PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('fetchColumn')->willReturn($value); + + return $stmt; + } + + private function stmtForWrite(int $rowCount = 1): MockObject&PDOStatement + { + $stmt = $this->createMock(PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('rowCount')->willReturn($rowCount); + + return $stmt; + } + + + // ── findAll ──────────────────────────────────────────────────── + + /** + * findAll() retourne un tableau vide si aucun taxon n'existe. + */ + public function testFindAllReturnsEmptyArrayWhenNone(): void + { + $stmt = $this->stmtForRead([]); + $this->db->method('query')->willReturn($stmt); + + $this->assertSame([], $this->repository->findAll()); + } + + /** + * findAll() retourne des instances Taxon hydratées. + */ + public function testFindAllReturnsTaxonInstances(): void + { + $stmt = $this->stmtForRead([$this->rowPhp]); + $this->db->method('query')->willReturn($stmt); + + $result = $this->repository->findAll(); + + $this->assertCount(1, $result); + $this->assertInstanceOf(Taxon::class, $result[0]); + $this->assertSame('PHP', $result[0]->getName()); + $this->assertSame('php', $result[0]->getSlug()); + } + + /** + * findAll() interroge bien la table historique `categories`. + */ + public function testFindAllRequestsCategoriesQuery(): void + { + $stmt = $this->stmtForRead([]); + + $this->db->expects($this->once()) + ->method('query') + ->with($this->stringContains('FROM categories')) + ->willReturn($stmt); + + $this->repository->findAll(); + } + + + // ── findById ─────────────────────────────────────────────────── + + /** + * findById() retourne null si le taxon est absent. + */ + public function testFindByIdReturnsNullWhenMissing(): void + { + $stmt = $this->stmtForRead(row: false); + $this->db->expects($this->once())->method('prepare')->willReturn($stmt); + + $this->assertNull($this->repository->findById(99)); + } + + /** + * findById() retourne une instance Taxon si le taxon existe. + */ + public function testFindByIdReturnsTaxonWhenFound(): void + { + $stmt = $this->stmtForRead(row: $this->rowPhp); + $this->db->method('prepare')->willReturn($stmt); + + $result = $this->repository->findById(1); + + $this->assertInstanceOf(Taxon::class, $result); + $this->assertSame(1, $result->getId()); + $this->assertSame('PHP', $result->getName()); + } + + /** + * findById() exécute 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($this->callback(fn (array $params): bool => in_array(42, $params, true))); + + $this->repository->findById(42); + } + + + // ── findBySlug ───────────────────────────────────────────────── + + /** + * findBySlug() retourne null si le slug est absent. + */ + public function testFindBySlugReturnsNullWhenMissing(): void + { + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertNull($this->repository->findBySlug('inconnu')); + } + + /** + * findBySlug() retourne une instance Taxon si le slug existe. + */ + public function testFindBySlugReturnsTaxonWhenFound(): void + { + $stmt = $this->stmtForRead(row: $this->rowPhp); + $this->db->method('prepare')->willReturn($stmt); + + $result = $this->repository->findBySlug('php'); + + $this->assertInstanceOf(Taxon::class, $result); + $this->assertSame('php', $result->getSlug()); + } + + /** + * findBySlug() exécute avec le bon slug. + */ + public function testFindBySlugQueriesWithCorrectSlug(): void + { + $stmt = $this->stmtForRead(row: false); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(fn (array $params): bool => in_array('php', $params, true))); + + $this->repository->findBySlug('php'); + } + + + // ── create ───────────────────────────────────────────────────── + + /** + * create() prépare un INSERT avec le nom et le slug du taxon. + */ + public function testCreateCallsInsertWithNameAndSlug(): void + { + $taxon = Taxon::fromArray($this->rowPhp); + $stmt = $this->stmtForWrite(); + + $this->db->expects($this->once())->method('prepare') + ->with($this->stringContains('INSERT INTO categories')) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback( + fn (array $data): bool => + $data[':name'] === $taxon->getName() + && $data[':slug'] === $taxon->getSlug(), + )); + + $this->db->method('lastInsertId')->willReturn('1'); + + $this->repository->create($taxon); + } + + /** + * create() retourne l'identifiant généré par la base de données. + */ + public function testCreateReturnsGeneratedId(): void + { + $taxon = Taxon::fromArray($this->rowPhp); + $stmt = $this->stmtForWrite(); + $this->db->method('prepare')->willReturn($stmt); + $this->db->method('lastInsertId')->willReturn('7'); + + $this->assertSame(7, $this->repository->create($taxon)); + } + + + // ── delete ───────────────────────────────────────────────────── + + /** + * delete() prépare un DELETE avec le bon identifiant. + */ + public function testDeleteCallsDeleteWithCorrectId(): void + { + $stmt = $this->stmtForWrite(1); + + $this->db->expects($this->once()) + ->method('prepare') + ->with($this->stringContains('DELETE FROM categories')) + ->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(fn (array $params): bool => in_array(3, $params, true))); + + $this->repository->delete(3); + } + + /** + * delete() retourne le nombre de lignes supprimées. + */ + public function testDeleteReturnsDeletedRowCount(): void + { + $stmt = $this->stmtForWrite(1); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertSame(1, $this->repository->delete(3)); + } + + /** + * delete() retourne 0 si le taxon n'existait plus. + */ + public function testDeleteReturnsZeroWhenNotFound(): void + { + $stmt = $this->stmtForWrite(0); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertSame(0, $this->repository->delete(99)); + } + + + // ── nameExists ───────────────────────────────────────────────── + + /** + * nameExists() retourne true si le nom existe déjà. + */ + public function testNameExistsReturnsTrueWhenTaken(): void + { + $stmt = $this->stmtForScalar(1); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertTrue($this->repository->nameExists('PHP')); + } + + /** + * nameExists() retourne false si le nom est disponible. + */ + public function testNameExistsReturnsFalseWhenFree(): void + { + $stmt = $this->stmtForScalar(false); + $this->db->method('prepare')->willReturn($stmt); + + $this->assertFalse($this->repository->nameExists('Nouveau')); + } + + /** + * nameExists() exécute avec le bon nom. + */ + public function testNameExistsQueriesWithCorrectName(): void + { + $stmt = $this->stmtForScalar(false); + $this->db->method('prepare')->willReturn($stmt); + + $stmt->expects($this->once()) + ->method('execute') + ->with($this->callback(fn (array $params): bool => in_array('PHP', $params, true))); + + $this->repository->nameExists('PHP'); + } +} diff --git a/tests/Taxonomy/TaxonomyControllerTest.php b/tests/Taxonomy/TaxonomyControllerTest.php new file mode 100644 index 0000000..6b3bea3 --- /dev/null +++ b/tests/Taxonomy/TaxonomyControllerTest.php @@ -0,0 +1,201 @@ +view = $this->makeTwigMock(); + $this->taxonomyService = $this->createMock(TaxonomyServiceInterface::class); + $this->flash = $this->createMock(FlashServiceInterface::class); + + $this->controller = new TaxonomyController( + $this->view, + $this->taxonomyService, + $this->flash, + ); + } + + // ── index ──────────────────────────────────────────────────────── + + /** + * index() doit rendre la vue avec la liste des taxons. + */ + public function testIndexRendersWithCategories(): void + { + $this->taxonomyService->method('findPaginated')->willReturn(new PaginatedResult([], 0, 1, 20)); + + $this->view->expects($this->once()) + ->method('render') + ->with($this->anything(), '@Taxonomy/admin/index.twig', $this->anything()) + ->willReturnArgument(0); + + $res = $this->controller->index($this->makeGet('/admin/categories'), $this->makeResponse()); + + $this->assertStatus($res, 200); + } + + // ── create ─────────────────────────────────────────────────────── + + /** + * create() doit flasher un succès et rediriger en cas de création réussie. + */ + public function testCreateRedirectsWithSuccessFlash(): void + { + $this->taxonomyService->method('create')->willReturn(1); + + $this->flash->expects($this->once())->method('set') + ->with('taxonomy_success', $this->stringContains('PHP')); + + $req = $this->makePost('/admin/categories/create', ['name' => 'PHP']); + $res = $this->controller->create($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/admin/categories'); + } + + /** + * create() doit flasher une erreur si le service lève une InvalidArgumentException. + */ + public function testCreateRedirectsWithErrorOnInvalidArgument(): void + { + $this->taxonomyService->method('create') + ->willThrowException(new \InvalidArgumentException('Ce terme existe déjà')); + + $this->flash->expects($this->once())->method('set') + ->with('taxonomy_error', 'Ce terme existe déjà'); + + $req = $this->makePost('/admin/categories/create', ['name' => 'Duplicate']); + $res = $this->controller->create($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/admin/categories'); + } + + /** + * create() doit flasher une erreur générique pour toute autre exception. + */ + public function testCreateRedirectsWithGenericErrorOnUnexpectedException(): void + { + $this->taxonomyService->method('create') + ->willThrowException(new \RuntimeException('DB error')); + + $this->flash->expects($this->once())->method('set') + ->with('taxonomy_error', $this->stringContains('inattendue')); + + $req = $this->makePost('/admin/categories/create', ['name' => 'PHP']); + $res = $this->controller->create($req, $this->makeResponse()); + + $this->assertRedirectTo($res, '/admin/categories'); + } + + // ── delete ─────────────────────────────────────────────────────── + + /** + * delete() doit flasher une erreur et rediriger si le taxon est introuvable. + */ + public function testDeleteRedirectsWithErrorWhenNotFound(): void + { + $this->taxonomyService->method('findById')->willReturn(null); + + $this->flash->expects($this->once())->method('set') + ->with('taxonomy_error', 'Terme de taxonomie introuvable'); + + $res = $this->controller->delete( + $this->makePost('/admin/categories/delete/99'), + $this->makeResponse(), + ['id' => '99'], + ); + + $this->assertRedirectTo($res, '/admin/categories'); + } + + /** + * delete() doit flasher un succès et rediriger en cas de suppression réussie. + */ + public function testDeleteRedirectsWithSuccessFlash(): void + { + $category = new Taxon(3, 'PHP', 'php'); + $this->taxonomyService->method('findById')->willReturn($category); + $this->flash->expects($this->once())->method('set') + ->with('taxonomy_success', $this->stringContains('PHP')); + + $res = $this->controller->delete( + $this->makePost('/admin/categories/delete/3'), + $this->makeResponse(), + ['id' => '3'], + ); + + $this->assertRedirectTo($res, '/admin/categories'); + } + + /** + * delete() doit flasher une erreur si le service refuse la suppression + * (ex: des contenus sont rattachés au taxon). + */ + public function testDeleteRedirectsWithErrorWhenServiceRefuses(): void + { + $category = new Taxon(3, 'PHP', 'php'); + $this->taxonomyService->method('findById')->willReturn($category); + $this->taxonomyService->method('delete') + ->willThrowException(new \InvalidArgumentException('Le terme « PHP » est encore utilisé et ne peut pas être supprimé')); + + $this->flash->expects($this->once())->method('set') + ->with('taxonomy_error', 'Le terme « PHP » est encore utilisé et ne peut pas être supprimé'); + + $res = $this->controller->delete( + $this->makePost('/admin/categories/delete/3'), + $this->makeResponse(), + ['id' => '3'], + ); + + $this->assertRedirectTo($res, '/admin/categories'); + } + + /** + * delete() doit passer l'identifiant de route au service findById(). + */ + public function testDeletePassesCorrectIdToService(): void + { + $this->taxonomyService->expects($this->once()) + ->method('findById') + ->with(7) + ->willReturn(null); + + $this->flash->method('set'); + + $this->controller->delete( + $this->makePost('/admin/categories/delete/7'), + $this->makeResponse(), + ['id' => '7'], + ); + } +} diff --git a/tests/Taxonomy/TaxonomyServiceReaderTest.php b/tests/Taxonomy/TaxonomyServiceReaderTest.php new file mode 100644 index 0000000..9a7d1d5 --- /dev/null +++ b/tests/Taxonomy/TaxonomyServiceReaderTest.php @@ -0,0 +1,52 @@ +taxonomyService = $this->createMock(TaxonomyServiceInterface::class); + $this->reader = new TaxonomyServiceReader($this->taxonomyService); + } + + public function testFindAllMapsCategoriesToTaxonViews(): void + { + $this->taxonomyService->expects($this->once()) + ->method('findAll') + ->willReturn([ + new Taxon(1, 'PHP', 'php'), + new Taxon(2, 'Slim', 'slim'), + ]); + + $taxons = $this->reader->findAll(); + + self::assertCount(2, $taxons); + self::assertSame('PHP', $taxons[0]->name); + self::assertSame('slim', $taxons[1]->slug); + } + + public function testFindBySlugReturnsNullWhenMissing(): void + { + $this->taxonomyService->expects($this->once()) + ->method('findBySlug') + ->with('missing') + ->willReturn(null); + + self::assertNull($this->reader->findBySlug('missing')); + } +} diff --git a/tests/Taxonomy/TaxonomyServiceTest.php b/tests/Taxonomy/TaxonomyServiceTest.php new file mode 100644 index 0000000..743345a --- /dev/null +++ b/tests/Taxonomy/TaxonomyServiceTest.php @@ -0,0 +1,203 @@ +repository = $this->createMock(TaxonRepositoryInterface::class); + $this->taxonUsageChecker = $this->createMock(TaxonUsageCheckerInterface::class); + $this->service = new TaxonomyApplicationService( + $this->repository, + new CreateTaxon($this->repository, new TaxonSlugGenerator()), + new DeleteTaxon($this->repository, $this->taxonUsageChecker), + ); + } + + + // ── create ───────────────────────────────────────────────────── + + /** + * create() doit générer le slug depuis le nom et persister le terme. + */ + public function testCreateGeneratesSlugAndPersists(): void + { + $this->repository->method('nameExists')->willReturn(false); + $this->repository->expects($this->once()) + ->method('create') + ->with($this->callback( + fn (Taxon $c) => + $c->getName() === 'Développement web' + && $c->getSlug() === 'developpement-web', + )) + ->willReturn(1); + + $id = $this->service->create('Développement web'); + + $this->assertSame(1, $id); + } + + /** + * create() doit trimmer le nom avant de générer le slug. + */ + public function testCreateTrimsName(): void + { + $this->repository->method('nameExists')->willReturn(false); + $this->repository->expects($this->once()) + ->method('create') + ->with($this->callback(fn (Taxon $c) => $c->getName() === 'PHP')) + ->willReturn(2); + + $this->service->create(' PHP '); + } + + /** + * create() doit lever InvalidArgumentException si le slug généré est vide. + */ + public function testCreateNonAsciiNameThrowsException(): void + { + $this->repository->expects($this->never())->method('create'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('slug URL valide'); + + $this->service->create('日本語'); + } + + /** + * create() doit lever InvalidArgumentException si le nom est existe déjà. + */ + public function testCreateDuplicateNameThrowsException(): void + { + $this->repository->method('nameExists')->willReturn(true); + $this->repository->expects($this->never())->method('create'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('existe déjà'); + + $this->service->create('PHP'); + } + + /** + * create() doit lever InvalidArgumentException si le nom est vide. + */ + public function testCreateEmptyNameThrowsException(): void + { + $this->repository->method('nameExists')->willReturn(false); + $this->repository->expects($this->never())->method('create'); + + $this->expectException(\InvalidArgumentException::class); + + $this->service->create(''); + } + + /** + * create() doit lever InvalidArgumentException si le nom dépasse 100 caractères. + */ + public function testCreateNameTooLongThrowsException(): void + { + $longName = str_repeat('a', 101); + $this->repository->method('nameExists')->willReturn(false); + $this->repository->expects($this->never())->method('create'); + + $this->expectException(\InvalidArgumentException::class); + + $this->service->create($longName); + } + + + // ── delete ───────────────────────────────────────────────────── + + /** + * delete() doit supprimer le terme s'il n'est pas utilisé. + */ + public function testDeleteSucceedsWhenTaxonIsUnused(): void + { + $taxon = new Taxon(5, 'PHP', 'php'); + + $this->taxonUsageChecker->expects($this->once())->method('isTaxonInUse')->with(5)->willReturn(false); + $this->repository->expects($this->once())->method('delete')->with(5); + + $this->service->delete($taxon); + } + + /** + * delete() doit lever InvalidArgumentException si le terme est encore utilisé. + */ + public function testDeleteBlockedWhenTaxonIsStillUsed(): void + { + $taxon = new Taxon(5, 'PHP', 'php'); + + $this->taxonUsageChecker->expects($this->once())->method('isTaxonInUse')->with(5)->willReturn(true); + $this->repository->expects($this->never())->method('delete'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('est encore utilisé'); + + $this->service->delete($taxon); + } + + + // ── Lectures déléguées ───────────────────────────────────────── + + /** + * findAll() doit déléguer au repository et retourner son résultat. + */ + public function testFindAllDelegatesToRepository(): void + { + $cats = [new Taxon(1, 'PHP', 'php'), new Taxon(2, 'CSS', 'css')]; + $this->repository->method('findAll')->willReturn($cats); + + $this->assertSame($cats, $this->service->findAll()); + } + + /** + * findById() doit retourner null si le terme n'existe pas. + */ + public function testFindByIdReturnsNullWhenMissing(): void + { + $this->repository->method('findById')->willReturn(null); + + $this->assertNull($this->service->findById(99)); + } + + /** + * findBySlug() doit retourner le terme correspondant. + */ + public function testFindBySlugReturnsTaxonWhenFound(): void + { + $taxon = new Taxon(3, 'PHP', 'php'); + $this->repository->expects($this->once())->method('findBySlug')->with('php')->willReturn($taxon); + + $this->assertSame($taxon, $this->service->findBySlug('php')); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..27fb0a7 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,12 @@ +