From fc67264808a216dea4a729a31eedf1e67636b2e7 Mon Sep 17 00:00:00 2001 From: Mohammed Shifreen Date: Fri, 31 Jul 2026 11:34:57 +0530 Subject: [PATCH] Copied all the files from domain reposiotry. Fixed some of the chanegs which were not updated. Made PHPStan and sniff passed includingg the unit tests. --- .github/CODE_OF_CONDUCT.md | 133 + .github/CONTRIBUTING.md | 100 + .github/workflows/pull-request-checks.yml | 14 + .gitignore | 19 + .idea/.gitignore | 5 + .php-cs-fixer.dist.php | 55 + LICENSE.md | 21 + README.md | 44 +- composer.json | 58 + composer.lock | 9620 +++++++++++++++++ phpstan.dist.neon | 32 + phpunit.dist.xml | 37 + rector.php | 23 + .../Session/FindSessionByIdInterface.php | 31 + .../Session/FindSessionsByUserInterface.php | 42 + .../Application/Query/Session/Session.php | 33 + .../Query/Session/SessionCollection.php | 32 + .../Domain/Entity/Challenge.php | 207 + src/Authentication/Domain/Entity/Session.php | 205 + .../Domain/Event/ChallengeFailed.php | 50 + .../Domain/Event/ChallengeVerified.php | 52 + .../Domain/Event/SessionWasOpened.php | 49 + .../Domain/Event/SessionWasRefreshed.php | 49 + .../Domain/Event/SessionWasRevoked.php | 49 + .../AccessTokenGenerationFailedException.php | 55 + .../AuthenticationFailedException.php | 139 + .../CannotRefreshSessionException.php | 51 + ...ChallengeCodeGenerationFailedException.php | 55 + .../Exception/ChallengeNotFoundException.php | 67 + ...hallengeTokenGenerationFailedException.php | 55 + .../ChallengeVerificationFailedException.php | 81 + .../Exception/InvalidAccessTokenException.php | 52 + .../InvalidChallengeCodeException.php | 52 + .../Exception/InvalidChallengeIdException.php | 52 + .../InvalidChallengeSecretException.php | 51 + .../InvalidChallengeTokenException.php | 52 + .../InvalidChallengeTypeException.php | 52 + .../InvalidRefreshTokenException.php | 52 + .../Exception/InvalidSessionIdException.php | 52 + .../InvalidUserCredentialException.php | 51 + .../Exception/InvalidUserIdException.php | 52 + .../RefreshTokenGenerationFailedException.php | 55 + .../Exception/SessionNotFoundException.php | 82 + .../Domain/Guard/UserMustBeActive.php | 35 + .../ChallengeRepositoryInterface.php | 53 + .../Repository/SessionRepositoryInterface.php | 60 + .../Domain/Service/ChallengeBase.php | 182 + .../ChallengeSecretDeliveryInterface.php | 31 + .../Domain/Service/IssueChallenge.php | 98 + .../Domain/Service/OpenSession.php | 65 + .../Domain/Service/PasswordBase.php | 147 + .../Domain/Service/RefreshSession.php | 56 + .../Domain/Service/RevokeSession.php | 50 + .../Domain/Service/UserStatusTranslator.php | 39 + .../Domain/Service/VerifyChallenge.php | 71 + .../Domain/Strategy/ChallengeCodeStrategy.php | 66 + .../Strategy/ChallengeTokenStrategy.php | 66 + .../Domain/ValueObject/AccessToken.php | 48 + .../Domain/ValueObject/AuthenticatedUser.php | 74 + .../Domain/ValueObject/ChallengeCode.php | 133 + .../Domain/ValueObject/ChallengeId.php | 35 + .../ValueObject/ChallengeSecretInterface.php | 25 + .../Domain/ValueObject/ChallengeStatus.php | 39 + .../Domain/ValueObject/ChallengeToken.php | 60 + .../Domain/ValueObject/ChallengeType.php | 41 + .../Domain/ValueObject/RefreshToken.php | 48 + .../Domain/ValueObject/SessionId.php | 35 + .../Domain/ValueObject/UserId.php | 33 + .../Domain/ValueObject/UserStatus.php | 31 + .../Exception/StrategyNotFoundException.php | 52 + .../Service/StrategyRegister.php | 69 + .../Query/Role/FindAllRolesInterface.php | 27 + .../Query/Role/FindRoleByIdInterface.php | 29 + .../Query/Role/FindRoleBySlugInterface.php | 29 + .../Application/Query/Role/Role.php | 39 + .../Application/Query/Role/RoleCollection.php | 32 + .../FindAllRoleAssignmentsInterface.php | 30 + .../FindRoleAssignmentByIdInterface.php | 27 + .../FindRoleAssignmentBySubjectInterface.php | 32 + .../FindRoleAssignmentsByRoleInterface.php | 31 + .../FindRoleAssignmentsByTenantInterface.php | 31 + .../Query/RoleAssignment/RoleAssignment.php | 33 + .../RoleAssignmentCollection.php | 32 + .../Collection/PermissionCollection.php | 33 + .../Collection/RoleAssignmentCollection.php | 33 + src/Authorization/Domain/Entity/Role.php | 180 + .../Domain/Entity/RoleAssignment.php | 145 + .../Domain/Event/PermissionGranted.php | 56 + .../Domain/Event/PermissionRevoked.php | 56 + .../Domain/Event/RoleAssigned.php | 51 + .../Domain/Event/RoleCreated.php | 62 + .../Domain/Event/RoleRevoked.php | 51 + .../DuplicateRoleAssignmentException.php | 60 + .../InvalidRoleAssignmentIdException.php | 54 + .../Exception/InvalidRoleIdException.php | 54 + .../Exception/InvalidRoleNameException.php | 54 + .../Exception/InvalidRoleSlugException.php | 71 + .../Exception/InvalidSubjectIdException.php | 54 + .../Exception/InvalidTenantIdException.php | 54 + .../PermissionValidationException.php | 51 + .../Exception/RoleAlreadyExistsException.php | 56 + .../RoleAssignmentNotFoundException.php | 52 + .../Exception/RoleNotFoundException.php | 67 + .../SystemRoleModificationException.php | 54 + .../Domain/Guard/DuplicateRoleAssignment.php | 51 + .../Domain/Guard/RoleAlreadyExists.php | 45 + .../RoleAssignmentRepositoryInterface.php | 79 + .../Repository/RoleRepositoryInterface.php | 48 + .../Domain/Service/AssignRole.php | 90 + .../Domain/Service/Authorization.php | 146 + .../Domain/Service/CheckAccess.php | 71 + .../Domain/Service/CreateRole.php | 100 + .../Domain/Service/RevokeRole.php | 71 + .../Domain/ValueObject/Permission.php | 162 + .../Domain/ValueObject/RoleAssignmentId.php | 33 + .../Domain/ValueObject/RoleId.php | 33 + .../Domain/ValueObject/RoleName.php | 86 + .../Domain/ValueObject/RoleSlug.php | 120 + .../Domain/ValueObject/SubjectId.php | 33 + .../Domain/ValueObject/TenantId.php | 33 + src/Authorization/Infrastructure/.gitkeep | 0 .../Query/User/FindAllUserInterface.php | 27 + .../Query/User/FindUserByEmailInterface.php | 31 + .../Query/User/FindUserByIdInterface.php | 31 + src/Identity/Application/Query/User/User.php | 39 + .../Application/Query/User/UserCollection.php | 32 + src/Identity/Domain/Entity/User.php | 251 + .../Event/UserDisplayNameWasChanged.php | 49 + .../Domain/Event/UserEmailWasChanged.php | 49 + .../Domain/Event/UserPasswordWasChanged.php | 47 + .../Domain/Event/UserWasActivated.php | 48 + .../Domain/Event/UserWasDeactivated.php | 48 + .../Domain/Event/UserWasRegistered.php | 49 + .../CurrentPasswordNotMatchedException.php | 51 + .../Exception/EmailMustBeUniqueException.php | 52 + .../Exception/InvalidDisplayNameException.php | 55 + .../InvalidPasswordHashException.php | 54 + .../Exception/InvalidUserEmailException.php | 54 + .../Exception/InvalidUserIdException.php | 54 + .../UserAlreadyActivatedException.php | 51 + .../UserAlreadyDeactivatedException.php | 51 + .../UserCannotBeDeactivatedException.php | 51 + .../Exception/UserNotFoundException.php | 67 + .../Exception/WeakPasswordException.php | 107 + .../Domain/Guard/EmailMustBeUnique.php | 43 + .../Domain/Guard/PasswordMustBeStrong.php | 72 + src/Identity/Domain/Policy/PasswordPolicy.php | 113 + .../Repository/UserRepositoryInterface.php | 50 + src/Identity/Domain/Service/RegisterUser.php | 69 + .../Domain/Service/UpdateUserEmail.php | 55 + .../Domain/Service/UpdateUserPassword.php | 66 + .../Domain/ValueObject/DisplayName.php | 90 + .../Domain/ValueObject/PasswordHash.php | 80 + src/Identity/Domain/ValueObject/UserEmail.php | 38 + src/Identity/Domain/ValueObject/UserId.php | 33 + .../Domain/ValueObject/UserStatus.php | 48 + .../FailedToHashPasswordException.php | 54 + .../Infrastructure/Service/PasswordHasher.php | 44 + .../AuthenticationSpecification.php | 110 + .../AuthorizationSpecification.php | 110 + test/Architecture/IdentitySpecification.php | 110 + .../Domain/Entity/ChallengeTest.php | 275 + .../Domain/Entity/SessionTest.php | 238 + .../Domain/Guard/UserMustBeActiveTest.php | 54 + .../Domain/Service/ChallengeBaseTest.php | 489 + .../Domain/Service/IssueChallengeTest.php | 128 + .../Domain/Service/OpenSessionTest.php | 112 + .../Domain/Service/PasswordBaseTest.php | 358 + .../Domain/Service/RefreshSessionTest.php | 128 + .../Domain/Service/RevokeSessionTest.php | 141 + .../Service/UserStatusTranslatorTest.php | 60 + .../Domain/Service/VerifyChallengeTest.php | 212 + .../Strategy/ChallengeCodeStrategyTest.php | 176 + .../Strategy/ChallengeTokenStrategyTest.php | 173 + .../Domain/ValueObject/AccessTokenTest.php | 125 + .../Domain/ValueObject/ChallengeCodeTest.php | 143 + .../Domain/ValueObject/ChallengeIdTest.php | 53 + .../ValueObject/ChallengeStatusTest.php | 83 + .../Domain/ValueObject/ChallengeTokenTest.php | 125 + .../Domain/ValueObject/ChallengeTypeTest.php | 86 + .../Domain/ValueObject/RefreshTokenTest.php | 125 + .../Domain/ValueObject/SessionIdTest.php | 54 + .../Service/StrategyRegisterTest.php | 123 + .../Domain/Entity/RoleAssignmentTest.php | 215 + .../Authorization/Domain/Entity/RoleTest.php | 218 + .../Guard/DuplicateRoleAssignmentTest.php | 74 + .../Domain/Guard/RoleAlreadyExistsTest.php | 72 + .../Domain/Service/AssignRoleTest.php | 291 + .../Domain/Service/AuthorizationTest.php | 1232 +++ .../Domain/Service/CheckAccessTest.php | 541 + .../Domain/Service/CreateRoleTest.php | 269 + .../Domain/Service/RevokeRoleTest.php | 192 + .../Domain/ValueObject/PermissionTest.php | 224 + .../ValueObject/RoleAssignmentIdTest.php | 102 + .../Domain/ValueObject/RoleIdTest.php | 99 + .../Domain/ValueObject/RoleNameTest.php | 89 + .../Domain/ValueObject/RoleSlugTest.php | 226 + .../Domain/ValueObject/SubjectIdTest.php | 101 + .../Domain/ValueObject/TenantIdTest.php | 104 + test/Unit/Identity/Domain/Entity/UserTest.php | 221 + .../Domain/Guard/EmailMustBeUniqueTest.php | 72 + .../Domain/Guard/PasswordMustBeStrongTest.php | 114 + .../Domain/Service/PasswordHasherTest.php | 109 + .../Domain/Service/RegisterUserTest.php | 161 + .../Domain/Service/UpdateUserEmailTest.php | 195 + .../Domain/Service/UpdateUserPasswordTest.php | 211 + .../Domain/ValueObject/DisplayNameTest.php | 141 + .../Domain/ValueObject/PasswordHashTest.php | 86 + .../Domain/ValueObject/UserEmailTest.php | 50 + .../Domain/ValueObject/UserIdTest.php | 50 + .../Domain/ValueObject/UserStatusTest.php | 102 + 211 files changed, 28334 insertions(+), 2 deletions(-) create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/workflows/pull-request-checks.yml create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 LICENSE.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 phpstan.dist.neon create mode 100644 phpunit.dist.xml create mode 100644 rector.php create mode 100644 src/Authentication/Application/Query/Session/FindSessionByIdInterface.php create mode 100644 src/Authentication/Application/Query/Session/FindSessionsByUserInterface.php create mode 100644 src/Authentication/Application/Query/Session/Session.php create mode 100644 src/Authentication/Application/Query/Session/SessionCollection.php create mode 100644 src/Authentication/Domain/Entity/Challenge.php create mode 100644 src/Authentication/Domain/Entity/Session.php create mode 100644 src/Authentication/Domain/Event/ChallengeFailed.php create mode 100644 src/Authentication/Domain/Event/ChallengeVerified.php create mode 100644 src/Authentication/Domain/Event/SessionWasOpened.php create mode 100644 src/Authentication/Domain/Event/SessionWasRefreshed.php create mode 100644 src/Authentication/Domain/Event/SessionWasRevoked.php create mode 100644 src/Authentication/Domain/Exception/AccessTokenGenerationFailedException.php create mode 100644 src/Authentication/Domain/Exception/AuthenticationFailedException.php create mode 100644 src/Authentication/Domain/Exception/CannotRefreshSessionException.php create mode 100644 src/Authentication/Domain/Exception/ChallengeCodeGenerationFailedException.php create mode 100644 src/Authentication/Domain/Exception/ChallengeNotFoundException.php create mode 100644 src/Authentication/Domain/Exception/ChallengeTokenGenerationFailedException.php create mode 100644 src/Authentication/Domain/Exception/ChallengeVerificationFailedException.php create mode 100644 src/Authentication/Domain/Exception/InvalidAccessTokenException.php create mode 100644 src/Authentication/Domain/Exception/InvalidChallengeCodeException.php create mode 100644 src/Authentication/Domain/Exception/InvalidChallengeIdException.php create mode 100644 src/Authentication/Domain/Exception/InvalidChallengeSecretException.php create mode 100644 src/Authentication/Domain/Exception/InvalidChallengeTokenException.php create mode 100644 src/Authentication/Domain/Exception/InvalidChallengeTypeException.php create mode 100644 src/Authentication/Domain/Exception/InvalidRefreshTokenException.php create mode 100644 src/Authentication/Domain/Exception/InvalidSessionIdException.php create mode 100644 src/Authentication/Domain/Exception/InvalidUserCredentialException.php create mode 100644 src/Authentication/Domain/Exception/InvalidUserIdException.php create mode 100644 src/Authentication/Domain/Exception/RefreshTokenGenerationFailedException.php create mode 100644 src/Authentication/Domain/Exception/SessionNotFoundException.php create mode 100644 src/Authentication/Domain/Guard/UserMustBeActive.php create mode 100644 src/Authentication/Domain/Repository/ChallengeRepositoryInterface.php create mode 100644 src/Authentication/Domain/Repository/SessionRepositoryInterface.php create mode 100644 src/Authentication/Domain/Service/ChallengeBase.php create mode 100644 src/Authentication/Domain/Service/ChallengeSecretDeliveryInterface.php create mode 100644 src/Authentication/Domain/Service/IssueChallenge.php create mode 100644 src/Authentication/Domain/Service/OpenSession.php create mode 100644 src/Authentication/Domain/Service/PasswordBase.php create mode 100644 src/Authentication/Domain/Service/RefreshSession.php create mode 100644 src/Authentication/Domain/Service/RevokeSession.php create mode 100644 src/Authentication/Domain/Service/UserStatusTranslator.php create mode 100644 src/Authentication/Domain/Service/VerifyChallenge.php create mode 100644 src/Authentication/Domain/Strategy/ChallengeCodeStrategy.php create mode 100644 src/Authentication/Domain/Strategy/ChallengeTokenStrategy.php create mode 100644 src/Authentication/Domain/ValueObject/AccessToken.php create mode 100644 src/Authentication/Domain/ValueObject/AuthenticatedUser.php create mode 100644 src/Authentication/Domain/ValueObject/ChallengeCode.php create mode 100644 src/Authentication/Domain/ValueObject/ChallengeId.php create mode 100644 src/Authentication/Domain/ValueObject/ChallengeSecretInterface.php create mode 100644 src/Authentication/Domain/ValueObject/ChallengeStatus.php create mode 100644 src/Authentication/Domain/ValueObject/ChallengeToken.php create mode 100644 src/Authentication/Domain/ValueObject/ChallengeType.php create mode 100644 src/Authentication/Domain/ValueObject/RefreshToken.php create mode 100644 src/Authentication/Domain/ValueObject/SessionId.php create mode 100644 src/Authentication/Domain/ValueObject/UserId.php create mode 100644 src/Authentication/Domain/ValueObject/UserStatus.php create mode 100644 src/Authentication/Infrastructure/Exception/StrategyNotFoundException.php create mode 100644 src/Authentication/Infrastructure/Service/StrategyRegister.php create mode 100644 src/Authorization/Application/Query/Role/FindAllRolesInterface.php create mode 100644 src/Authorization/Application/Query/Role/FindRoleByIdInterface.php create mode 100644 src/Authorization/Application/Query/Role/FindRoleBySlugInterface.php create mode 100644 src/Authorization/Application/Query/Role/Role.php create mode 100644 src/Authorization/Application/Query/Role/RoleCollection.php create mode 100644 src/Authorization/Application/Query/RoleAssignment/FindAllRoleAssignmentsInterface.php create mode 100644 src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentByIdInterface.php create mode 100644 src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentBySubjectInterface.php create mode 100644 src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentsByRoleInterface.php create mode 100644 src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentsByTenantInterface.php create mode 100644 src/Authorization/Application/Query/RoleAssignment/RoleAssignment.php create mode 100644 src/Authorization/Application/Query/RoleAssignment/RoleAssignmentCollection.php create mode 100644 src/Authorization/Domain/Collection/PermissionCollection.php create mode 100644 src/Authorization/Domain/Collection/RoleAssignmentCollection.php create mode 100644 src/Authorization/Domain/Entity/Role.php create mode 100644 src/Authorization/Domain/Entity/RoleAssignment.php create mode 100644 src/Authorization/Domain/Event/PermissionGranted.php create mode 100644 src/Authorization/Domain/Event/PermissionRevoked.php create mode 100644 src/Authorization/Domain/Event/RoleAssigned.php create mode 100644 src/Authorization/Domain/Event/RoleCreated.php create mode 100644 src/Authorization/Domain/Event/RoleRevoked.php create mode 100644 src/Authorization/Domain/Exception/DuplicateRoleAssignmentException.php create mode 100644 src/Authorization/Domain/Exception/InvalidRoleAssignmentIdException.php create mode 100644 src/Authorization/Domain/Exception/InvalidRoleIdException.php create mode 100644 src/Authorization/Domain/Exception/InvalidRoleNameException.php create mode 100644 src/Authorization/Domain/Exception/InvalidRoleSlugException.php create mode 100644 src/Authorization/Domain/Exception/InvalidSubjectIdException.php create mode 100644 src/Authorization/Domain/Exception/InvalidTenantIdException.php create mode 100644 src/Authorization/Domain/Exception/PermissionValidationException.php create mode 100644 src/Authorization/Domain/Exception/RoleAlreadyExistsException.php create mode 100644 src/Authorization/Domain/Exception/RoleAssignmentNotFoundException.php create mode 100644 src/Authorization/Domain/Exception/RoleNotFoundException.php create mode 100644 src/Authorization/Domain/Exception/SystemRoleModificationException.php create mode 100644 src/Authorization/Domain/Guard/DuplicateRoleAssignment.php create mode 100644 src/Authorization/Domain/Guard/RoleAlreadyExists.php create mode 100644 src/Authorization/Domain/Repository/RoleAssignmentRepositoryInterface.php create mode 100644 src/Authorization/Domain/Repository/RoleRepositoryInterface.php create mode 100644 src/Authorization/Domain/Service/AssignRole.php create mode 100644 src/Authorization/Domain/Service/Authorization.php create mode 100644 src/Authorization/Domain/Service/CheckAccess.php create mode 100644 src/Authorization/Domain/Service/CreateRole.php create mode 100644 src/Authorization/Domain/Service/RevokeRole.php create mode 100644 src/Authorization/Domain/ValueObject/Permission.php create mode 100644 src/Authorization/Domain/ValueObject/RoleAssignmentId.php create mode 100644 src/Authorization/Domain/ValueObject/RoleId.php create mode 100644 src/Authorization/Domain/ValueObject/RoleName.php create mode 100644 src/Authorization/Domain/ValueObject/RoleSlug.php create mode 100644 src/Authorization/Domain/ValueObject/SubjectId.php create mode 100644 src/Authorization/Domain/ValueObject/TenantId.php create mode 100644 src/Authorization/Infrastructure/.gitkeep create mode 100644 src/Identity/Application/Query/User/FindAllUserInterface.php create mode 100644 src/Identity/Application/Query/User/FindUserByEmailInterface.php create mode 100644 src/Identity/Application/Query/User/FindUserByIdInterface.php create mode 100644 src/Identity/Application/Query/User/User.php create mode 100644 src/Identity/Application/Query/User/UserCollection.php create mode 100644 src/Identity/Domain/Entity/User.php create mode 100644 src/Identity/Domain/Event/UserDisplayNameWasChanged.php create mode 100644 src/Identity/Domain/Event/UserEmailWasChanged.php create mode 100644 src/Identity/Domain/Event/UserPasswordWasChanged.php create mode 100644 src/Identity/Domain/Event/UserWasActivated.php create mode 100644 src/Identity/Domain/Event/UserWasDeactivated.php create mode 100644 src/Identity/Domain/Event/UserWasRegistered.php create mode 100644 src/Identity/Domain/Exception/CurrentPasswordNotMatchedException.php create mode 100644 src/Identity/Domain/Exception/EmailMustBeUniqueException.php create mode 100644 src/Identity/Domain/Exception/InvalidDisplayNameException.php create mode 100644 src/Identity/Domain/Exception/InvalidPasswordHashException.php create mode 100644 src/Identity/Domain/Exception/InvalidUserEmailException.php create mode 100644 src/Identity/Domain/Exception/InvalidUserIdException.php create mode 100644 src/Identity/Domain/Exception/UserAlreadyActivatedException.php create mode 100644 src/Identity/Domain/Exception/UserAlreadyDeactivatedException.php create mode 100644 src/Identity/Domain/Exception/UserCannotBeDeactivatedException.php create mode 100644 src/Identity/Domain/Exception/UserNotFoundException.php create mode 100644 src/Identity/Domain/Exception/WeakPasswordException.php create mode 100644 src/Identity/Domain/Guard/EmailMustBeUnique.php create mode 100644 src/Identity/Domain/Guard/PasswordMustBeStrong.php create mode 100644 src/Identity/Domain/Policy/PasswordPolicy.php create mode 100644 src/Identity/Domain/Repository/UserRepositoryInterface.php create mode 100644 src/Identity/Domain/Service/RegisterUser.php create mode 100644 src/Identity/Domain/Service/UpdateUserEmail.php create mode 100644 src/Identity/Domain/Service/UpdateUserPassword.php create mode 100644 src/Identity/Domain/ValueObject/DisplayName.php create mode 100644 src/Identity/Domain/ValueObject/PasswordHash.php create mode 100644 src/Identity/Domain/ValueObject/UserEmail.php create mode 100644 src/Identity/Domain/ValueObject/UserId.php create mode 100644 src/Identity/Domain/ValueObject/UserStatus.php create mode 100644 src/Identity/Infrastructure/Exception/FailedToHashPasswordException.php create mode 100644 src/Identity/Infrastructure/Service/PasswordHasher.php create mode 100644 test/Architecture/AuthenticationSpecification.php create mode 100644 test/Architecture/AuthorizationSpecification.php create mode 100644 test/Architecture/IdentitySpecification.php create mode 100644 test/Unit/Authentication/Domain/Entity/ChallengeTest.php create mode 100644 test/Unit/Authentication/Domain/Entity/SessionTest.php create mode 100644 test/Unit/Authentication/Domain/Guard/UserMustBeActiveTest.php create mode 100644 test/Unit/Authentication/Domain/Service/ChallengeBaseTest.php create mode 100644 test/Unit/Authentication/Domain/Service/IssueChallengeTest.php create mode 100644 test/Unit/Authentication/Domain/Service/OpenSessionTest.php create mode 100644 test/Unit/Authentication/Domain/Service/PasswordBaseTest.php create mode 100644 test/Unit/Authentication/Domain/Service/RefreshSessionTest.php create mode 100644 test/Unit/Authentication/Domain/Service/RevokeSessionTest.php create mode 100644 test/Unit/Authentication/Domain/Service/UserStatusTranslatorTest.php create mode 100644 test/Unit/Authentication/Domain/Service/VerifyChallengeTest.php create mode 100644 test/Unit/Authentication/Domain/Strategy/ChallengeCodeStrategyTest.php create mode 100644 test/Unit/Authentication/Domain/Strategy/ChallengeTokenStrategyTest.php create mode 100644 test/Unit/Authentication/Domain/ValueObject/AccessTokenTest.php create mode 100644 test/Unit/Authentication/Domain/ValueObject/ChallengeCodeTest.php create mode 100644 test/Unit/Authentication/Domain/ValueObject/ChallengeIdTest.php create mode 100644 test/Unit/Authentication/Domain/ValueObject/ChallengeStatusTest.php create mode 100644 test/Unit/Authentication/Domain/ValueObject/ChallengeTokenTest.php create mode 100644 test/Unit/Authentication/Domain/ValueObject/ChallengeTypeTest.php create mode 100644 test/Unit/Authentication/Domain/ValueObject/RefreshTokenTest.php create mode 100644 test/Unit/Authentication/Domain/ValueObject/SessionIdTest.php create mode 100644 test/Unit/Authentication/Infrastructure/Service/StrategyRegisterTest.php create mode 100644 test/Unit/Authorization/Domain/Entity/RoleAssignmentTest.php create mode 100644 test/Unit/Authorization/Domain/Entity/RoleTest.php create mode 100644 test/Unit/Authorization/Domain/Guard/DuplicateRoleAssignmentTest.php create mode 100644 test/Unit/Authorization/Domain/Guard/RoleAlreadyExistsTest.php create mode 100644 test/Unit/Authorization/Domain/Service/AssignRoleTest.php create mode 100644 test/Unit/Authorization/Domain/Service/AuthorizationTest.php create mode 100644 test/Unit/Authorization/Domain/Service/CheckAccessTest.php create mode 100644 test/Unit/Authorization/Domain/Service/CreateRoleTest.php create mode 100644 test/Unit/Authorization/Domain/Service/RevokeRoleTest.php create mode 100644 test/Unit/Authorization/Domain/ValueObject/PermissionTest.php create mode 100644 test/Unit/Authorization/Domain/ValueObject/RoleAssignmentIdTest.php create mode 100644 test/Unit/Authorization/Domain/ValueObject/RoleIdTest.php create mode 100644 test/Unit/Authorization/Domain/ValueObject/RoleNameTest.php create mode 100644 test/Unit/Authorization/Domain/ValueObject/RoleSlugTest.php create mode 100644 test/Unit/Authorization/Domain/ValueObject/SubjectIdTest.php create mode 100644 test/Unit/Authorization/Domain/ValueObject/TenantIdTest.php create mode 100644 test/Unit/Identity/Domain/Entity/UserTest.php create mode 100644 test/Unit/Identity/Domain/Guard/EmailMustBeUniqueTest.php create mode 100644 test/Unit/Identity/Domain/Guard/PasswordMustBeStrongTest.php create mode 100644 test/Unit/Identity/Domain/Service/PasswordHasherTest.php create mode 100644 test/Unit/Identity/Domain/Service/RegisterUserTest.php create mode 100644 test/Unit/Identity/Domain/Service/UpdateUserEmailTest.php create mode 100644 test/Unit/Identity/Domain/Service/UpdateUserPasswordTest.php create mode 100644 test/Unit/Identity/Domain/ValueObject/DisplayNameTest.php create mode 100644 test/Unit/Identity/Domain/ValueObject/PasswordHashTest.php create mode 100644 test/Unit/Identity/Domain/ValueObject/UserEmailTest.php create mode 100644 test/Unit/Identity/Domain/ValueObject/UserIdTest.php create mode 100644 test/Unit/Identity/Domain/ValueObject/UserStatusTest.php diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e2edd59 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behaviour that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behaviour that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[codeofconduct@webifycms.com](mailto:codeofconduct@webifycms.com). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behaviour. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..16381b6 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,100 @@ +# 👋 Hey there, fellow contributor + +> Thank you for being here and considering a contribution to WebifyCMS! +> Together we can make great software. + +--- + +## ⚖️ Developer Certificate of Origin (DCO) + +To keep WebifyCMS open, secure, and legally clean, we use a **Developer Certificate of Origin (DCO)** +instead of a complex contributor agreement. + +By contributing to this project, you certify that you have the right to submit the code +under the project's open-source license. + +### How to Sign Your Work +All you need to do is sign off on your git commits. +You can do this automatically by adding the `-s` flag when you commit: + +```bash +git commit -s -m "Your meaningful commit message" +``` + +This automatically appends a line to your commit message like this: +`Signed-off-by: Your Name ` + +*Note: Pull Requests with unsigned commits cannot be merged. If you forget to sign a commit, +you can fix it using `git commit --amend -s` before pushing.* + +--- + +## 🛠️ Let's Set Up for Development + +### Prerequisites + +* An environment capable of running Docker or Docker Desktop. + +### 1. Installation + +Clone the main repository and use the [WebifyCMS Installer](https://github.com/webifycms/installer) +package to initialize the local stack setup. + +### 2. Environment Configuration + +Navigate to your application `app` directory and copy the sample environment file: + +```bash +cd app && cp .env.sample .env +``` + +Open the newly created `.env` file and adjust the values for your local setup. +Ensure the environment variables are configured for local debugging: + +* `APP_ENVIRONMENT='development'` +* `APP_DEBUG=true` + +Add your configured `APP_BASE_URL` value to your local system's host file if you are using a custom local domain. + +At this stage, your local folder structure should match this layout: + +``` +. +└── webifycms-stack/ + ├── app/ + │ ├── ... + │ ├── .env + │ ├── .env.sample + │ ├── composer.json + │ ├── composer.lock + │ ├── src/ + │ ├── extensions/ + │ ├── themes/ + │ ├── vendor/ + │ └── ... + ├── extensions/ + │ └── ext-base/ + └── themes/ + └── theme-canvas/ +``` + +### 3. Install Dependencies + +Switch to your working branches inside the `app` and `extensions` directories, then install the PHP packages: + +```bash +php composer install +``` + +### 4. Run the Application + +You can spin up the application quickly using PHP's built-in development web server: + +```bash +php -S localhost:8000 -t public/ +``` + +The application will now be available in your browser at `http://localhost:8000`. + +> 💡 **Note:** The built-in PHP server is single-threaded and intended strictly for local development. +> Use a dedicated web server like Nginx, Apache, or Caddy for production deployments. \ No newline at end of file diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml new file mode 100644 index 0000000..03bcaed --- /dev/null +++ b/.github/workflows/pull-request-checks.yml @@ -0,0 +1,14 @@ +name: Pull Request Checks + +on: + push: + branches: + - main + pull_request: + +jobs: + pr-check: + uses: webifycms/github/.github/workflows/pull-request-checks.yml@main + with: + php-version: '8.4' + composer-version: 'v2' \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..347b328 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# phpstorm project files +/.idea + +# vscode project files +/.vscode + +# windows thumbnail cache +Thumbs.db + +# composer +/vendor/ +composer.phar + +# Mac DS_Store Files +.DS_Store + +# ignores the cache files +.php-cs-fixer.cache +/.phpunit.cache \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..672d8b5 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,55 @@ + + */ +declare(strict_types=1); + +// should require the composer autoloader on first +require __DIR__ . '/vendor/autoload.php'; + +use PhpCsFixer\Finder; +use Webify\Tools\Fixer\Fixer; + +$finder = Finder::create() + ->in(__DIR__) + ->exclude( + [ + 'vendor', + ] + ) + ->ignoreDotFiles(false) + ->name('*.php') +; +$header = <<<'HEADER' + The file is part of the "webifycms/ext-user", WebifyCMS extension package. + + @see https://webifycms.com/extension/user + + @copyright Copyright (c) 2023 - Present WebifyCMS + @license https://webifycms.com/license + @author Mohammed Shifreen + HEADER; +$rules = [ + 'header_comment' => [ + 'header' => $header, + 'location' => 'after_open', + 'comment_type' => 'PHPDoc', + 'separate' => 'top', + ], + 'php_unit_test_class_requires_covers' => false, + 'class_definition' => [ + 'multi_line_extends_each_single_line' => true, + ], +]; + +return new Fixer($finder, $rules) + ->getConfig() + ->setUsingCache(false) +; diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..9b86eb7 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# WebifyCMS License + +Copyright © 2023 WebifyCMS () + +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 index 8da62ad..c1ab790 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,42 @@ -# ext-user -User extenison of WebifyCMS which represents the User bounded context +# WebifyCMS User Extension + +[![PHP Version](https://img.shields.io/badge/PHP-8.4%2B-blue)](https://www.php.net) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE.md) +[![Version](https://img.shields.io/badge/version-0.1.0--alpha-orange)](https://github.com/webifycms/ext-base) +[![Type](https://img.shields.io/badge/type-extension-green)]() + +> 👋 Hey there! We're still in the early stage of development, so you may not find all the features just yet. +> It's not quite ready for use in productions, but we'd love for you to give it a try, +> Don't mind to [log bugs](https://github.com/webifycms/app/issues) and help us improve it! Thanks for your support. + +`ext-user` is an extension of WebifyCMS which is managing the user bounded context. +It's handles all user-related tasks, including user management, profile and account management, login, and registration. + +> This is not a standalone application, it is an extension of WebifyCMS. + +## 🤝 Like to contribute? + +Contributions, issues, and feature requests are welcome! Feel free to check the following pages: + +* [Wiki Page](https://github.com/webifycms/app/wiki) +* [Project](https://github.com/orgs/webifycms/projects/4) +* [Contribution notes](https://github.com/webifycms/app/blob/main/CONTRIBUTING.md). +* [Issues page](https://github.com/webifycms/app/issues). +* [Code of conduct notes](https://github.com/webifycms/app/blob/main/CODE_OF_CONDUCT.md). + +## ⭐️ Show your support + +If this project interests you, please consider giving it a ⭐️! + +## Author + +👤 **Mohammed Shifreen** (Project Lead) + +* Website: +* Github: [@Shifrin](https://github.com/Shifrin) +* LinkedIn: [@https://www.linkedin.com/in/mshifreen/](https://linkedin.com/in/https://www.linkedin.com/in/mshifreen/) + +## License + +WebifyCMS is licensed under MIT license, see the [LICENSE.md](https://webifycms.com/license) +file for details. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c7995dc --- /dev/null +++ b/composer.json @@ -0,0 +1,58 @@ +{ + "name": "webifycms/ext-user", + "description": "The WebifyCMS user extension handles all user-related tasks, including user management, profile and account management, login, and registration.", + "type": "webifycms-extension", + "authors": [ + { + "name": "Mohammed Shifreen", + "email": "mshifreen@gmail.com" + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.4", + "webifycms/ext-base": "^0.1.0-alpha" + }, + "require-dev": { + "webifycms/dev-tools": "^1.2", + "phpunit/phpunit": "^13.1", + "fakerphp/faker": "^1.24", + "phpat/phpat": "^0.12", + "filp/whoops": "^2.18", + "middlewares/whoops": "^2.1", + "roave/security-advisories": "dev-latest" + }, + "autoload": { + "psr-4": { + "Webify\\User\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Webify\\User\\Test\\": "test/" + } + }, + "optimize-autoloader": true, + "repositories": [ + { + "type": "composer", + "url": "https://asset-packagist.org" + } + ], + "scripts": { + "analyse": [ + "phpstan" + ], + "sniff": [ + "php-cs-fixer fix --show-progress=dots --diff --dry-run" + ], + "test": [ + "phpunit test" + ], + "code-check": ["@analyse", "@sniff"], + "code-format": [ + "php-cs-fixer fix --show-progress=dots" + ] + } +} \ No newline at end of file diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..adf127c --- /dev/null +++ b/composer.lock @@ -0,0 +1,9620 @@ +{ + "_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": "663a167694abba321806e7317a164a25", + "packages": [ + { + "name": "brick/math", + "version": "0.18.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.18.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-06-14T18:21:03+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+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": "illuminate/collections", + "version": "v13.23.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/collections.git", + "reference": "79b5d04507a255e35ce34cec621696aca84ee242" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/collections/zipball/79b5d04507a255e35ce34cec621696aca84ee242", + "reference": "79b5d04507a255e35ce34cec621696aca84ee242", + "shasum": "" + }, + "require": { + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "php": "^8.3", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36" + }, + "suggest": { + "illuminate/http": "Required to convert collections to API resources (^13.0).", + "symfony/var-dumper": "Required to use the dump method (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Collections package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-07-26T13:07:32+00:00" + }, + { + "name": "illuminate/conditionable", + "version": "v13.23.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/conditionable.git", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/conditionable/zipball/7f1ef52d9a346f829421b296adfb7644a951b216", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216", + "shasum": "" + }, + "require": { + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Conditionable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-02-25T16:07:55+00:00" + }, + { + "name": "illuminate/container", + "version": "v13.23.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/container.git", + "reference": "1f268e02fd0c8e4c32fc896c229f9023da735a83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/container/zipball/1f268e02fd0c8e4c32fc896c229f9023da735a83", + "reference": "1f268e02fd0c8e4c32fc896c229f9023da735a83", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^13.0", + "illuminate/reflection": "^13.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36" + }, + "provide": { + "psr/container-implementation": "1.1 || 2.0" + }, + "suggest": { + "illuminate/auth": "Required to use the Auth attribute", + "illuminate/cache": "Required to use the Cache attribute", + "illuminate/config": "Required to use the Config attribute", + "illuminate/database": "Required to use the DB attribute", + "illuminate/filesystem": "Required to use the Storage attribute", + "illuminate/log": "Required to use the Log or Context attributes" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Container\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Container package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-07-23T15:58:38+00:00" + }, + { + "name": "illuminate/contracts", + "version": "v13.23.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/contracts.git", + "reference": "98179ca5d07025c2fe4c5c70e0b57b5284fa0071" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/98179ca5d07025c2fe4c5c70e0b57b5284fa0071", + "reference": "98179ca5d07025c2fe4c5c70e0b57b5284fa0071", + "shasum": "" + }, + "require": { + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-07-15T15:50:17+00:00" + }, + { + "name": "illuminate/database", + "version": "v13.23.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/database.git", + "reference": "2a42f0341a99c6958e67328992233409562bff81" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/database/zipball/2a42f0341a99c6958e67328992233409562bff81", + "reference": "2a42f0341a99c6958e67328992233409562bff81", + "shasum": "" + }, + "require": { + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18", + "ext-pdo": "*", + "illuminate/collections": "^13.0", + "illuminate/container": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "illuminate/support": "^13.0", + "laravel/serializable-closure": "^2.0.10", + "php": "^8.3", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36" + }, + "suggest": { + "ext-filter": "Required to use the Postgres database driver.", + "fakerphp/faker": "Required to use the eloquent factory builder (^1.24).", + "illuminate/console": "Required to use the database commands (^13.0).", + "illuminate/events": "Required to use the observers with Eloquent (^13.0).", + "illuminate/filesystem": "Required to use the migrations (^13.0).", + "illuminate/http": "Required to convert Eloquent models to API resources (^13.0).", + "illuminate/pagination": "Required to paginate the result set (^13.0).", + "symfony/finder": "Required to use Eloquent model factories (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Database\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Database package.", + "homepage": "https://laravel.com", + "keywords": [ + "database", + "laravel", + "orm", + "sql" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-07-24T14:36:26+00:00" + }, + { + "name": "illuminate/macroable", + "version": "v13.23.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/macroable.git", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-04-29T09:35:06+00:00" + }, + { + "name": "illuminate/reflection", + "version": "v13.23.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/reflection.git", + "reference": "83788072c0f11f504c0a3dccfe9c15f5156d8b90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/reflection/zipball/83788072c0f11f504c0a3dccfe9c15f5156d8b90", + "reference": "83788072c0f11f504c0a3dccfe9c15f5156d8b90", + "shasum": "" + }, + "require": { + "illuminate/collections": "^13.0", + "illuminate/contracts": "^13.0", + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Reflection package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-07-16T14:41:30+00:00" + }, + { + "name": "illuminate/support", + "version": "v13.23.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/support.git", + "reference": "c44c62550a18912175966ba340345ffd800ac54f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/support/zipball/c44c62550a18912175966ba340345ffd800ac54f", + "reference": "c44c62550a18912175966ba340345ffd800ac54f", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-mbstring": "*", + "illuminate/collections": "^13.0", + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "illuminate/reflection": "^13.0", + "nesbot/carbon": "^3.8.4", + "php": "^8.3", + "symfony/polyfill-php85": "^1.36", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "replace": { + "spatie/once": "*" + }, + "suggest": { + "illuminate/filesystem": "Required to use the Composer class (^13.0).", + "laravel/serializable-closure": "Required to use the once function (^2.0.10).", + "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.7).", + "league/uri": "Required to use the Uri class (^7.5.1).", + "ramsey/uuid": "Required to use Str::uuid() (^4.7).", + "symfony/process": "Required to use the Composer class (^7.4 || ^8.0).", + "symfony/uid": "Required to use Str::ulid() (^7.4 || ^8.0).", + "symfony/var-dumper": "Required to use the dd function (^7.4 || ^8.0).", + "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.6.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-07-26T13:08:33+00:00" + }, + { + "name": "laminas/laminas-httphandlerrunner", + "version": "2.13.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-httphandlerrunner.git", + "reference": "181eaeeb838ad3d80fbbcfb0657a46bc212bbd4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-httphandlerrunner/zipball/181eaeeb838ad3d80fbbcfb0657a46bc212bbd4e", + "reference": "181eaeeb838ad3d80fbbcfb0657a46bc212bbd4e", + "shasum": "" + }, + "require": { + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-message-implementation": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "require-dev": { + "laminas/laminas-coding-standard": "~3.1.0", + "laminas/laminas-diactoros": "^3.6.0", + "phpunit/phpunit": "^10.5.46", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.10.3" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Laminas\\HttpHandlerRunner\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Laminas\\HttpHandlerRunner\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Execute PSR-15 RequestHandlerInterface instances and emit responses they generate.", + "homepage": "https://laminas.dev", + "keywords": [ + "components", + "laminas", + "mezzio", + "psr-15", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-httphandlerrunner/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-httphandlerrunner/issues", + "rss": "https://github.com/laminas/laminas-httphandlerrunner/releases.atom", + "source": "https://github.com/laminas/laminas-httphandlerrunner" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-12T20:58:29+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.15", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", + "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-07-21T16:49:22+00:00" + }, + { + "name": "league/event", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/event.git", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/event/zipball/ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "shasum": "" + }, + "require": { + "php": ">=7.2.0", + "psr/event-dispatcher": "^1.0" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "phpstan/phpstan": "^0.12.45", + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Event\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Event package", + "keywords": [ + "emitter", + "event", + "listener" + ], + "support": { + "issues": "https://github.com/thephpleague/event/issues", + "source": "https://github.com/thephpleague/event/tree/3.0.3" + }, + "time": "2024-09-04T16:06:53+00:00" + }, + { + "name": "league/route", + "version": "6.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/route.git", + "reference": "38775ed32d49ff1ce98d88adaa06a8d66b923436" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/route/zipball/38775ed32d49ff1ce98d88adaa06a8d66b923436", + "reference": "38775ed32d49ff1ce98d88adaa06a8d66b923436", + "shasum": "" + }, + "require": { + "laravel/serializable-closure": "^2.0.0", + "nikic/fast-route": "^1.3", + "php": "^8.1", + "psr/container": "^2.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^2.0", + "psr/http-server-handler": "^1.0.1", + "psr/http-server-middleware": "^1.0.1", + "psr/simple-cache": "^3.0" + }, + "replace": { + "orno/http": "~1.0", + "orno/route": "~1.0" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.5", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^10.2", + "roave/security-advisories": "dev-latest", + "scrutinizer/ocular": "^1.8", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev", + "dev-3.x": "3.x-dev", + "dev-4.x": "4.x-dev", + "dev-5.x": "5.x-dev", + "dev-6.x": "6.x-dev", + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Route\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Phil Bennett", + "email": "mail@philbennett.co.uk", + "role": "Developer" + } + ], + "description": "Fast routing and dispatch component including PSR-15 middleware, built on top of FastRoute.", + "homepage": "https://github.com/thephpleague/route", + "keywords": [ + "dispatcher", + "league", + "psr-15", + "psr-7", + "psr15", + "psr7", + "route", + "router" + ], + "support": { + "issues": "https://github.com/thephpleague/route/issues", + "source": "https://github.com/thephpleague/route/tree/6.2.0" + }, + "funding": [ + { + "url": "https://github.com/philipobenito", + "type": "github" + } + ], + "time": "2024-11-25T08:10:15+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": "nesbot/carbon", + "version": "3.13.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-07-09T18:23:49+00:00" + }, + { + "name": "nikic/fast-route", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/nikic/FastRoute.git", + "reference": "7476684f39bb9124b3be69ed3d84b66723920298" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/FastRoute/zipball/7476684f39bb9124b3be69ed3d84b66723920298", + "reference": "7476684f39bb9124b3be69ed3d84b66723920298", + "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/1.3.1" + }, + "time": "2026-07-09T19:38:47+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "nyholm/psr7-server", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7-server.git", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "nyholm/nsa": "^1.1", + "nyholm/psr7": "^1.3", + "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Nyholm\\Psr7Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "Helper classes to handle PSR-7 server requests", + "homepage": "http://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7-server/issues", + "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2023-11-08T09:30:43+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": "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/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+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/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": "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": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "symfony/cache", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "c14decc1b0755b1e8ab6babeef56e1880348e817" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/c14decc1b0755b1e8ab6babeef56e1880348e817", + "reference": "c14decc1b0755b1e8ab6babeef56e1880348e817", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^8.1" + }, + "conflict": { + "ext-redis": "<6.1", + "ext-relay": "<0.12.1" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "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 extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v8.1.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": "2026-06-17T15:04:37+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "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 caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.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": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "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": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", + "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "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": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^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/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^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.1.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": "2026-06-16T12:55:20+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-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.7.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": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "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.37.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": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-deepclone", + "version": "v1.40.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/dca4ccba5f360070b574414dce4c1e7a559844fa", + "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "provide": { + "ext-deepclone": "*" + }, + "suggest": { + "ext-deepclone": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\DeepClone\\": "" + }, + "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 the deepclone extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "deepclone", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.40.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": "2026-06-12T07:27:17+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "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.41.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": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "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.38.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": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "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.38.2" + }, + "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-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "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.37.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": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "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.38.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": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "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\\Php85\\": "" + }, + "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.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.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": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "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\\Php86\\": "" + }, + "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.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.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": "2026-07-02T13:42:24+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.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": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "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.7-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.7.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": "2026-06-16T09:55:08+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "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.1.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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "342b4218630dc2cf284cedcb2080c80b13404014" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/342b4218630dc2cf284cedcb2080c80b13404014", + "reference": "342b4218630dc2cf284cedcb2080c80b13404014", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "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 to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.1.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": "2026-06-06T11:11:44+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "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 translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.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": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/uid", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/7393f157a55f7e70a4de0334435c55a5a8fe749a", + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v8.1.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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "75b74315b4e4be40e5534cf9c5cc30dd0907ed71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/75b74315b4e4be40e5534cf9c5cc30dd0907ed71", + "reference": "75b74315b4e4be40e5534cf9c5cc30dd0907ed71", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-deepclone": "^1.40" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "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 tools to export, instantiate, hydrate, clone and lazy-load PHP objects", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "deep-clone", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v8.1.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": "2026-06-27T09:05:56+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.4", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", + "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.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-07-06T19:11:50+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "webifycms/ext-base", + "version": "v0.1.0.alpha.2", + "source": { + "type": "git", + "url": "https://github.com/webifycms/ext-base.git", + "reference": "4dbb69b097761f1614e876013a31910bf57bd0d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webifycms/ext-base/zipball/4dbb69b097761f1614e876013a31910bf57bd0d3", + "reference": "4dbb69b097761f1614e876013a31910bf57bd0d3", + "shasum": "" + }, + "require": { + "illuminate/database": "^13.12", + "laminas/laminas-httphandlerrunner": "^2.13", + "league/event": "^3.0", + "league/route": "^6.2", + "monolog/monolog": "^3.10", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "php": ">=8.4", + "php-di/php-di": "^7.1", + "psr/container": "^2.0", + "psr/event-dispatcher": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^3.0", + "psr/simple-cache": "^3.0", + "symfony/cache": "^8.1", + "symfony/console": "^8.1", + "symfony/string": "^8.1", + "symfony/translation": "^8.1", + "symfony/uid": "^8.1", + "vlucas/phpdotenv": "^5.6" + }, + "require-dev": { + "fakerphp/faker": "^1.24", + "filp/whoops": "^2.18", + "middlewares/whoops": "^2.1", + "phpat/phpat": "^0.12", + "phpunit/phpunit": "^13.1", + "roave/security-advisories": "dev-latest", + "webifycms/dev-tools": "^1.2" + }, + "type": "webifycms-extension", + "autoload": { + "psr-4": { + "Webify\\Base\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mohammed Shifreen", + "email": "mshifreen@gmail.com" + } + ], + "description": "The WebifyCMS base extension serves as the foundation for the entire application, holding abstractions and reusable components. It is adjusted as needed when new features are introduced.", + "support": { + "issues": "https://github.com/webifycms/ext-base/issues", + "source": "https://github.com/webifycms/ext-base/tree/v0.1.0.alpha.2" + }, + "time": "2026-06-29T07:22:21+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.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^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.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+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": "ergebnis/agent-detector", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-05-07T08:19:07+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": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+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": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.95.17", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "0ee88422118f3cc59c8c3def222ba7f1493b6d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/0ee88422118f3cc59c8c3def222ba7f1493b6d5b", + "reference": "0ee88422118f3cc59c8c3def222ba7f1493b6d5b", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", + "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 || ^9.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.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "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.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.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.95.17" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-07-24T13:54:39+00:00" + }, + { + "name": "middlewares/utils", + "version": "v4.0.2", + "source": { + "type": "git", + "url": "https://github.com/middlewares/utils.git", + "reference": "749a3055972ebf4197f663eedcb38cae3c81f1c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/middlewares/utils/zipball/749a3055972ebf4197f663eedcb38cae3c81f1c8", + "reference": "749a3055972ebf4197f663eedcb38cae3c81f1c8", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.0 || ^2.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.41", + "guzzlehttp/psr7": "^2.6", + "laminas/laminas-diactoros": "^3.3", + "nyholm/psr7": "^1.8", + "oscarotero/php-cs-fixer-config": "^2.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "slim/psr7": "^1.6", + "squizlabs/php_codesniffer": "^3.8", + "sunrise/http-message": "^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Middlewares\\Utils\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Common utils for PSR-15 middleware packages", + "homepage": "https://github.com/middlewares/utils", + "keywords": [ + "PSR-11", + "http", + "middleware", + "psr-15", + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/middlewares/utils/issues", + "source": "https://github.com/middlewares/utils/tree/v4.0.2" + }, + "time": "2025-01-23T13:33:55+00:00" + }, + { + "name": "middlewares/whoops", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/middlewares/whoops.git", + "reference": "51568de566dce840683c81ae6ea7a269e45c553f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/middlewares/whoops/zipball/51568de566dce840683c81ae6ea7a269e45c553f", + "reference": "51568de566dce840683c81ae6ea7a269e45c553f", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.17", + "middlewares/utils": "^2 || ^3 || ^4", + "php": "^7.2 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "psr/http-server-middleware": "^1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "laminas/laminas-diactoros": "^2 || ^3", + "oscarotero/php-cs-fixer-config": "^2", + "phpstan/phpstan": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9", + "squizlabs/php_codesniffer": "^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Middlewares\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Middleware to use Whoops as error handler", + "homepage": "https://github.com/middlewares/whoops", + "keywords": [ + "error", + "http", + "middleware", + "psr-15", + "psr-7", + "server", + "whoops" + ], + "support": { + "issues": "https://github.com/middlewares/whoops/issues", + "source": "https://github.com/middlewares/whoops/tree/v2.1.1" + }, + "time": "2025-04-27T11:33:14+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.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "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.8.0" + }, + "time": "2026-07-04T14:30:18+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": "phpat/phpat", + "version": "0.12.4", + "source": { + "type": "git", + "url": "https://github.com/carlosas/phpat.git", + "reference": "5319264270c335f548451209bb0f32b55aa59924" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/carlosas/phpat/zipball/5319264270c335f548451209bb0f32b55aa59924", + "reference": "5319264270c335f548451209bb0f32b55aa59924", + "shasum": "" + }, + "require": { + "php": "^8.1", + "phpstan/phpstan": "^2.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.87", + "kubawerlos/php-cs-fixer-custom-fixers": "3.32.*", + "phpunit/phpunit": "^10.5", + "vimeo/psalm": "^6.13" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "files": [ + "helpers.php" + ], + "psr-4": { + "PHPat\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Carlos Alandete Sastre", + "email": "carlos.alandete@gmail.com" + } + ], + "description": "PHP Architecture Tester", + "support": { + "issues": "https://github.com/carlosas/phpat/issues", + "source": "https://github.com/carlosas/phpat/tree/0.12.4" + }, + "time": "2026-03-17T16:47:43+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.6", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a6e9b5a9420f6109c091e87d82683bd1a80b87ed", + "reference": "a6e9b5a9420f6109c091e87d82683bd1a80b87ed", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "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-07-26T21:22:49+00:00" + }, + { + "name": "phpstan/phpstan-deprecation-rules", + "version": "2.0.5", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", + "reference": "67bedd65c24bc72840afc45aed48b1059dd44bec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/67bedd65c24bc72840afc45aed48b1059dd44bec", + "reference": "67bedd65c24bc72840afc45aed48b1059dd44bec", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.39" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", + "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.5" + }, + "time": "2026-07-22T06:50:43+00:00" + }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.0.18", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f5dc20ff8082d02339b60cab68ec3eb0d859fb30", + "reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30", + "shasum": "" + }, + "require": { + "phar-io/version": "^3.2", + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.2.3" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.18" + }, + "time": "2026-07-04T12:16:09+00:00" + }, + { + "name": "phpstan/phpstan-strict-rules", + "version": "2.0.12", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-strict-rules.git", + "reference": "2bc5ae19ae965663b62ac907ee6342c3903ec93b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/2bc5ae19ae965663b62ac907ee6342c3903ec93b", + "reference": "2bc5ae19ae965663b62ac907ee6342c3903ec93b", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.52" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Extra strict and opinionated rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.12" + }, + "time": "2026-07-19T07:24:06+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "14.2.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "82f6e49ff224e2cde923d74425e583a883910783" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783", + "reference": "82f6e49ff224e2cde923d74425e583a883910783", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.8.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.1", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.2" + }, + "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": "14.2.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/14.2.3" + }, + "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-07-06T15:04:02+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.2.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5d2afe181339a56348ef9a80fa7eb806b7eae508" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5d2afe181339a56348ef9a80fa7eb806b7eae508", + "reference": "5d2afe181339a56348ef9a80fa7eb806b7eae508", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "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": "^14.2.3", + "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.3.0", + "sebastian/diff": "^9.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.1.1", + "sebastian/file-filter": "^1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.1", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.2-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.2.6" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-28T14:00:09+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": "rector/rector", + "version": "2.5.8", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "861c77fd2a6d45317a401f4e0b617f947e8b6f96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/861c77fd2a6d45317a401f4e0b617f947e8b6f96", + "reference": "861c77fd2a6d45317a401f4e0b617f947e8b6f96", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.2.6" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "suggest": { + "ext-dom": "To manipulate phpunit.xml via the custom-rule command" + }, + "bin": [ + "bin/rector" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.5.8" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2026-07-27T06:19:16+00:00" + }, + { + "name": "roave/security-advisories", + "version": "dev-latest", + "source": { + "type": "git", + "url": "https://github.com/Roave/SecurityAdvisories.git", + "reference": "e0739cf6f94302acbdfb1807731568a47830bb4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/e0739cf6f94302acbdfb1807731568a47830bb4d", + "reference": "e0739cf6f94302acbdfb1807731568a47830bb4d", + "shasum": "" + }, + "conflict": { + "3f/pygmentize": "<1.2", + "adaptcms/adaptcms": "<=1.3", + "adawolfa/isdoc": "<1.4.3|>=1.5,<1.5.1|>=1.6,<1.6.1", + "admidio/admidio": "<=5.0.11", + "adodb/adodb-php": "<=5.22.9", + "aheinze/cockpit": "<2.2", + "aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2", + "aimeos/ai-admin-jsonadm": "<2020.10.13|>=2021.04.1,<2021.10.6|>=2022.04.1,<2022.10.3|>=2023.04.1,<2023.10.4|==2024.04.1", + "aimeos/ai-client-html": ">=2020.04.1,<2020.10.27|>=2021.04.1,<2021.10.22|>=2022.04.1,<2022.10.13|>=2023.04.1,<2023.10.15|>=2024.04.1,<2024.04.7", + "aimeos/ai-cms-grapesjs": ">=2021.04.1,<2021.10.8|>=2022.04.1,<2022.10.9|>=2023.04.1,<2023.10.15|>=2024.04.1,<2024.10.8|>=2025.04.1,<2025.10.2", + "aimeos/ai-controller-frontend": "<2020.10.15|>=2021.04.1,<2021.10.8|>=2022.04.1,<2022.10.8|>=2023.04.1,<2023.10.9|==2024.04.1", + "aimeos/aimeos-core": ">=2022.04.1,<2022.10.17|>=2023.04.1,<2023.10.17|>=2024.04.1,<2024.04.7", + "aimeos/aimeos-laravel": "==2021.10", + "aimeos/aimeos-typo3": "<19.10.12|>=20,<20.10.5", + "aimeos/pagible": "<0.10.4", + "airesvsg/acf-to-rest-api": "<=3.1", + "akaunting/akaunting": "<2.1.13", + "akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53", + "alextselegidis/easyappointments": "<=1.5.2", + "alexusmai/laravel-file-manager": "<=3.3.1", + "algolia/algoliasearch-magento-2": "<=3.16.1|>=3.17.0.0-beta1,<=3.17.1", + "almirhodzic/nova-toggle-5": "<1.3", + "alt-design/alt-redirect": "<1.6.4", + "altcha-org/altcha": "<1.3.1", + "alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1", + "amazing/media2click": ">=1,<1.3.3", + "ameos/ameos_tarteaucitron": "<1.2.23", + "amphp/artax": "<1.0.6|>=2,<2.0.6", + "amphp/http": "<=1.7.2|>=2,<=2.1", + "amphp/http-client": ">=4,<4.4", + "amphp/http-server": ">=2.0.0.0-RC1-dev,<2.1.10|>=3.0.0.0-beta1,<3.4.4", + "anchorcms/anchor-cms": "<=0.12.7", + "andreapollastri/cipi": "<=3.1.15", + "andrewhaine/silverstripe-form-capture": ">=0.2,<=0.2.3|>=1,<1.0.2|>=2,<2.2.5", + "aoe/restler": "<1.7.1", + "apache-solr-for-typo3/solr": "<2.8.3", + "apereo/phpcas": "<1.6", + "api-platform/core": "<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", + "api-platform/graphql": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5", + "api-platform/hal": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", + "api-platform/json-api": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", + "appwrite/server-ce": "<=1.2.1", + "arc/web": "<3", + "area17/twill": "<1.2.5|>=2,<2.5.3", + "artesaos/seotools": "<0.17.2", + "asymmetricrypt/asymmetricrypt": "<9.9.99", + "athlon1600/php-proxy": "<=5.1", + "athlon1600/php-proxy-app": "<=3", + "athlon1600/youtube-downloader": "<=4", + "aureuserp/aureuserp": "<1.3.0.0-beta1", + "austintoddj/canvas": "<=3.4.2", + "auth0/auth0-php": ">=3.3,<=8.18", + "auth0/login": "<=7.20", + "auth0/symfony": "<=5.8", + "auth0/wordpress": "<=5.5", + "automad/automad": "<=2.0.0.0-beta27", + "automattic/jetpack": "<9.8", + "awesome-support/awesome-support": "<=6.0.7", + "aws/aws-sdk-php": "<=3.371.3", + "ayacoo/redirect-tab": "<2.1.2|>=3,<3.1.7|>=4,<4.0.5", + "azuracast/azuracast": "<=0.23.5", + "b13/seo_basics": "<0.8.2", + "backdrop/backdrop": "<=1.32", + "backpack/crud": "<4.0.63|>=4.1,<4.1.69|>=5,<5.0.13", + "backpack/filemanager": "<2.0.2|>=3,<3.0.9", + "bacula-web/bacula-web": "<9.7.1", + "badaso/core": "<=2.9.11", + "bagisto/bagisto": "<=2.3.15", + "barrelstrength/sprout-base-email": "<1.2.7", + "barrelstrength/sprout-forms": "<3.9", + "barryvdh/laravel-translation-manager": "<0.6.8", + "barzahlen/barzahlen-php": "<2.0.1", + "baserproject/basercms": "<=5.2.2", + "bassjobsen/bootstrap-3-typeahead": ">4.0.2", + "bbpress/bbpress": "<2.6.5", + "bcit-ci/codeigniter": "<3.1.3", + "bcosca/fatfree": "<3.7.2", + "bedita/bedita": "<4", + "bednee/cooluri": "<1.0.30", + "bigfork/silverstripe-form-capture": ">=3,<3.1.1", + "billabear/billabear": "<=2025.01.03", + "billz/raspap-webgui": "<3.3.6", + "binarytorch/larecipe": "<2.8.1", + "bk2k/bootstrap-package": ">=7.1,<7.1.2|>=8,<8.0.8|>=9,<9.0.4|>=9.1,<9.1.3|>=10,<10.0.10|>=11,<11.0.3", + "blueimp/jquery-file-upload": "==6.4.4", + "bmarshall511/wordpress_zero_spam": "<5.2.13", + "bolt/bolt": "<3.7.2", + "bolt/core": "<=4.2", + "born05/craft-twofactorauthentication": "<3.3.4", + "bottelet/flarepoint": "<2.2.1", + "bref/bref": "<2.1.17", + "brightlocal/phpwhois": "<=4.2.5", + "brotkrueml/codehighlight": "<2.7", + "brotkrueml/schema": "<1.13.1|>=2,<2.5.1", + "brotkrueml/typo3-matomo-integration": "<1.3.2", + "buddypress/buddypress": "<7.2.1", + "bugsnag/bugsnag-laravel": ">=2,<2.0.2", + "bvbmedia/multishop": "<2.0.39", + "bytefury/crater": "<6.0.2", + "cachethq/cachet": "<2.5.1", + "cadmium-org/cadmium-cms": "<=0.4.9", + "cakephp/authentication": "<3.3.6|>=4,<4.1.1", + "cakephp/cakephp": "<4.5.11|>=4.6,<4.6.4|>=5,<5.1.7|>=5.2,<5.2.13|>=5.3,<5.3.6", + "cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", + "cardgate/magento2": "<2.0.33", + "cardgate/woocommerce": "<=3.1.15", + "cart2quote/module-quotation": ">=4.1.6,<4.4.6|>=5,<5.4.4", + "cart2quote/module-quotation-encoded": ">=4.1.6,<=4.4.5|>=5,<5.4.4", + "cartalyst/sentry": "<2.1.7", + "catfan/medoo": "<1.7.5", + "causal/oidc": "<4", + "cecil/cecil": "<7.47.1", + "centreon/centreon": "<22.10.15", + "cesargb/laravel-magiclink": ">=2,<2.25.1", + "cesnet/simplesamlphp-module-proxystatistics": "<3.1", + "chriskacerguis/codeigniter-restserver": "<=2.7.1", + "chrome-php/chrome": "<1.14", + "ci4-cms-erp/ci4ms": "<=0.31.8", + "civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3", + "ckeditor/ckeditor": "<4.25", + "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", + "co-stack/fal_sftp": "<0.2.6", + "cockpit-hq/cockpit": "<=2.14", + "code16/sharp": "<9.22.3", + "codeception/codeception": "<3.1.3|>=4,<4.1.22", + "codeigniter/framework": "<3.1.10", + "codeigniter4/framework": "<4.7.2", + "codeigniter4/shield": "<1.0.0.0-beta8", + "codiad/codiad": "<=2.8.4", + "codingms/additional-tca": ">=1.7,<1.15.17|>=1.16,<1.16.9", + "codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5", + "commerceteam/commerce": ">=0.9.6,<0.9.9", + "components/jquery": ">=1.0.3,<3.5", + "composer/composer": "<2.2.29|>=2.3,<2.10.2", + "concrete5/concrete5": "<9.5.2", + "concrete5/core": "<8.5.8|>=9,<9.1", + "contao-components/mediaelement": ">=2.14.2,<2.21.1", + "contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4", + "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<5.3.48|>=5.4,<5.7.9", + "contao/core": "<3.5.39", + "contao/core-bundle": "<5.3.48|>=5.4,<5.7.9", + "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", + "contao/managed-edition": "<=1.5", + "coreshop/core-shop": "<4.1.9|==5", + "corveda/phpsandbox": "<1.3.5", + "cosenary/instagram": "<=2.3", + "cotonti/cotonti": "<=1", + "couleurcitron/tarteaucitron-wp": "<0.3", + "cpsit/typo3-mailqueue": "<0.4.5|>=0.5,<0.5.2", + "craftcms/aws-s3": ">=2.0.2,<=2.2.4", + "craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1", + "craftcms/cms": "<4.18|>=5,<5.10", + "craftcms/commerce": ">=4,<=4.11.1|>=5,<=5.6.4", + "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", + "craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21", + "craftcms/google-cloud": ">=2.0.0.0-beta1,<=2.2", + "craftcms/webhooks": ">=3,<3.2", + "croogo/croogo": "<=4.0.7", + "cuyz/valinor": "<0.12", + "czim/file-handling": "<1.5|>=2,<2.3", + "czproject/git-php": "<4.0.3", + "damienharper/auditor-bundle": "<5.2.6", + "dapphp/securimage": "<3.6.6", + "darylldoyle/safe-svg": "<1.9.10", + "datadog/dd-trace": ">=0.30,<0.30.2", + "datahihi1/tiny-env": "<1.0.3|>=1.0.9,<1.0.11", + "datatables/datatables": "<1.10.10", + "david-garcia/phpwhois": "<=4.3.1", + "dbrisinajumi/d2files": "<1", + "dcat/laravel-admin": "<=2.1.3|==2.2.0.0-beta|==2.2.2.0-beta", + "dedoc/scramble": ">=0.13.2,<0.13.22", + "derhansen/fe_change_pwd": "<2.0.5|>=3,<3.0.3", + "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1|>=7,<7.4", + "desperado/xml-bundle": "<=0.1.7", + "dev-lancer/minecraft-motd-parser": "<=1.0.5", + "devcode-it/openstamanager": "<=2.10.1", + "devgroup/dotplant": "<2020.09.14-dev", + "digimix/wp-svg-upload": "<=1", + "directmailteam/direct-mail": "<6.0.3|>=7,<7.0.3|>=8,<9.5.2", + "directorytree/imapengine": "<1.22.3", + "dl/yag": "<3.0.1", + "dmk/webkitpdf": "<1.1.4", + "dnadesign/silverstripe-elemental": "<5.3.12", + "doctrine/annotations": "<1.2.7", + "doctrine/cache": ">=1,<1.3.2|>=1.4,<1.4.2", + "doctrine/common": "<2.4.3|>=2.5,<2.5.1", + "doctrine/dbal": ">=2,<2.0.8|>=2.1,<2.1.2|>=3,<3.1.4", + "doctrine/doctrine-bundle": "<1.5.2", + "doctrine/doctrine-module": "<0.7.2", + "doctrine/mongodb-odm": "<1.0.2", + "doctrine/mongodb-odm-bundle": "<3.0.1", + "doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4", + "dolibarr/dolibarr": "<=23.0.2", + "dompdf/dompdf": "<3.1.6", + "doublethreedigital/guest-entries": "<3.1.2", + "dreamfactory/df-core": "<1.0.4", + "drupal-pattern-lab/unified-twig-extensions": "<=0.1", + "drupal/access_code": "<2.0.5", + "drupal/acquia_dam": "<1.1.5", + "drupal/admin_audit_trail": "<1.0.5", + "drupal/ai": "<1.0.5", + "drupal/alogin": "<2.0.6", + "drupal/cache_utility": "<1.2.1", + "drupal/civictheme": "<1.12", + "drupal/commerce_alphabank_redirect": "<1.0.3", + "drupal/commerce_eurobank_redirect": "<2.1.1", + "drupal/config_split": "<1.10|>=2,<2.0.2", + "drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.5.10|>=10.6,<10.6.9|>=11,<11.2.12|>=11.3,<11.3.10", + "drupal/core-recommended": ">=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", + "drupal/currency": "<3.5", + "drupal/drupal": ">=5,<5.11|>=6,<6.38|>=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", + "drupal/email_tfa": "<2.0.6", + "drupal/formatter_suite": "<2.1", + "drupal/gdpr": "<3.0.1|>=3.1,<3.1.2", + "drupal/google_tag": "<1.8|>=2,<2.0.8", + "drupal/ignition": "<1.0.4", + "drupal/json_field": "<1.5", + "drupal/lightgallery": "<1.6", + "drupal/link_field_display_mode_formatter": "<1.6", + "drupal/matomo": "<1.24", + "drupal/oauth2_client": "<4.1.3", + "drupal/oauth2_server": "<2.1", + "drupal/obfuscate": "<2.0.1", + "drupal/plausible_tracking": "<1.0.2", + "drupal/quick_node_block": "<2", + "drupal/rapidoc_elements_field_formatter": "<1.0.1", + "drupal/reverse_proxy_header": "<1.1.2", + "drupal/simple_multistep": "<2", + "drupal/simple_oauth": ">=6,<6.0.7", + "drupal/spamspan": "<3.2.1", + "drupal/tfa": "<1.10", + "drupal/umami_analytics": "<1.0.1", + "duncanmcclean/guest-entries": "<3.1.2", + "dweeves/magmi": "<=0.7.24", + "easycorp/easyadmin-bundle": ">=4,<4.29.10|>=5,<5.0.13", + "ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.3.1", + "ecodev/newsletter": "<=4", + "ectouch/ectouch": "<=2.7.2", + "egroupware/egroupware": "<23.1.20260601|>=26.0.20251208,<26.5.20260507", + "elefant/cms": "<2.0.7", + "elgg/elgg": "<3.3.24|>=4,<4.0.5", + "elijaa/phpmemcacheadmin": "<=1.3", + "elmsln/haxcms": "<11.0.14", + "encore/laravel-admin": "<=1.8.19", + "endroid/qr-code-bundle": "<3.4.2", + "enhavo/enhavo-app": "<=0.13.1", + "enshrined/svg-sanitize": "<0.22", + "erusev/parsedown": "<1.7.2", + "ether/logs": "<3.0.4", + "evolutioncms/evolution": "<=3.2.3", + "evoweb/sf-register": "<13.2.4|>=14,<14.0.2", + "exceedone/exment": "<4.4.3|>=5,<5.0.3", + "exceedone/laravel-admin": "<2.2.3|==3", + "ezsystems/demobundle": ">=5.4,<5.4.6.1-dev", + "ezsystems/ez-support-tools": ">=2.2,<2.2.3", + "ezsystems/ezdemo-ls-extension": ">=5.4,<5.4.2.1-dev", + "ezsystems/ezfind-ls": ">=5.3,<5.3.6.1-dev|>=5.4,<5.4.11.1-dev|>=2017.12,<2017.12.0.1-dev", + "ezsystems/ezplatform": "<=1.13.6|>=2,<=2.5.24", + "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6|>=1.5,<1.5.29|>=2.3,<2.3.39|>=3.3,<3.3.39", + "ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2.1|>=5,<5.0.1|>=5.1,<5.1.1|>=5.3.0.0-beta1,<5.3.5", + "ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1-dev,<1.0.13|>=2.0.0.0-beta1,<2.3.12", + "ezsystems/ezplatform-http-cache": "<2.3.16", + "ezsystems/ezplatform-kernel": "<=1.2.5|>=1.3,<1.3.35", + "ezsystems/ezplatform-rest": ">=1.2,<=1.2.2|>=1.3,<1.3.8", + "ezsystems/ezplatform-richtext": ">=2.3,<2.3.26|>=3.3,<3.3.40", + "ezsystems/ezplatform-solr-search-engine": ">=1.7,<1.7.12|>=2,<2.0.2|>=3.3,<3.3.15", + "ezsystems/ezplatform-user": ">=1,<1.0.1", + "ezsystems/ezpublish-kernel": "<=6.13.8.1|>=7,<7.5.31", + "ezsystems/ezpublish-legacy": "<=2017.12.7.3|>=2018.6,<=2019.03.5.1", + "ezsystems/platform-ui-assets-bundle": ">=4.2,<4.2.3", + "ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15", + "ezyang/htmlpurifier": "<=4.2", + "facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2", + "facturascripts/facturascripts": "<=2026.2", + "fastly/magento2": "<1.2.26", + "feehi/cms": "<=2.1.1", + "feehi/feehicms": "<=2.1.1", + "fenom/fenom": "<=2.12.1", + "filament/actions": ">=3.2,<3.2.123|>=4,<=4.11.3|>=5,<=5.6.3", + "filament/filament": ">=3,<=3.3.51|>=4,<4.11.5|>=5,<5.6.5", + "filament/forms": ">=3,<=3.3.52", + "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", + "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", + "filegator/filegator": "<7.8", + "filp/whoops": "<2.1.13", + "fineuploader/php-traditional-server": "<=1.2.2", + "firebase/php-jwt": "<7", + "fisharebest/webtrees": "<=2.1.18", + "fixpunkt/fp-masterquiz": "<2.2.1|>=3,<3.5.2", + "fixpunkt/fp-newsletter": "<1.1.1|>=1.2,<2.1.2|>=2.2,<3.2.6", + "flarum/core": "<=1.8.15|>=2.0.0.0-beta1,<=2.0.0.0-beta8", + "flarum/flarum": "<0.1.0.0-beta8", + "flarum/framework": "<1.8.10", + "flarum/mentions": "<1.6.3", + "flarum/nicknames": "<1.8.3", + "flarum/sticky": ">=0.1.0.0-beta14,<=0.1.0.0-beta15", + "flarum/tags": "<=0.1.0.0-beta13", + "flightphp/core": "<3.18.1", + "floriangaerber/magnesium": "<0.3.1", + "fluidtypo3/vhs": "<5.1.1", + "fof/byobu": ">=0.3.0.0-beta2,<1.1.7", + "fof/pretty-mail": "<=1.1.2", + "fof/upload": "<1.2.3", + "foodcoopshop/foodcoopshop": ">=3.2,<3.6.1", + "fooman/tcpdf": "<6.2.22", + "forkcms/forkcms": "<5.11.1", + "fossar/tcpdf-parser": "<6.2.22", + "francoisjacquet/rosariosis": "<=11.5.1", + "frappant/frp-form-answers": "<3.1.2|>=4,<4.0.2", + "friendsofsymfony/oauth2-php": "<1.3", + "friendsofsymfony/rest-bundle": ">=1.2,<1.2.2", + "friendsofsymfony/user-bundle": ">=1,<1.3.5", + "friendsofsymfony1/swiftmailer": ">=4,<5.4.13|>=6,<6.2.5", + "friendsofsymfony1/symfony1": ">=1.1,<1.5.19", + "friendsoftypo3/mediace": ">=7.6.2,<7.6.5", + "friendsoftypo3/openid": ">=4.5,<4.5.31|>=4.7,<4.7.16|>=6,<6.0.11|>=6.1,<6.1.6", + "friendsoftypo3/tt-address": "<8.1.2|>=9,<9.1.1|>=10,<10.0.1", + "froala/wysiwyg-editor": "<=4.3", + "frosh/adminer-platform": "<2.2.1", + "froxlor/froxlor": "<2.3.7", + "frozennode/administrator": "<=5.0.12", + "fuel/core": "<1.8.1", + "funadmin/funadmin": "<=7.1.0.0-RC6", + "gaoming13/wechat-php-sdk": "<=1.10.2", + "genix/cms": "<=1.1.11", + "georgringer/news": "<10.0.4|>=11,<11.4.4|>=12,<12.3.2|>=13,<13.0.2|>=14,<14.0.3", + "geshi/geshi": "<=1.0.9.1", + "getformwork/formwork": "<=2.3.3", + "getgrav/grav": "<=2.0.0.0-RC8", + "getgrav/grav-plugin-api": "<1.0.0.0-beta15", + "getgrav/grav-plugin-form": "<9.1", + "getkirby/cms": "<=4.9.3|>=5,<=5.4.3", + "getkirby/kirby": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1", + "getkirby/panel": "<2.5.14", + "getkirby/starterkit": "<=3.7.0.2", + "gilacms/gila": "<=1.15.4", + "gleez/cms": "<=1.3|==2", + "globalpayments/php-sdk": "<2", + "goalgorilla/open_social": "<12.3.11|>=12.4,<12.4.10|>=13.0.0.0-alpha1,<13.0.0.0-alpha11", + "gogentooss/samlbase": "<1.2.7", + "goodoneuz/pay-uz": "<=2.2.24", + "google/protobuf": "<4.33.6", + "gos/web-socket-bundle": "<1.10.4|>=2,<2.6.1|>=3,<3.3", + "gp247/core": "<1.1.24", + "gree/jose": "<2.2.1", + "gregwar/rst": "<1.0.3", + "grumpydictator/firefly-iii": "<=6.6.2", + "gugoan/economizzer": "<=0.9.0.0-beta1", + "guzzlehttp/guzzle": "<7.15.1", + "guzzlehttp/guzzle-services": "<1.5.4", + "guzzlehttp/oauth-subscriber": "<0.8.1", + "guzzlehttp/psr7": "<2.12.3", + "haffner/jh_captcha": "<=2.1.3|>=3,<=3.0.2", + "handcraftedinthealps/goodby-csv": "<1.4.3", + "harvesthq/chosen": "<1.8.7", + "helloxz/imgurl": "<=2.31", + "hhxsv5/laravel-s": "<3.7.36", + "hillelcoren/invoice-ninja": "<5.3.35", + "himiklab/yii2-jqgrid-widget": "<1.0.8", + "hjue/justwriting": "<=1", + "hov/jobfair": "<1.0.13|>=2,<2.0.2", + "httpsoft/http-message": "<1.0.12", + "hybridauth/hybridauth": "<=3.12.2", + "hyn/multi-tenant": ">=5.6,<5.7.2", + "ibexa/admin-ui": ">=4.2,<4.2.3|>=4.6,<4.6.25|>=5,<5.0.3", + "ibexa/admin-ui-assets": ">=4.6.0.0-alpha1,<4.6.21", + "ibexa/core": ">=4,<4.0.7|>=4.1,<4.1.4|>=4.2,<4.2.3|>=4.5,<4.5.6|>=4.6,<4.6.2", + "ibexa/fieldtype-richtext": ">=4.6,<4.6.25|>=5,<5.0.3", + "ibexa/graphql": ">=2.5,<2.5.31|>=3.3,<3.3.28|>=4.2,<4.2.3", + "ibexa/http-cache": ">=4.6,<4.6.14", + "ibexa/post-install": "<1.0.16|>=4.6,<4.6.14", + "ibexa/solr": ">=4.5,<4.5.4", + "ibexa/user": ">=4,<4.4.3|>=5,<5.0.4", + "icecoder/icecoder": "<=8.1", + "idno/known": "<1.6.4", + "ilicmiljan/secure-props": ">=1.2,<1.2.2", + "illuminate/auth": "<5.5.10", + "illuminate/cookie": ">=4,<=4.0.11|>=4.1,<6.18.31|>=7,<7.22.4", + "illuminate/database": "<6.20.26|>=7,<7.30.5|>=8,<8.40", + "illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15", + "illuminate/mail": ">=9,<12.60|>=13,<13.10", + "illuminate/view": "<6.20.42|>=7,<7.30.6|>=8,<8.75", + "imdbphp/imdbphp": "<=5.1.1", + "impresscms/impresscms": "<=1.4.5", + "impresspages/impresspages": "<1.0.13", + "in2code/femanager": "<6.4.2|>=7,<7.5.3|>=8,<8.3.1", + "in2code/ipandlanguageredirect": "<5.1.2", + "in2code/lux": "<17.6.1|>=18,<24.0.2", + "in2code/powermail": "<7.5.1|>=8,<8.5.1|>=9,<10.9.1|>=11,<12.5.3|==13", + "innologi/typo3-appointments": "<2.0.6", + "intelliants/subrion": "<4.2.2", + "inter-mediator/inter-mediator": "==5.5", + "intercom/intercom-php": "==5.0.2", + "invoiceninja/invoiceninja": "<5.13.4", + "ipl/web": "<=0.10.2|>=0.11,<=0.13", + "islandora/crayfish": "<4.1", + "islandora/islandora": ">=2,<2.4.1", + "ivankristianto/phpwhois": "<=4.3", + "j0k3r/graby": "<=2.5", + "jackalope/jackalope-doctrine-dbal": "<1.7.4", + "jambagecom/div2007": "<0.10.2", + "james-heinrich/getid3": "<1.9.21", + "james-heinrich/phpthumb": "<=1.7.23", + "jasig/phpcas": "<1.3.3", + "jbartels/wec-map": "<3.0.3", + "jcbrand/converse.js": "<3.3.3", + "jleehr/canto-saas-api": "<=2", + "joedolson/my-calendar": "<3.7.7", + "joelbutcher/socialstream": "<5.6|>=6,<6.2", + "johnbillion/query-monitor": "<3.20.4", + "johnbillion/wp-crontrol": "<1.16.2|>=1.17,<1.19.2", + "joomla/application": "<1.0.13", + "joomla/archive": "<1.1.12|>=2,<2.0.1", + "joomla/database": ">=1,<2.2|>=3,<3.4", + "joomla/filesystem": "<1.6.2|>=2,<2.0.1", + "joomla/filter": "<2.0.6|>=3,<3.0.5|==4", + "joomla/framework": "<1.5.7|>=2.5.4,<=3.8.12", + "joomla/input": ">=2,<2.0.2", + "joomla/joomla-cms": "<3.9.12|>=4,<4.4.13|>=5,<5.2.6", + "joomla/joomla-platform": "<1.5.4", + "joomla/session": "<1.3.1", + "joyqi/hyper-down": "<=2.4.27", + "jsdecena/laracom": "<2.0.9", + "jsmitty12/phpwhois": "<5.1", + "juzaweb/cms": "<=3.4.2", + "jweiland/events2": "<8.3.8|>=9,<9.0.6", + "jweiland/kk-downloader": "<1.2.2", + "kantorge/yaffa": "<=2", + "kazist/phpwhois": "<=4.2.6", + "kelvinmo/simplejwt": "<=1.1", + "kelvinmo/simplexrd": "<3.1.1", + "kevinpapst/kimai2": "<1.16.7", + "khodakhah/nodcms": "<=3.4.1", + "kimai/kimai": "<2.59", + "kitodo/presentation": "<3.2.3|>=3.3,<3.3.4", + "klaviyo/magento2-extension": ">=1,<3", + "knplabs/knp-snappy": "<=1.7", + "kohana/core": "<3.3.3", + "koillection/koillection": "<1.6.12", + "krayin/laravel-crm": "<=2.2", + "kreait/firebase-php": ">=3.2,<3.8.1", + "kumbiaphp/kumbiapp": "<=1.1.1", + "la-haute-societe/tcpdf": "<6.2.22", + "laktak/hjson": "<2.3", + "laminas/laminas-diactoros": "<2.18.1|==2.19|==2.20|==2.21|==2.22|==2.23|>=2.24,<2.24.2|>=2.25,<2.25.2", + "laminas/laminas-form": "<2.17.1|>=3,<3.0.2|>=3.1,<3.1.1", + "laminas/laminas-http": "<2.14.2", + "lara-zeus/artemis": ">=1,<=1.0.6", + "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", + "laravel/fortify": "<1.11.1", + "laravel/framework": "<12.61.1|>=13,<13.12", + "laravel/laravel": ">=5.4,<5.4.22", + "laravel/passport": ">=13,<13.7.1", + "laravel/pulse": "<1.3.1", + "laravel/reverb": "<1.7", + "laravel/socialite": ">=1,<2.0.10", + "latte/latte": "<2.10.8", + "lavalite/cms": "<=10.1", + "lavitto/typo3-form-to-database": "<2.2.5|>=3,<3.2.2|>=4,<4.2.3|>=5,<5.0.2", + "lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5", + "league/commonmark": "<=2.8.1", + "league/flysystem": "<1.1.4|>=2,<2.1.1", + "league/oauth2-server": ">=8.3.2,<8.4.2|>=8.5,<8.5.3", + "leantime/leantime": "<3.3", + "lexik/jwt-authentication-bundle": "<2.10.7|>=2.11,<2.11.3", + "libreform/libreform": ">=2,<=2.0.8", + "librenms/librenms": "<26.3", + "liftkit/database": "<2.13.2", + "lightsaml/lightsaml": "<1.3.5", + "limesurvey/limesurvey": "<6.15.4", + "livehelperchat/livehelperchat": "<=3.91", + "livewire-filemanager/filemanager": "<=1.0.4", + "livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<3.6.4", + "livewire/volt": "<1.7", + "lms/routes": "<2.1.1", + "localizationteam/l10nmgr": "<7.4|>=8,<8.7|>=9,<9.2", + "lomkit/laravel-rest-api": "<2.13", + "luracast/restler": "<3.1", + "luyadev/yii-helpers": "<1.2.1", + "macropay-solutions/laravel-crud-wizard-free": "<3.4.17", + "maestroerror/php-heic-to-jpg": "<1.0.5", + "magento/community-edition": "<2.4.6.0-patch13|>=2.4.7.0-beta1,<2.4.7.0-patch8|>=2.4.8.0-beta1,<2.4.8.0-patch3|>=2.4.9.0-alpha1,<2.4.9.0-alpha3|==2.4.9", + "magento/core": "<=1.9.4.5", + "magento/magento1ce": "<1.9.4.3-dev", + "magento/magento1ee": ">=1,<1.14.4.3-dev", + "magento/product-community-edition": "<2.4.4.0-patch9|>=2.4.5,<2.4.5.0-patch8|>=2.4.6,<2.4.6.0-patch6|>=2.4.7,<2.4.7.0-patch1", + "magento/project-community-edition": "<=2.0.2", + "magneto/core": "<1.9.4.4-dev", + "mahocommerce/maho": "<25.9", + "maikuolan/phpmussel": ">=1,<1.6", + "mainwp/mainwp": "<=4.4.3.3", + "manogi/nova-tiptap": "<=3.2.6", + "mantisbt/mantisbt": "<=2.28.3", + "marcwillmann/turn": "<0.3.3", + "markhuot/craftql": "<=1.3.7", + "marshmallow/nova-tiptap": "<5.7", + "matomo/matomo": "<1.11", + "matyhtf/framework": "<3.0.6", + "mautic/core": "<5.2.11|>=6,<6.0.9|>=7,<7.1.2", + "mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1", + "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", + "maximebf/debugbar": "<1.19", + "mckenziearts/livewire-markdown-editor": "<1.3", + "mdanter/ecc": "<2", + "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", + "mediawiki/cargo": "<3.8.3", + "mediawiki/core": "<1.39.5|==1.40", + "mediawiki/data-transfer": ">=1.39,<1.39.11|>=1.41,<1.41.3|>=1.42,<1.42.2", + "mediawiki/maps": "<12.1.3", + "mediawiki/matomo": "<2.4.3", + "mediawiki/semantic-media-wiki": "<4.0.2", + "mehrwert/phpmyadmin": "<3.2", + "melisplatform/melis-asset-manager": "<5.0.1", + "melisplatform/melis-cms": "<5.3.4", + "melisplatform/melis-cms-slider": "<5.3.1", + "melisplatform/melis-core": "<5.3.11", + "melisplatform/melis-front": "<5.0.1", + "mezzio/mezzio-swoole": "<3.7|>=4,<4.3", + "mgallegos/laravel-jqgrid": "<=1.3", + "microsoft/microsoft-graph": ">=1.16,<1.109.1|>=2,<2.0.1", + "microsoft/microsoft-graph-beta": "<2.0.1", + "microsoft/microsoft-graph-core": "<2.0.2", + "microweber/microweber": "<2.0.20", + "mikehaertl/php-shellcommand": "<1.6.1", + "mineadmin/mineadmin": "<=3.0.9", + "miniorange/miniorange-saml": "<1.4.3", + "miraheze/ts-portal": "<=33", + "mittwald/typo3_forum": "<1.2.1", + "mix/mix": ">=2,<=2.2.17", + "mmc/ceselector": "<3.0.3|>=4,<4.0.2|>=5,<5.0.1|>=6,<6.0.1", + "mobiledetect/mobiledetectlib": "<2.8.32", + "modx/revolution": "<=3.1", + "mojo42/jirafeau": "<4.4", + "mongodb/mongodb": ">=1,<1.9.2", + "mongodb/mongodb-extension": "<1.21.2", + "monolog/monolog": ">=1.8,<1.12", + "moodle/moodle": "<4.5.9|>=5.0.0.0-beta,<5.0.5|>=5.1.0.0-beta,<5.1.2", + "moonshine/moonshine": "<=3.12.5", + "mos/cimage": "<0.7.19", + "movim/moxl": ">=0.8,<=0.10", + "movingbytes/social-network": "<=1.2.1", + "mpdf/mpdf": "<=7.1.7", + "mtdowling/jmespath.php": "<2.9.1", + "munkireport/comment": "<4", + "munkireport/managedinstalls": "<2.6", + "munkireport/munki_facts": "<1.5", + "munkireport/reportdata": "<3.5", + "munkireport/softwareupdate": "<1.6", + "mustache/mustache": ">=2,<2.14.1", + "mwdelaney/wp-enable-svg": "<=0.2", + "nabeel/phpvms": "<7.0.6", + "namshi/jose": "<2.2", + "nasirkhan/laravel-starter": "<11.11", + "nategood/httpful": "<1", + "neoan3-apps/template": "<1.1.1", + "neorazorx/facturascripts": "<2022.04", + "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", + "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", + "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", + "neos/swiftmailer": "<5.4.5", + "nesbot/carbon": "<2.72.6|>=3,<3.8.4", + "netcarver/textile": "<=4.1.2", + "netgen/tagsbundle": ">=3.4,<3.4.11|>=4,<4.0.15", + "nette/application": ">=2,<2.0.19|>=2.1,<2.1.13|>=2.2,<2.2.10|>=2.3,<2.3.14|>=2.4,<2.4.16|>=3,<3.0.6", + "nette/nette": ">=2,<2.0.19|>=2.1,<2.1.13", + "neuron-core/neuron-ai": "<=2.8.11", + "nilsteampassnet/teampass": "<3.1.3.1-dev", + "nitsan/ns-backup": "<13.0.1", + "nonfiction/nterchange": "<4.1.1", + "notrinos/notrinos-erp": "<=1", + "noumo/easyii": "<=0.9", + "novaksolutions/infusionsoft-php-sdk": "<1", + "novosga/novosga": "<=2.2.12", + "nukeviet/nukeviet": "<4.6.00", + "nyholm/psr7": "<1.6.1", + "nystudio107/craft-seomatic": "<3.4.12", + "nzedb/nzedb": "<0.8", + "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1", + "october/backend": "<1.1.2", + "october/cms": "<1.0.469|==1.0.469|==1.0.471|==1.1.1", + "october/october": "<3.7.14|>=4,<4.1.10", + "october/rain": "<=3.7.13|>=4,<=4.1.9", + "october/system": "<3.7.16|>=4,<4.1.16", + "oliverklee/phpunit": "<3.5.15", + "omeka/omeka-s": "<4.0.3", + "onelogin/php-saml": "<2.21.1|>=3,<3.8.1|>=4,<4.3.1", + "oneup/uploader-bundle": ">=1,<1.9.3|>=2,<2.1.5", + "open-web-analytics/open-web-analytics": "<1.8.1", + "opencart/opencart": ">=0", + "openid/php-openid": "<2.3", + "openmage/magento-lts": "<=20.17", + "opensolutions/vimbadmin": "<=3.0.15", + "opensource-workshop/connect-cms": "<1.41.1|>=2,<2.41.1", + "orchid/platform": ">=8,<14.43", + "oro/calendar-bundle": ">=4.2,<=4.2.6|>=5,<=5.0.6|>=5.1,<5.1.1", + "oro/commerce": ">=4.1,<5.0.11|>=5.1,<5.1.1", + "oro/crm": ">=1.7,<1.7.4|>=3.1,<4.1.17|>=4.2,<4.2.7", + "oro/crm-call-bundle": ">=4.2,<=4.2.5|>=5,<5.0.4|>=5.1,<5.1.1", + "oro/customer-portal": ">=4.1,<=4.1.13|>=4.2,<=4.2.10|>=5,<=5.0.11|>=5.1,<=5.1.3", + "oro/platform": ">=1.7,<1.7.4|>=3.1,<3.1.29|>=4.1,<4.1.17|>=4.2,<=4.2.10|>=5,<=5.0.12|>=5.1,<=5.1.3", + "oveleon/contao-cookiebar": "<1.16.3|>=2,<2.1.3", + "oxid-esales/oxideshop-ce": "<=7.0.5", + "oxid-esales/paymorrow-module": ">=1,<1.0.2|>=2,<2.0.1", + "packbackbooks/lti-1-3-php-library": "<5", + "padraic/humbug_get_contents": "<1.1.2", + "pagarme/pagarme-php": "<3", + "pagekit/pagekit": "<=1.0.18", + "paragonie/ecc": "<2.0.1", + "paragonie/random_compat": "<2", + "paragonie/sodium_compat": "<1.24|>=2,<2.5", + "passbolt/passbolt_api": "<4.6.2", + "paymenter/paymenter": "<=1.5.4", + "paypal/adaptivepayments-sdk-php": "<=3.9.2", + "paypal/invoice-sdk-php": "<=3.9", + "paypal/merchant-sdk-php": "<3.12", + "paypal/permissions-sdk-php": "<=3.9.1", + "pear/archive_tar": "<1.4.14", + "pear/auth": "<1.2.4", + "pear/crypt_gpg": "<1.6.7", + "pear/http_request2": "<2.7", + "pear/pear": "<=1.10.1", + "pegasus/google-for-jobs": "<1.5.1|>=2,<2.1.1", + "personnummer/personnummer": "<3.0.2", + "ph7software/ph7builder": "<=17.9.1", + "phanan/koel": "<=9.7", + "pheditor/pheditor": "<2.0.8", + "phenx/php-svg-lib": "<0.5.2", + "php-censor/php-censor": "<2.0.13|>=2.1,<2.1.5", + "php-mod/curl": "<2.3.2", + "php-standard-library/h2": ">=6.1,<6.1.2|>=6.2,<6.2.1", + "php-standard-library/php-standard-library": ">=6.1,<6.1.2|>=6.2,<6.2.1", + "phpbb/phpbb": "<3.3.16|==4.0.0.0-alpha1", + "phpems/phpems": ">=6,<=6.1.3", + "phpfastcache/phpfastcache": "<6.1.5|>=7,<7.1.2|>=8,<8.0.7", + "phpmailer/phpmailer": "<6.5", + "phpmussel/phpmussel": ">=1,<1.6", + "phpmyadmin/phpmyadmin": "<5.2.2", + "phpmyfaq/phpmyfaq": "<4.1.4", + "phpoffice/common": "<0.2.9", + "phpoffice/math": "<=0.2", + "phpoffice/phpexcel": "<=1.8.2", + "phpoffice/phpspreadsheet": "<=1.30.5|>=2,<=2.1.17|>=2.2,<=2.4.6|>=3,<=3.10.6|>=4,<=5.8", + "phppgadmin/phppgadmin": "<=7.13", + "phpseclib/phpseclib": "<=2.0.54|>=3,<=3.0.53", + "phpservermon/phpservermon": "<3.6", + "phpsysinfo/phpsysinfo": "<3.4.3", + "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6", + "phpwhois/phpwhois": "<=4.2.5", + "phpxmlrpc/extras": "<0.6.1", + "phpxmlrpc/phpxmlrpc": "<4.9.2", + "phraseanet/phraseanet": "==4.0.3", + "pi/pi": "<=2.5", + "pimcore/admin-ui-classic-bundle": "<1.7.18|>=2.0.0.0-RC1-dev,<=2.3.5", + "pimcore/customer-management-framework-bundle": "<4.2.1", + "pimcore/data-hub": "<1.2.4", + "pimcore/data-importer": "<1.8.9|>=1.9,<1.9.3", + "pimcore/demo": "<10.3", + "pimcore/ecommerce-framework-bundle": "<1.0.10", + "pimcore/perspective-editor": "<1.5.1", + "pimcore/pimcore": "<=12.3.8|>=2026.1,<2026.1.3", + "pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1", + "piwik/piwik": "<1.11", + "pixelfed/pixelfed": "<0.12.5", + "plotly/plotly.js": "<2.25.2", + "pocketmine/bedrock-protocol": "<8.0.2", + "pocketmine/pocketmine-mp": "<5.42.1", + "pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1", + "pontedilana/php-weasyprint": "<=2.5.1", + "poweradmin/poweradmin": "<4.2.5|>=4.3,<4.3.4", + "pressbooks/pressbooks": "<5.18", + "prestashop/autoupgrade": ">=4,<4.10.1", + "prestashop/blockreassurance": "<=5.1.3", + "prestashop/blockwishlist": ">=2,<2.1.1", + "prestashop/contactform": ">=1.0.1,<4.3", + "prestashop/gamification": "<2.3.2", + "prestashop/prestashop": "<8.2.6|>=9,<9.1.1", + "prestashop/productcomments": "<5.0.2", + "prestashop/ps_checkout": "<5.3", + "prestashop/ps_contactinfo": "<=3.3.2", + "prestashop/ps_emailsubscription": "<2.6.1", + "prestashop/ps_facetedsearch": "<4.0.4", + "prestashop/ps_linklist": "<3.1", + "privatebin/privatebin": "<1.4|>=1.5,<1.7.4|>=1.7.7,<2.0.3", + "processwire/processwire": "<=3.0.255", + "propel/propel": ">=2.0.0.0-alpha1,<2.0.0.0-alpha8", + "propel/propel1": ">=1,<1.7.2", + "psy/psysh": "<=0.11.22|>=0.12,<=0.12.18", + "pterodactyl/panel": "<=1.12.4", + "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2", + "ptrofimov/beanstalk_console": "<1.7.14", + "pubnub/pubnub": "<6.1", + "punktde/pt_extbase": "<1.5.1", + "pusher/pusher-php-server": "<2.2.1", + "putyourlightson/craft-sprig": ">=2,<2.15.2|>=3,<3.7.2", + "pwweb/laravel-core": "<=0.3.6.0-beta", + "pxlrbt/filament-excel": "<1.1.14|>=2.0.0.0-alpha,<2.3.3", + "pyrocms/pyrocms": "<=3.9.1", + "qcubed/qcubed": "<=3.1.1", + "quickapps/cms": "<=2.0.0.0-beta2", + "rainlab/blog-plugin": "<1.4.1", + "rainlab/debugbar-plugin": "<3.1", + "rainlab/user-plugin": "<=1.4.5", + "ralffreit/mfa-email": "<1.0.7|==2", + "rankmath/seo-by-rank-math": "<=1.0.95", + "rap2hpoutre/laravel-log-viewer": "<0.13", + "react/http": ">=0.7,<1.9", + "really-simple-plugins/complianz-gdpr": "<6.4.2", + "redaxo/source": "<5.21", + "remdex/livehelperchat": "<4.29", + "renolit/reint-downloadmanager": "<4.0.2|>=5,<5.0.1", + "reportico-web/reportico": "<=8.1", + "rhukster/dom-sanitizer": "<1.0.10", + "rmccue/requests": ">=1.6,<1.8", + "roadiz/documents": "<2.3.42|>=2.4,<2.5.44|>=2.6,<2.6.28|>=2.7,<2.7.9", + "roadiz/openid": "<2.3.43|>=2.5,<2.5.45|>=2.6,<2.6.31|>=2.7,<2.7.18", + "robrichards/xmlseclibs": "<3.1.5", + "roots/soil": "<4.1", + "roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11|>=1.7.0.0-beta,<1.7.0.0-RC5-dev", + "rudloff/alltube": "<3.0.3", + "rudloff/rtmpdump-bin": "<=2.3.1", + "s-cart/core": "<=9.0.5", + "s-cart/s-cart": "<6.9", + "s9y/serendipity": "<2.6", + "sabberworm/php-css-parser": ">=1,<1.0.1|>=2,<2.0.1|>=3,<3.0.1|>=4,<4.0.1|>=5,<5.0.9|>=5.1,<5.1.3|>=5.2,<5.2.1|>=6,<6.0.2|>=7,<7.0.4|>=8,<8.0.1|>=8.1,<8.1.1|>=8.2,<8.2.1|>=8.3,<8.3.1", + "sabre/dav": ">=1.6,<1.7.11|>=1.8,<1.8.9", + "saloonphp/saloon": "<4", + "samwilson/unlinked-wikibase": "<1.42", + "scheb/two-factor-bundle": "<3.26|>=4,<4.11", + "sensiolabs/connect": "<4.2.3", + "serluck/phpwhois": "<=4.2.6", + "setasign/fpdi": "<2.6.7", + "sfroemken/url_redirect": "<=1.2.1", + "sheng/yiicms": "<1.2.1", + "shopper/cart": "<2.8", + "shopper/framework": "<2.8", + "shopware/core": "<6.6.10.18-dev|>=6.7,<6.7.10.1-dev", + "shopware/platform": "<6.6.10.18-dev|>=6.7,<6.7.10.1-dev", + "shopware/production": "<=6.3.5.2", + "shopware/shopware": "<=6.3.5.2|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev", + "shopware/storefront": "<6.6.10.10-dev|>=6.7,<6.7.5.1-dev", + "shopxo/shopxo": "<=6.4", + "showdoc/showdoc": "<3.8.1", + "shuchkin/simplexlsx": ">=1.0.12,<1.1.13", + "silverstripe-australia/advancedreports": ">=1,<=2", + "silverstripe/admin": "<1.13.19|>=2,<2.1.8", + "silverstripe/assets": "<2.4.5|>=3,<3.1.3", + "silverstripe/cms": "<6.2.1", + "silverstripe/comments": ">=1.3,<3.1.1", + "silverstripe/forum": "<0.6.2|>=0.7,<0.7.4", + "silverstripe/framework": "<6.2.2", + "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.8.2|>=4,<4.3.7|>=5,<5.1.3", + "silverstripe/hybridsessions": ">=1,<2.4.1|>=2.5,<2.5.1", + "silverstripe/recipe-cms": ">=4.5,<4.5.3", + "silverstripe/registry": ">=2.1,<2.1.2|>=2.2,<2.2.1", + "silverstripe/reports": "<5.2.3", + "silverstripe/restfulserver": ">=1,<1.0.9|>=2,<2.0.4|>=2.1,<2.1.2", + "silverstripe/silverstripe-omnipay": "<2.5.2|>=3,<3.0.2|>=3.1,<3.1.4|>=3.2,<3.2.1", + "silverstripe/subsites": ">=2,<2.6.1", + "silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1", + "silverstripe/userforms": "<6.4.9|>=7,<7.0.7|>=7.1,<7.1.1", + "silverstripe/versioned": "<3.2.1", + "silverstripe/versioned-admin": ">=1,<1.11.1", + "simogeo/filemanager": "<=2.5", + "simple-updates/phpwhois": "<=1", + "simplesamlphp/saml2": "<=4.20.2|>=5,<5.0.6|>=6,<6.2.1", + "simplesamlphp/saml2-legacy": "<=4.20.2", + "simplesamlphp/simplesamlphp": "<=2.4.6|>=2.5,<=2.5.1", + "simplesamlphp/simplesamlphp-module-casserver": "<=7.0.2", + "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1", + "simplesamlphp/simplesamlphp-module-openid": "<1", + "simplesamlphp/simplesamlphp-module-openidprovider": "<0.9", + "simplesamlphp/xml-common": "<1.20", + "simplesamlphp/xml-security": "<1.13.9|>=2,<2.3.1", + "simplito/elliptic-php": "<1.0.6", + "sitegeist/fluid-components": "<3.5", + "sjbr/sr-feuser-register": "<2.6.2|>=5.1,<12.5", + "sjbr/sr-freecap": "<2.4.6|>=2.5,<2.5.3", + "sjbr/static-info-tables": "<2.3.1", + "slim/psr7": "<1.4.1|>=1.5,<1.5.1|>=1.6,<1.6.1", + "slim/slim": "<2.6|>=4.4,<=4.15.1", + "slub/slub-events": "<3.0.3", + "smarty/smarty": "<4.5.3|>=5,<5.1.1", + "snipe/snipe-it": "<=8.6.1", + "socalnick/scn-social-auth": "<1.15.2", + "socialiteproviders/steam": "<1.1", + "solidinvoice/solidinvoice": "<=2.3.15", + "solspace/craft-freeform": "<4.1.29|>=5,<=5.14.6", + "soosyze/soosyze": "<=2", + "spatie/browsershot": "<5.0.5", + "spatie/image-optimizer": "<1.7.3", + "spatie/laravel-medialibrary": "<11.23", + "spatie/schema-org": ">=3.23.1,<3.23.2|>=4,<4.0.2", + "spencer14420/sp-php-email-handler": "<1", + "spipu/html2pdf": "<5.2.8", + "spiral/roadrunner": "<2025.1", + "spomky-labs/otphp": "<11.4.3", + "spoon/library": "<1.4.1", + "spoonity/tcpdf": "<6.2.22", + "squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1", + "ssddanbrown/bookstack": "<24.05.1", + "starcitizentools/citizen-skin": ">=1.9.4,<3.9", + "starcitizentools/short-description": ">=4,<4.0.1", + "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1", + "starcitizenwiki/embedvideo": "<=4", + "statamic/cms": "<5.74|>=6,<6.20.3", + "stormpath/sdk": "<9.9.99", + "studio-42/elfinder": "<=2.1.67", + "studiomitte/friendlycaptcha": "<0.1.4", + "subhh/libconnect": "<7.0.8|>=8,<8.1", + "sukohi/surpass": "<1", + "sulu/form-bundle": ">=2,<2.5.3", + "sulu/sulu": "<=2.6.22|>=3,<=3.0.5", + "sumocoders/framework-user-bundle": "<1.4", + "superbig/craft-audit": "<3.0.2", + "svewap/a21glossary": "<=0.4.10", + "swag/paypal": "<5.4.4", + "swiftmailer/swiftmailer": "<6.2.5", + "swiftyedit/swiftyedit": "<1.2", + "sylius/admin-bundle": ">=1,<1.0.17|>=1.1,<1.1.9|>=1.2,<1.2.2", + "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", + "sylius/grid-bundle": "<1.10.1", + "sylius/paypal-plugin": "<1.6.2|>=1.7,<1.7.2|>=2,<2.0.2", + "sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", + "sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<2.0.18|>=2.1,<2.1.15|>=2.2,<2.2.6", + "symbiote/silverstripe-advancedworkflow": "<6.4.5|>=7,<7.1.3|>=7.2,<7.2.1", + "symbiote/silverstripe-multivaluefield": ">=3,<3.1", + "symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4", + "symbiote/silverstripe-seed": "<6.0.3", + "symbiote/silverstripe-versionedfiles": "<=2.0.3", + "symfont/process": ">=0", + "symfony/cache": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/dom-crawler": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4", + "symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1", + "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=5.3.14,<5.3.15|>=5.4.3,<5.4.4|>=6.0.3,<6.0.4", + "symfony/html-sanitizer": ">=6.1,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", + "symfony/http-client": ">=4.3,<5.4.53|>=6,<6.4.15|>=7,<7.1.8", + "symfony/http-foundation": "<5.4.50|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", + "symfony/http-kernel": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6|>=7.4,<7.4.12|>=8,<8.0.12", + "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", + "symfony/json-path": ">=7.3,<7.4.12|>=8,<8.0.12", + "symfony/lox24-notifier": ">=7.1,<7.4.12|>=8,<8.0.12", + "symfony/mailer": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/mailjet-mailer": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/mailomat-mailer": ">=7.2,<7.4.13|>=8,<8.0.13", + "symfony/mailtrap-mailer": ">=7.2,<7.4.12|>=8,<8.0.12", + "symfony/maker-bundle": ">=1.27,<1.29.2|>=1.30,<1.31.1", + "symfony/mime": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/monolog-bridge": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/polyfill": ">=1,<1.10|>=1.17.1,<1.38.1", + "symfony/polyfill-intl-idn": ">=1.17.1,<1.38.1", + "symfony/polyfill-php55": ">=1,<1.10", + "symfony/process": "<5.4.51|>=6,<6.4.33|>=7,<7.1.7|>=7.3,<7.3.11|>=7.4,<7.4.5|>=8,<8.0.5", + "symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/routing": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", + "symfony/runtime": ">=5.3,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/security": ">=2,<2.7.51|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.8", + "symfony/security-bundle": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.4.10|>=7,<7.0.10|>=7.1,<7.1.3", + "symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.9", + "symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", + "symfony/security-guard": ">=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", + "symfony/security-http": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", + "symfony/serializer": ">=2,<2.0.11|>=4.1,<4.4.35|>=5,<5.3.12", + "symfony/symfony": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13", + "symfony/translation": ">=2,<2.0.17", + "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8|>=6.4.24,<6.4.40", + "symfony/twilio-notifier": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/ux-autocomplete": "<2.36|>=3,<3.1", + "symfony/ux-icons": ">=2.17,<2.36.1|>=3,<3.2", + "symfony/ux-live-component": "<2.36|>=3,<3.1", + "symfony/ux-toolkit": ">=2.32,<2.36.1|>=3,<3.2", + "symfony/ux-twig-component": "<2.25.1", + "symfony/validator": "<5.4.43|>=6,<6.4.11|>=7,<7.1.4", + "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", + "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4|>=7.2.9,<7.4.12|>=8,<8.0.12", + "symfony/webhook": ">=6.3,<6.3.8", + "symfony/yaml": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symphonycms/symphony-2": "<2.6.4", + "t3/dce": "<0.11.5|>=2.2,<2.6.2", + "t3g/svg-sanitizer": "<1.0.3", + "t3s/content-consent": "<1.0.3|>=2,<2.0.2", + "tastyigniter/tastyigniter": "<4", + "tcg/voyager": "<=1.8", + "tecnickcom/tc-lib-pdf-font": "<2.6.4", + "tecnickcom/tcpdf": "<6.8", + "terminal42/contao-tablelookupwizard": "<3.3.5", + "thelia/backoffice-default-template": ">=2.1,<2.1.2", + "thelia/thelia": ">=2.0.0.0-beta1,<2.1.3", + "theonedemon/phpwhois": "<=4.2.5", + "thinkcmf/thinkcmf": "<6.0.8", + "thorsten/phpmyfaq": "<4.1.4", + "tikiwiki/tiki-manager": "<=17.1", + "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", + "tinymce/tinymce": "<7.9.3|>=8,<8.5.1", + "tinymighty/wiki-seo": "<1.2.2", + "titon/framework": "<9.9.99", + "tltneon/lgsl": "<7", + "tobiasbg/tablepress": "<=2.0.0.0-RC1", + "tomasnorre/crawler": "<11.0.13|>=12,<12.0.11", + "topthink/framework": "<6.0.17|>=6.1,<=8.0.4", + "topthink/think": "<=6.1.1", + "topthink/thinkphp": "<=3.2.3|>=6.1.3,<=8.0.4", + "torrentpier/torrentpier": "<=2.8.8", + "tpwd/ke_search": "<5.6.2|>=6,<6.6.1|>=7,<7.0.1", + "tribalsystems/zenario": "<=9.7.61188", + "truckersmp/phpwhois": "<=4.3.1", + "ttskch/pagination-service-provider": "<1", + "twbs/bootstrap": "<3.4.1|>=4,<4.3.1", + "twig/cssinliner-extra": "<3.26", + "twig/intl-extra": "<3.26", + "twig/markdown-extra": "<3.26", + "twig/twig": "<3.27", + "typicms/core": "<12.0.5|>=13,<13.0.9|>=14,<14.0.27|>=15,<15.0.29|>=16,<16.1.7", + "typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", + "typo3/cms-backend": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", + "typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", + "typo3/cms-beuser": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", + "typo3/cms-core": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", + "typo3/cms-dashboard": ">=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", + "typo3/cms-extbase": "<6.2.24|>=7,<7.6.8|==8.1.1", + "typo3/cms-extensionmanager": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", + "typo3/cms-felogin": ">=4.2,<4.2.3", + "typo3/cms-filelist": ">=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", + "typo3/cms-fluid": "<4.3.4|>=4.4,<4.4.1|>=8,<8.7.23|>=9,<9.5.4", + "typo3/cms-form": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.5", + "typo3/cms-frontend": "<4.3.9|>=4.4,<4.4.5", + "typo3/cms-indexed-search": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<13.4.31|>=14,<14.3.3", + "typo3/cms-install": "<4.1.14|>=4.2,<4.2.16|>=4.3,<4.3.9|>=4.4,<4.4.5|>=12.2,<12.4.8|==13.4.2", + "typo3/cms-lowlevel": ">=11,<=11.5.41", + "typo3/cms-recordlist": ">=11,<11.5.48", + "typo3/cms-recycler": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", + "typo3/cms-redirects": ">=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1", + "typo3/cms-rte-ckeditor": ">=9.5,<9.5.42|>=10,<10.4.39|>=11,<11.5.30", + "typo3/cms-scheduler": ">=11,<=11.5.41", + "typo3/cms-setup": ">=9,<=9.5.50|>=10,<=10.4.49|>=11,<=11.5.43|>=12,<=12.4.30|>=13,<=13.4.11", + "typo3/cms-webhooks": ">=12,<=12.4.30|>=13,<=13.4.11", + "typo3/cms-workspaces": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", + "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "typo3/html-sanitizer": "<2.3.2", + "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<2.3.99|>=3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<3.3.23|>=4,<4.0.17|>=4.1,<4.1.16|>=4.2,<4.2.12|>=4.3,<4.3.3", + "typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1", + "typo3/swiftmailer": ">=4.1,<4.1.99|>=5.4,<5.4.5", + "typo3fluid/fluid": ">=2,<2.0.8|>=2.1,<2.1.7|>=2.2,<2.2.4|>=2.3,<2.3.7|>=2.4,<2.4.4|>=2.5,<2.5.11|>=2.6,<2.6.10", + "ua-parser/uap-php": "<3.8", + "uasoft-indonesia/badaso": "<=2.9.7", + "unisharp/laravel-filemanager": "<2.9.1", + "universal-omega/dynamic-page-list3": "<3.6.4", + "unopim/unopim": "<=0.3", + "userfrosting/userfrosting": ">=0.3.1,<4.6.3", + "usmanhalalit/pixie": "<1.0.3|>=2,<2.0.2", + "uvdesk/community-skeleton": "<=1.1.1", + "uvdesk/core-framework": "<=1.1.1", + "vanilla/safecurl": "<0.9.2", + "verbb/comments": "<1.5.5", + "verbb/formie": "<3.1.28", + "verbb/image-resizer": "<2.0.9", + "verbb/knock-knock": "<1.2.8", + "verot/class.upload.php": "<=2.1.6", + "vertexvaar/falsftp": "<0.2.6", + "villagedefrance/opencart-overclocked": "<=1.11.1", + "vova07/yii2-fileapi-widget": "<0.1.9", + "vrana/adminer": "<5.4.2", + "vufind/vufind": ">=2,<9.1.1", + "waldhacker/hcaptcha": "<2.1.2", + "wallabag/tcpdf": "<6.2.22", + "wallabag/wallabag": "<2.6.11", + "wanglelecc/laracms": "<=1.0.3", + "wapplersystems/a21glossary": "<=0.4.10", + "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4|>=5.3,<5.3.1", + "web-auth/webauthn-lib": ">=4.5,<5.3.5", + "web-auth/webauthn-symfony-bundle": "<5.3.4", + "web-feet/coastercms": "==5.5", + "web-token/jwt-bundle": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7", + "web-token/jwt-experimental": "<4.1.7", + "web-token/jwt-framework": "<4.1.7", + "web-token/jwt-library": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7", + "web-tp3/wec_map": "<3.0.3", + "webbuilders-group/silverstripe-kapost-bridge": "<0.4", + "webcoast/deferred-image-processing": "<1.0.2", + "webklex/laravel-imap": "<5.3", + "webklex/php-imap": "<5.3", + "webonyx/graphql-php": "<=15.32.2", + "webpa/webpa": "<3.1.2", + "webreinvent/vaahcms": "<=2.3.1", + "wikibase/wikibase": "<=1.39.3", + "wikimedia/parsoid": "<0.12.2", + "willdurand/js-translation-bundle": "<2.1.1", + "winter/wn-backend-module": "<1.2.12", + "winter/wn-cms-module": "<=1.2.9", + "winter/wn-dusk-plugin": "<2.1", + "winter/wn-system-module": "<1.2.4", + "wintercms/winter": "<=1.2.3", + "wireui/wireui": "<1.19.3|>=2,<2.1.3", + "wnx/laravel-backup-restore": "<=1.9.3", + "woocommerce/woocommerce": "<6.6|>=8.8,<8.8.5|>=8.9,<8.9.3", + "wp-cli/wp-cli": ">=0.12,<2.5", + "wp-coding-standards/wpcs": ">=0.14.1,<3.4.1", + "wp-graphql/wp-graphql": "<=1.14.5", + "wp-premium/gravityforms": "<2.4.21", + "wpanel/wpanel4-cms": "<=4.3.1", + "wpcloud/wp-stateless": "<3.2", + "wpglobus/wpglobus": "<=1.9.6", + "wpmetabox/meta-box": "<5.11.2", + "wwbn/avideo": "<=29", + "xataface/xataface": "<3", + "xpressengine/xpressengine": "<3.0.15", + "yab/quarx": "<2.4.5", + "yansongda/pay": "<=3.7.19", + "yeswiki/yeswiki": "<4.6.6", + "yetiforce/yetiforce-crm": "<6.5", + "yidashi/yii2cmf": "<=2", + "yii2mod/yii2-cms": "<1.9.2", + "yiisoft/yii": "<1.1.31", + "yiisoft/yii2": "<2.0.55", + "yiisoft/yii2-authclient": "<2.2.15", + "yiisoft/yii2-bootstrap": "<2.0.4", + "yiisoft/yii2-dev": "<=2.0.45", + "yiisoft/yii2-elasticsearch": "<2.0.5", + "yiisoft/yii2-gii": "<=2.2.4", + "yiisoft/yii2-jui": "<2.0.4", + "yiisoft/yii2-redis": "<2.0.20", + "yikesinc/yikes-inc-easy-mailchimp-extender": "<6.8.6", + "yoast-seo-for-typo3/yoast_seo": "<7.2.3", + "yoast/duplicate-post": "<=4.5", + "yourls/yourls": "<=1.10.2", + "yuan1994/tpadmin": "<=1.3.12", + "yungifez/skuul": "<=2.6.5", + "z-push/z-push-dev": "<2.7.6", + "zencart/zencart": "<=1.5.7.0-beta", + "zendesk/zendesk_api_client_php": "<2.2.11", + "zendframework/zend-cache": ">=2.4,<2.4.8|>=2.5,<2.5.3", + "zendframework/zend-captcha": ">=2,<2.4.9|>=2.5,<2.5.2", + "zendframework/zend-crypt": ">=2,<2.4.9|>=2.5,<2.5.2", + "zendframework/zend-db": "<2.2.10|>=2.3,<2.3.5", + "zendframework/zend-developer-tools": ">=1.2.2,<1.2.3", + "zendframework/zend-diactoros": "<1.8.4", + "zendframework/zend-feed": "<2.10.3", + "zendframework/zend-form": ">=2,<2.2.7|>=2.3,<2.3.1", + "zendframework/zend-http": "<2.8.1", + "zendframework/zend-json": ">=2.1,<2.1.6|>=2.2,<2.2.6", + "zendframework/zend-ldap": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.8|>=2.3,<2.3.3", + "zendframework/zend-mail": "<2.4.11|>=2.5,<2.7.2", + "zendframework/zend-navigation": ">=2,<2.2.7|>=2.3,<2.3.1", + "zendframework/zend-session": ">=2,<2.2.9|>=2.3,<2.3.4", + "zendframework/zend-validator": ">=2.3,<2.3.6", + "zendframework/zend-view": ">=2,<2.2.7|>=2.3,<2.3.1", + "zendframework/zend-xmlrpc": ">=2.1,<2.1.6|>=2.2,<2.2.6", + "zendframework/zendframework": "<=3", + "zendframework/zendframework1": "<1.12.20", + "zendframework/zendopenid": "<2.0.2", + "zendframework/zendrest": "<2.0.2", + "zendframework/zendservice-amazon": "<2.0.3", + "zendframework/zendservice-api": "<1", + "zendframework/zendservice-audioscrobbler": "<2.0.2", + "zendframework/zendservice-nirvanix": "<2.0.2", + "zendframework/zendservice-slideshare": "<2.0.2", + "zendframework/zendservice-technorati": "<2.0.2", + "zendframework/zendservice-windowsazure": "<2.0.2", + "zendframework/zendxml": ">=1,<1.0.1", + "zenstruck/collection": "<0.2.1", + "zetacomponents/mail": "<1.8.2", + "zf-commons/zfc-user": "<1.2.2", + "zfcampus/zf-apigility-doctrine": ">=1,<1.0.3", + "zfr/zfr-oauth2-server-module": "<0.1.2", + "zoujingli/thinkadmin": "<=6.1.53", + "zumba/json-serializer": "<3.2.3" + }, + "default-branch": true, + "type": "metapackage", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "role": "maintainer" + }, + { + "name": "Ilya Tribusean", + "email": "slash3b@gmail.com", + "role": "maintainer" + } + ], + "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it", + "keywords": [ + "dev" + ], + "support": { + "issues": "https://github.com/Roave/SecurityAdvisories/issues", + "source": "https://github.com/Roave/SecurityAdvisories/tree/latest" + }, + "funding": [ + { + "url": "https://github.com/Ocramius", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/roave/security-advisories", + "type": "tidelift" + } + ], + "time": "2026-07-28T15:20:31+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.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c025fc7604afab3f195fab7cdaf72327331af241", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.3-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.3.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-06-05T03:06:45+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": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2", + "symfony/process": "^7.4.13" + }, + "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" + }, + { + "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/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/diff", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:04:51+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.11" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-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.3.2" + }, + "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-05-25T13:41:38+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-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.1.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/exporter", + "type": "tidelift" + } + ], + "time": "2026-07-13T11:35:11+00:00" + }, + { + "name": "sebastian/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.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 filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.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/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.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 describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.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/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.1.13" + }, + "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.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/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:11:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.8.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "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.2" + }, + "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-07-09T08:42:34+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.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "fee0309275847fefd7636167085e379c1dbf6990" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "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.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/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:49:11+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/event-dispatcher", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "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.1.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": "2026-06-09T12:28:30+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "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.7-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.7.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": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "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.1.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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/e2989e762c70f9490fa3a00a0ac0fae5aa97a531", + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "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.1.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": "2026-06-27T09:05:56+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "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.1.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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "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.38.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": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/process", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "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.1.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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "21c07b026905d596e8379caeb115d87aa479499d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", + "reference": "21c07b026905d596e8379caeb115d87aa479499d", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "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.1.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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "40096a2515a979f3125c5c928603995b8664c62a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/40096a2515a979f3125c5c928603995b8664c62a", + "reference": "40096a2515a979f3125c5c928603995b8664c62a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "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 mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v8.1.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": "2026-06-09T10:54:51+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" + }, + { + "name": "webifycms/dev-tools", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/webifycms/dev-tools.git", + "reference": "422e86d1f6751f3c1a5129ce9b46cc4689526e78" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webifycms/dev-tools/zipball/422e86d1f6751f3c1a5129ce9b46cc4689526e78", + "reference": "422e86d1f6751f3c1a5129ce9b46cc4689526e78", + "shasum": "" + }, + "require": { + "friendsofphp/php-cs-fixer": "^3.0", + "php": ">=8.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "rector/rector": "^2.0", + "symfony/var-dumper": "^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^13", + "roave/security-advisories": "dev-latest" + }, + "type": "webifycms-tool", + "autoload": { + "psr-4": { + "Webify\\Tools\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mohammed Shifreen", + "email": "mshifreen@gmail.com" + } + ], + "description": "Set of development tools to analyze and auto fix code standards, formatting and other stuffs for WebifyCMS packages.", + "support": { + "issues": "https://github.com/webifycms/dev-tools/issues", + "source": "https://github.com/webifycms/dev-tools/tree/v1.2.1" + }, + "time": "2026-06-01T16:09:42+00:00" + } + ], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": { + "roave/security-advisories": 20 + }, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.4" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 0000000..9e882d3 --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,32 @@ +# The file is part of the "webifycms/ext-user", WebifyCMS development tools. +# +# @see https://webifycms.com/extenion/user +# +# @license Copyright (c) 2023 - Present WebifyCMS +# @license https://webifycms.com/license +# @author Mohammed Shifreen + +includes: + - vendor/webifycms/dev-tools/phpstan-default.neon + +parameters: + paths: + - src + - test + excludePaths: + - vendor + reportUnmatchedIgnoredErrors: false + +services: + - + class: Webify\User\Test\Architecture\AuthenticationSpecification + tags: + - phpat.test + - + class: Webify\User\Test\Architecture\AuthorizationSpecification + tags: + - phpat.test + - + class: Webify\User\Test\Architecture\IdentitySpecification + tags: + - phpat.test \ No newline at end of file diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..0b7e9cd --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,37 @@ + + + + + test/User/Authentication + + + + test/User/Authorization + + + + test/User/Identity + + + + + + src + + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..a16416d --- /dev/null +++ b/rector.php @@ -0,0 +1,23 @@ + + */ +declare(strict_types=1); + +use Webify\Tools\Rector\Rector; + +return new Rector() + ->initialize( + [ + __DIR__ . '/src', + __DIR__ . '/test', + ] + )->withPhpSets(php84: true) +; diff --git a/src/Authentication/Application/Query/Session/FindSessionByIdInterface.php b/src/Authentication/Application/Query/Session/FindSessionByIdInterface.php new file mode 100644 index 0000000..8617ee6 --- /dev/null +++ b/src/Authentication/Application/Query/Session/FindSessionByIdInterface.php @@ -0,0 +1,31 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Application\Query\Session; + +use Webify\User\Authentication\Domain\ValueObject\SessionId; + +/** + * Query class defines the contract for a query to find a session by its identifier. + */ +interface FindSessionByIdInterface +{ + /** + * Finds a session by its identifier. + * + * @param SessionId $id the identifier of the session to find + * + * @return null|Session the session if found, or null if no session matches the given identifier + */ + public function find(SessionId $id): ?Session; +} diff --git a/src/Authentication/Application/Query/Session/FindSessionsByUserInterface.php b/src/Authentication/Application/Query/Session/FindSessionsByUserInterface.php new file mode 100644 index 0000000..d94ef09 --- /dev/null +++ b/src/Authentication/Application/Query/Session/FindSessionsByUserInterface.php @@ -0,0 +1,42 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Application\Query\Session; + +use Webify\User\Authentication\Domain\ValueObject\ChallengeSecretInterface; + +/** + * Query class defines the contract for a query to find sessions by user. + */ +interface FindSessionsByUserInterface +{ + /** + * Finds all sessions by user. + * + * @param ChallengeSecretInterface $userId the identifier of the user whose sessions are to be found + * + * @return SessionCollection a collection of sessions associated with the given user identifier, + * or an empty session collection if no sessions exist + */ + public function find(ChallengeSecretInterface $userId): SessionCollection; + + /** + * Finds all active sessions for the user. + * + * @param ChallengeSecretInterface $userId the identifier of the user whose active sessions are to be found + * + * @return SessionCollection a collection of active sessions associated with the given user identifier, + * or an empty session collection if no active sessions exist + */ + public function findActive(ChallengeSecretInterface $userId): SessionCollection; +} diff --git a/src/Authentication/Application/Query/Session/Session.php b/src/Authentication/Application/Query/Session/Session.php new file mode 100644 index 0000000..02bcf87 --- /dev/null +++ b/src/Authentication/Application/Query/Session/Session.php @@ -0,0 +1,33 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Application\Query\Session; + +/** + * Session read model. + */ +final readonly class Session +{ + /** + * The constructor. + */ + public function __construct( + public string $id, + public string $userId, + public string $accessToken, + public string $refreshToken, + public string $expiresAt, + public string $createdAt, + public bool $revoked + ) {} +} diff --git a/src/Authentication/Application/Query/Session/SessionCollection.php b/src/Authentication/Application/Query/Session/SessionCollection.php new file mode 100644 index 0000000..60cf474 --- /dev/null +++ b/src/Authentication/Application/Query/Session/SessionCollection.php @@ -0,0 +1,32 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Application\Query\Session; + +use Webify\Base\Contract\Collection\Collection; + +/** + * Collection class for Session's read models. + * + * @extends Collection + */ +final class SessionCollection extends Collection +{ + /** + * {@inheritDoc} + */ + protected function type(): string + { + return Session::class; + } +} diff --git a/src/Authentication/Domain/Entity/Challenge.php b/src/Authentication/Domain/Entity/Challenge.php new file mode 100644 index 0000000..fcaa6cb --- /dev/null +++ b/src/Authentication/Domain/Entity/Challenge.php @@ -0,0 +1,207 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Entity; + +use Webify\Base\Domain\Entity\AggregateRoot; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Event\{ChallengeFailed, ChallengeVerified}; +use Webify\User\Authentication\Domain\Exception\{ChallengeVerificationFailedException, InvalidChallengeSecretException}; +use Webify\User\Authentication\Domain\ValueObject\{ + ChallengeCode, + ChallengeId, + ChallengeSecretInterface, + ChallengeStatus, + ChallengeToken, + ChallengeType, + UserId +}; + +/** + * Challenge aggregate root. + * + * The aggregate root to handle the 2FA authentication challenge It should support challenge types. + * The aggregate protects the invariants that make these flows secure: + * - A verified challenge cannot be verified again (replay protection). + * - An expired challenge cannot be verified (time-bounding). + * - Excessive failed attempts to lock the challenge (brute-force protection). + */ +final class Challenge extends AggregateRoot +{ + /** + * The maximum number of attempts allowed for a challenge. + */ + private const int MAX_ATTEMPTS = 6; + + /** + * Indicates whether the challenge has been verified. + */ + private bool $isVerified = false; + + /** + * The number of attempts made to verify the challenge. + */ + private int $attempts = 0; + + /** + * Private constructor enforces the use of the factory methods to initiate this entity. + */ + private function __construct( + private readonly ChallengeId $id, + private readonly UserId $userId, + private readonly ChallengeType $type, + private readonly ChallengeSecretInterface $secret, + private readonly DateTime $expiresAt, + private readonly DateTime $issuedAt, + private ChallengeStatus $status + ) {} + + /** + * Returns the identifier of the challenge. + */ + public function getId(): ChallengeId + { + return $this->id; + } + + /** + * Returns the identifier of the user associated with the challenge. + */ + public function getUserId(): UserId + { + return $this->userId; + } + + /** + * Returns the type of the challenge. + */ + public function getType(): ChallengeType + { + return $this->type; + } + + /** + * Returns the secret associated with the challenge. + */ + public function getSecret(): ChallengeSecretInterface + { + return $this->secret; + } + + /** + * Returns the expiration date of the challenge. + */ + public function getExpiresAt(): DateTime + { + return $this->expiresAt; + } + + /** + * Returns the date and time when the challenge was issued. + */ + public function getIssuedAt(): DateTime + { + return $this->issuedAt; + } + + /** + * Returns the status of the challenge. + */ + public function getStatus(): ChallengeStatus + { + return $this->status; + } + + /** + * Checks if the challenge has been verified. + */ + public function isVerified(): bool + { + return $this->isVerified; + } + + /** + * Checks if the challenge has expired. + */ + public function isExpired(): bool + { + return $this->expiresAt->isBefore(DateTime::now()); + } + + /** + * Verifies the challenge using the provided secret. + * + * @throws ChallengeVerificationFailedException if the challenge is expired, already verified, + * or exceeds the maximum number of attempts + * @throws InvalidChallengeSecretException if the secret is invalid + */ + public function verify(ChallengeSecretInterface $secret): void + { + if ($this->isExpired()) { + throw ChallengeVerificationFailedException::forExpired($this->type->value); + } + + if ($this->isVerified()) { + throw ChallengeVerificationFailedException::forAlreadyVerified($this->type->value); + } + + if (self::MAX_ATTEMPTS <= $this->attempts) { + throw ChallengeVerificationFailedException::forMaximumAttempts($this->type->value); + } + + if (!$this->secret->verify($secret)) { + ++$this->attempts; + + $this->recordDomainEvent( + new ChallengeFailed( + $this->id->toNative(), + $this->userId->toNative(), + $this->type->value, + $this->attempts, + DateTime::now()->toNative() + ) + ); + + throw InvalidChallengeSecretException::create(); + } + + $this->isVerified = true; + $this->status = ChallengeStatus::Completed; + + $this->recordDomainEvent( + new ChallengeVerified( + $this->id->toNative(), + $this->userId->toNative(), + $this->type->value, + $this->expiresAt->toNative(), + $this->issuedAt->toNative(), + $this->status->value, + DateTime::now()->toNative() + ) + ); + } + + /** + * Issues a new challenge for the specified user. + */ + public static function issue( + ChallengeId $id, + UserId $userId, + ChallengeType $type, + DateTime $expiresAt + ): self { + $secret = $type->isCode() ? ChallengeCode::generate() : ChallengeToken::generate(); + + return new self($id, $userId, $type, $secret, $expiresAt, DateTime::now(), ChallengeStatus::Pending); + } +} diff --git a/src/Authentication/Domain/Entity/Session.php b/src/Authentication/Domain/Entity/Session.php new file mode 100644 index 0000000..233c8c9 --- /dev/null +++ b/src/Authentication/Domain/Entity/Session.php @@ -0,0 +1,205 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Entity; + +use Webify\Base\Domain\Entity\AggregateRoot; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Event\{SessionWasOpened, SessionWasRefreshed, SessionWasRevoked}; +use Webify\User\Authentication\Domain\Exception\CannotRefreshSessionException; +use Webify\User\Authentication\Domain\ValueObject\{ + AccessToken, + RefreshToken, + SessionId, + UserId +}; + +/** + * Session aggregate root. + * + * Represents the lifecycle of a single authenticated user session. A Session holds an AccessToken, + * a RefreshToken, a reference to the authenticated user, and optional expiry. It encapsulates + * the business rules around token rotation and session expiry. + */ +final class Session extends AggregateRoot +{ + /** + * Private constructor enforces the use of the factory methods to initiate this aggregate root. + * + * @param SessionId $id unique session identifier + * @param UserId $userId user's identifier + * @param AccessToken $accessToken token used for accessing resources + * @param RefreshToken $refreshToken token used to refresh expired access tokens + * @param DateTime $expiresAt date and time when the session expires + * @param DateTime $createdAt date and time when the session was created + * @param bool $revoked indicates whether the session has been revoked. Defaults to false. + */ + private function __construct( + private readonly SessionId $id, + private readonly UserId $userId, + private AccessToken $accessToken, + private RefreshToken $refreshToken, + private DateTime $expiresAt, + private readonly DateTime $createdAt, + private bool $revoked = false + ) {} + + /** + * Get the ID. + */ + public function getId(): SessionId + { + return $this->id; + } + + /** + * Get the user ID. + */ + public function getUserId(): UserId + { + return $this->userId; + } + + /** + * Get the access token. + */ + public function getAccessToken(): AccessToken + { + return $this->accessToken; + } + + /** + * Get the refresh token. + */ + public function getRefreshToken(): RefreshToken + { + return $this->refreshToken; + } + + /** + * Get the expiration date of the session. + */ + public function getExpiresAt(): DateTime + { + return $this->expiresAt; + } + + /** + * Get the creation date of the session. + */ + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + /** + * Refresh the session with new access and refresh tokens and extend expiry. + */ + public function refresh(AccessToken $newAccessToken, RefreshToken $newRefreshToken, DateTime $newExpireAt): void + { + if ($this->isExpired() || $this->isRevoked()) { + throw CannotRefreshSessionException::create(); + } + + $this->accessToken = $newAccessToken; + $this->refreshToken = $newRefreshToken; + $this->expiresAt = $newExpireAt; + + $this->recordDomainEvent( + new SessionWasRefreshed( + $this->id->toNative(), + $this->userId->toNative(), + $this->expiresAt->toNative(), + $this->createdAt->toNative() + ) + ); + } + + /** + * Revoke the session. + */ + public function revoke(): void + { + // Prevent revocation if the session has already been revoked (idempotent behaviour). + if ($this->revoked) { + return; + } + + $this->revoked = true; + + $this->recordDomainEvent( + new SessionWasRevoked( + $this->id->toNative(), + $this->userId->toNative(), + $this->expiresAt->toNative(), + $this->createdAt->toNative() + ) + ); + } + + /** + * Check if the session has expired. + */ + public function isExpired(): bool + { + return DateTime::now() > $this->expiresAt; + } + + /** + * Check if the session is active. + */ + public function isActive(): bool + { + return !$this->isExpired() && !$this->isRevoked(); + } + + /** + * Check if the session has been revoked. + */ + public function isRevoked(): bool + { + return $this->revoked; + } + + /** + * Open a new session with the provided details. + * + * @param SessionId $id the unique identifier for the session + * @param UserId $userId the user's unique identifier + * @param AccessToken $accessToken the access token for the session + * @param RefreshToken $refreshToken the refresh token for the session + * @param DateTime $expiresAt the expiration date and time for the session + * + * @return Session a new session instance + */ + public static function open( + SessionId $id, + UserId $userId, + AccessToken $accessToken, + RefreshToken $refreshToken, + DateTime $expiresAt + ): Session { + $session = new self($id, $userId, $accessToken, $refreshToken, $expiresAt, DateTime::now()); + + $session->recordDomainEvent( + new SessionWasOpened( + $session->getId()->toNative(), + $session->getUserId()->toNative(), + $session->getExpiresAt()->toNative(), + $session->getCreatedAt()->toNative() + ) + ); + + return $session; + } +} diff --git a/src/Authentication/Domain/Event/ChallengeFailed.php b/src/Authentication/Domain/Event/ChallengeFailed.php new file mode 100644 index 0000000..361f65f --- /dev/null +++ b/src/Authentication/Domain/Event/ChallengeFailed.php @@ -0,0 +1,50 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * The event that is triggered when an authentication challenge failed. + */ +final readonly class ChallengeFailed implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $challengeId, + public string $userId, + public string $type, + public int $attempts, + public DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.authentication.challenge_failed'; + } +} diff --git a/src/Authentication/Domain/Event/ChallengeVerified.php b/src/Authentication/Domain/Event/ChallengeVerified.php new file mode 100644 index 0000000..7f0db81 --- /dev/null +++ b/src/Authentication/Domain/Event/ChallengeVerified.php @@ -0,0 +1,52 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * The event that is triggered when an authentication challenge verified. + */ +final readonly class ChallengeVerified implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $challengeId, + public string $userId, + public string $type, + public DateTimeImmutable $expiresAt, + public DateTimeImmutable $issuedAt, + public string $status, + public DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.authentication.challenge_verified'; + } +} diff --git a/src/Authentication/Domain/Event/SessionWasOpened.php b/src/Authentication/Domain/Event/SessionWasOpened.php new file mode 100644 index 0000000..5587f76 --- /dev/null +++ b/src/Authentication/Domain/Event/SessionWasOpened.php @@ -0,0 +1,49 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * The event that is triggered when a session was opened. + */ +final readonly class SessionWasOpened implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $sessionId, + public string $userId, + public DateTimeImmutable $expiresAt, + public DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.authentication.session_was_opened'; + } +} diff --git a/src/Authentication/Domain/Event/SessionWasRefreshed.php b/src/Authentication/Domain/Event/SessionWasRefreshed.php new file mode 100644 index 0000000..2f2fc79 --- /dev/null +++ b/src/Authentication/Domain/Event/SessionWasRefreshed.php @@ -0,0 +1,49 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * The event that is triggered when a session was refreshed. + */ +final readonly class SessionWasRefreshed implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $sessionId, + public string $userId, + public DateTimeImmutable $expiresAt, + public DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.authentication.session_was_refreshed'; + } +} diff --git a/src/Authentication/Domain/Event/SessionWasRevoked.php b/src/Authentication/Domain/Event/SessionWasRevoked.php new file mode 100644 index 0000000..91bd071 --- /dev/null +++ b/src/Authentication/Domain/Event/SessionWasRevoked.php @@ -0,0 +1,49 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * The event that is triggered when a session was revoked. + */ +final readonly class SessionWasRevoked implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $sessionId, + public string $userId, + public DateTimeImmutable $expiresAt, + public DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.authentication.session_was_revoked'; + } +} diff --git a/src/Authentication/Domain/Exception/AccessTokenGenerationFailedException.php b/src/Authentication/Domain/Exception/AccessTokenGenerationFailedException.php new file mode 100644 index 0000000..baed523 --- /dev/null +++ b/src/Authentication/Domain/Exception/AccessTokenGenerationFailedException.php @@ -0,0 +1,55 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use RuntimeException; +use Throwable; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when a access token generation fails. + */ +final class AccessTokenGenerationFailedException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + * @param Throwable $previous the previous throwable used for the exception chaining (optional) + */ + private function __construct( + public readonly Throwable $previous, + public readonly ExceptionTranslation $translation, + string $message = '', + ) { + parent::__construct($message, 0, $previous); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function create(Throwable $throwable): AccessTokenGenerationFailedException + { + return new self( + $throwable, + new ExceptionTranslation( + 'user.authentication', + 'access_token_generation_failed', + ), + 'Unable to generate access token.', + ); + } +} diff --git a/src/Authentication/Domain/Exception/AuthenticationFailedException.php b/src/Authentication/Domain/Exception/AuthenticationFailedException.php new file mode 100644 index 0000000..7dc5ee8 --- /dev/null +++ b/src/Authentication/Domain/Exception/AuthenticationFailedException.php @@ -0,0 +1,139 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when invalid user credential is encountered. + */ +final class AuthenticationFailedException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this when an email is missing. + */ + public static function forMissingEmail(): AuthenticationFailedException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'email_required_for_authentication', + ), + 'Email is required for authentication.' + ); + } + + /** + * Factory method to initiate this when a password is missing. + */ + public static function forMissingPassword(): AuthenticationFailedException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'password_required_for_authentication', + ), + 'Password is required for authentication.' + ); + } + + /** + * Factory method to initiate this when a secret is missing. + */ + public static function forMissingSecret(): AuthenticationFailedException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'secret_required_for_authentication', + ), + 'Secret is required for passwordless authentication.' + ); + } + + /** + * Factory method to initiate this when invalid user credentials are encountered. + */ + public static function forInvalid(): AuthenticationFailedException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'invalid_user_credentials', + ), + 'Invalid user credentials.' + ); + } + + /** + * Factory method to initiate this when a user is not found. + * + * @param string $email the email used to authenticate + */ + public static function userNotFound(string $email): AuthenticationFailedException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'user_not_found', + ['email' => $email] + ), + sprintf('User with email "%s" not found.', $email) + ); + } + + /** + * Factory method to initiate this when a user is not active. + */ + public static function userNotActive(): AuthenticationFailedException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'user_not_active' + ), + 'User is not active.' + ); + } + + /** + * Factory method to initiate this when an authentication strategy is not supported or not found. + */ + public static function strategyNotSupported(string $strategyIdentifier): AuthenticationFailedException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'strategy_not_supported', + ['strategy' => $strategyIdentifier] + ), + sprintf('Authentication strategy "%s" is not supported.', $strategyIdentifier) + ); + } +} diff --git a/src/Authentication/Domain/Exception/CannotRefreshSessionException.php b/src/Authentication/Domain/Exception/CannotRefreshSessionException.php new file mode 100644 index 0000000..5ff78d8 --- /dev/null +++ b/src/Authentication/Domain/Exception/CannotRefreshSessionException.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use DomainException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when a session cannot be refreshed. + */ +final class CannotRefreshSessionException extends DomainException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create an exception for a session that cannot be refreshed due to it being expired or revoked. + */ + public static function create(): CannotRefreshSessionException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'expired_or_revoked_session', + ), + 'Cannot refresh session. Session has expired or has been revoked.' + ); + } +} diff --git a/src/Authentication/Domain/Exception/ChallengeCodeGenerationFailedException.php b/src/Authentication/Domain/Exception/ChallengeCodeGenerationFailedException.php new file mode 100644 index 0000000..1fabf19 --- /dev/null +++ b/src/Authentication/Domain/Exception/ChallengeCodeGenerationFailedException.php @@ -0,0 +1,55 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use RuntimeException; +use Throwable; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when a challenge code generation fails. + */ +final class ChallengeCodeGenerationFailedException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + * @param Throwable $previous the previous throwable used for the exception chaining (optional) + */ + private function __construct( + public readonly Throwable $previous, + public readonly ExceptionTranslation $translation, + string $message = '', + ) { + parent::__construct($message, 0, $previous); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function create(Throwable $throwable): ChallengeCodeGenerationFailedException + { + return new self( + $throwable, + new ExceptionTranslation( + 'user.authentication', + 'challenge_code_generation_failed', + ), + 'Unable to generate authentication challenge code.', + ); + } +} diff --git a/src/Authentication/Domain/Exception/ChallengeNotFoundException.php b/src/Authentication/Domain/Exception/ChallengeNotFoundException.php new file mode 100644 index 0000000..49f1704 --- /dev/null +++ b/src/Authentication/Domain/Exception/ChallengeNotFoundException.php @@ -0,0 +1,67 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use RuntimeException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when an authentication challenge is not found. + */ +final class ChallengeNotFoundException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Creates a new instance for the given authentication challenge ID. + */ + public static function forId(string $id): ChallengeNotFoundException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'challenge_not_found_for_id', + ['id' => $id] + ), + sprintf('Authentication challenge not found for id: "%s"', $id) + ); + } + + /** + * Creates a new instance for the given authentication challenge secret. + */ + public static function forSecret(string $token): ChallengeNotFoundException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'challenge_not_found_for_secret', + ['token' => $token] + ), + sprintf('Authentication challenge not found for secret: "%s"', $token) + ); + } +} diff --git a/src/Authentication/Domain/Exception/ChallengeTokenGenerationFailedException.php b/src/Authentication/Domain/Exception/ChallengeTokenGenerationFailedException.php new file mode 100644 index 0000000..299e99c --- /dev/null +++ b/src/Authentication/Domain/Exception/ChallengeTokenGenerationFailedException.php @@ -0,0 +1,55 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use RuntimeException; +use Throwable; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when challenge token generation fails. + */ +final class ChallengeTokenGenerationFailedException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + * @param Throwable $previous the previous throwable used for the exception chaining (optional) + */ + private function __construct( + public readonly Throwable $previous, + public readonly ExceptionTranslation $translation, + string $message = '', + ) { + parent::__construct($message, 0, $previous); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function create(Throwable $throwable): ChallengeTokenGenerationFailedException + { + return new self( + $throwable, + new ExceptionTranslation( + 'user.authentication', + 'challenge_token_generation_failed', + ), + 'Unable to generate secure challenge token.', + ); + } +} diff --git a/src/Authentication/Domain/Exception/ChallengeVerificationFailedException.php b/src/Authentication/Domain/Exception/ChallengeVerificationFailedException.php new file mode 100644 index 0000000..10dc185 --- /dev/null +++ b/src/Authentication/Domain/Exception/ChallengeVerificationFailedException.php @@ -0,0 +1,81 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use DomainException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when an authentication challenge fails to verify. + */ +final class ChallengeVerificationFailedException extends DomainException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create an exception for an expired verification code or token supplied by the user. + */ + public static function forExpired(string $type): ChallengeVerificationFailedException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'challenge_expired', + ['type' => $type] + ), + sprintf('Authentication challenge verification failed due to expired %s.', $type) + ); + } + + /** + * Factory method to create an exception for an already verified code or token supplied by the user. + */ + public static function forAlreadyVerified(string $type): ChallengeVerificationFailedException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'challenge_already_verified', + ['type' => $type] + ), + sprintf('The authentication challenge %s has already been verified.', $type) + ); + } + + /** + * Factory method to create an exception for maximum attempts reached. + */ + public static function forMaximumAttempts(string $type): ChallengeVerificationFailedException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'challenge_maximum_attempts_reached', + ), + sprintf('Maximum attempts reached for authentication challenge %s.', $type) + ); + } +} diff --git a/src/Authentication/Domain/Exception/InvalidAccessTokenException.php b/src/Authentication/Domain/Exception/InvalidAccessTokenException.php new file mode 100644 index 0000000..6260fc8 --- /dev/null +++ b/src/Authentication/Domain/Exception/InvalidAccessTokenException.php @@ -0,0 +1,52 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid access token is encountered. + */ +final class InvalidAccessTokenException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function forInvalidAccessToken(string $token): InvalidAccessTokenException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'invalid_access_token', + ['token' => $token] + ), + sprintf('The access token "%s" is invalid.', $token) + ); + } +} diff --git a/src/Authentication/Domain/Exception/InvalidChallengeCodeException.php b/src/Authentication/Domain/Exception/InvalidChallengeCodeException.php new file mode 100644 index 0000000..13a5eb6 --- /dev/null +++ b/src/Authentication/Domain/Exception/InvalidChallengeCodeException.php @@ -0,0 +1,52 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid challenge code is encountered. + */ +final class InvalidChallengeCodeException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function forInvalidCode(string $code): InvalidChallengeCodeException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'invalid_challenge_code', + ['code' => $code] + ), + sprintf('The authentication challenge code "%s" is invalid.', $code) + ); + } +} diff --git a/src/Authentication/Domain/Exception/InvalidChallengeIdException.php b/src/Authentication/Domain/Exception/InvalidChallengeIdException.php new file mode 100644 index 0000000..0898aed --- /dev/null +++ b/src/Authentication/Domain/Exception/InvalidChallengeIdException.php @@ -0,0 +1,52 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid challenge ID is encountered. + */ +final class InvalidChallengeIdException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function forInvalidId(string $id): InvalidChallengeIdException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'invalid_challenge_id', + ['id' => $id] + ), + sprintf('The authentication challenge ID "%s" is invalid.', $id) + ); + } +} diff --git a/src/Authentication/Domain/Exception/InvalidChallengeSecretException.php b/src/Authentication/Domain/Exception/InvalidChallengeSecretException.php new file mode 100644 index 0000000..2e403c8 --- /dev/null +++ b/src/Authentication/Domain/Exception/InvalidChallengeSecretException.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use DomainException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when encountered an invalid authentication challenge secret during verification. + */ +final class InvalidChallengeSecretException extends DomainException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create an exception for an invalid verification code or token supplied by the user. + */ + public static function create(): InvalidChallengeSecretException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'challenge_invalid_secret', + ), + 'Provided authentication challenge secret is invalid.' + ); + } +} diff --git a/src/Authentication/Domain/Exception/InvalidChallengeTokenException.php b/src/Authentication/Domain/Exception/InvalidChallengeTokenException.php new file mode 100644 index 0000000..32eff8c --- /dev/null +++ b/src/Authentication/Domain/Exception/InvalidChallengeTokenException.php @@ -0,0 +1,52 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid challenge token is encountered. + */ +final class InvalidChallengeTokenException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function forInvalidToken(string $token): InvalidChallengeTokenException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'invalid_challenge_token', + ['token' => $token] + ), + sprintf('The authentication challenge token "%s" is invalid.', $token) + ); + } +} diff --git a/src/Authentication/Domain/Exception/InvalidChallengeTypeException.php b/src/Authentication/Domain/Exception/InvalidChallengeTypeException.php new file mode 100644 index 0000000..5c66f32 --- /dev/null +++ b/src/Authentication/Domain/Exception/InvalidChallengeTypeException.php @@ -0,0 +1,52 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid authentication challenge type is encountered. + */ +final class InvalidChallengeTypeException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function forInvalidType(string $type): InvalidChallengeTypeException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'invalid_challenge_type', + ['type' => $type] + ), + sprintf('The authentication challenge type "%s" is invalid.', $type) + ); + } +} diff --git a/src/Authentication/Domain/Exception/InvalidRefreshTokenException.php b/src/Authentication/Domain/Exception/InvalidRefreshTokenException.php new file mode 100644 index 0000000..665462b --- /dev/null +++ b/src/Authentication/Domain/Exception/InvalidRefreshTokenException.php @@ -0,0 +1,52 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid refresh token is encountered. + */ +final class InvalidRefreshTokenException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function forInvalidRefreshToken(string $token): InvalidRefreshTokenException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'invalid_refresh_token', + ['token' => $token] + ), + sprintf('The refresh token "%s" is invalid.', $token) + ); + } +} diff --git a/src/Authentication/Domain/Exception/InvalidSessionIdException.php b/src/Authentication/Domain/Exception/InvalidSessionIdException.php new file mode 100644 index 0000000..7925a8d --- /dev/null +++ b/src/Authentication/Domain/Exception/InvalidSessionIdException.php @@ -0,0 +1,52 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid session ID is encountered. + */ +final class InvalidSessionIdException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function forInvalidId(string $id): InvalidSessionIdException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'invalid_session_id', + ['id' => $id] + ), + sprintf('The session ID "%s" is invalid.', $id) + ); + } +} diff --git a/src/Authentication/Domain/Exception/InvalidUserCredentialException.php b/src/Authentication/Domain/Exception/InvalidUserCredentialException.php new file mode 100644 index 0000000..d01df68 --- /dev/null +++ b/src/Authentication/Domain/Exception/InvalidUserCredentialException.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when invalid user credential is encountered. + */ +final class InvalidUserCredentialException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function create(): InvalidUserCredentialException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'invalid_user_credential', + ), + 'Invalid user credential.' + ); + } +} diff --git a/src/Authentication/Domain/Exception/InvalidUserIdException.php b/src/Authentication/Domain/Exception/InvalidUserIdException.php new file mode 100644 index 0000000..1bae4c7 --- /dev/null +++ b/src/Authentication/Domain/Exception/InvalidUserIdException.php @@ -0,0 +1,52 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid user ID is encountered. + */ +final class InvalidUserIdException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function forInvalidId(string $id): InvalidUserIdException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'invalid_user_id', + ['id' => $id] + ), + sprintf('The authentication user ID "%s" is invalid.', $id) + ); + } +} diff --git a/src/Authentication/Domain/Exception/RefreshTokenGenerationFailedException.php b/src/Authentication/Domain/Exception/RefreshTokenGenerationFailedException.php new file mode 100644 index 0000000..00f0d4a --- /dev/null +++ b/src/Authentication/Domain/Exception/RefreshTokenGenerationFailedException.php @@ -0,0 +1,55 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use RuntimeException; +use Throwable; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when a refresh token generation fails. + */ +final class RefreshTokenGenerationFailedException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + * @param Throwable $previous the previous throwable used for the exception chaining (optional) + */ + private function __construct( + public readonly Throwable $previous, + public readonly ExceptionTranslation $translation, + string $message = '', + ) { + parent::__construct($message, 0, $previous); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function create(Throwable $throwable): RefreshTokenGenerationFailedException + { + return new self( + $throwable, + new ExceptionTranslation( + 'user.authentication', + 'refresh_token_generation_failed', + ), + 'Unable to generate refresh token.', + ); + } +} diff --git a/src/Authentication/Domain/Exception/SessionNotFoundException.php b/src/Authentication/Domain/Exception/SessionNotFoundException.php new file mode 100644 index 0000000..4e6ae2f --- /dev/null +++ b/src/Authentication/Domain/Exception/SessionNotFoundException.php @@ -0,0 +1,82 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Exception; + +use RuntimeException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when the session is not found. + */ +final class SessionNotFoundException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Creates a new instance of SessionNotFoundException for the given session ID. + */ + public static function forId(string $id): SessionNotFoundException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'session_not_found_for_id', + ['id' => $id] + ), + sprintf('Session not found for id: "%s"', $id) + ); + } + + /** + * Creates a new instance of SessionNotFoundException for the given access token. + */ + public static function forAccessToken(string $token): SessionNotFoundException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'session_not_found_for_access_token', + ['token' => $token] + ), + sprintf('Session not found for access token: "%s"', $token) + ); + } + + /** + * Creates a new instance of SessionNotFoundException for the given refresh token. + */ + public static function forRefreshToken(string $token): SessionNotFoundException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'session_not_found_for_refresh_token', + ['token' => $token] + ), + sprintf('Session not found for refresh token: "%s"', $token) + ); + } +} diff --git a/src/Authentication/Domain/Guard/UserMustBeActive.php b/src/Authentication/Domain/Guard/UserMustBeActive.php new file mode 100644 index 0000000..95552e1 --- /dev/null +++ b/src/Authentication/Domain/Guard/UserMustBeActive.php @@ -0,0 +1,35 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Guard; + +use Webify\User\Authentication\Domain\Exception\AuthenticationFailedException; +use Webify\User\Authentication\Domain\ValueObject\UserStatus; + +/** + * Guard that ensures that the user is active. + */ +final class UserMustBeActive +{ + /** + * Ensures that the given status meets the required conditions. + * + * @param UserStatus $status the status to be checked + */ + public function guard(UserStatus $status): void + { + if (!$status->isActive()) { + throw AuthenticationFailedException::userNotActive(); + } + } +} diff --git a/src/Authentication/Domain/Repository/ChallengeRepositoryInterface.php b/src/Authentication/Domain/Repository/ChallengeRepositoryInterface.php new file mode 100644 index 0000000..b3c6945 --- /dev/null +++ b/src/Authentication/Domain/Repository/ChallengeRepositoryInterface.php @@ -0,0 +1,53 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Repository; + +use Webify\User\Authentication\Domain\Entity\Challenge; +use Webify\User\Authentication\Domain\Exception\ChallengeNotFoundException; +use Webify\User\Authentication\Domain\ValueObject\{ChallengeId, UserId}; + +/** + * ChallengeRepositoryInterface defines the contract for an authentication challenge repository. + */ +interface ChallengeRepositoryInterface +{ + /** + * Get an authentication challenge by its ID. + * + * @throws ChallengeNotFoundException if the challenge with the given ID is not found + */ + public function getById(ChallengeId $id): Challenge; + + /** + * Get an authentication challenge by its user ID. + * + * @throws ChallengeNotFoundException if the challenge with the given user ID is not found + */ + public function getByUser(UserId $userId): Challenge; + + /** + * Persists the given authentication challenge object to the data store. + */ + public function persist(Challenge $challenge): void; + + /** + * Removes the specified authentication challenge from the data store. + */ + public function delete(Challenge $challenge): void; + + /** + * Removes all stale authentication challenges (expired and verified) for the specified user. + */ + public function deleteStale(UserId $userId): void; +} diff --git a/src/Authentication/Domain/Repository/SessionRepositoryInterface.php b/src/Authentication/Domain/Repository/SessionRepositoryInterface.php new file mode 100644 index 0000000..01bb4dc --- /dev/null +++ b/src/Authentication/Domain/Repository/SessionRepositoryInterface.php @@ -0,0 +1,60 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Repository; + +use Webify\User\Authentication\Domain\Entity\Session; +use Webify\User\Authentication\Domain\Exception\SessionNotFoundException; +use Webify\User\Authentication\Domain\ValueObject\{AccessToken, ChallengeSecretInterface, RefreshToken, SessionId}; + +/** + * SessionRepositoryInterface defines the contract for the session repository. + */ +interface SessionRepositoryInterface +{ + /** + * Get a session by its ID. + * + * @throws SessionNotFoundException if the session with the given ID is not found + */ + public function getById(SessionId $id): Session; + + /** + * Get a session by access token. + * + * @throws SessionNotFoundException if the session with the given access token is not found + */ + public function getByAccessToken(AccessToken $accessToken): Session; + + /** + * Get a session by refresh token. + * + * @throws SessionNotFoundException if the session with the given refresh token is not found + */ + public function getByRefreshToken(RefreshToken $refreshToken): Session; + + /** + * Persists the given Session object to the data store. + */ + public function persist(Session $session): void; + + /** + * Deletes the specified Session. + */ + public function delete(Session $session): void; + + /** + * Removes all stale sessions (expired and revoked) for the specified user. + */ + public function deleteStale(ChallengeSecretInterface $userId): void; +} diff --git a/src/Authentication/Domain/Service/ChallengeBase.php b/src/Authentication/Domain/Service/ChallengeBase.php new file mode 100644 index 0000000..e3986f8 --- /dev/null +++ b/src/Authentication/Domain/Service/ChallengeBase.php @@ -0,0 +1,182 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Service; + +use DateTimeImmutable; +use DateTimeZone; +use Webify\Base\Domain\Contract\Authentication\{ + AuthenticatedUser, + CredentialsInterface, + StrategyInterface, + UserCredentials, + UserCredentialsLookupInterface +}; +use Webify\Base\Domain\Contract\Authentication\Service\{ChallengeBasedInterface, StrategyRegisterInterface}; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Entity\Session; +use Webify\User\Authentication\Domain\Exception\{ + AuthenticationFailedException, + ChallengeNotFoundException, + InvalidChallengeSecretException +}; +use Webify\User\Authentication\Domain\Guard\UserMustBeActive; +use Webify\User\Authentication\Domain\ValueObject\{ + AuthenticatedUser as AuthenticatedUserValueObject, + ChallengeCode, + ChallengeSecretInterface, + ChallengeToken, + ChallengeType, + UserId +}; + +/** + * Implementation of the challenge-based authentication service. + */ +final readonly class ChallengeBase implements ChallengeBasedInterface +{ + /** + * The number of minutes for which the session will be valid. + */ + private const int SESSION_TTL_MINUTES = 60; + + /** + * The constructor. + */ + public function __construct( + private UserCredentialsLookupInterface $userLookup, + private StrategyRegisterInterface $strategyRegister, + private UserMustBeActive $userMustBeActive, + private UserStatusTranslator $userStatusTranslator, + private VerifyChallenge $verifyChallenge, + private OpenSession $openSession + ) {} + + /** + * {@inheritDoc} + * + * @throws AuthenticationFailedException if the authentication initiation fails + */ + public function initiate(CredentialsInterface $credentials): void + { + $user = $this->userLookup($credentials); + + $this->userMustBeActive->guard($this->userStatusTranslator->translate($user->status)); + + $strategy = $this->strategyLookup($credentials); + + $strategy->initiate($credentials, $user); + } + + /** + * {@inheritDoc} + * + * @throws AuthenticationFailedException if the authentication fails + */ + public function complete(CredentialsInterface $credentials): AuthenticatedUser + { + if (!$credentials->has('secret')) { + throw AuthenticationFailedException::forMissingSecret(); + } + + $user = $this->userLookup($credentials); + + $this->userMustBeActive->guard($this->userStatusTranslator->translate($user->status)); + + $userId = UserId::fromString($user->id); + $secret = $this->identifySecretType($credentials); + + try { + $this->verifyChallenge->verify($userId, $secret); + } catch (ChallengeNotFoundException|InvalidChallengeSecretException) { // @phpstan-ignore catch.neverThrown + throw AuthenticationFailedException::forInvalid(); + } + + $session = $this->openSession(UserId::fromString($user->id)); + + return new AuthenticatedUser( + $userId->toNative(), + $user->email, + $user->displayName, + DateTime::now()->toNative(), + $session->getAccessToken()->toNative(), + $session->getRefreshToken()->toNative() + ); + } + + /** + * Looks up the authentication strategy. + */ + private function strategyLookup(CredentialsInterface $credentials): StrategyInterface + { + $strategy = $this->strategyRegister->get($credentials->getStrategyIdentifier()); + + if (!$strategy->isSupported($credentials)) { + throw AuthenticationFailedException::strategyNotSupported($credentials->getStrategyIdentifier()); + } + + return $strategy; + } + + /** + * Identifies the type of secret. + */ + private function identifySecretType(CredentialsInterface $credentials): ChallengeSecretInterface + { + $strategy = $this->strategyLookup($credentials); + $type = ChallengeType::tryFrom(str_replace('challenge_', '', $strategy->getIdentifier())); + + if (null === $type) { + throw AuthenticationFailedException::forInvalid(); + } + + if ($type->isCode()) { + return ChallengeCode::fromString($credentials->get('secret')); + } + + return ChallengeToken::fromString($credentials->get('secret')); + } + + /** + * Looks up the user credentials. + */ + private function userLookup(CredentialsInterface $credentials): UserCredentials + { + if (!$credentials->has('email')) { + throw AuthenticationFailedException::forMissingEmail(); + } + + $email = $credentials->get('email'); + $user = $this->userLookup->findByEmail($email); + + if (null === $user) { + throw AuthenticationFailedException::userNotFound($email); + } + + return $user; + } + + /** + * Opens a session for the user. + */ + private function openSession(UserId $userId): Session + { + return $this->openSession->open( + AuthenticatedUserValueObject::fromSuccessfulAuthentication($userId), + new DateTimeImmutable( + sprintf('+%d minutes', self::SESSION_TTL_MINUTES), + new DateTimeZone('UTC') + ) + ); + } +} diff --git a/src/Authentication/Domain/Service/ChallengeSecretDeliveryInterface.php b/src/Authentication/Domain/Service/ChallengeSecretDeliveryInterface.php new file mode 100644 index 0000000..5c27987 --- /dev/null +++ b/src/Authentication/Domain/Service/ChallengeSecretDeliveryInterface.php @@ -0,0 +1,31 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Service; + +use Webify\User\Authentication\Domain\ValueObject\UserId; + +/** + * The service defines the contract for delivering the authentication challenge secret. + */ +interface ChallengeSecretDeliveryInterface +{ + /** + * Delivers the authentication challenge secret to the user's email address. + * + * @param UserId $userId the user identifier + * @param string $challengeType the authentication challenge type + * @param string $challengeSecret the authentication challenge secret + */ + public function deliver(UserId $userId, string $challengeType, string $challengeSecret): void; +} diff --git a/src/Authentication/Domain/Service/IssueChallenge.php b/src/Authentication/Domain/Service/IssueChallenge.php new file mode 100644 index 0000000..69f41c4 --- /dev/null +++ b/src/Authentication/Domain/Service/IssueChallenge.php @@ -0,0 +1,98 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Service; + +use DateInvalidTimeZoneException; +use DateMalformedStringException; +use DateTimeImmutable; +use DateTimeZone; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Exception\DateTimeException; +use Webify\Base\Domain\Service\UlidGeneratorInterface; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Entity\Challenge; +use Webify\User\Authentication\Domain\Repository\ChallengeRepositoryInterface; +use Webify\User\Authentication\Domain\ValueObject\{ChallengeId, ChallengeType, UserId}; + +/** + * IssueChallenge creates and persists a new challenge, triggers delivery, + * and returns a PendingChallenge descriptor to the caller. + * + * It cleans up stale challenges for the user before issuing a new one, + * ensuring the repository does not accumulate unbounded rows. + * + * TTL constants are intentionally placed here as a single source of truth. + * If per-tenant or per-installation TTL configuration is needed in the future, + * replace the constants with a ChallengeTtlPolicy value object injected + * via the constructor. + */ +final readonly class IssueChallenge +{ + /** + * The number of minutes a challenge is live for. + */ + private const int CHALLENGE_TTL_MINUTES = 10; + + /** + * The constructor. + */ + public function __construct( + private UlidGeneratorInterface $idGenerator, + private ChallengeRepositoryInterface $repository, + private DomainEventPublisherInterface $eventPublisher + ) {} + + /** + * Issues a new challenge for the given user and type. + * + * @param UserId $userId the user ID to issue the challenge for + * @param ChallengeType $type the type of the challenge @see ChallengeType + * + * @return Challenge the issued challenge + */ + public function issue(UserId $userId, ChallengeType $type): Challenge + { + $this->repository->deleteStale($userId); + + $challenge = Challenge::issue( + ChallengeId::fromString($this->idGenerator->generate()), + $userId, + $type, + $this->expiryFromNow() + ); + + $this->repository->persist($challenge); + $this->eventPublisher->publish(...$challenge->getDomainEvents()); + + return $challenge; + } + + /** + * Creates and return a DateTime object representing the current time plus the TTL. + * + * @throws DateTimeException if the DateTime object cannot be created + */ + private function expiryFromNow(): DateTime + { + $datetime = sprintf('+%d minutes', self::CHALLENGE_TTL_MINUTES); + + try { + return DateTime::fromNative(new DateTimeImmutable($datetime, new DateTimeZone('UTC'))); + } catch (DateInvalidTimeZoneException $exception) { + throw DateTimeException::forInvalidTimezone(value: 'UTC', previous: $exception); + } catch (DateMalformedStringException $exception) { + throw DateTimeException::forInvalidDatetime(value: $datetime, previous: $exception); + } + } +} diff --git a/src/Authentication/Domain/Service/OpenSession.php b/src/Authentication/Domain/Service/OpenSession.php new file mode 100644 index 0000000..984d4a4 --- /dev/null +++ b/src/Authentication/Domain/Service/OpenSession.php @@ -0,0 +1,65 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Service; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\UlidGeneratorInterface; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Entity\Session; +use Webify\User\Authentication\Domain\Repository\SessionRepositoryInterface; +use Webify\User\Authentication\Domain\ValueObject\{AccessToken, AuthenticatedUser, RefreshToken, SessionId}; + +/** + * OpenSession domain service handles the opening of a new session. + * + * Encapsulate the use case of opening a session for an authenticated user and persisting it to the repository. + * + * @internal this class is not intended for public use, it should only be used within the Authentication strategies + */ +final readonly class OpenSession +{ + /** + * The constructor. + */ + public function __construct( + private SessionRepositoryInterface $repository, + private UlidGeneratorInterface $idGenerator, + private DomainEventPublisherInterface $eventPublisher + ) {} + + /** + * Opens a new session for a user after validating their credentials. + * + * @param AuthenticatedUser $authenticatedUser the authenticated user + * @param DateTimeImmutable $expiresAt the expiration time for the session + */ + public function open( + AuthenticatedUser $authenticatedUser, + DateTimeImmutable $expiresAt + ): Session { + $session = Session::open( + SessionId::fromString($this->idGenerator->generate()), + $authenticatedUser->getUserId(), + AccessToken::generate(), + RefreshToken::generate(), + DateTime::fromNative($expiresAt) + ); + + $this->repository->persist($session); + $this->eventPublisher->publish(...$session->getDomainEvents()); + + return $session; + } +} diff --git a/src/Authentication/Domain/Service/PasswordBase.php b/src/Authentication/Domain/Service/PasswordBase.php new file mode 100644 index 0000000..58cb171 --- /dev/null +++ b/src/Authentication/Domain/Service/PasswordBase.php @@ -0,0 +1,147 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Service; + +use DateTimeImmutable; +use DateTimeZone; +use Webify\Base\Domain\Contract\Authentication\{ + AuthenticatedUser, + CredentialsInterface, + Service\PasswordBaseInterface, + UserCredentials, + UserCredentialsLookupInterface +}; +use Webify\Base\Domain\Contract\Identity\Service\PasswordHasherInterface; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Entity\Session; +use Webify\User\Authentication\Domain\Exception\AuthenticationFailedException; +use Webify\User\Authentication\Domain\Guard\UserMustBeActive; +use Webify\User\Authentication\Domain\ValueObject\{ + AuthenticatedUser as AuthenticatedUserValueObject, + ChallengeCode, + ChallengeType, + UserId +}; + +/** + * The implementation of the password base authentication service. + */ +final readonly class PasswordBase implements PasswordBaseInterface +{ + /** + * The number of minutes for which the session will be valid. + */ + private const int SESSION_TTL_MINUTES = 60; + + /** + * The constructor. + */ + public function __construct( + private UserCredentialsLookupInterface $credentialLookup, + private PasswordHasherInterface $passwordHasher, + private UserMustBeActive $userMustBeActive, + private UserStatusTranslator $userStatusTranslator, + private IssueChallenge $issueChallenge, + private OpenSession $openSession, + private ChallengeSecretDeliveryInterface $delivery + ) {} + + /** + * {@inheritDoc} + * + * @throws AuthenticationFailedException if the authentication fails + */ + public function authenticate(CredentialsInterface $request): ?AuthenticatedUser + { + if (!$request->has('email')) { + throw AuthenticationFailedException::forMissingEmail(); + } + + if (!$request->has('password')) { + throw AuthenticationFailedException::forMissingPassword(); + } + + $userCredentials = $this->userLookup($request->get('email')); + + $this->userMustBeActive->guard($this->userStatusTranslator->translate($userCredentials->status)); + + if (!$this->passwordHasher->verify($request->get('password'), $userCredentials->passwordHash)) { + throw AuthenticationFailedException::forInvalid(); + } + + $userId = UserId::fromString($userCredentials->id); + + if ($userCredentials->isTwoFactorEnabled) { + $this->issueCode($userId); + + // Interrupt the authentication process to wait for the user to enter the code + return null; + } + + // Open a session for the user + $session = $this->openSession($userId); + + return new AuthenticatedUser( + $userId->toNative(), + $userCredentials->email, + $userCredentials->displayName, + DateTime::now()->toNative(), + $session->getAccessToken()->toNative(), + $session->getRefreshToken()->toNative() + ); + } + + /** + * Looks up the user. + * + * @throws AuthenticationFailedException if the user is not found + */ + private function userLookup(string $email): UserCredentials + { + $userCredentials = $this->credentialLookup->findByEmail($email); + + if (null === $userCredentials) { + throw AuthenticationFailedException::userNotFound($email); + } + + return $userCredentials; + } + + /** + * Issues a challenge code for the user when the user enabled two-factor authentication. + */ + private function issueCode(UserId $userId): void + { + $challenge = $this->issueChallenge->issue($userId, ChallengeType::Code); + + /** @var ChallengeCode $challengeCode */ + $challengeCode = $challenge->getSecret(); + + $this->delivery->deliver($userId, $challenge->getType()->value, $challengeCode->toNative()); + } + + /** + * Opens a session for the user. + */ + private function openSession(UserId $userId): Session + { + return $this->openSession->open( + AuthenticatedUserValueObject::fromSuccessfulAuthentication($userId), + new DateTimeImmutable( + sprintf('+%d minutes', self::SESSION_TTL_MINUTES), + new DateTimeZone('UTC') + ) + ); + } +} diff --git a/src/Authentication/Domain/Service/RefreshSession.php b/src/Authentication/Domain/Service/RefreshSession.php new file mode 100644 index 0000000..0a71116 --- /dev/null +++ b/src/Authentication/Domain/Service/RefreshSession.php @@ -0,0 +1,56 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Service; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Repository\SessionRepositoryInterface; +use Webify\User\Authentication\Domain\ValueObject\{AccessToken, RefreshToken}; + +/** + * RefreshSession domain service handles the refreshing of the session. + * + * Encapsulate the use case of exchanging a valid RefreshToken for a new token pair, extending + * the session. The old tokens are invalidated atomically by replacing them inside the same + * AuthSession aggregate. + */ +final readonly class RefreshSession +{ + /** + * The constructor. + */ + public function __construct( + private SessionRepositoryInterface $repository, + private DomainEventPublisherInterface $eventPublisher + ) {} + + /** + * Refreshes the session by generating a new access token and refresh token, then updates the session + * with the provided expiration date. + * + * @param string $token the refresh token used to identify the session + * @param DateTimeImmutable $expiresAt the new expiration date for the session + */ + public function refresh(string $token, DateTimeImmutable $expiresAt): void + { + // If session is not found, throw an exception itself + $session = $this->repository->getByRefreshToken(RefreshToken::fromString($token)); + + // If the session is expired or revoked, throw an exception itself + $session->refresh(AccessToken::generate(), RefreshToken::generate(), DateTime::fromNative($expiresAt)); + $this->repository->persist($session); + $this->eventPublisher->publish(...$session->getDomainEvents()); + } +} diff --git a/src/Authentication/Domain/Service/RevokeSession.php b/src/Authentication/Domain/Service/RevokeSession.php new file mode 100644 index 0000000..8d4a256 --- /dev/null +++ b/src/Authentication/Domain/Service/RevokeSession.php @@ -0,0 +1,50 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Service; + +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\User\Authentication\Domain\Repository\SessionRepositoryInterface; +use Webify\User\Authentication\Domain\ValueObject\SessionId; + +/** + * RevokeSession domain service handles the revoking of the session. + * + * Encapsulate the use case of explicitly invalidating an authentication session (logout). + * The service is idempotent and silent when the session does not exist. + */ +final readonly class RevokeSession +{ + /** + * The constructor. + */ + public function __construct( + private SessionRepositoryInterface $repository, + private DomainEventPublisherInterface $eventPublisher + ) {} + + /** + * Revokes a session identified by the provided session ID. + * + * @param string $sessionId the unique identifier of the session to be revoked + */ + public function revoke(string $sessionId): void + { + // If session is not found, throw an exception itself + $session = $this->repository->getById(SessionId::fromString($sessionId)); + + $session->revoke(); + $this->repository->persist($session); + $this->eventPublisher->publish(...$session->getDomainEvents()); + } +} diff --git a/src/Authentication/Domain/Service/UserStatusTranslator.php b/src/Authentication/Domain/Service/UserStatusTranslator.php new file mode 100644 index 0000000..18ce658 --- /dev/null +++ b/src/Authentication/Domain/Service/UserStatusTranslator.php @@ -0,0 +1,39 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Service; + +use Webify\User\Authentication\Domain\ValueObject\UserStatus; + +/** + * UserStatusTranslator service handles the translation of the user status. + * + * Translates the user status string to a UserStatus enum value. + */ +final class UserStatusTranslator +{ + /** + * Translates a given user status string to a corresponding Authentication known status. + * + * @param string $status the user-provided status input, such as 'active' + * + * @return UserStatus the translated status corresponding to the input, or an empty string if no match is found + */ + public function translate(string $status): UserStatus + { + return match ($status) { + 'active' => UserStatus::Active, + default => UserStatus::Unverified, + }; + } +} diff --git a/src/Authentication/Domain/Service/VerifyChallenge.php b/src/Authentication/Domain/Service/VerifyChallenge.php new file mode 100644 index 0000000..11d1c72 --- /dev/null +++ b/src/Authentication/Domain/Service/VerifyChallenge.php @@ -0,0 +1,71 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Service; + +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\User\Authentication\Domain\Entity\Challenge; +use Webify\User\Authentication\Domain\Exception\InvalidChallengeSecretException; +use Webify\User\Authentication\Domain\Repository\ChallengeRepositoryInterface; +use Webify\User\Authentication\Domain\ValueObject\{ + ChallengeSecretInterface, + UserId +}; + +/** + * VerifyChallenge domain service verifies the challenge secret and updates the challenge state accordingly. + * + * It is the uniform second step for all challenge-based flows and responsible for handling + * the challenge verification process, persisting the increment attempt counter. + */ +final readonly class VerifyChallenge +{ + /** + * The constructor. + */ + public function __construct( + private ChallengeRepositoryInterface $repository, + private DomainEventPublisherInterface $eventPublisher + ) {} + + /** + * Verifies the challenge secret and updates the challenge state accordingly. + * + * @throws InvalidChallengeSecretException if the secret is invalid + */ + public function verify(UserId $userId, ChallengeSecretInterface $secret): void + { + $challenge = $this->repository->getByUser($userId); + + try { + $challenge->verify($secret); + } catch (InvalidChallengeSecretException $exception) { + // Persist the incremented attempt counter + $this->flush($challenge); + + throw $exception; + } + + // If the secret is valid, persist the consumed state + $this->flush($challenge); + } + + /** + * Persist the challenge entity and publish any associated events. + */ + private function flush(Challenge $challenge): void + { + $this->repository->persist($challenge); + $this->eventPublisher->publish(...$challenge->getDomainEvents()); + } +} diff --git a/src/Authentication/Domain/Strategy/ChallengeCodeStrategy.php b/src/Authentication/Domain/Strategy/ChallengeCodeStrategy.php new file mode 100644 index 0000000..22f740b --- /dev/null +++ b/src/Authentication/Domain/Strategy/ChallengeCodeStrategy.php @@ -0,0 +1,66 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Strategy; + +use Webify\Base\Domain\Contract\Authentication\{CredentialsInterface, StrategyInterface, UserCredentials}; +use Webify\User\Authentication\Domain\Service\{ChallengeSecretDeliveryInterface, IssueChallenge}; +use Webify\User\Authentication\Domain\ValueObject\{ChallengeCode, ChallengeType, UserId}; + +/** + * ChallengeCodeStrategy handles challenge code authentication flow that sent via email or any other channels. + */ +final readonly class ChallengeCodeStrategy implements StrategyInterface +{ + /** + * The constructor. + */ + public function __construct( + private IssueChallenge $issueChallenge, + private ChallengeSecretDeliveryInterface $delivery, + ) {} + + /** + * {@inheritDoc} + */ + public function getIdentifier(): string + { + return 'challenge_code'; + } + + /** + * {@inheritDoc} + */ + public function isSupported(CredentialsInterface $credentials): bool + { + return $this->getIdentifier() === $credentials->getStrategyIdentifier(); + } + + /** + * {@inheritDoc} + */ + public function initiate( + CredentialsInterface $requestCredentials, + UserCredentials $userCredentials + ): void { + $type = ChallengeType::Code; + $userId = UserId::fromString($userCredentials->id); + $challenge = $this->issueChallenge->issue($userId, $type); + + /** @var ChallengeCode $challengeCode */ + $challengeCode = $challenge->getSecret(); + + // Deliver the challenge code to the user + $this->delivery->deliver($userId, $type->value, $challengeCode->toNative()); + } +} diff --git a/src/Authentication/Domain/Strategy/ChallengeTokenStrategy.php b/src/Authentication/Domain/Strategy/ChallengeTokenStrategy.php new file mode 100644 index 0000000..10ef5f4 --- /dev/null +++ b/src/Authentication/Domain/Strategy/ChallengeTokenStrategy.php @@ -0,0 +1,66 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\Strategy; + +use Webify\Base\Domain\Contract\Authentication\{CredentialsInterface, StrategyInterface, UserCredentials}; +use Webify\User\Authentication\Domain\Service\{ChallengeSecretDeliveryInterface, IssueChallenge}; +use Webify\User\Authentication\Domain\ValueObject\{ChallengeToken, ChallengeType, UserId}; + +/** + * ChallengeTokenStrategy handles challenge token authentication flow (via a magic link) that sent by email. + */ +final readonly class ChallengeTokenStrategy implements StrategyInterface +{ + /** + * The constructor. + */ + public function __construct( + private IssueChallenge $issueChallenge, + private ChallengeSecretDeliveryInterface $delivery, + ) {} + + /** + * {@inheritDoc} + */ + public function getIdentifier(): string + { + return 'challenge_token'; + } + + /** + * {@inheritDoc} + */ + public function isSupported(CredentialsInterface $credentials): bool + { + return $this->getIdentifier() === $credentials->getStrategyIdentifier(); + } + + /** + * {@inheritDoc} + */ + public function initiate( + CredentialsInterface $requestCredentials, + UserCredentials $userCredentials + ): void { + $type = ChallengeType::Token; + $userId = UserId::fromString($userCredentials->id); + $challenge = $this->issueChallenge->issue($userId, $type); + + /** @var ChallengeToken $challengeToken */ + $challengeToken = $challenge->getSecret(); + + // Deliver the challenge token via a link to the user + $this->delivery->deliver($userId, $type->value, $challengeToken->toNative()); + } +} diff --git a/src/Authentication/Domain/ValueObject/AccessToken.php b/src/Authentication/Domain/ValueObject/AccessToken.php new file mode 100644 index 0000000..f258e59 --- /dev/null +++ b/src/Authentication/Domain/ValueObject/AccessToken.php @@ -0,0 +1,48 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\ValueObject; + +use Webify\Base\Domain\Exception\SecureTokenGenerationFailedException; +use Webify\Base\Domain\ValueObject\SecureToken; +use Webify\User\Authentication\Domain\Exception\{AccessTokenGenerationFailedException, InvalidAccessTokenException}; + +/** + * Access token value object. + */ +final readonly class AccessToken extends SecureToken +{ + /** + * Overrides the parent method to handle the exception to make a domain-specific one. + * + * @throws AccessTokenGenerationFailedException + */ + public static function generate(): static + { + try { + return parent::generate(); + } catch (SecureTokenGenerationFailedException $exception) { + throw AccessTokenGenerationFailedException::create($exception); + } + } + + /** + * Throws an exception for an invalid access token. + * + * @throws InvalidAccessTokenException if the access token is invalid + */ + public function throwException(string $value): never + { + throw InvalidAccessTokenException::forInvalidAccessToken($value); + } +} diff --git a/src/Authentication/Domain/ValueObject/AuthenticatedUser.php b/src/Authentication/Domain/ValueObject/AuthenticatedUser.php new file mode 100644 index 0000000..16c0c93 --- /dev/null +++ b/src/Authentication/Domain/ValueObject/AuthenticatedUser.php @@ -0,0 +1,74 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\ValueObject; + +use Webify\Base\Domain\ValueObject\DateTime; + +/** + * Authenticated user value object. + * + * Represents the authenticated user after successful authentication. + */ +final readonly class AuthenticatedUser +{ + /** + * Private constructor enforces the use of the factory method. + * + * @param UserId $userId authenticated user ID + * @param DateTime $authenticatedAt timestamp of successful authentication + */ + private function __construct( + private UserId $userId, + private DateTime $authenticatedAt, + ) {} + + /** + * Gets the authenticated user ID. + */ + public function getUserId(): UserId + { + return $this->userId; + } + + /** + * Gets the date and time of successful authentication. + */ + public function getAuthenticatedAt(): DateTime + { + return $this->authenticatedAt; + } + + /** + * Factory method to create an authenticated user. + * + * @param UserId $userId authenticated user ID + */ + public static function fromSuccessfulAuthentication(UserId $userId): self + { + return new self($userId, DateTime::now()); + } + + /** + * Converts the authenticated user to an array representation. + * + * @return array + */ + public function toNative(): array + { + return [ + 'user_id' => $this->userId->toNative(), + 'authenticated_at' => $this->authenticatedAt->defaultFormat(), + ]; + } +} diff --git a/src/Authentication/Domain/ValueObject/ChallengeCode.php b/src/Authentication/Domain/ValueObject/ChallengeCode.php new file mode 100644 index 0000000..44853a2 --- /dev/null +++ b/src/Authentication/Domain/ValueObject/ChallengeCode.php @@ -0,0 +1,133 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\ValueObject; + +use Random\RandomException; +use Webify\User\Authentication\Domain\Exception\{ + ChallengeCodeGenerationFailedException, + InvalidChallengeCodeException +}; + +/** + * Challenge code value object. + * + * Represents a 6-digit numeric code used for authentication challenges. + */ +final readonly class ChallengeCode implements ChallengeSecretInterface +{ + /** + * The length of the challenge code. + */ + private const int LENGTH = 6; + + /** + * Private constructor enforces the use of the factory methods. + * 1. Validates the provided value. + * 2. Throws an exception if the value is invalid. + */ + private function __construct( + private string $value + ) { + if (!$this->isValid()) { + throw InvalidChallengeCodeException::forInvalidCode($this->value); + } + } + + /** + * Converts the object into its string representation. + */ + public function __toString(): string + { + return $this->value; + } + + /** + * Creates an instance of the class from a native string value. + */ + public static function fromNative(string $value): self + { + return new self(str_pad($value, self::LENGTH, '0', STR_PAD_LEFT)); + } + + /** + * Creates an instance of the class from a string representation. + */ + public static function fromString(string $value): self + { + return new self($value); + } + + /** + * Generates a new challenge code. + */ + public static function generate(): self + { + try { + $code = str_pad( + (string) random_int(0, 999999), + self::LENGTH, + '0', + STR_PAD_LEFT + ); + } catch (RandomException $exception) { + throw ChallengeCodeGenerationFailedException::create($exception); + } + + return new self($code); + } + + /** + * Checks if the current challenge code is equal to another challenge code. + */ + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + /** + * Converts the object into its native string representation. + */ + public function toNative(): string + { + return $this->value; + } + + /** + * {@inheritDoc} + */ + public function verify(ChallengeSecretInterface $secret): bool + { + if (!$secret instanceof self) { + return false; + } + + return $this->equals($secret); + } + + /** + * Validates the challenge code. + */ + private function isValid(): bool + { + if (strlen($this->value) !== self::LENGTH) { + return false; + } + + if (!ctype_digit($this->value)) { + return false; + } + + return true; + } +} diff --git a/src/Authentication/Domain/ValueObject/ChallengeId.php b/src/Authentication/Domain/ValueObject/ChallengeId.php new file mode 100644 index 0000000..d6522fc --- /dev/null +++ b/src/Authentication/Domain/ValueObject/ChallengeId.php @@ -0,0 +1,35 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\ValueObject; + +use Webify\Base\Domain\ValueObject\AggregateId; +use Webify\User\Authentication\Domain\Exception\InvalidChallengeIdException; + +/** + * Challenge ID value object. + * + * Define a typed identity for an authentication challenge. + */ +final readonly class ChallengeId extends AggregateId +{ + /** + * Throws an exception for an invalid challenge ID. + * + * @throws InvalidChallengeIdException + */ + protected function throwException(string $value): never + { + throw InvalidChallengeIdException::forInvalidId($value); + } +} diff --git a/src/Authentication/Domain/ValueObject/ChallengeSecretInterface.php b/src/Authentication/Domain/ValueObject/ChallengeSecretInterface.php new file mode 100644 index 0000000..ce512f4 --- /dev/null +++ b/src/Authentication/Domain/ValueObject/ChallengeSecretInterface.php @@ -0,0 +1,25 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\ValueObject; + +/** + * The challenge secret interface. + */ +interface ChallengeSecretInterface +{ + /** + * Verifies the challenge provided by the user against the stored challenge. + */ + public function verify(self $secret): bool; +} diff --git a/src/Authentication/Domain/ValueObject/ChallengeStatus.php b/src/Authentication/Domain/ValueObject/ChallengeStatus.php new file mode 100644 index 0000000..541fef6 --- /dev/null +++ b/src/Authentication/Domain/ValueObject/ChallengeStatus.php @@ -0,0 +1,39 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\ValueObject; + +/** + * The authentication challenge status value object. + */ +enum ChallengeStatus: string +{ + case Pending = 'pending'; + case Completed = 'completed'; + + /** + * Check if the challenge is pending. + */ + public function isPending(): bool + { + return self::Pending === $this; + } + + /** + * Check if the challenge is completed. + */ + public function isCompleted(): bool + { + return self::Completed === $this; + } +} diff --git a/src/Authentication/Domain/ValueObject/ChallengeToken.php b/src/Authentication/Domain/ValueObject/ChallengeToken.php new file mode 100644 index 0000000..cbab07a --- /dev/null +++ b/src/Authentication/Domain/ValueObject/ChallengeToken.php @@ -0,0 +1,60 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\ValueObject; + +use Webify\Base\Domain\Exception\SecureTokenGenerationFailedException; +use Webify\Base\Domain\ValueObject\SecureToken; +use Webify\User\Authentication\Domain\Exception\{ChallengeTokenGenerationFailedException, InvalidChallengeTokenException}; + +/** + * Challenge token value object. + * + * Represents a secure token used for authentication challenges. + */ +final readonly class ChallengeToken extends SecureToken implements ChallengeSecretInterface +{ + /** + * Overrides the parent method to handle the exception to make a domain-specific one. + * + * @throws ChallengeTokenGenerationFailedException + */ + public static function generate(): static + { + try { + return parent::generate(); + } catch (SecureTokenGenerationFailedException $exception) { + throw ChallengeTokenGenerationFailedException::create($exception); + } + } + + /** + * Throws an exception for an invalid challenge token. + */ + public function throwException(string $value): never + { + throw InvalidChallengeTokenException::forInvalidToken($value); + } + + /** + * {@inheritDoc} + */ + public function verify(ChallengeSecretInterface $secret): bool + { + if (!$secret instanceof self) { + return false; + } + + return $this->equals($secret); + } +} diff --git a/src/Authentication/Domain/ValueObject/ChallengeType.php b/src/Authentication/Domain/ValueObject/ChallengeType.php new file mode 100644 index 0000000..abfd294 --- /dev/null +++ b/src/Authentication/Domain/ValueObject/ChallengeType.php @@ -0,0 +1,41 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\ValueObject; + +/** + * The challenge type value object. + * + * Enumerates the 2FA authentication challenge types the domain supports natively. + */ +enum ChallengeType: string +{ + case Code = 'code'; + case Token = 'token'; + + /** + * Determines if the current instance represents a Code. + */ + public function isCode(): bool + { + return self::Code === $this; + } + + /** + * Determines if the current instance represents a Token. + */ + public function isToken(): bool + { + return self::Token === $this; + } +} diff --git a/src/Authentication/Domain/ValueObject/RefreshToken.php b/src/Authentication/Domain/ValueObject/RefreshToken.php new file mode 100644 index 0000000..4b1ed39 --- /dev/null +++ b/src/Authentication/Domain/ValueObject/RefreshToken.php @@ -0,0 +1,48 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\ValueObject; + +use Webify\Base\Domain\Exception\SecureTokenGenerationFailedException; +use Webify\Base\Domain\ValueObject\SecureToken; +use Webify\User\Authentication\Domain\Exception\{InvalidRefreshTokenException, RefreshTokenGenerationFailedException}; + +/** + * Refresh token value object. + */ +final readonly class RefreshToken extends SecureToken +{ + /** + * Overrides the parent method to handle the exception to make a domain-specific one. + * + * @throws RefreshTokenGenerationFailedException + */ + public static function generate(): static + { + try { + return parent::generate(); + } catch (SecureTokenGenerationFailedException $exception) { + throw RefreshTokenGenerationFailedException::create($exception); + } + } + + /** + * Throws an exception for an invalid access token. + * + * @throws InvalidRefreshTokenException if the access token is invalid + */ + public function throwException(string $value): never + { + throw InvalidRefreshTokenException::forInvalidRefreshToken($value); + } +} diff --git a/src/Authentication/Domain/ValueObject/SessionId.php b/src/Authentication/Domain/ValueObject/SessionId.php new file mode 100644 index 0000000..d4fe2cb --- /dev/null +++ b/src/Authentication/Domain/ValueObject/SessionId.php @@ -0,0 +1,35 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\ValueObject; + +use Webify\Base\Domain\ValueObject\AggregateId; +use Webify\User\Authentication\Domain\Exception\InvalidSessionIdException; + +/** + * Session ID value object. + * + * Define a typed identity for an authentication session. + */ +final readonly class SessionId extends AggregateId +{ + /** + * Will throw an exception if the token id value is not valid. + * + * @throws InvalidSessionIdException + */ + protected function throwException(string $value): never + { + throw InvalidSessionIdException::forInvalidId($value); + } +} diff --git a/src/Authentication/Domain/ValueObject/UserId.php b/src/Authentication/Domain/ValueObject/UserId.php new file mode 100644 index 0000000..8449cf6 --- /dev/null +++ b/src/Authentication/Domain/ValueObject/UserId.php @@ -0,0 +1,33 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\ValueObject; + +use Webify\Base\Domain\ValueObject\AggregateId; +use Webify\User\Authentication\Domain\Exception\InvalidUserIdException; + +/** + * User ID value object. + */ +final readonly class UserId extends AggregateId +{ + /** + * Throws an exception for an invalid user ID. + * + * @throws InvalidUserIdException if the user ID is invalid + */ + protected function throwException(string $value): never + { + throw InvalidUserIdException::forInvalidId($value); + } +} diff --git a/src/Authentication/Domain/ValueObject/UserStatus.php b/src/Authentication/Domain/ValueObject/UserStatus.php new file mode 100644 index 0000000..b1b301e --- /dev/null +++ b/src/Authentication/Domain/ValueObject/UserStatus.php @@ -0,0 +1,31 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Domain\ValueObject; + +/** + * The user status value object. + */ +enum UserStatus: string +{ + case Active = 'active'; + case Unverified = 'unverified'; + + /** + * Check if the user is active. + */ + public function isActive(): bool + { + return self::Active === $this; + } +} diff --git a/src/Authentication/Infrastructure/Exception/StrategyNotFoundException.php b/src/Authentication/Infrastructure/Exception/StrategyNotFoundException.php new file mode 100644 index 0000000..4da4311 --- /dev/null +++ b/src/Authentication/Infrastructure/Exception/StrategyNotFoundException.php @@ -0,0 +1,52 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Infrastructure\Exception; + +use RuntimeException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when the authentication strategy is not found in the registry. + */ +final class StrategyNotFoundException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this for a given identifier. + */ + public static function forIdentifier(string $identifier): StrategyNotFoundException + { + return new self( + new ExceptionTranslation( + 'user.authentication', + 'strategy_not_found_for_identifier', + ['identifier' => $identifier] + ), + sprintf('The authentication strategy with identifier "%s" is not registered.', $identifier) + ); + } +} diff --git a/src/Authentication/Infrastructure/Service/StrategyRegister.php b/src/Authentication/Infrastructure/Service/StrategyRegister.php new file mode 100644 index 0000000..0ae9611 --- /dev/null +++ b/src/Authentication/Infrastructure/Service/StrategyRegister.php @@ -0,0 +1,69 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authentication\Infrastructure\Service; + +use Webify\Base\Domain\Contract\Authentication\Service\StrategyRegisterInterface; +use Webify\Base\Domain\Contract\Authentication\StrategyInterface; +use Webify\User\Authentication\Infrastructure\Exception\StrategyNotFoundException; + +/** + * Implementation of the StrategyRegisterInterface. + */ +final class StrategyRegister implements StrategyRegisterInterface +{ + /** + * The store of registered strategies. + * 1. The key is the identifier of the strategy. + * 2. The value is the strategy instance. + * + * @var array + */ + private array $store = []; + + /** + * {@inheritDoc} + */ + public function register(StrategyInterface $strategy): void + { + $this->store[$strategy->getIdentifier()] = $strategy; + } + + /** + * {@inheritDoc} + */ + public function get(string $identifier): StrategyInterface + { + if (!$this->has($identifier)) { + throw StrategyNotFoundException::forIdentifier($identifier); + } + + return $this->store[$identifier]; + } + + /** + * {@inheritDoc} + */ + public function getAll(): array + { + return $this->store; + } + + /** + * Check if the strategy with the given identifier is registered. + */ + private function has(string $identifier): bool + { + return array_key_exists($identifier, $this->store); + } +} diff --git a/src/Authorization/Application/Query/Role/FindAllRolesInterface.php b/src/Authorization/Application/Query/Role/FindAllRolesInterface.php new file mode 100644 index 0000000..7cb064b --- /dev/null +++ b/src/Authorization/Application/Query/Role/FindAllRolesInterface.php @@ -0,0 +1,27 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Application\Query\Role; + +/** + * Query class defines the contract for a query to find all roles. + */ +interface FindAllRolesInterface +{ + /** + * Retrieves a collection of all Role models. + * + * @return RoleCollection the collection containing all Role models + */ + public function findAll(): RoleCollection; +} diff --git a/src/Authorization/Application/Query/Role/FindRoleByIdInterface.php b/src/Authorization/Application/Query/Role/FindRoleByIdInterface.php new file mode 100644 index 0000000..8bf7e25 --- /dev/null +++ b/src/Authorization/Application/Query/Role/FindRoleByIdInterface.php @@ -0,0 +1,29 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Application\Query\Role; + +use Webify\User\Authorization\Domain\ValueObject\RoleId; + +/** + * Query class defines the contract for a query to find a role by its identifier. + */ +interface FindRoleByIdInterface +{ + /** + * Retrieves a Role model by its identifier. + * + * @return null|Role the Role real model if found, or null if no match is found + */ + public function find(RoleId $id): ?Role; +} diff --git a/src/Authorization/Application/Query/Role/FindRoleBySlugInterface.php b/src/Authorization/Application/Query/Role/FindRoleBySlugInterface.php new file mode 100644 index 0000000..8de00c1 --- /dev/null +++ b/src/Authorization/Application/Query/Role/FindRoleBySlugInterface.php @@ -0,0 +1,29 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Application\Query\Role; + +use Webify\User\Authorization\Domain\ValueObject\RoleSlug; + +/** + * Query class defines the contract for a query to find a role by its slug. + */ +interface FindRoleBySlugInterface +{ + /** + * Retrieves a Role model based on the given slug. + * + * @return null|Role the Role model if found, or null if no match is found + */ + public function find(RoleSlug $slug): ?Role; +} diff --git a/src/Authorization/Application/Query/Role/Role.php b/src/Authorization/Application/Query/Role/Role.php new file mode 100644 index 0000000..2b6791b --- /dev/null +++ b/src/Authorization/Application/Query/Role/Role.php @@ -0,0 +1,39 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Application\Query\Role; + +/** + * The Role read model represents raw data of a role entity. + * + * @todo Permissions type should be decided later after the persistence layer is implemented. + */ +final readonly class Role +{ + /** + * The constructor. + * + * @param array $permissions + */ + public function __construct( + public string $id, + public string $name, + public string $slug, + public array $permissions, + public bool $isSystemRole + ) {} +} diff --git a/src/Authorization/Application/Query/Role/RoleCollection.php b/src/Authorization/Application/Query/Role/RoleCollection.php new file mode 100644 index 0000000..f2e4329 --- /dev/null +++ b/src/Authorization/Application/Query/Role/RoleCollection.php @@ -0,0 +1,32 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Application\Query\Role; + +use Webify\Base\Contract\Collection\Collection; + +/** + * Collection class for Role-read models. + * + * @extends Collection + */ +final class RoleCollection extends Collection +{ + /** + * {@inheritDoc} + */ + protected function type(): string + { + return Role::class; + } +} diff --git a/src/Authorization/Application/Query/RoleAssignment/FindAllRoleAssignmentsInterface.php b/src/Authorization/Application/Query/RoleAssignment/FindAllRoleAssignmentsInterface.php new file mode 100644 index 0000000..0f86a05 --- /dev/null +++ b/src/Authorization/Application/Query/RoleAssignment/FindAllRoleAssignmentsInterface.php @@ -0,0 +1,30 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Application\Query\RoleAssignment; + +/** + * RoleAssignmentQueryInterface defines the contract for a role assignment query. + * + * Defines methods for retrieving role assignments by their unique identifier, role ID, subject ID, or tenant ID. + * And a method to retrieve all role assignments models as a collection. + */ +interface FindAllRoleAssignmentsInterface +{ + /** + * Retrieves all role assignments. + * + * @return RoleAssignmentCollection a collection containing all role assignments + */ + public function findAll(): RoleAssignmentCollection; +} diff --git a/src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentByIdInterface.php b/src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentByIdInterface.php new file mode 100644 index 0000000..d952676 --- /dev/null +++ b/src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentByIdInterface.php @@ -0,0 +1,27 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Application\Query\RoleAssignment; + +use Webify\User\Authorization\Domain\ValueObject\RoleAssignmentId; + +/** + * Query class defines the contract for a query to find role assignment by its identifier. + */ +interface FindRoleAssignmentByIdInterface +{ + /** + * Retrieves a role assignment by its unique identifier. + */ + public function find(RoleAssignmentId $id): ?RoleAssignment; +} diff --git a/src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentBySubjectInterface.php b/src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentBySubjectInterface.php new file mode 100644 index 0000000..a8ccb6a --- /dev/null +++ b/src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentBySubjectInterface.php @@ -0,0 +1,32 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Application\Query\RoleAssignment; + +use Webify\User\Authorization\Domain\ValueObject\SubjectId; + +/** + * Query class defines the contract for a query to find role assignments by subject. + */ +interface FindRoleAssignmentBySubjectInterface +{ + /** + * Retrieves a collection of role assignments associated with a specific subject identifier. + * + * @param SubjectId $subjectId the unique identifier of the subject + * + * @return RoleAssignmentCollection a collection of role assignment read models linked + * to the specified subject + */ + public function find(SubjectId $subjectId): RoleAssignmentCollection; +} diff --git a/src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentsByRoleInterface.php b/src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentsByRoleInterface.php new file mode 100644 index 0000000..872b4c4 --- /dev/null +++ b/src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentsByRoleInterface.php @@ -0,0 +1,31 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Application\Query\RoleAssignment; + +use Webify\User\Authorization\Domain\ValueObject\RoleId; + +/** + * Query class defines the contract for a query to find role assignments by role. + */ +interface FindRoleAssignmentsByRoleInterface +{ + /** + * Retrieves a collection of role assignments associated with the specified role ID. + * + * @param RoleId $roleId the identifier of the role to find assignments for + * + * @return RoleAssignmentCollection a collection of role assignments corresponding to the role ID + */ + public function find(RoleId $roleId): RoleAssignmentCollection; +} diff --git a/src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentsByTenantInterface.php b/src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentsByTenantInterface.php new file mode 100644 index 0000000..47f4454 --- /dev/null +++ b/src/Authorization/Application/Query/RoleAssignment/FindRoleAssignmentsByTenantInterface.php @@ -0,0 +1,31 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Application\Query\RoleAssignment; + +use Webify\User\Authorization\Domain\ValueObject\TenantId; + +/** + * Query class defines the contract for a query to find role assignments by tenant. + */ +interface FindRoleAssignmentsByTenantInterface +{ + /** + * Retrieves a collection of role assignment read models associated with a specific tenant. + * + * @param TenantId $tenantId the identifier of the tenant + * + * @return RoleAssignmentCollection the collection of role assignment read models for the given tenant + */ + public function find(TenantId $tenantId): RoleAssignmentCollection; +} diff --git a/src/Authorization/Application/Query/RoleAssignment/RoleAssignment.php b/src/Authorization/Application/Query/RoleAssignment/RoleAssignment.php new file mode 100644 index 0000000..e197e54 --- /dev/null +++ b/src/Authorization/Application/Query/RoleAssignment/RoleAssignment.php @@ -0,0 +1,33 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Application\Query\RoleAssignment; + +use DateTimeImmutable; + +/** + * The RoleAssignment read model represents raw data of a role assignment entity. + */ +final readonly class RoleAssignment +{ + /** + * The constructor. + */ + public function __construct( + public string $id, + public string $roleId, + public string $subjectId, + public ?string $tenantId = null, + public ?DateTimeImmutable $expiresAt = null + ) {} +} diff --git a/src/Authorization/Application/Query/RoleAssignment/RoleAssignmentCollection.php b/src/Authorization/Application/Query/RoleAssignment/RoleAssignmentCollection.php new file mode 100644 index 0000000..cdc49b9 --- /dev/null +++ b/src/Authorization/Application/Query/RoleAssignment/RoleAssignmentCollection.php @@ -0,0 +1,32 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Application\Query\RoleAssignment; + +use Webify\Base\Contract\Collection\Collection; + +/** + * Collection class for RoleAssignment read model. + * + * @extends Collection + */ +final class RoleAssignmentCollection extends Collection +{ + /** + * {@inheritDoc} + */ + protected function type(): string + { + return RoleAssignment::class; + } +} diff --git a/src/Authorization/Domain/Collection/PermissionCollection.php b/src/Authorization/Domain/Collection/PermissionCollection.php new file mode 100644 index 0000000..290117f --- /dev/null +++ b/src/Authorization/Domain/Collection/PermissionCollection.php @@ -0,0 +1,33 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Collection; + +use Webify\Base\Contract\Collection\Collection; +use Webify\User\Authorization\Domain\ValueObject\Permission; + +/** + * Permission collection. + * + * @extends Collection + */ +final class PermissionCollection extends Collection +{ + /** + * {@inheritDoc} + */ + protected function type(): string + { + return Permission::class; + } +} diff --git a/src/Authorization/Domain/Collection/RoleAssignmentCollection.php b/src/Authorization/Domain/Collection/RoleAssignmentCollection.php new file mode 100644 index 0000000..7992a5b --- /dev/null +++ b/src/Authorization/Domain/Collection/RoleAssignmentCollection.php @@ -0,0 +1,33 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Collection; + +use Webify\Base\Contract\Collection\Collection; +use Webify\User\Authorization\Domain\Entity\RoleAssignment; + +/** + * Role assignment collection. + * + * @extends Collection + */ +final class RoleAssignmentCollection extends Collection +{ + /** + * {@inheritDoc} + */ + protected function type(): string + { + return RoleAssignment::class; + } +} diff --git a/src/Authorization/Domain/Entity/Role.php b/src/Authorization/Domain/Entity/Role.php new file mode 100644 index 0000000..daf738c --- /dev/null +++ b/src/Authorization/Domain/Entity/Role.php @@ -0,0 +1,180 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Entity; + +use Webify\Base\Domain\Entity\AggregateRoot; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authorization\Domain\Collection\PermissionCollection; +use Webify\User\Authorization\Domain\Event\{PermissionGranted, PermissionRevoked, RoleCreated}; +use Webify\User\Authorization\Domain\Exception\SystemRoleModificationException; +use Webify\User\Authorization\Domain\ValueObject\{Permission, RoleId, RoleName, RoleSlug}; + +/** + * Role aggregate root. + * + * Represent a named bundle of permissions that can be assigned to subjects. + * The Role encapsulates the permission-matching logic so that no caller ever + * iterates over permissions directly; they ask the role whether it permits something. + * Roles support a built-in flag to mark system roles (e.g. Super Admin) as non-deletable by application code. + */ +final class Role extends AggregateRoot +{ + /** + * Private constructor enforces the use of the factory method. + */ + private function __construct( + private readonly RoleId $id, + private RoleName $name, + private RoleSlug $slug, + private PermissionCollection $permissions, + private bool $isSystemRole + ) {} + + /** + * Get the ID. + */ + public function getId(): RoleId + { + return $this->id; + } + + /** + * Get the name. + */ + public function getName(): RoleName + { + return $this->name; + } + + /** + * Get the slug. + */ + public function getSlug(): RoleSlug + { + return $this->slug; + } + + /** + * Get the permissions. + */ + public function getPermissions(): PermissionCollection + { + return $this->permissions; + } + + /** + * Check if the role is a system role. + */ + public function isSystemRole(): bool + { + return $this->isSystemRole; + } + + /** + * Grant permission to the role. + */ + public function grant(Permission $permission): void + { + $this->guardSystemRole(); + + $this->permissions = $this->permissions->with($permission); + + $this->recordDomainEvent( + new PermissionGranted( + $this->id->toNative(), + $this->name->toNative(), + (string) $this->slug, + (string) $permission, + DateTime::now()->toNative() + ) + ); + } + + /** + * Revoke permission from the role. + */ + public function revoke(Permission $permission): void + { + $this->guardSystemRole(); + + $this->permissions = $this->permissions->without( + fn (Permission $existingPermission): bool => $existingPermission->equals($permission) + ); + + $this->recordDomainEvent( + new PermissionRevoked( + $this->id->toNative(), + $this->name->toNative(), + (string) $this->slug, + (string) $permission, + DateTime::now()->toNative() + ) + ); + } + + /** + * Check if the role allows a permission. + */ + public function allows(string $scope, string $action, string $resource): bool + { + foreach ($this->permissions as $permission) { + if ($permission->matches($scope, $action, $resource)) { + return true; + } + } + + return false; + } + + /** + * Factory method to create a new role. + */ + public static function create( + RoleId $id, + RoleName $name, + RoleSlug $slug, + PermissionCollection $permissions, + bool $isSystemRole = false + ): self { + $role = new self($id, $name, $slug, $permissions, $isSystemRole); + + $role->recordDomainEvent( + new RoleCreated( + $role->getId()->toNative(), + $role->getName()->toNative(), + (string) $role->getSlug(), + array_map( + static fn (Permission $permission): array => $permission->toNative(), + $role->getPermissions()->toArray() + ), + $role->isSystemRole(), + DateTime::now()->toNative() + ) + ); + + return $role; + } + + /** + * Guards against modifying a system role. + * + * @throws SystemRoleModificationException if the role is a system role + */ + private function guardSystemRole(): void + { + if ($this->isSystemRole) { + throw SystemRoleModificationException::forSystemRole($this->name->toNative()); + } + } +} diff --git a/src/Authorization/Domain/Entity/RoleAssignment.php b/src/Authorization/Domain/Entity/RoleAssignment.php new file mode 100644 index 0000000..1bc0311 --- /dev/null +++ b/src/Authorization/Domain/Entity/RoleAssignment.php @@ -0,0 +1,145 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Entity; + +use Webify\Base\Domain\Entity\AggregateRoot; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authorization\Domain\Event\RoleAssigned; +use Webify\User\Authorization\Domain\ValueObject\{RoleAssignmentId, RoleId, SubjectId, TenantId}; + +/** + * Role assignment entity. + * + * Represent the link between a subject and a role, optionally scoped to a tenant. + * This is what makes the system multi-tenant-aware at the data level — the same role + * can be assigned globally (tenantId = null) or restricted to a specific organization. + * An expiry date is supported so temporary elevated access (e.g. covering a colleague's leave) + * can be modelled without needing a separate revocation step. + * The isApplicableFor method encapsulates the scoping logic, so callers never perform tenant comparisons manually. + */ +final class RoleAssignment extends AggregateRoot +{ + /** + * Private constructor enforces the use of the factory method. + * + * @param RoleAssignmentId $id unique identifier for the role assignment + * @param RoleId $roleId identifier of the associated role + * @param SubjectId $subjectId identifier of the subject tied to the role assignment + * @param null|TenantId $tenantId optional identifier for the tenant + * @param null|DateTime $expiresAt optional expiration date for the role assignment, if null it will never expire + */ + private function __construct( + private readonly RoleAssignmentId $id, + private readonly RoleId $roleId, + private readonly SubjectId $subjectId, + private readonly ?TenantId $tenantId= null, + private readonly ?DateTime $expiresAt = null + ) {} + + /** + * Get the identifier. + */ + public function getId(): RoleAssignmentId + { + return $this->id; + } + + /** + * Get the role identifier. + */ + public function getRoleId(): RoleId + { + return $this->roleId; + } + + /** + * Get the subject identifier. + */ + public function getSubjectId(): SubjectId + { + return $this->subjectId; + } + + /** + * Get the tenant identifier. + */ + public function getTenantId(): ?TenantId + { + return $this->tenantId; + } + + /** + * Get the expiration date of the role assignment. + */ + public function getExpiresAt(): ?DateTime + { + return $this->expiresAt; + } + + /** + * Check if the role assignment is applicable for the given tenant. + */ + public function isApplicableFor(?TenantId $tenantId): bool + { + // This means global role assignment + if (null === $this->tenantId) { + return true; + } + + // This means role assignment is scope, but context is global + if (null === $tenantId) { + return false; + } + + return $this->tenantId->equals($tenantId); + } + + /** + * Check if the role assignment has expired. + */ + public function isExpired(): bool + { + if (null === $this->expiresAt) { + return false; + } + + return $this->expiresAt->isBefore(DateTime::now()); + } + + /** + * Factory method to assign a new role. + */ + public static function assign( + RoleAssignmentId $id, + RoleId $roleId, + SubjectId $subjectId, + ?TenantId $tenantId = null, + ?DateTime $expiresAt = null + ): self { + $roleAssignment = new self($id, $roleId, $subjectId, $tenantId, $expiresAt); + + $roleAssignment->recordDomainEvent( + new RoleAssigned( + $roleAssignment->getId()->toNative(), + $roleAssignment->getRoleId()->toNative(), + $roleAssignment->getSubjectId()->toNative(), + $roleAssignment->getTenantId()?->toNative(), + $roleAssignment->getExpiresAt()?->toNative(), + DateTime::now()->toNative() + ) + ); + + return $roleAssignment; + } +} diff --git a/src/Authorization/Domain/Event/PermissionGranted.php b/src/Authorization/Domain/Event/PermissionGranted.php new file mode 100644 index 0000000..6779a58 --- /dev/null +++ b/src/Authorization/Domain/Event/PermissionGranted.php @@ -0,0 +1,56 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * Event that is triggered when permission granted. + */ +final readonly class PermissionGranted implements DomainEventInterface +{ + /** + * The constructor. + * + * @param string $roleId the role ID + * @param string $roleName the role name + * @param string $roleSlug the role slug + * @param string $permission the granted permission + * @param DateTimeImmutable $createdAt the date and time when the role was created + */ + public function __construct( + public string $roleId, + public string $roleName, + public string $roleSlug, + public string $permission, + private DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.authorization.permission_was_granted'; + } +} diff --git a/src/Authorization/Domain/Event/PermissionRevoked.php b/src/Authorization/Domain/Event/PermissionRevoked.php new file mode 100644 index 0000000..7174bd6 --- /dev/null +++ b/src/Authorization/Domain/Event/PermissionRevoked.php @@ -0,0 +1,56 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * Event that is triggered when permission revoked. + */ +final readonly class PermissionRevoked implements DomainEventInterface +{ + /** + * The constructor. + * + * @param string $roleId the role ID + * @param string $roleName the role name + * @param string $roleSlug the role slug + * @param string $permission the revoked permission + * @param DateTimeImmutable $createdAt the date and time when the role was created + */ + public function __construct( + public string $roleId, + public string $roleName, + public string $roleSlug, + public string $permission, + private DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.authorization.permission_was_revoked'; + } +} diff --git a/src/Authorization/Domain/Event/RoleAssigned.php b/src/Authorization/Domain/Event/RoleAssigned.php new file mode 100644 index 0000000..163922a --- /dev/null +++ b/src/Authorization/Domain/Event/RoleAssigned.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * Event that is triggered when a role was assigned. + */ +final readonly class RoleAssigned implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $roleAssignmentId, + public string $roleId, + public string $subjectId, + public ?string $tenantId, + public ?DateTimeImmutable $expiresAt, + private DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.authorization.role_was_assigned'; + } +} diff --git a/src/Authorization/Domain/Event/RoleCreated.php b/src/Authorization/Domain/Event/RoleCreated.php new file mode 100644 index 0000000..6de4396 --- /dev/null +++ b/src/Authorization/Domain/Event/RoleCreated.php @@ -0,0 +1,62 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * Event that is triggered when a role was created. + */ +final readonly class RoleCreated implements DomainEventInterface +{ + /** + * The constructor. + * + * @param string $roleId the role ID + * @param string $roleName the role name + * @param string $roleSlug the role slug + * @param array $permissions the permissions associated with the role + * @param bool $isSystemRole whether the role is a system role + * @param DateTimeImmutable $createdAt the date and time when the role was created + */ + public function __construct( + public string $roleId, + public string $roleName, + public string $roleSlug, + public array $permissions, + public bool $isSystemRole, + private DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.authorization.role_was_created'; + } +} diff --git a/src/Authorization/Domain/Event/RoleRevoked.php b/src/Authorization/Domain/Event/RoleRevoked.php new file mode 100644 index 0000000..7bcd824 --- /dev/null +++ b/src/Authorization/Domain/Event/RoleRevoked.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * Event that is triggered when a role was revoked. + */ +final readonly class RoleRevoked implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $roleAssignmentId, + public string $roleId, + public string $subjectId, + public ?string $tenantId, + public ?DateTimeImmutable $expiresAt, + private DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.authorization.role_was_revoked'; + } +} diff --git a/src/Authorization/Domain/Exception/DuplicateRoleAssignmentException.php b/src/Authorization/Domain/Exception/DuplicateRoleAssignmentException.php new file mode 100644 index 0000000..42b2137 --- /dev/null +++ b/src/Authorization/Domain/Exception/DuplicateRoleAssignmentException.php @@ -0,0 +1,60 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Exception; + +use DomainException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when a role assignment already exists. + */ +final class DuplicateRoleAssignmentException extends DomainException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + */ + public static function forRoleAndSubject(string $roleId, string $subjectId, ?string $tenantId = null): self + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'role_assignment_exists', + [ + 'roleId' => $roleId, + 'subjectId' => $subjectId, + 'tenantId' => $tenantId, + ] + ), + sprintf( + 'Role assignment is already exists for the given role "%s" and subject "%s".', + $roleId, + $subjectId + ) + ); + } +} diff --git a/src/Authorization/Domain/Exception/InvalidRoleAssignmentIdException.php b/src/Authorization/Domain/Exception/InvalidRoleAssignmentIdException.php new file mode 100644 index 0000000..f66ef63 --- /dev/null +++ b/src/Authorization/Domain/Exception/InvalidRoleAssignmentIdException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid role assignment id value is encountered. + */ +final class InvalidRoleAssignmentIdException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + * + * @param string $value the invalid role assignment id value + */ + public static function forInvalidId(string $value): self + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'invalid_role_assignment_id', + ['value' => $value] + ), + sprintf('Invalid role assignment id "%s"', $value) + ); + } +} diff --git a/src/Authorization/Domain/Exception/InvalidRoleIdException.php b/src/Authorization/Domain/Exception/InvalidRoleIdException.php new file mode 100644 index 0000000..1769a24 --- /dev/null +++ b/src/Authorization/Domain/Exception/InvalidRoleIdException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid role id value is encountered. + */ +final class InvalidRoleIdException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + * + * @param string $value the invalid role id value + */ + public static function forInvalidId(string $value): self + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'invalid_role_id', + ['value' => $value] + ), + sprintf('Invalid role id "%s"', $value) + ); + } +} diff --git a/src/Authorization/Domain/Exception/InvalidRoleNameException.php b/src/Authorization/Domain/Exception/InvalidRoleNameException.php new file mode 100644 index 0000000..6d22f3d --- /dev/null +++ b/src/Authorization/Domain/Exception/InvalidRoleNameException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid role name value is encountered. + */ +final class InvalidRoleNameException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + * + * @param string $value the invalid role name value + */ + public static function forInvalidName(string $value): self + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'invalid_role_name', + ['value' => $value] + ), + sprintf('Invalid role name "%s"', $value) + ); + } +} diff --git a/src/Authorization/Domain/Exception/InvalidRoleSlugException.php b/src/Authorization/Domain/Exception/InvalidRoleSlugException.php new file mode 100644 index 0000000..71296d5 --- /dev/null +++ b/src/Authorization/Domain/Exception/InvalidRoleSlugException.php @@ -0,0 +1,71 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid role slug value is encountered. + */ +final class InvalidRoleSlugException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + * + * @param string $value the invalid role slug value + */ + public static function forInvalidSlug(string $value): self + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'invalid_role_slug', + ['value' => $value] + ), + sprintf('Invalid role slug "%s"', $value) + ); + } + + /** + * Creates an instance of the exception for an invalid role slug separator. + * + * @param string $value the slug with separator that is considered invalid + */ + public static function forInvalidSeparator(string $value): self + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'invalid_role_slug', + ['value' => $value] + ), + sprintf('Invalid role slug separator "%s"', $value) + ); + } +} diff --git a/src/Authorization/Domain/Exception/InvalidSubjectIdException.php b/src/Authorization/Domain/Exception/InvalidSubjectIdException.php new file mode 100644 index 0000000..09ab30a --- /dev/null +++ b/src/Authorization/Domain/Exception/InvalidSubjectIdException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid subject id value is encountered. + */ +final class InvalidSubjectIdException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + * + * @param string $value the invalid subject id value + */ + public static function forInvalidId(string $value): self + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'invalid_subject_id', + ['value' => $value] + ), + sprintf('Invalid subject id "%s"', $value) + ); + } +} diff --git a/src/Authorization/Domain/Exception/InvalidTenantIdException.php b/src/Authorization/Domain/Exception/InvalidTenantIdException.php new file mode 100644 index 0000000..01143b1 --- /dev/null +++ b/src/Authorization/Domain/Exception/InvalidTenantIdException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid tenant id value is encountered. + */ +final class InvalidTenantIdException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + * + * @param string $value the invalid tenant id value + */ + public static function forInvalidId(string $value): self + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'invalid_tenant_id', + ['value' => $value] + ), + sprintf('Invalid tenant id "%s"', $value) + ); + } +} diff --git a/src/Authorization/Domain/Exception/PermissionValidationException.php b/src/Authorization/Domain/Exception/PermissionValidationException.php new file mode 100644 index 0000000..321d102 --- /dev/null +++ b/src/Authorization/Domain/Exception/PermissionValidationException.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when invalid permission properties are encountered. + */ +final class PermissionValidationException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Creates a new instance of PermissionValidationException for invalid permission properties. + */ + public static function forInvalidProperties(): PermissionValidationException + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'invalid_permission' + ), + 'Invalid permission properties.' + ); + } +} diff --git a/src/Authorization/Domain/Exception/RoleAlreadyExistsException.php b/src/Authorization/Domain/Exception/RoleAlreadyExistsException.php new file mode 100644 index 0000000..1478606 --- /dev/null +++ b/src/Authorization/Domain/Exception/RoleAlreadyExistsException.php @@ -0,0 +1,56 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Exception; + +use DomainException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when a role already exists. + */ +final class RoleAlreadyExistsException extends DomainException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + * + * @param string $slug the role slug + */ + public static function forSlug(string $slug): self + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'role_already_exists', + [ + 'slug' => $slug, + ] + ), + sprintf('Role is already exists for the given role slug "%s".', $slug) + ); + } +} diff --git a/src/Authorization/Domain/Exception/RoleAssignmentNotFoundException.php b/src/Authorization/Domain/Exception/RoleAssignmentNotFoundException.php new file mode 100644 index 0000000..67c9b5a --- /dev/null +++ b/src/Authorization/Domain/Exception/RoleAssignmentNotFoundException.php @@ -0,0 +1,52 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Exception; + +use RuntimeException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when the role assignment is not found. + */ +final class RoleAssignmentNotFoundException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Creates a new instance of RoleAssignmentNotFoundException for the given role assignment ID. + */ + public static function forId(string $id): RoleAssignmentNotFoundException + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'role_assignment_not_found', + ['id' => $id] + ), + sprintf('Role assignment not found for id: "%s"', $id) + ); + } +} diff --git a/src/Authorization/Domain/Exception/RoleNotFoundException.php b/src/Authorization/Domain/Exception/RoleNotFoundException.php new file mode 100644 index 0000000..bdf625e --- /dev/null +++ b/src/Authorization/Domain/Exception/RoleNotFoundException.php @@ -0,0 +1,67 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Exception; + +use RuntimeException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when the role is not found. + */ +final class RoleNotFoundException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Creates a new RoleNotFoundException instance for the given role ID. + */ + public static function forId(string $id): RoleNotFoundException + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'role_not_found', + ['id'=> $id] + ), + sprintf('Role not found for id: "%s"', $id) + ); + } + + /** + * Creates a new RoleNotFoundException instance for the given role slug. + */ + public static function forSlug(string $slug): RoleNotFoundException + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'role_not_found', + ['slug'=> $slug] + ), + sprintf('Role not found for slug: "%s"', $slug) + ); + } +} diff --git a/src/Authorization/Domain/Exception/SystemRoleModificationException.php b/src/Authorization/Domain/Exception/SystemRoleModificationException.php new file mode 100644 index 0000000..e5bf77d --- /dev/null +++ b/src/Authorization/Domain/Exception/SystemRoleModificationException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Exception; + +use DomainException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when system roles try to be modified. + */ +final class SystemRoleModificationException extends DomainException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate this with a default message. + * + * @param string $value the system role value + */ + public static function forSystemRole(string $value): self + { + return new self( + new ExceptionTranslation( + 'user.authorization', + 'cannot_modify_system_role', + ['value' => $value] + ), + sprintf('System role "%s" cannot be modified.', $value) + ); + } +} diff --git a/src/Authorization/Domain/Guard/DuplicateRoleAssignment.php b/src/Authorization/Domain/Guard/DuplicateRoleAssignment.php new file mode 100644 index 0000000..ebd6ef8 --- /dev/null +++ b/src/Authorization/Domain/Guard/DuplicateRoleAssignment.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Guard; + +use Webify\User\Authorization\Domain\Exception\DuplicateRoleAssignmentException; +use Webify\User\Authorization\Domain\Repository\RoleAssignmentRepositoryInterface; +use Webify\User\Authorization\Domain\ValueObject\{RoleId, SubjectId, TenantId}; + +/** + * Guard class to prevent duplicate role assignments. + */ +final readonly class DuplicateRoleAssignment +{ + /** + * The constructor. + */ + public function __construct( + private RoleAssignmentRepositoryInterface $repository + ) {} + + /** + * Ensures that a role-subject assignment does not already exist. + * + * @param RoleId $roleId the identifier for the role + * @param SubjectId $subjectId the identifier for the subject + * @param null|TenantId $tenantId the identifier for the tenant, if applicable + * + * @throws DuplicateRoleAssignmentException if the role-subject assignment already exists + */ + public function guard(RoleId $roleId, SubjectId $subjectId, ?TenantId $tenantId = null): void + { + if ($this->repository->isExists($roleId, $subjectId, $tenantId)) { + throw DuplicateRoleAssignmentException::forRoleAndSubject( + $roleId->toNative(), + $subjectId->toNative(), + $tenantId?->toNative() + ); + } + } +} diff --git a/src/Authorization/Domain/Guard/RoleAlreadyExists.php b/src/Authorization/Domain/Guard/RoleAlreadyExists.php new file mode 100644 index 0000000..ef23618 --- /dev/null +++ b/src/Authorization/Domain/Guard/RoleAlreadyExists.php @@ -0,0 +1,45 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Guard; + +use Webify\User\Authorization\Domain\Exception\RoleAlreadyExistsException; +use Webify\User\Authorization\Domain\Repository\RoleRepositoryInterface; +use Webify\User\Authorization\Domain\ValueObject\RoleSlug; + +/** + * Guard class prevents against the creation of a role with a slug that already exists. + */ +final readonly class RoleAlreadyExists +{ + /** + * The constructor. + */ + public function __construct( + private RoleRepositoryInterface $repository + ) {} + + /** + * Guards against the creation of a role with a slug that already exists. + * + * @param RoleSlug $slug the slug of the role being checked + * + * @throws RoleAlreadyExistsException if a role with the given slug already exists + */ + public function guard(RoleSlug $slug): void + { + if ($this->repository->isExist($slug)) { + throw RoleAlreadyExistsException::forSlug((string) $slug); + } + } +} diff --git a/src/Authorization/Domain/Repository/RoleAssignmentRepositoryInterface.php b/src/Authorization/Domain/Repository/RoleAssignmentRepositoryInterface.php new file mode 100644 index 0000000..afddfde --- /dev/null +++ b/src/Authorization/Domain/Repository/RoleAssignmentRepositoryInterface.php @@ -0,0 +1,79 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Repository; + +use Webify\User\Authorization\Domain\Collection\RoleAssignmentCollection; +use Webify\User\Authorization\Domain\Entity\RoleAssignment; +use Webify\User\Authorization\Domain\Exception\RoleAssignmentNotFoundException; +use Webify\User\Authorization\Domain\ValueObject\{RoleAssignmentId, RoleId, SubjectId, TenantId}; + +/** + * RoleAssignmentRepositoryInterface defines the contract for a role assignment repository. + */ +interface RoleAssignmentRepositoryInterface +{ + /** + * Retrieves a RoleAssignment object by its unique identifier. + * + * @param RoleAssignmentId $id the unique identifier of the RoleAssignment + * + * @return RoleAssignment the RoleAssignment object associated with the given identifier + * + * @throws RoleAssignmentNotFoundException if the RoleAssignment with the given identifier is not found + */ + public function getById(RoleAssignmentId $id): RoleAssignment; + + /** + * Retrieves a collection of RoleAssignment objects associated with a specific role ID. + * + * @param RoleId $roleId the ID of the role + * + * @return RoleAssignmentCollection a collection of RoleAssignment objects associated with the given role ID + */ + public function getByRoleId(RoleId $roleId): RoleAssignmentCollection; + + /** + * Retrieves a collection of RoleAssignment objects associated with a specific subject ID. + * + * @param SubjectId $subjectId the ID of the subject + * + * @return RoleAssignmentCollection a collection of RoleAssignment objects associated with the given subject ID + */ + public function getBySubjectId(SubjectId $subjectId): RoleAssignmentCollection; + + /** + * Checks if a role assignment exists for the given role ID and subject ID. + * + * @param RoleId $roleId the ID of the role + * @param SubjectId $subjectId the ID of the subject + * @param null|TenantId $tenantId the ID of the tenant, if applicable + * + * @return bool true if a role assignment exists, false otherwise + */ + public function isExists(RoleId $roleId, SubjectId $subjectId, ?TenantId $tenantId): bool; + + /** + * Persists the given RoleAssignment object to the data store. + * + * @param RoleAssignment $roleAssignment the RoleAssignment object to be saved + */ + public function persist(RoleAssignment $roleAssignment): void; + + /** + * Deletes the specified role assignment. + * + * @param RoleAssignment $roleAssignment the role assignment instance to be deleted + */ + public function delete(RoleAssignment $roleAssignment): void; +} diff --git a/src/Authorization/Domain/Repository/RoleRepositoryInterface.php b/src/Authorization/Domain/Repository/RoleRepositoryInterface.php new file mode 100644 index 0000000..19026f1 --- /dev/null +++ b/src/Authorization/Domain/Repository/RoleRepositoryInterface.php @@ -0,0 +1,48 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Repository; + +use Webify\User\Authorization\Domain\Entity\Role; +use Webify\User\Authorization\Domain\Exception\RoleNotFoundException; +use Webify\User\Authorization\Domain\ValueObject\{RoleId, RoleSlug}; + +/** + * RoleRepositoryInterface defines the contract for a role repository. + */ +interface RoleRepositoryInterface +{ + /** + * Retrieves a Role entity by its ID. + * + * @throws RoleNotFoundException if the role with the given ID is not found + */ + public function getById(RoleId $id): Role; + + /** + * Retrieves a Role entity by its slug. + * + * @throws RoleNotFoundException if the role with the given slug is not found + */ + public function getBySlug(RoleSlug $slug): Role; + + /** + * Checks if a role with the given slug exists. + */ + public function isExist(RoleSlug $slug): bool; + + /** + * Persists the given Role entity to the data store. + */ + public function persist(Role $role): void; +} diff --git a/src/Authorization/Domain/Service/AssignRole.php b/src/Authorization/Domain/Service/AssignRole.php new file mode 100644 index 0000000..d70594d --- /dev/null +++ b/src/Authorization/Domain/Service/AssignRole.php @@ -0,0 +1,90 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Service; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\UlidGeneratorInterface; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authorization\Domain\Entity\{Role, RoleAssignment}; +use Webify\User\Authorization\Domain\Guard\DuplicateRoleAssignment; +use Webify\User\Authorization\Domain\Repository\{RoleAssignmentRepositoryInterface, RoleRepositoryInterface}; +use Webify\User\Authorization\Domain\ValueObject\{RoleAssignmentId, RoleSlug, SubjectId, TenantId}; + +/** + * AssignRole service handles the assigning roles to subjects. + * + * Encapsulate the use case of giving a subject a role, optionally scoped to a tenant and with optional expiry. + * It validates that the role exists before creating the assignment, produces a clear + * domain exception if it does not, and persists the assignment through the repository. + */ +final readonly class AssignRole +{ + /** + * The constructor. + */ + public function __construct( + private RoleRepositoryInterface $roleRepository, + private RoleAssignmentRepositoryInterface $roleAssignmentRepository, + private UlidGeneratorInterface $idGenerator, + private DuplicateRoleAssignment $duplicateRoleAssignment, + private DomainEventPublisherInterface $eventPublisher + ) {} + + /** + * Assign the role to the given subject. + * + * @param string $subjectId the ID of the subject to whom the role is assigned + * @param string $roleSlug the slug of the role to be assigned + * @param null|string $tenantId the optional tenant ID for scoping the assignment + * @param null|DateTimeImmutable $expireAt the optional expiration date and time for the assignment + */ + public function assign( + string $subjectId, + string $roleSlug, + ?string $tenantId = null, + ?DateTimeImmutable $expireAt = null + ): void { + $subjectId = SubjectId::fromString($subjectId); + $role = $this->getRole($roleSlug); + $tenantId = null !== $tenantId ? TenantId::fromString($tenantId) : null; + + // Guard against duplicate role assignments + $this->duplicateRoleAssignment->guard($role->getId(), $subjectId, $tenantId); + + $expireAt = null !== $expireAt ? DateTime::fromNative($expireAt) : null; + $assignment = RoleAssignment::assign( + RoleAssignmentId::fromString($this->idGenerator->generate()), + $role->getId(), + $subjectId, + $tenantId, + $expireAt + ); + + $this->roleAssignmentRepository->persist($assignment); + $this->eventPublisher->publish(...$assignment->getDomainEvents()); + } + + /** + * Retrieves a Role object based on the provided role slug. + * + * @param string $roleSlug the slug of the role to retrieve + * + * @return Role the Role object corresponding to the given slug + */ + private function getRole(string $roleSlug): Role + { + return $this->roleRepository->getBySlug(RoleSlug::fromString($roleSlug)); + } +} diff --git a/src/Authorization/Domain/Service/Authorization.php b/src/Authorization/Domain/Service/Authorization.php new file mode 100644 index 0000000..f69b850 --- /dev/null +++ b/src/Authorization/Domain/Service/Authorization.php @@ -0,0 +1,146 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Service; + +use Webify\Base\Domain\Contract\Authorization\{ + AuthorizableResourceInterface, + AuthorizableSubjectInterface +}; +use Webify\Base\Domain\Contract\Authorization\Service\RuleRegistryInterface; +use Webify\User\Authorization\Domain\Exception\RoleNotFoundException; +use Webify\User\Authorization\Domain\Repository\{RoleAssignmentRepositoryInterface, RoleRepositoryInterface}; +use Webify\User\Authorization\Domain\ValueObject\{SubjectId, TenantId}; + +/** + * Authorization class provides the implementation of the AuthorizationInterface. + */ +final class Authorization +{ + /** + * Cache for authorization results. + * Key: Cache key (action, subjectId, resourceScope, resourceType, tenantId, or global). + * Value: Authorization result (true or false). + * + * @var array + */ + private array $cache = []; + + /** + * The constructor. + */ + public function __construct( + private readonly RoleRepositoryInterface $roleRepository, + private readonly RoleAssignmentRepositoryInterface $assignmentRepository, + private readonly RuleRegistryInterface $ruleRegistry + ) {} + + /** + * Checks whether the given subject is authorized to access the specified resource. + * + * @param string $action the action being checked for authorization + * @param AuthorizableSubjectInterface $subject the subject whose authorization is being evaluated + * @param AuthorizableResourceInterface $resource the resource for which access is being checked + * + * @return bool returns true if the subject is authorized to access the resource, false otherwise + */ + public function authorize( + string $action, + AuthorizableSubjectInterface $subject, + AuthorizableResourceInterface $resource + ): bool { + $key = $this->generateCacheKey($action, $subject, $resource); + + if (isset($this->cache[$key])) { + return $this->cache[$key]; + } + + return $this->cache[$key] = $this->evaluate($action, $subject, $resource); + } + + /** + * Evaluates the authorization based on the provided parameters. + */ + private function evaluate( + string $action, + AuthorizableSubjectInterface $subject, + AuthorizableResourceInterface $resource + ): bool { + try { + $assignments = $this->assignmentRepository->getBySubjectId(SubjectId::fromString($subject->subjectId())); + $rules = $this->ruleRegistry->getAllApplicableTo($resource); + + foreach ($assignments as $assignment) { + if ( + $assignment->isExpired() + || !$assignment->isApplicableFor($this->getTenantId($subject->tenantId())) + ) { + continue; + } + + $role = $this->roleRepository->getById($assignment->getRoleId()); + + if (!$role->allows($resource->resourceScope(), $action, $resource->resourceType())) { + continue; + } + + foreach ($rules as $rule) { + if (!$rule->isSatisfied($subject, $resource)) { + continue 2; + } + } + + return true; + } + + return false; + } catch (RoleNotFoundException) { + return false; + } + } + + /** + * Converts the tenant ID string to a TenantId object, or returns null if the tenant ID is null. + */ + private function getTenantId(?string $tenantId): ?TenantId + { + return null !== $tenantId ? TenantId::fromString($tenantId) : null; + } + + /** + * Generates a cache key based on the given parameters. + * + * @param string $action the action being performed + * @param AuthorizableSubjectInterface $subject the subject associated with the action + * @param AuthorizableResourceInterface $resource the resource associated with the action + * + * @return string the generated cache key + */ + private function generateCacheKey( + string $action, + AuthorizableSubjectInterface $subject, + AuthorizableResourceInterface $resource + ): string { + return implode( + '|', + [ + $action, + $subject->subjectId(), + $resource->resourceScope(), + $resource->resourceType(), + $resource->resourceId(), + $subject->tenantId() ?? 'global', + ] + ); + } +} diff --git a/src/Authorization/Domain/Service/CheckAccess.php b/src/Authorization/Domain/Service/CheckAccess.php new file mode 100644 index 0000000..0e23657 --- /dev/null +++ b/src/Authorization/Domain/Service/CheckAccess.php @@ -0,0 +1,71 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Service; + +use Webify\Base\Domain\Contract\Authorization\{ + AuthorizableResourceInterface, + AuthorizableSubjectInterface, +}; +use Webify\Base\Domain\Exception\AccessDeniedException; + +/** + * CheckAccess is the implementation of the `CheckAccessInterface` that uses the + * `AuthorizationInterface` to check access permissions. + */ +final readonly class CheckAccess +{ + /** + * The constructor. + */ + public function __construct( + private Authorization $authorization + ) {} + + /** + * Checks if the given subject has permission to perform the specified action on the resource. + * + * @param string $action the action being performed + * @param AuthorizableSubjectInterface $subject the subject performing the action + * @param AuthorizableResourceInterface $resource the resource being accessed + * + * @return bool true if access is allowed, false otherwise + */ + public function isAllowed( + string $action, + AuthorizableSubjectInterface $subject, + AuthorizableResourceInterface $resource + ): bool { + return $this->authorization->authorize($action, $subject, $resource); + } + + /** + * Checks if the given subject has permission to perform the specified action on the resource, + * and throws an `AccessDeniedException` if access is denied. + * + * @param string $action the action being performed + * @param AuthorizableSubjectInterface $subject the subject performing the action + * @param AuthorizableResourceInterface $resource the resource being accessed + * + * @throws AccessDeniedException if access is denied + */ + public function denyUnless( + string $action, + AuthorizableSubjectInterface $subject, + AuthorizableResourceInterface $resource + ): void { + if (!$this->authorization->authorize($action, $subject, $resource)) { + throw AccessDeniedException::deniedFor($action, $subject->subjectId(), $resource->resourceId()); + } + } +} diff --git a/src/Authorization/Domain/Service/CreateRole.php b/src/Authorization/Domain/Service/CreateRole.php new file mode 100644 index 0000000..d131e04 --- /dev/null +++ b/src/Authorization/Domain/Service/CreateRole.php @@ -0,0 +1,100 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Service; + +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\{SlugifyInterface, UlidGeneratorInterface}; +use Webify\User\Authorization\Domain\Collection\PermissionCollection; +use Webify\User\Authorization\Domain\Entity\Role; +use Webify\User\Authorization\Domain\Guard\RoleAlreadyExists; +use Webify\User\Authorization\Domain\Repository\RoleRepositoryInterface; +use Webify\User\Authorization\Domain\ValueObject\{Permission, RoleId, RoleName, RoleSlug}; + +/** + * CrateRole service handles the creation of a new role. + * + * Encapsulate the use case of defining a new role and its initial set of permissions. + * The slug is derived automatically from the name if not provided explicitly, ensuring uniqueness and consistency. + * This service is the entry point for both framework-defined default roles (seeded at installation time) + * and extension-defined roles (registered during extension activation). + * + * Attempting to create a role with a duplicate slug must fail with a clear exception rather than silently overwriting. + */ +final readonly class CreateRole +{ + /** + * The constructor. + */ + public function __construct( + private SlugifyInterface $slugify, + private UlidGeneratorInterface $idGenerator, + private RoleAlreadyExists $roleAlreadyExists, + private RoleRepositoryInterface $repository, + private DomainEventPublisherInterface $eventPublisher + ) {} + + /** + * Creates a new role with the specified name and permissions. + * + * @param string $name the name of the role + * @param array $permissions A list of permissions assigned to the role + * @param string $vendor the vendor use to prefx the role slug + * + * @return Role the newly created role instance + */ + public function create(string $name, array $permissions, string $vendor): Role + { + $roleName = RoleName::fromString($name); + $fullSlug = $this->slugify->slugify($vendor) . '.' . $this->slugify->slugify($roleName->toNative()); + $roleSlug = RoleSlug::fromString($fullSlug); + + $this->roleAlreadyExists->guard($roleSlug); + + $role = Role::create( + RoleId::fromString($this->idGenerator->generate()), + $roleName, + $roleSlug, + $this->createPermissionCollection($permissions) + ); + + $this->repository->persist($role); + $this->eventPublisher->publish(...$role->getDomainEvents()); + + return $role; + } + + /** + * Creates a PermissionCollection from an array of permissions. + * + * @param array $permissions The array of permissions in their native form + */ + private function createPermissionCollection(array $permissions): PermissionCollection + { + $objects = []; + + foreach ($permissions as $permission) { + $objects[] = Permission::fromNative($permission); + } + + return PermissionCollection::from($objects); + } +} diff --git a/src/Authorization/Domain/Service/RevokeRole.php b/src/Authorization/Domain/Service/RevokeRole.php new file mode 100644 index 0000000..2d53769 --- /dev/null +++ b/src/Authorization/Domain/Service/RevokeRole.php @@ -0,0 +1,71 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\Service; + +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authorization\Domain\Event\RoleRevoked; +use Webify\User\Authorization\Domain\Exception\RoleAssignmentNotFoundException; +use Webify\User\Authorization\Domain\Repository\RoleAssignmentRepositoryInterface; +use Webify\User\Authorization\Domain\ValueObject\RoleAssignmentId; + +/** + * RevokeRole service for revoking roles from subjects. + * + * Encapsulate the use case of removing a role assignment from a subject. + * Revocation must be scoped to the same tenant context the assignment was created in — revoking globally + * should not affect tenant-scoped assignments and vice versa. + * + * The service is silent if the assignment does not exist (idempotent behaviour), so callers do not need to check first. + */ +final readonly class RevokeRole +{ + /** + * The constructor. + */ + public function __construct( + private RoleAssignmentRepositoryInterface $roleAssignmentRepository, + private DomainEventPublisherInterface $eventPublisher + ) {} + + /** + * Revoke the role from the given subject. + * + * @param string $roleAssignmentId the ID of the role assignment to be revoked + */ + public function revoke(string $roleAssignmentId): void + { + $id = RoleAssignmentId::fromString($roleAssignmentId); + + try { + $assignment = $this->roleAssignmentRepository->getById($id); + + $this->roleAssignmentRepository->delete($assignment); + + $this->eventPublisher->publish( + new RoleRevoked( + $id->toNative(), + $assignment->getRoleId()->toNative(), + $assignment->getSubjectId()->toNative(), + $assignment->getTenantId()?->toNative(), + $assignment->getExpiresAt()?->toNative(), + DateTime::now()->toNative() + ) + ); + } catch (RoleAssignmentNotFoundException) { + // Silent if the assignment does not exist (idempotent behaviour) + return; + } + } +} diff --git a/src/Authorization/Domain/ValueObject/Permission.php b/src/Authorization/Domain/ValueObject/Permission.php new file mode 100644 index 0000000..33b5163 --- /dev/null +++ b/src/Authorization/Domain/ValueObject/Permission.php @@ -0,0 +1,162 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\ValueObject; + +use JsonSerializable; +use Webify\User\Authorization\Domain\Exception\PermissionValidationException; + +/** + * Permission value object - Represent the atomic unit of access + * — "action X is allowed on resource type Y". A Permission is immutable and has no identity of its own; + * two permissions with the same action and resource are equal. + * It supports a wildcard character (*) on both fields so that roles like Super Admin + * can be expressed as a single Permission("*", "*") rather than an exhaustive matrix. + * The wildcard logic must live inside this value object, so it is never reimplemented across the codebase. + */ +final readonly class Permission implements JsonSerializable +{ + /** + * Constructs a new instance of the class. + * + * @param string $scope the scope of the permission, e.g., "post", "user" + * @param string $action the action to be performed + * @param string $resource the resource associated with the action + * + * @throws PermissionValidationException if the one of the property is empty + */ + private function __construct( + private string $scope, + private string $action, + private string $resource, + ) { + if (!$this->isValid()) { + throw PermissionValidationException::forInvalidProperties(); + } + } + + /** + * Returns a string representation of the Permission object. + */ + public function __toString(): string + { + return 'scope: ' . $this->scope + . ', action: ' . $this->action + . ', resource: ' . $this->resource; + } + + /** + * Checks if the permission matches the provided scope, action, and resource. + * A permission matches if the scope, action, and resource are equal to the provided values, + * or if the provided values are wildcards. + */ + public function matches(string $scope, string $action, string $resource): bool + { + $match = '*' === $this->scope || $this->scope === $scope; + $match = $match && ('*' === $this->action || $this->action === $action); + + return $match && ('*' === $this->resource || $this->resource === $resource); + } + + /** + * Converts the current object state into a native array representation. + * + * @return array{ + * scope: string, + * action: string, + * resource: string, + * } an associative array containing the action and resource + */ + public function toNative(): array + { + return [ + 'scope' => $this->scope, + 'action' => $this->action, + 'resource' => $this->resource, + ]; + } + + /** + * Converts the current object into a JSON-serializable format. + * + * @return array{ + * scope: string, + * action: string, + * resource: string, + * } an associative array containing the action and resource + */ + public function jsonSerialize(): array + { + return $this->toNative(); + } + + /** + * Checks if the current object is equal to another Permission object. + */ + public function equals(self $other): bool + { + return $this->toNative() === $other->toNative(); + } + + /** + * Creates a new instance of the class from a native array structure. + * + * @param array{ + * scope: string, + * action: string, + * resource: string, + * } $permission an associative array containing 'action' and 'resource' keys + * + * @return self a new instance of the class initialized with the provided data + */ + public static function fromNative(array $permission): self + { + return new self($permission['scope'], $permission['action'], $permission['resource']); + } + + /** + * Creates a new instance with wildcard values. + * + * @return self a new instance with wildcard configuration + */ + public static function wildcard(): self + { + return new self('*', '*', '*'); + } + + /** + * Creates a new instance with wildcard values for a specific scope. + * + * @param string $scope the scope for which to create the wildcard permission + * + * @return self a new instance with wildcard configuration for the specified scope + */ + public static function scopedWildcard(string $scope): self + { + return new self($scope, '*', '*'); + } + + /** + * Checks whether the action and resource are valid. + * + * @return bool true if both action and resource are non-empty, false otherwise + */ + private function isValid(): bool + { + if ('' === trim($this->scope) || '' === trim($this->action) || '' === trim($this->resource)) { + return false; + } + + return true; + } +} diff --git a/src/Authorization/Domain/ValueObject/RoleAssignmentId.php b/src/Authorization/Domain/ValueObject/RoleAssignmentId.php new file mode 100644 index 0000000..59db018 --- /dev/null +++ b/src/Authorization/Domain/ValueObject/RoleAssignmentId.php @@ -0,0 +1,33 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\ValueObject; + +use Webify\Base\Domain\ValueObject\AggregateId; +use Webify\User\Authorization\Domain\Exception\InvalidRoleAssignmentIdException; + +/** + * Role assignment ID value object. + */ +final readonly class RoleAssignmentId extends AggregateId +{ + /** + * Throws an exception if the value is not valid. + * + * @throws InvalidRoleAssignmentIdException + */ + protected function throwException(string $value): never + { + throw InvalidRoleAssignmentIdException::forInvalidId($value); + } +} diff --git a/src/Authorization/Domain/ValueObject/RoleId.php b/src/Authorization/Domain/ValueObject/RoleId.php new file mode 100644 index 0000000..b8c053f --- /dev/null +++ b/src/Authorization/Domain/ValueObject/RoleId.php @@ -0,0 +1,33 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\ValueObject; + +use Webify\Base\Domain\ValueObject\AggregateId; +use Webify\User\Authorization\Domain\Exception\InvalidRoleIdException; + +/** + * Role ID value object. + */ +final readonly class RoleId extends AggregateId +{ + /** + * Will throw an exception if the value is not valid. + * + * @throws InvalidRoleIdException + */ + protected function throwException(string $value): never + { + throw InvalidRoleIdException::forInvalidId($value); + } +} diff --git a/src/Authorization/Domain/ValueObject/RoleName.php b/src/Authorization/Domain/ValueObject/RoleName.php new file mode 100644 index 0000000..8536a69 --- /dev/null +++ b/src/Authorization/Domain/ValueObject/RoleName.php @@ -0,0 +1,86 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\ValueObject; + +use Webify\User\Authorization\Domain\Exception\InvalidRoleNameException; + +/** + * Role name value object. + */ +final readonly class RoleName +{ + /** + * Private constructor enforces the use of the factory method. + * + * @param string $name the name to be associated with the instance + * + * @throws InvalidRoleNameException if the provided name is invalid + */ + private function __construct(private string $name) + { + if (!$this->isValid()) { + throw InvalidRoleNameException::forInvalidName($this->name); + } + } + + /** + * Returns the string representation of the object. + */ + public function __toString(): string + { + return $this->name; + } + + /** + * Creates an instance of the class from the given string. + */ + public static function fromString(string $name): self + { + return new self($name); + } + + /** + * Converts the object to its native string representation. + */ + public function toNative(): string + { + return $this->name; + } + + /** + * Determines if the current object is equal to the specified object. + * + * @param self $other the object to compare with the current object + * + * @return bool true if the objects are equal, false otherwise + */ + public function equals(self $other): bool + { + return $this->toNative() === $other->toNative(); + } + + /** + * Determines if the current object state is valid. + * + * @return bool true if valid, false otherwise + */ + private function isValid(): bool + { + if ('' === $this->name) { + return false; + } + + return true; + } +} diff --git a/src/Authorization/Domain/ValueObject/RoleSlug.php b/src/Authorization/Domain/ValueObject/RoleSlug.php new file mode 100644 index 0000000..f247342 --- /dev/null +++ b/src/Authorization/Domain/ValueObject/RoleSlug.php @@ -0,0 +1,120 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\ValueObject; + +use Webify\User\Authorization\Domain\Exception\InvalidRoleSlugException; + +/** + * Role slug value object. + */ +final readonly class RoleSlug +{ + /** + * Slug pattern format: + * - Must be lowercase. + * - Must contain only letters, numbers, and hyphens. + * - Must not start or end with a hyphen. + * - Must not contain consecutive hyphens. + */ + private const string PATTERN_FORMAT = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/'; + + /** + * The closed constructor enforces to use factory methods to initiate this object. + */ + final public function __construct( + private string $vendor, + private string $slug + ) { + if (!$this->isValid()) { + throw InvalidRoleSlugException::forInvalidSlug($this->slug); + } + } + + /** + * Converts the object to its string representation. + */ + public function __toString(): string + { + return $this->vendor . '.' . $this->slug; + } + + /** + * Factory method to create a slug value object from a string. + * + * @throws InvalidRoleSlugException if the slug did not have the dot separator + */ + public static function fromString(string $value): self + { + if (false === str_contains($value, '.')) { + throw InvalidRoleSlugException::forInvalidSeparator($value); + } + + [$vendor, $slug] = explode('.', $value); + + return new self($vendor, $slug); + } + + /** + * Returns the vendor part of the slug. + */ + public function getVendor(): string + { + return $this->vendor; + } + + /** + * Returns the slug part of the slug. + */ + public function getSlug(): string + { + return $this->slug; + } + + /** + * Converts the object to its native string representation. + * + * @return array{vendor: string, slug: string} + */ + public function toNative(): array + { + return [ + 'vendor' => $this->vendor, + 'slug' => $this->slug, + ]; + } + + /** + * Compares the current object with another instance for equality. + * + * @param RoleSlug $other the instance to compare with the current object + * + * @return bool true if both instances are considered equal, false otherwise + */ + public function equals(self $other): bool + { + return $this->toNative() === $other->toNative(); + } + + /** + * Validates the value against a predefined format pattern. + */ + private function isValid(): bool + { + if (false === (bool) preg_match(self::PATTERN_FORMAT, $this->vendor)) { + return false; + } + + return (bool) preg_match(self::PATTERN_FORMAT, $this->slug); + } +} diff --git a/src/Authorization/Domain/ValueObject/SubjectId.php b/src/Authorization/Domain/ValueObject/SubjectId.php new file mode 100644 index 0000000..1c28a60 --- /dev/null +++ b/src/Authorization/Domain/ValueObject/SubjectId.php @@ -0,0 +1,33 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\ValueObject; + +use Webify\Base\Domain\ValueObject\AggregateId; +use Webify\User\Authorization\Domain\Exception\InvalidSubjectIdException; + +/** + * Subject ID value object. + */ +final readonly class SubjectId extends AggregateId +{ + /** + * Throws an exception if the value is not valid. + * + * @throws InvalidSubjectIdException + */ + protected function throwException(string $value): never + { + throw InvalidSubjectIdException::forInvalidId($value); + } +} diff --git a/src/Authorization/Domain/ValueObject/TenantId.php b/src/Authorization/Domain/ValueObject/TenantId.php new file mode 100644 index 0000000..987bf56 --- /dev/null +++ b/src/Authorization/Domain/ValueObject/TenantId.php @@ -0,0 +1,33 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Authorization\Domain\ValueObject; + +use Webify\Base\Domain\ValueObject\AggregateId; +use Webify\User\Authorization\Domain\Exception\InvalidTenantIdException; + +/** + * Tenant ID value object. + */ +final readonly class TenantId extends AggregateId +{ + /** + * Throws an exception if the value is not valid. + * + * @throws InvalidTenantIdException + */ + protected function throwException(string $value): never + { + throw InvalidTenantIdException::forInvalidId($value); + } +} diff --git a/src/Authorization/Infrastructure/.gitkeep b/src/Authorization/Infrastructure/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Identity/Application/Query/User/FindAllUserInterface.php b/src/Identity/Application/Query/User/FindAllUserInterface.php new file mode 100644 index 0000000..0d67f74 --- /dev/null +++ b/src/Identity/Application/Query/User/FindAllUserInterface.php @@ -0,0 +1,27 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Application\Query\User; + +/** + * Query class defines the contract for a query to find all users. + */ +interface FindAllUserInterface +{ + /** + * Retrieves a collection of all users. + * + * @return UserCollection a collection containing all user entities + */ + public function findAll(): UserCollection; +} diff --git a/src/Identity/Application/Query/User/FindUserByEmailInterface.php b/src/Identity/Application/Query/User/FindUserByEmailInterface.php new file mode 100644 index 0000000..b0a1b37 --- /dev/null +++ b/src/Identity/Application/Query/User/FindUserByEmailInterface.php @@ -0,0 +1,31 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Application\Query\User; + +use Webify\User\Identity\Domain\ValueObject\UserEmail; + +/** + * Query class defines the contract for a query to find a user by its identifier. + */ +interface FindUserByEmailInterface +{ + /** + * Finds a User by its email. + * + * @param UserEmail $email the email of the user to find + * + * @return null|User the User read-model if found, or null if no user matches the given email + */ + public function find(UserEmail $email): ?User; +} diff --git a/src/Identity/Application/Query/User/FindUserByIdInterface.php b/src/Identity/Application/Query/User/FindUserByIdInterface.php new file mode 100644 index 0000000..32e8d8a --- /dev/null +++ b/src/Identity/Application/Query/User/FindUserByIdInterface.php @@ -0,0 +1,31 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Application\Query\User; + +use Webify\User\Identity\Domain\ValueObject\UserId; + +/** + * Query class defines the contract for a query to find a user by its identifier. + */ +interface FindUserByIdInterface +{ + /** + * Finds a User by its identifier. + * + * @param UserId $id the identifier of the user to find + * + * @return null|User the User read-model if found, or null if no user matches the given ID + */ + public function find(UserId $id): ?User; +} diff --git a/src/Identity/Application/Query/User/User.php b/src/Identity/Application/Query/User/User.php new file mode 100644 index 0000000..42f77be --- /dev/null +++ b/src/Identity/Application/Query/User/User.php @@ -0,0 +1,39 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Application\Query\User; + +/** + * User read model. + */ +final readonly class User +{ + /** + * Constructor method to initialize the object with the provided parameters. + * + * @param string $id unique identifier for the object + * @param string $email email address associated with the object + * @param string $displayName display name for the object + * @param string $status current status of the object + * @param string $createdAt creation timestamp of the object + * @param string $updatedAt last updated timestamp of the object + */ + public function __construct( + public string $id, + public string $email, + public string $displayName, + public string $status, + public string $createdAt, + public string $updatedAt + ) {} +} diff --git a/src/Identity/Application/Query/User/UserCollection.php b/src/Identity/Application/Query/User/UserCollection.php new file mode 100644 index 0000000..e002551 --- /dev/null +++ b/src/Identity/Application/Query/User/UserCollection.php @@ -0,0 +1,32 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Application\Query\User; + +use Webify\Base\Contract\Collection\Collection; + +/** + * Collection class for User-read models. + * + * @extends Collection + */ +final class UserCollection extends Collection +{ + /** + * {@inheritDoc} + */ + protected function type(): string + { + return User::class; + } +} diff --git a/src/Identity/Domain/Entity/User.php b/src/Identity/Domain/Entity/User.php new file mode 100644 index 0000000..291631c --- /dev/null +++ b/src/Identity/Domain/Entity/User.php @@ -0,0 +1,251 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Entity; + +use Webify\Base\Domain\Entity\AggregateRoot; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Identity\Domain\Event\{UserDisplayNameWasChanged, + UserEmailWasChanged, + UserPasswordWasChanged, + UserWasActivated, + UserWasDeactivated, + UserWasRegistered +}; +use Webify\User\Identity\Domain\Exception\{UserAlreadyActivatedException, + UserAlreadyDeactivatedException, + UserCannotBeDeactivatedException}; +use Webify\User\Identity\Domain\ValueObject\{DisplayName, PasswordHash, UserEmail, UserId, UserStatus}; + +/** + * User aggregate root. + */ +final class User extends AggregateRoot +{ + /** + * Private constructor enforces the use of the factory methods to initiate this aggregate root. + * + * @param UserId $id the unique identifier for the user + * @param UserEmail $email the email address of the user + * @param PasswordHash $passwordHash the hashed password of the user + * @param DisplayName $displayName the display name of the user + * @param DateTime $createdAt the datetime when the user was created + * @param DateTime $updatedAt the datetime when the user was last updated + * @param UserStatus $status the status indicating the user state + */ + private function __construct( + private readonly UserId $id, + private UserEmail $email, + private PasswordHash $passwordHash, + private DisplayName $displayName, + private readonly DateTime $createdAt, + private DateTime $updatedAt, + private UserStatus $status = UserStatus::Inactive + ) {} + + /** + * Get the ID. + */ + public function getId(): UserId + { + return $this->id; + } + + /** + * Get the email. + */ + public function getEmail(): UserEmail + { + return $this->email; + } + + /** + * Get the password hash. + */ + public function getPasswordHash(): PasswordHash + { + return $this->passwordHash; + } + + /** + * Get the display name. + */ + public function getDisplayName(): DisplayName + { + return $this->displayName; + } + + /** + * Get the creation datetime. + */ + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + /** + * Get the updated datetime. + */ + public function getUpdatedAt(): DateTime + { + return $this->updatedAt; + } + + /** + * Check if the user is active. + */ + public function getStatus(): UserStatus + { + return $this->status; + } + + /** + * Activate the user. + * + * @throws UserAlreadyActivatedException if the user is already active + */ + public function activate(): void + { + if ($this->status->isActive()) { + throw UserAlreadyActivatedException::create(); + } + + $this->status = UserStatus::Active; + $this->updatedAt = DateTime::now(); + + $this->recordDomainEvent( + new UserWasActivated( + $this->id->toNative(), + $this->email->toNative(), + $this->updatedAt->toNative() + ) + ); + } + + /** + * Deactivate the user. + * + * @throws UserAlreadyDeactivatedException if the user is already deactivated + * @throws UserCannotBeDeactivatedException if the user is not in activate state + */ + public function deactivate(): void + { + if ($this->status->isDeactivated()) { + throw UserAlreadyDeactivatedException::create(); + } + + if (!$this->status->isActive()) { + throw UserCannotBeDeactivatedException::create(); + } + + $this->status = UserStatus::Deactivated; + $this->updatedAt = DateTime::now(); + + $this->recordDomainEvent( + new UserWasDeactivated( + $this->id->toNative(), + $this->email->toNative(), + $this->updatedAt->toNative() + ) + ); + } + + /** + * Change the email address of the user. + */ + public function changeEmail(UserEmail $email): void + { + $oldEmail = $this->email; + $this->email = $email; + $this->updatedAt = DateTime::now(); + + $this->recordDomainEvent( + new UserEmailWasChanged( + $this->id->toNative(), + $oldEmail->toNative(), + $email->toNative(), + $this->updatedAt->toNative() + ) + ); + } + + /** + * Change the password of the user. + */ + public function changePassword(PasswordHash $password): void + { + $this->passwordHash = $password; + $this->updatedAt = DateTime::now(); + + $this->recordDomainEvent( + new UserPasswordWasChanged( + $this->id->toNative(), + $this->updatedAt->toNative() + ) + ); + } + + /** + * Change the display name of the user. + */ + public function changeDisplayName(DisplayName $displayName): void + { + if ($this->displayName->equals($displayName)) { + return; + } + + $oldDisplayName = $this->displayName; + $this->displayName = $displayName; + $this->updatedAt = DateTime::now(); + + $this->recordDomainEvent( + new UserDisplayNameWasChanged( + $this->id->toNative(), + $oldDisplayName->toNative(), + $displayName->toNative(), + $this->updatedAt->toNative() + ) + ); + } + + /** + * Register a new user. + */ + public static function register( + UserId $id, + UserEmail $email, + PasswordHash $password, + DisplayName $displayName + ): self { + $user = new self( + $id, + $email, + $password, + $displayName, + DateTime::now(), + DateTime::now(), + UserStatus::Inactive + ); + + $user->recordDomainEvent( + new UserWasRegistered( + $user->getId()->toNative(), + $user->getEmail()->toNative(), + $user->getDisplayName()->toNative(), + $user->getCreatedAt()->toNative() + ) + ); + + return $user; + } +} diff --git a/src/Identity/Domain/Event/UserDisplayNameWasChanged.php b/src/Identity/Domain/Event/UserDisplayNameWasChanged.php new file mode 100644 index 0000000..462e6ec --- /dev/null +++ b/src/Identity/Domain/Event/UserDisplayNameWasChanged.php @@ -0,0 +1,49 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * The event that is triggered when a user's display name was changed. + */ +final readonly class UserDisplayNameWasChanged implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $userId, + public string $oldDisplayName, + public string $newDisplayName, + private DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.identity.display_name_was_changed'; + } +} diff --git a/src/Identity/Domain/Event/UserEmailWasChanged.php b/src/Identity/Domain/Event/UserEmailWasChanged.php new file mode 100644 index 0000000..b486382 --- /dev/null +++ b/src/Identity/Domain/Event/UserEmailWasChanged.php @@ -0,0 +1,49 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * The event that is triggered when a user was activated. + */ +final readonly class UserEmailWasChanged implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $userId, + public string $oldEmail, + public string $newEmail, + private DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.identity.email_was_changed'; + } +} diff --git a/src/Identity/Domain/Event/UserPasswordWasChanged.php b/src/Identity/Domain/Event/UserPasswordWasChanged.php new file mode 100644 index 0000000..1c66073 --- /dev/null +++ b/src/Identity/Domain/Event/UserPasswordWasChanged.php @@ -0,0 +1,47 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * The event that is triggered when a user was activated. + */ +final readonly class UserPasswordWasChanged implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $userId, + private DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.identity.password_was_changed'; + } +} diff --git a/src/Identity/Domain/Event/UserWasActivated.php b/src/Identity/Domain/Event/UserWasActivated.php new file mode 100644 index 0000000..bd3af54 --- /dev/null +++ b/src/Identity/Domain/Event/UserWasActivated.php @@ -0,0 +1,48 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * The event that is triggered when a user was activated. + */ +final readonly class UserWasActivated implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $userId, + public string $email, + private DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.identity.was_activated'; + } +} diff --git a/src/Identity/Domain/Event/UserWasDeactivated.php b/src/Identity/Domain/Event/UserWasDeactivated.php new file mode 100644 index 0000000..8f05c6a --- /dev/null +++ b/src/Identity/Domain/Event/UserWasDeactivated.php @@ -0,0 +1,48 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * The event that is triggered when a user was deactivated. + */ +final readonly class UserWasDeactivated implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $userId, + public string $email, + private DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.identity.was_deactivated'; + } +} diff --git a/src/Identity/Domain/Event/UserWasRegistered.php b/src/Identity/Domain/Event/UserWasRegistered.php new file mode 100644 index 0000000..18e4f67 --- /dev/null +++ b/src/Identity/Domain/Event/UserWasRegistered.php @@ -0,0 +1,49 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Event; + +use DateTimeImmutable; +use Webify\Base\Domain\Event\DomainEventInterface; + +/** + * The event that is triggered when a user was registered. + */ +final readonly class UserWasRegistered implements DomainEventInterface +{ + /** + * The constructor. + */ + public function __construct( + public string $userId, + public string $email, + public string $displayName, + private DateTimeImmutable $createdAt + ) {} + + /** + * {@inheritDoc} + */ + public function occurredOn(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * {@inheritDoc} + */ + public function eventName(): string + { + return 'user.identity.was_registered'; + } +} diff --git a/src/Identity/Domain/Exception/CurrentPasswordNotMatchedException.php b/src/Identity/Domain/Exception/CurrentPasswordNotMatchedException.php new file mode 100644 index 0000000..ecceacb --- /dev/null +++ b/src/Identity/Domain/Exception/CurrentPasswordNotMatchedException.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Exception; + +use DomainException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when the user's current password is not matched. + */ +final class CurrentPasswordNotMatchedException extends DomainException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create a CurrentPasswordNotMatchedException for a specific user email. + */ + public static function create(): CurrentPasswordNotMatchedException + { + return new self( + new ExceptionTranslation( + 'user.identity', + 'current_password_not_matched' + ), + 'Current password does not matched.' + ); + } +} diff --git a/src/Identity/Domain/Exception/EmailMustBeUniqueException.php b/src/Identity/Domain/Exception/EmailMustBeUniqueException.php new file mode 100644 index 0000000..d22be68 --- /dev/null +++ b/src/Identity/Domain/Exception/EmailMustBeUniqueException.php @@ -0,0 +1,52 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Exception; + +use DomainException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when the email is not unique. + */ +final class EmailMustBeUniqueException extends DomainException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create an EmailMustBeUniqueException for a specific user email. + */ + public static function create(string $email): EmailMustBeUniqueException + { + return new self( + new ExceptionTranslation( + 'user.identity', + 'email_must_be_unique', + ['email' => $email] + ), + sprintf('Email must be unique: %s.', $email) + ); + } +} diff --git a/src/Identity/Domain/Exception/InvalidDisplayNameException.php b/src/Identity/Domain/Exception/InvalidDisplayNameException.php new file mode 100644 index 0000000..6406608 --- /dev/null +++ b/src/Identity/Domain/Exception/InvalidDisplayNameException.php @@ -0,0 +1,55 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Throws when an invalid user display name value is encountered. + */ +final class InvalidDisplayNameException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate an InvalidDisplayNameException with a default message. + */ + public static function create(int $minLength, int $maxLength): self + { + return new self( + new ExceptionTranslation( + 'user.identity', + 'invalid_display_name', + [ + 'min_length' => $minLength, + 'max_length' => $maxLength, + ] + ), + sprintf('Invalid display name, allowed minimum and maximum length are: "%d-%d"', $minLength, $maxLength) + ); + } +} diff --git a/src/Identity/Domain/Exception/InvalidPasswordHashException.php b/src/Identity/Domain/Exception/InvalidPasswordHashException.php new file mode 100644 index 0000000..4d18d7b --- /dev/null +++ b/src/Identity/Domain/Exception/InvalidPasswordHashException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid password hash value is encountered. + */ +final class InvalidPasswordHashException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate an InvalidPasswordHashException with a default message. + * + * @param string $hash the invalid password hash value + */ + public static function forInvalidPasswordHash(string $hash): self + { + return new self( + new ExceptionTranslation( + 'user.identity', + 'invalid_password_hash', + ['hash' => $hash] + ), + sprintf('Invalid password hash: "%s"', $hash) + ); + } +} diff --git a/src/Identity/Domain/Exception/InvalidUserEmailException.php b/src/Identity/Domain/Exception/InvalidUserEmailException.php new file mode 100644 index 0000000..a710e70 --- /dev/null +++ b/src/Identity/Domain/Exception/InvalidUserEmailException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid user email value is encountered. + */ +final class InvalidUserEmailException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate an InvalidUserEmailException with a default message. + * + * @param string $email the invalid user email value + */ + public static function forInvalidEmail(string $email): self + { + return new self( + new ExceptionTranslation( + 'user.identity', + 'invalid_user_email', + ['email' => $email] + ), + sprintf('Invalid user email: "%s"', $email) + ); + } +} diff --git a/src/Identity/Domain/Exception/InvalidUserIdException.php b/src/Identity/Domain/Exception/InvalidUserIdException.php new file mode 100644 index 0000000..74ef70b --- /dev/null +++ b/src/Identity/Domain/Exception/InvalidUserIdException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when an invalid user id value is encountered. + */ +final class InvalidUserIdException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate an InvalidUserIdException with a default message. + * + * @param string $id the invalid user id value + */ + public static function forInvalidId(string $id): self + { + return new self( + new ExceptionTranslation( + 'user.identity', + 'invalid_user_id', + ['id' => $id] + ), + sprintf('Invalid user id: "%s"', $id) + ); + } +} diff --git a/src/Identity/Domain/Exception/UserAlreadyActivatedException.php b/src/Identity/Domain/Exception/UserAlreadyActivatedException.php new file mode 100644 index 0000000..9e10369 --- /dev/null +++ b/src/Identity/Domain/Exception/UserAlreadyActivatedException.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Exception; + +use LogicException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when a user was already activated and trying to activate again. + */ +final class UserAlreadyActivatedException extends LogicException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create a new instance of UserAlreadyActivateException. + */ + public static function create(): UserAlreadyActivatedException + { + return new self( + new ExceptionTranslation( + 'user.identity', + 'user_already_activate' + ), + 'User is already activated.' + ); + } +} diff --git a/src/Identity/Domain/Exception/UserAlreadyDeactivatedException.php b/src/Identity/Domain/Exception/UserAlreadyDeactivatedException.php new file mode 100644 index 0000000..08d086e --- /dev/null +++ b/src/Identity/Domain/Exception/UserAlreadyDeactivatedException.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Exception; + +use LogicException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when a user was already deactivated and trying to deactivate again. + */ +final class UserAlreadyDeactivatedException extends LogicException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create a new instance of UserAlreadyDeactivatedException. + */ + public static function create(): UserAlreadyDeactivatedException + { + return new self( + new ExceptionTranslation( + 'user.identity', + 'user_already_deactivated' + ), + 'User is already deactivated.' + ); + } +} diff --git a/src/Identity/Domain/Exception/UserCannotBeDeactivatedException.php b/src/Identity/Domain/Exception/UserCannotBeDeactivatedException.php new file mode 100644 index 0000000..a1680d1 --- /dev/null +++ b/src/Identity/Domain/Exception/UserCannotBeDeactivatedException.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Exception; + +use LogicException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when a user is cannot be deactivated. + */ +final class UserCannotBeDeactivatedException extends LogicException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create a new instance of UserCannotBeDeactivatedException. + */ + public static function create(): UserCannotBeDeactivatedException + { + return new self( + new ExceptionTranslation( + 'user.identity', + 'user_cannot_deactivate' + ), + 'Unable to deactivate, the user is not in active state.' + ); + } +} diff --git a/src/Identity/Domain/Exception/UserNotFoundException.php b/src/Identity/Domain/Exception/UserNotFoundException.php new file mode 100644 index 0000000..2673e03 --- /dev/null +++ b/src/Identity/Domain/Exception/UserNotFoundException.php @@ -0,0 +1,67 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Exception; + +use RuntimeException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when the user is not found. + */ +final class UserNotFoundException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create a UserNotFoundException for a specific user ID. + */ + public static function forId(string $id): UserNotFoundException + { + return new self( + new ExceptionTranslation( + 'user.identity', + 'user_not_found', + ['id' => $id] + ), + sprintf('User not found for id: %s.', $id) + ); + } + + /** + * Factory method to create a UserNotFoundException for a specific user email. + */ + public static function forEmail(string $email): UserNotFoundException + { + return new self( + new ExceptionTranslation( + 'user.identity', + 'user_not_found', + ['email' => $email] + ), + sprintf('User not found for email: %s.', $email) + ); + } +} diff --git a/src/Identity/Domain/Exception/WeakPasswordException.php b/src/Identity/Domain/Exception/WeakPasswordException.php new file mode 100644 index 0000000..8994ab6 --- /dev/null +++ b/src/Identity/Domain/Exception/WeakPasswordException.php @@ -0,0 +1,107 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Exception; + +use DomainException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when the user's current password is not strong enough. + */ +final class WeakPasswordException extends DomainException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Creates an exception indicating that the provided password is too short. + * + * @param int $minLength the minimum required length for the password + */ + public static function tooShort(int $minLength): self + { + return new self( + new ExceptionTranslation('user.identity', 'password_too_short', ['length' => $minLength]), + sprintf('Password must be at least %d characters long.', $minLength) + ); + } + + /** + * Creates an exception for passwords that exceed the maximum allowed length. + * + * @param int $maxLength the maximum permitted length for the password + */ + public static function tooLong(int $maxLength): self + { + return new self( + new ExceptionTranslation('user.identity', 'password_too_long', ['length' => $maxLength]), + sprintf('Password must not exceed %d characters.', $maxLength) + ); + } + + /** + * Creates an instance of this exception indicating that the password is missing an uppercase letter. + */ + public static function missingUppercase(): self + { + return new self( + new ExceptionTranslation('user.identity', 'password_missing_uppercase'), + 'Password must contain at least one uppercase letter.' + ); + } + + /** + * Creates an instance of this exception indicating that the password is missing an lowercase letter. + */ + public static function missingLowercase(): self + { + return new self( + new ExceptionTranslation('user.identity', 'password_missing_lowercase'), + 'Password must contain at least one lowercase letter.' + ); + } + + /** + * Creates an instance of this exception indicating that the password is missing a digit. + */ + public static function missingDigit(): self + { + return new self( + new ExceptionTranslation('user.identity', 'password_missing_digit'), + 'Password must contain at least one digit.' + ); + } + + /** + * Creates an instance of this exception indicating that the password is missing a special character. + */ + public static function missingSpecialChar(): self + { + return new self( + new ExceptionTranslation('user.identity', 'password_missing_special_char'), + 'Password must contain at least one special character.' + ); + } +} diff --git a/src/Identity/Domain/Guard/EmailMustBeUnique.php b/src/Identity/Domain/Guard/EmailMustBeUnique.php new file mode 100644 index 0000000..c0ae239 --- /dev/null +++ b/src/Identity/Domain/Guard/EmailMustBeUnique.php @@ -0,0 +1,43 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Guard; + +use Webify\User\Identity\Domain\Exception\EmailMustBeUniqueException; +use Webify\User\Identity\Domain\Repository\UserRepositoryInterface; +use Webify\User\Identity\Domain\ValueObject\UserEmail; + +/** + * Guard prevents against the user registration with existed registered email. + */ +final readonly class EmailMustBeUnique +{ + /** + * The constructor. + */ + public function __construct( + private UserRepositoryInterface $repository + ) {} + + /** + * Guards against the registration of a user with an email that already exists. + * + * @throws EmailMustBeUniqueException + */ + public function guard(UserEmail $email): void + { + if ($this->repository->isExists($email)) { + throw EmailMustBeUniqueException::create((string) $email); + } + } +} diff --git a/src/Identity/Domain/Guard/PasswordMustBeStrong.php b/src/Identity/Domain/Guard/PasswordMustBeStrong.php new file mode 100644 index 0000000..8c54645 --- /dev/null +++ b/src/Identity/Domain/Guard/PasswordMustBeStrong.php @@ -0,0 +1,72 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Guard; + +use Webify\User\Identity\Domain\Exception\WeakPasswordException; +use Webify\User\Identity\Domain\Policy\PasswordPolicy; + +/** + * Guard prevents against the weak password. + */ +final readonly class PasswordMustBeStrong +{ + /** + * The constructor. + */ + public function __construct( + private PasswordPolicy $policy + ) {} + + /** + * Validates the provided password against the defined password policy. + * + * @param string $password the password to be validated + * + * @throws WeakPasswordException If the password does not meet the policy requirements: + * - Too short + * - Too long + * - Missing uppercase character + * - Missing lowercase character + * - Missing digit + * - Missing special character + */ + public function guard(string $password): void + { + $length = mb_strlen($password); + + if (!$this->policy->isSatisfiedMinimumLength($length)) { + throw WeakPasswordException::tooShort($length); + } + + if (!$this->policy->isSatisfiedMaximumLength($length)) { + throw WeakPasswordException::tooLong($length); + } + + if (!$this->policy->isSatisfiedUppercase($password)) { + throw WeakPasswordException::missingUppercase(); + } + + if (!$this->policy->isSatisfiedLowercase($password)) { + throw WeakPasswordException::missingLowercase(); + } + + if (!$this->policy->isSatisfiedDigit($password)) { + throw WeakPasswordException::missingDigit(); + } + + if (!$this->policy->isSatisfiedSpecialCharacter($password)) { + throw WeakPasswordException::missingSpecialChar(); + } + } +} diff --git a/src/Identity/Domain/Policy/PasswordPolicy.php b/src/Identity/Domain/Policy/PasswordPolicy.php new file mode 100644 index 0000000..d92cbc0 --- /dev/null +++ b/src/Identity/Domain/Policy/PasswordPolicy.php @@ -0,0 +1,113 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Policy; + +/** + * Represents a password policy that validates passwords against specific criteria. + * This class defines rules such as minimum and maximum length, inclusion of uppercase + * and lowercase letters, digits, and special characters. + */ +final readonly class PasswordPolicy +{ + /** + * The constructor. + * + * @param int $minimumLength minimal length of the password + * @param int $maximumLength maximum length of the password + * @param int $minimumUppercaseChars minimum number of uppercase characters in the password + * @param int $minimumLowercaseChars minimum number of lowercase characters in the password + * @param int $minimumDigits minimum number of digits in the password + * @param int $minimumSpecialChars minimum number of special characters in the password + */ + public function __construct( + private int $minimumLength = 12, + private int $maximumLength = 128, + private int $minimumUppercaseChars = 2, + private int $minimumLowercaseChars = 2, + private int $minimumDigits = 1, + private int $minimumSpecialChars = 1 + ) {} + + /** + * Checks if the given length satisfies the minimum required length. + * + * @param int $length the length to be checked + * + * @return bool true if the length is greater than or equal to the minimum required length, false otherwise + */ + public function isSatisfiedMinimumLength(int $length): bool + { + return $this->minimumLength <= $length; + } + + /** + * Checks if the given length satisfies the maximum length constraint. + * + * @param int $length the length to be validated against the maximum allowed value + * + * @return bool returns true if the length is less than or equal to the maximum length, otherwise false + */ + public function isSatisfiedMaximumLength(int $length): bool + { + return $this->maximumLength >= $length; + } + + /** + * Checks if the given password contains at least one uppercase letter. + * + * @param string $password the password to be validated for an uppercase letter + * + * @return bool returns true if the password contains at least one uppercase letter, otherwise false + */ + public function isSatisfiedUppercase(string $password): bool + { + return preg_match_all('/[A-Z]/', $password) >= $this->minimumUppercaseChars; + } + + /** + * Determines if the given password contains at least one lowercase letter. + * + * @param string $password the password to check for the presence of lowercase letters + * + * @return bool returns true if the password contains one or more lowercase letters, otherwise false + */ + public function isSatisfiedLowercase(string $password): bool + { + return preg_match_all('/[a-z]/', $password) >= $this->minimumLowercaseChars; + } + + /** + * Determines if the given password contains at least one digit. + * + * @param string $password the password to be checked for the presence of a digit + * + * @return bool returns true if the password contains at least one digit, otherwise false + */ + public function isSatisfiedDigit(string $password): bool + { + return preg_match_all('/\d/', $password) >= $this->minimumDigits; + } + + /** + * Checks if the given password contains at least one special character. + * + * @param string $password the password to evaluate + * + * @return bool returns true if the password contains a special character, otherwise false + */ + public function isSatisfiedSpecialCharacter(string $password): bool + { + return preg_match_all('/[\W_]/', $password) >= $this->minimumSpecialChars; + } +} diff --git a/src/Identity/Domain/Repository/UserRepositoryInterface.php b/src/Identity/Domain/Repository/UserRepositoryInterface.php new file mode 100644 index 0000000..27f687c --- /dev/null +++ b/src/Identity/Domain/Repository/UserRepositoryInterface.php @@ -0,0 +1,50 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Repository; + +use Webify\User\Identity\Domain\Entity\User; +use Webify\User\Identity\Domain\Exception\UserNotFoundException; +use Webify\User\Identity\Domain\ValueObject\{UserEmail, UserId}; + +/** + * UserRepositoryInterface defines the contract for a user repository. + */ +interface UserRepositoryInterface +{ + /** + * Retrieves an entity based on the provided unique UserId. + * + * @throws UserNotFoundException if the user is not found + */ + public function getById(UserId $id): User; + + /** + * Retrieves an entity based on the provided unique UserEmail. + * + * @throws UserNotFoundException if the user is not found + */ + public function getByEmail(UserEmail $email): User; + + /** + * Checks if a user with the given email already exists. + */ + public function isExists(UserEmail $email): bool; + + /** + * Persists the given user entity to the underlying storage. + * + * @param User $user the user entity to be persisted + */ + public function persist(User $user): void; +} diff --git a/src/Identity/Domain/Service/RegisterUser.php b/src/Identity/Domain/Service/RegisterUser.php new file mode 100644 index 0000000..2b4f39f --- /dev/null +++ b/src/Identity/Domain/Service/RegisterUser.php @@ -0,0 +1,69 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Service; + +use Webify\Base\Domain\Contract\Identity\Service\PasswordHasherInterface; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\UlidGeneratorInterface; +use Webify\User\Identity\Domain\Entity\User; +use Webify\User\Identity\Domain\Guard\{EmailMustBeUnique, PasswordMustBeStrong}; +use Webify\User\Identity\Domain\Repository\UserRepositoryInterface; +use Webify\User\Identity\Domain\ValueObject\{DisplayName, PasswordHash, UserEmail, UserId}; + +/** + * RegisterUser service handles the user registering process. + * + * Encapsulate the use case of registering a new user. Orchestrates email uniqueness validation, + * password hashing, user entity creation, persistence, and event publishing. + */ +final readonly class RegisterUser +{ + /** + * The constructor. + */ + public function __construct( + private PasswordHasherInterface $passwordHasher, + private UserRepositoryInterface $repository, + private EmailMustBeUnique $emailMustBeUnique, + private PasswordMustBeStrong $passwordMustBeStrong, + private UlidGeneratorInterface $ulidGenerator, + private DomainEventPublisherInterface $eventPublisher + ) {} + + /** + * Registers a new user with the provided email and password. + * + * @param string $email the email of the user to register + * @param string $password the password of the user to register + * @param string $displayName the display name of the user to register + */ + public function register(string $email, string $password, string $displayName): void + { + $userEmail = UserEmail::fromString($email); + + $this->emailMustBeUnique->guard($userEmail); + $this->passwordMustBeStrong->guard($password); + + $hashedPassword = $this->passwordHasher->hash($password); + $user = User::register( + UserId::fromString($this->ulidGenerator->generate()), + $userEmail, + PasswordHash::fromHash($hashedPassword), + DisplayName::fromString($displayName) + ); + + $this->repository->persist($user); + $this->eventPublisher->publish(...$user->getDomainEvents()); + } +} diff --git a/src/Identity/Domain/Service/UpdateUserEmail.php b/src/Identity/Domain/Service/UpdateUserEmail.php new file mode 100644 index 0000000..0904abe --- /dev/null +++ b/src/Identity/Domain/Service/UpdateUserEmail.php @@ -0,0 +1,55 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Service; + +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\User\Identity\Domain\Guard\EmailMustBeUnique; +use Webify\User\Identity\Domain\Repository\UserRepositoryInterface; +use Webify\User\Identity\Domain\ValueObject\{UserEmail, UserId}; + +/** + * UpdateUserEmail service handles the user email update process. + * + * Encapsulate the use case of changing a user's email address. Validates that the new email + * is not already taken before updating. + */ +final readonly class UpdateUserEmail +{ + /** + * The constructor. + */ + public function __construct( + private EmailMustBeUnique $emailMustBeUnique, + private UserRepositoryInterface $repository, + private DomainEventPublisherInterface $eventPublisher + ) {} + + /** + * Updates a user's password after verifying the current password. + * + * @param string $userId the unique identifier of the user + * @param string $newUserEmail the user's new email for change + */ + public function update(string $userId, string $newUserEmail): void + { + $user = $this->repository->getById(UserId::fromString($userId)); + $email = UserEmail::fromString($newUserEmail); + + $this->emailMustBeUnique->guard($email); + + $user->changeEmail($email); + $this->repository->persist($user); + $this->eventPublisher->publish(...$user->getDomainEvents()); + } +} diff --git a/src/Identity/Domain/Service/UpdateUserPassword.php b/src/Identity/Domain/Service/UpdateUserPassword.php new file mode 100644 index 0000000..bbe53ef --- /dev/null +++ b/src/Identity/Domain/Service/UpdateUserPassword.php @@ -0,0 +1,66 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\Service; + +use Webify\Base\Domain\Contract\Identity\Service\PasswordHasherInterface; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\User\Identity\Domain\Exception\CurrentPasswordNotMatchedException; +use Webify\User\Identity\Domain\Guard\PasswordMustBeStrong; +use Webify\User\Identity\Domain\Repository\UserRepositoryInterface; +use Webify\User\Identity\Domain\ValueObject\{PasswordHash, UserId}; + +/** + * UpdateUserPassword service handles the user password update process. + * + * Encapsulate the use case of changing a user's password. Verifies the current password + * before updating, then hashes the new password, and delegates to the aggregate. + */ +final readonly class UpdateUserPassword +{ + /** + * The constructor. + */ + public function __construct( + private PasswordHasherInterface $passwordHasher, + private UserRepositoryInterface $repository, + private PasswordMustBeStrong $passwordMustBeStrong, + private DomainEventPublisherInterface $eventPublisher + ) {} + + /** + * Updates a user's password after verifying the current password. + * + * @param string $userId the unique identifier of the user + * @param string $currentPassword the user's current password for verification + * @param string $newPassword the new password to be set for the user + * + * @throws CurrentPasswordNotMatchedException if the current password does not match the stored one + */ + public function update(string $userId, string $currentPassword, string $newPassword): void + { + $user = $this->repository->getById(UserId::fromString($userId)); + + if (!$this->passwordHasher->verify($currentPassword, $user->getPasswordHash()->toNative())) { + throw CurrentPasswordNotMatchedException::create(); + } + + $this->passwordMustBeStrong->guard($newPassword); + + $passwordHash = PasswordHash::fromHash($this->passwordHasher->hash($newPassword)); + + $user->changePassword($passwordHash); + $this->repository->persist($user); + $this->eventPublisher->publish(...$user->getDomainEvents()); + } +} diff --git a/src/Identity/Domain/ValueObject/DisplayName.php b/src/Identity/Domain/ValueObject/DisplayName.php new file mode 100644 index 0000000..da4612e --- /dev/null +++ b/src/Identity/Domain/ValueObject/DisplayName.php @@ -0,0 +1,90 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\ValueObject; + +use Webify\User\Identity\Domain\Exception\InvalidDisplayNameException; + +/** + * DisplayName value object represents the display name of a user. + */ +final readonly class DisplayName +{ + /** + * Allowed length. + */ + private const int MIN_LENGTH = 3; + private const int MAX_LENGTH = 255; + + /** + * Private constructor enforces the use of the factory method. + * + * @param string $value the display name value to be associated with the instance + * + * @throws InvalidDisplayNameException if the provided display name is invalid + */ + private function __construct(private string $value) + { + if (!$this->isValid()) { + throw InvalidDisplayNameException::create(self::MIN_LENGTH, self::MAX_LENGTH); + } + } + + /** + * Returns the string representation of the object. + */ + public function __toString(): string + { + return $this->value; + } + + /** + * Creates an instance of the class from the given string. + */ + public static function fromString(string $value): self + { + return new self(trim($value)); + } + + /** + * Converts the object to its native string representation. + */ + public function toNative(): string + { + return $this->value; + } + + /** + * Determines if the current object is equal to the specified object. + * + * @param self $other the object to compare with the current object + * + * @return bool true if the objects are equal, false otherwise + */ + public function equals(self $other): bool + { + return $this->toNative() === $other->toNative(); + } + + /** + * Determines if the current object state is valid. + * + * @return bool true if valid, false otherwise + */ + private function isValid(): bool + { + return '' !== $this->value + && mb_strlen($this->value) >= self::MIN_LENGTH + && mb_strlen($this->value) <= self::MAX_LENGTH; + } +} diff --git a/src/Identity/Domain/ValueObject/PasswordHash.php b/src/Identity/Domain/ValueObject/PasswordHash.php new file mode 100644 index 0000000..73e3e16 --- /dev/null +++ b/src/Identity/Domain/ValueObject/PasswordHash.php @@ -0,0 +1,80 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\ValueObject; + +use Webify\User\Identity\Domain\Exception\InvalidPasswordHashException; + +/** + * Password hash value object. + */ +final readonly class PasswordHash +{ + /** + * Private constructor enforces the use of the factory method. + * + * @param string $value the password hash value + * + * @throws InvalidPasswordHashException + */ + private function __construct(private string $value) + { + if (!$this->isValid()) { + throw InvalidPasswordHashException::forInvalidPasswordHash($this->value); + } + } + + /** + * Converts the object to its string representation. + */ + public function __toString(): string + { + return $this->value; + } + + /** + * Get the native string value of the password hash. + */ + public function toNative(): string + { + return $this->value; + } + + /** + * Factory method to create a password hash object from a hash. + */ + public static function fromHash(string $value): self + { + return new self($value); + } + + /** + * Checks if the password hash is equal to another password hash. + */ + public function equals(self $other): bool + { + return $this->toNative() === $other->toNative(); + } + + /** + * Validates the password hash value. + */ + private function isValid(): bool + { + if (empty($this->value)) { + return false; + } + + return true; + } +} diff --git a/src/Identity/Domain/ValueObject/UserEmail.php b/src/Identity/Domain/ValueObject/UserEmail.php new file mode 100644 index 0000000..5619d44 --- /dev/null +++ b/src/Identity/Domain/ValueObject/UserEmail.php @@ -0,0 +1,38 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\ValueObject; + +use Webify\Base\Domain\ValueObject\Email; +use Webify\User\Identity\Domain\Exception\InvalidUserEmailException; + +/** + * User email value object. + */ +final readonly class UserEmail extends Email +{ + /** + * {@inheritDoc} + * + * We don't have any domain specific validation for user emails and leaving this method empty. + */ + protected function validateDomainRules(string $value): void {} + + /** + * {@inheritDoc} + */ + protected function throwException(string $value): never + { + throw InvalidUserEmailException::forInvalidEmail($value); + } +} diff --git a/src/Identity/Domain/ValueObject/UserId.php b/src/Identity/Domain/ValueObject/UserId.php new file mode 100644 index 0000000..c4c8d8f --- /dev/null +++ b/src/Identity/Domain/ValueObject/UserId.php @@ -0,0 +1,33 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\ValueObject; + +use Webify\Base\Domain\ValueObject\AggregateId; +use Webify\User\Identity\Domain\Exception\InvalidUserIdException; + +/** + * The user ID value object. + */ +final readonly class UserId extends AggregateId +{ + /** + * Will throw an exception if the user id value is not valid. + * + * @throws InvalidUserIdException + */ + protected function throwException(string $value): never + { + throw InvalidUserIdException::forInvalidId($value); + } +} diff --git a/src/Identity/Domain/ValueObject/UserStatus.php b/src/Identity/Domain/ValueObject/UserStatus.php new file mode 100644 index 0000000..ae6080b --- /dev/null +++ b/src/Identity/Domain/ValueObject/UserStatus.php @@ -0,0 +1,48 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Domain\ValueObject; + +/** + * The user status value object. + */ +enum UserStatus: string +{ + case Active = 'active'; + case Inactive = 'inactive'; + case Deactivated = 'deactivated'; + + /** + * Check if the user is active. + */ + public function isActive(): bool + { + return self::Active === $this; + } + + /** + * Check if the user is inactive. + */ + public function isInactive(): bool + { + return self::Inactive === $this; + } + + /** + * Check if the user is deactivated. + */ + public function isDeactivated(): bool + { + return self::Deactivated === $this; + } +} diff --git a/src/Identity/Infrastructure/Exception/FailedToHashPasswordException.php b/src/Identity/Infrastructure/Exception/FailedToHashPasswordException.php new file mode 100644 index 0000000..dc76b89 --- /dev/null +++ b/src/Identity/Infrastructure/Exception/FailedToHashPasswordException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Infrastructure\Exception; + +use RuntimeException; +use Throwable; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when the password hashing process fails. + */ +final class FailedToHashPasswordException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '', + ?Throwable $previous = null + ) { + parent::__construct($message, 0, $previous); + } + + /** + * The constructor. + */ + public static function create(Throwable $previous): FailedToHashPasswordException + { + return new self( + new ExceptionTranslation( + 'user.identity', + 'failed_to_hash_password' + ), + 'Failed to hash password.', + $previous + ); + } +} diff --git a/src/Identity/Infrastructure/Service/PasswordHasher.php b/src/Identity/Infrastructure/Service/PasswordHasher.php new file mode 100644 index 0000000..7a20d9c --- /dev/null +++ b/src/Identity/Infrastructure/Service/PasswordHasher.php @@ -0,0 +1,44 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Identity\Infrastructure\Service; + +use ValueError; +use Webify\Base\Domain\Contract\Identity\Service\PasswordHasherInterface; +use Webify\User\Identity\Infrastructure\Exception\FailedToHashPasswordException; + +/** + * The implementation for the password hashing service. + */ +final readonly class PasswordHasher implements PasswordHasherInterface +{ + /** + * {@inheritDoc} + */ + public function hash(string $password): string + { + try { + return password_hash($password, PASSWORD_DEFAULT); + } catch (ValueError $throwable) { + throw FailedToHashPasswordException::create($throwable); + } + } + + /** + * {@inheritDoc} + */ + public function verify(string $password, string $hash): bool + { + return password_verify($password, $hash); + } +} diff --git a/test/Architecture/AuthenticationSpecification.php b/test/Architecture/AuthenticationSpecification.php new file mode 100644 index 0000000..a84a0e2 --- /dev/null +++ b/test/Architecture/AuthenticationSpecification.php @@ -0,0 +1,110 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Architecture; + +use Exception; +use PHPat\Selector\Selector; +use PHPat\Test\Builder\Rule; +use PHPat\Test\PHPat; + +/** + * Architecture tests that enforce DDD and clean architecture principles. + * + * Domain is the innermost layer — it must not know about Application or Infrastructure. + * Application sits between Domain and Infrastructure. + * Contract is a shared kernel that can be referenced by all layers. + * Infrastructure implements Domain interfaces (Dependency Inversion). + * + * @internal + */ +final class AuthenticationSpecification +{ + /** + * Domain classes must not depend on Application or Infrastructure. + * Dependency on Contract (shared kernel) is permitted. + */ + public function testDomainDoesNotDependOnApplicationOrInfrastructure(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Authentication\Authentication\Domain')) + ->shouldNot() + ->dependOn() + ->classes( + Selector::inNamespace('Webify\User\Authentication\Application'), + Selector::inNamespace('Webify\User\Authentication\Infrastructure'), + ) + ->because('Domain is the innermost layer and must not depend on outer layers') + ; + } + + /** + * Contract must not depend on Application or Infrastructure. + * It may reference Domain types. + */ + public function testContractDoesNotDependOnApplicationOrInfrastructure(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Authentication\Contract')) + ->shouldNot() + ->dependOn() + ->classes( + Selector::inNamespace('Webify\User\Authentication\Application'), + Selector::inNamespace('Webify\User\Authentication\Infrastructure'), + ) + ->because('Contract must not depend on outer layers') + ; + } + + /** + * Application classes must not depend on Infrastructure classes. + */ + public function testApplicationDoesNotDependOnInfrastructure(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Authentication\Application')) + ->shouldNot() + ->dependOn() + ->classes(Selector::inNamespace('Webify\User\Authentication\Infrastructure')) + ->because('Application should not depend on Infrastructure details') + ; + } + + /** + * All domain exception classes must extend \Exception. + */ + public function testDomainExceptionsExtendException(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Authentication\Domain\Exception')) + ->should() + ->extend() + ->classes(Selector::classname(Exception::class)) + ->because('All domain exceptions must extend \Exception to ensure consistent error handling') + ; + } + + /** + * All infrastructure exception classes must extend \Exception. + */ + public function testInfrastructureExceptionsExtendException(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Authentication\Infrastructure\Exception')) + ->should() + ->extend() + ->classes(Selector::classname(Exception::class)) + ->because('All infrastructure exceptions must extend \Exception') + ; + } +} diff --git a/test/Architecture/AuthorizationSpecification.php b/test/Architecture/AuthorizationSpecification.php new file mode 100644 index 0000000..3623f5c --- /dev/null +++ b/test/Architecture/AuthorizationSpecification.php @@ -0,0 +1,110 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Architecture; + +use Exception; +use PHPat\Selector\Selector; +use PHPat\Test\Builder\Rule; +use PHPat\Test\PHPat; + +/** + * Architecture tests that enforce DDD and clean architecture principles. + * + * Domain is the innermost layer — it must not know about Application or Infrastructure. + * Application sits between Domain and Infrastructure. + * Contract is a shared kernel that can be referenced by all layers. + * Infrastructure implements Domain interfaces (Dependency Inversion). + * + * @internal + */ +final class AuthorizationSpecification +{ + /** + * Domain classes must not depend on Application or Infrastructure. + * Dependency on Contract (shared kernel) is permitted. + */ + public function testDomainDoesNotDependOnApplicationOrInfrastructure(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Authorization\Authorization\Domain')) + ->shouldNot() + ->dependOn() + ->classes( + Selector::inNamespace('Webify\User\Authorization\Application'), + Selector::inNamespace('Webify\User\Authorization\Infrastructure'), + ) + ->because('Domain is the innermost layer and must not depend on outer layers') + ; + } + + /** + * Contract must not depend on Application or Infrastructure. + * It may reference Domain types. + */ + public function testContractDoesNotDependOnApplicationOrInfrastructure(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Authorization\Contract')) + ->shouldNot() + ->dependOn() + ->classes( + Selector::inNamespace('Webify\User\Authorization\Application'), + Selector::inNamespace('Webify\User\Authorization\Infrastructure'), + ) + ->because('Contract must not depend on outer layers') + ; + } + + /** + * Application classes must not depend on Infrastructure classes. + */ + public function testApplicationDoesNotDependOnInfrastructure(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Authorization\Application')) + ->shouldNot() + ->dependOn() + ->classes(Selector::inNamespace('Webify\User\Authorization\Infrastructure')) + ->because('Application should not depend on Infrastructure details') + ; + } + + /** + * All domain exception classes must extend \Exception. + */ + public function testDomainExceptionsExtendException(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Authorization\Domain\Exception')) + ->should() + ->extend() + ->classes(Selector::classname(Exception::class)) + ->because('All domain exceptions must extend \Exception to ensure consistent error handling') + ; + } + + /** + * All infrastructure exception classes must extend \Exception. + */ + public function testInfrastructureExceptionsExtendException(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Authorization\Infrastructure\Exception')) + ->should() + ->extend() + ->classes(Selector::classname(Exception::class)) + ->because('All infrastructure exceptions must extend \Exception') + ; + } +} diff --git a/test/Architecture/IdentitySpecification.php b/test/Architecture/IdentitySpecification.php new file mode 100644 index 0000000..704e092 --- /dev/null +++ b/test/Architecture/IdentitySpecification.php @@ -0,0 +1,110 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Architecture; + +use Exception; +use PHPat\Selector\Selector; +use PHPat\Test\Builder\Rule; +use PHPat\Test\PHPat; + +/** + * Architecture tests that enforce DDD and clean architecture principles. + * + * Domain is the innermost layer — it must not know about Application or Infrastructure. + * Application sits between Domain and Infrastructure. + * Contract is a shared kernel that can be referenced by all layers. + * Infrastructure implements Domain interfaces (Dependency Inversion). + * + * @internal + */ +final class IdentitySpecification +{ + /** + * Domain classes must not depend on Application or Infrastructure. + * Dependency on Contract (shared kernel) is permitted. + */ + public function testDomainDoesNotDependOnApplicationOrInfrastructure(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Identity\Identity\Domain')) + ->shouldNot() + ->dependOn() + ->classes( + Selector::inNamespace('Webify\User\Identity\Application'), + Selector::inNamespace('Webify\User\Identity\Infrastructure'), + ) + ->because('Domain is the innermost layer and must not depend on outer layers') + ; + } + + /** + * Contract must not depend on Application or Infrastructure. + * It may reference Domain types. + */ + public function testContractDoesNotDependOnApplicationOrInfrastructure(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Identity\Contract')) + ->shouldNot() + ->dependOn() + ->classes( + Selector::inNamespace('Webify\User\Identity\Application'), + Selector::inNamespace('Webify\User\Identity\Infrastructure'), + ) + ->because('Contract must not depend on outer layers') + ; + } + + /** + * Application classes must not depend on Infrastructure classes. + */ + public function testApplicationDoesNotDependOnInfrastructure(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Identity\Application')) + ->shouldNot() + ->dependOn() + ->classes(Selector::inNamespace('Webify\User\Identity\Infrastructure')) + ->because('Application should not depend on Infrastructure details') + ; + } + + /** + * All domain exception classes must extend \Exception. + */ + public function testDomainExceptionsExtendException(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Identity\Domain\Exception')) + ->should() + ->extend() + ->classes(Selector::classname(Exception::class)) + ->because('All domain exceptions must extend \Exception to ensure consistent error handling') + ; + } + + /** + * All infrastructure exception classes must extend \Exception. + */ + public function testInfrastructureExceptionsExtendException(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('Webify\User\Identity\Infrastructure\Exception')) + ->should() + ->extend() + ->classes(Selector::classname(Exception::class)) + ->because('All infrastructure exceptions must extend \Exception') + ; + } +} diff --git a/test/Unit/Authentication/Domain/Entity/ChallengeTest.php b/test/Unit/Authentication/Domain/Entity/ChallengeTest.php new file mode 100644 index 0000000..754fe9e --- /dev/null +++ b/test/Unit/Authentication/Domain/Entity/ChallengeTest.php @@ -0,0 +1,275 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Entity; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Entity\Challenge; +use Webify\User\Authentication\Domain\Event\{ChallengeFailed, ChallengeVerified}; +use Webify\User\Authentication\Domain\Exception\{ChallengeVerificationFailedException, InvalidChallengeSecretException}; +use Webify\User\Authentication\Domain\ValueObject\{ + ChallengeCode, + ChallengeId, + ChallengeStatus, + ChallengeToken, + ChallengeType, + UserId +}; + +/** + * Tests for the Challenge entity. + * + * @internal + */ +#[CoversClass(Challenge::class)] +#[CoversMethod(Challenge::class, 'issue')] +#[CoversMethod(Challenge::class, 'getId')] +#[CoversMethod(Challenge::class, 'getUserId')] +#[CoversMethod(Challenge::class, 'getType')] +#[CoversMethod(Challenge::class, 'getSecret')] +#[CoversMethod(Challenge::class, 'getExpiresAt')] +#[CoversMethod(Challenge::class, 'getIssuedAt')] +#[CoversMethod(Challenge::class, 'getStatus')] +#[CoversMethod(Challenge::class, 'isVerified')] +#[CoversMethod(Challenge::class, 'isExpired')] +#[CoversMethod(Challenge::class, 'verify')] +final class ChallengeTest extends TestCase +{ + /** + * The challenge ID used for testing. + */ + private const string CHALLENGE_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + /** + * The user ID used for testing. + */ + private const string USER_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAW'; + + /** + * The challenge entity under test. + */ + private ChallengeId $challengeId; + + /** + * The user ID associated with the challenge. + */ + private UserId $userId; + + /** + * The expiration date and time of the challenge. + */ + private DateTime $expiresAt; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->challengeId = ChallengeId::fromString(self::CHALLENGE_ID); + $this->userId = UserId::fromString(self::USER_ID); + $this->expiresAt = DateTime::fromString('2099-01-01 00:00:00'); + } + + /** + * Tests issuing a code challenge. + */ + #[Test] + public function testIssueCodeChallenge(): void + { + $challenge = Challenge::issue($this->challengeId, $this->userId, ChallengeType::Code, $this->expiresAt); + + $this->assertInstanceOf(ChallengeCode::class, $challenge->getSecret()); + $this->assertSame(ChallengeType::Code, $challenge->getType()); + $this->assertSame(ChallengeStatus::Pending, $challenge->getStatus()); + $this->assertFalse($challenge->isVerified()); + } + + /** + * Tests issuing a token challenge. + */ + #[Test] + public function testIssueTokenChallenge(): void + { + $challenge = Challenge::issue($this->challengeId, $this->userId, ChallengeType::Token, $this->expiresAt); + + $this->assertInstanceOf(ChallengeToken::class, $challenge->getSecret()); + $this->assertSame(ChallengeType::Token, $challenge->getType()); + $this->assertSame(ChallengeStatus::Pending, $challenge->getStatus()); + $this->assertFalse($challenge->isVerified()); + } + + /** + * Tests getters for challenge properties. + */ + #[Test] + public function testChallengeGetters(): void + { + $challenge = Challenge::issue($this->challengeId, $this->userId, ChallengeType::Code, $this->expiresAt); + + $this->assertTrue($this->challengeId->equals($challenge->getId())); + $this->assertTrue($this->userId->equals($challenge->getUserId())); + $this->assertInstanceOf(DateTime::class, $challenge->getIssuedAt()); + $this->assertTrue($this->expiresAt->equals($challenge->getExpiresAt())); + } + + /** + * Tests verifying a code challenge. + */ + #[Test] + public function testVerifyCodeChallengeSuccessfully(): void + { + $challenge = Challenge::issue($this->challengeId, $this->userId, ChallengeType::Code, $this->expiresAt); + + /** @var ChallengeCode $secret */ + $secret = $challenge->getSecret(); + + $challenge->verify($secret); + $this->assertTrue($challenge->isVerified()); + $this->assertSame(ChallengeStatus::Completed, $challenge->getStatus()); + + $events = $challenge->getDomainEvents(); + + $this->assertCount(1, $events); + $this->assertInstanceOf(ChallengeVerified::class, $events[0]); + } + + /** + * Tests verifying a token challenge. + */ + #[Test] + public function testVerifyTokenChallengeSuccessfully(): void + { + $challenge = Challenge::issue($this->challengeId, $this->userId, ChallengeType::Token, $this->expiresAt); + + /** @var ChallengeToken $secret */ + $secret = $challenge->getSecret(); + + $challenge->verify($secret); + + $this->assertTrue($challenge->isVerified()); + $this->assertSame(ChallengeStatus::Completed, $challenge->getStatus()); + + $events = $challenge->getDomainEvents(); + + $this->assertCount(1, $events); + $this->assertInstanceOf(ChallengeVerified::class, $events[0]); + } + + /** + * Tests verifying an expired challenge with an expected exception. + */ + #[Test] + public function testVerifyExpiredChallengeThrowsException(): void + { + $expiredAt = DateTime::fromString('2020-01-01 00:00:00'); + $challenge = Challenge::issue($this->challengeId, $this->userId, ChallengeType::Code, $expiredAt); + + /** @var ChallengeCode $secret */ + $secret = $challenge->getSecret(); + + $this->expectException(ChallengeVerificationFailedException::class); + $challenge->verify($secret); + } + + /** + * Tests verifying a challenge that has already been verified with an expected exception. + */ + #[Test] + public function testVerifyAlreadyVerifiedChallengeThrowsException(): void + { + $challenge = Challenge::issue($this->challengeId, $this->userId, ChallengeType::Code, $this->expiresAt); + + /** @var ChallengeCode $secret */ + $secret = $challenge->getSecret(); + $challenge->verify($secret); + $challenge->releaseDomainEvents(); + + $this->expectException(ChallengeVerificationFailedException::class); + $challenge->verify($secret); + } + + /** + * Tests verifying a challenge with a wrong secret with an expected exception. + */ + #[Test] + public function testVerifyWithWrongSecretThrowsException(): void + { + $challenge = Challenge::issue($this->challengeId, $this->userId, ChallengeType::Code, $this->expiresAt); + + $this->expectException(InvalidChallengeSecretException::class); + $challenge->verify(ChallengeCode::fromString('999999')); + } + + /** + * Tests verifying a challenge with a wrong secret expecting a `ChallengeFailed` event. + */ + #[Test] + public function testVerifyRecordsChallengeFailedEventOnWrongSecret(): void + { + $challenge = Challenge::issue($this->challengeId, $this->userId, ChallengeType::Code, $this->expiresAt); + + try { + $challenge->verify(ChallengeCode::fromString('999999')); + } catch (InvalidChallengeSecretException) { + $events = $challenge->getDomainEvents(); + $this->assertCount(1, $events); + $this->assertInstanceOf(ChallengeFailed::class, $events[0]); + } + } + + /** + * Tests verifying a challenge with a secret after max attempts and expecting exceptions. + */ + #[Test] + public function testMaxAttemptsLockout(): void + { + $challenge = Challenge::issue($this->challengeId, $this->userId, ChallengeType::Code, $this->expiresAt); + + // Use up all 6 attempts + for ($i = 0; 6 > $i; ++$i) { + try { + $challenge->verify(ChallengeCode::fromString('999999')); + } catch (InvalidChallengeSecretException) { + $challenge->releaseDomainEvents(); + } + } + + $this->expectException(ChallengeVerificationFailedException::class); + $challenge->verify(ChallengeCode::fromString('999999')); + } + + /** + * Tests the expired challenge with the `isExpired` method. + */ + #[Test] + public function testIsExpiredReturnsTrueForExpiredChallenge(): void + { + $expiredAt = DateTime::fromString('2020-01-01 00:00:00'); + $challenge = Challenge::issue($this->challengeId, $this->userId, ChallengeType::Code, $expiredAt); + + $this->assertTrue($challenge->isExpired()); + } + + /** + * Tests the valid challenge with the `isExpired` method. + */ + #[Test] + public function testIsExpiredReturnsFalseForValidChallenge(): void + { + $challenge = Challenge::issue($this->challengeId, $this->userId, ChallengeType::Code, $this->expiresAt); + + $this->assertFalse($challenge->isExpired()); + } +} diff --git a/test/Unit/Authentication/Domain/Entity/SessionTest.php b/test/Unit/Authentication/Domain/Entity/SessionTest.php new file mode 100644 index 0000000..b428a07 --- /dev/null +++ b/test/Unit/Authentication/Domain/Entity/SessionTest.php @@ -0,0 +1,238 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Entity; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Entity\Session; +use Webify\User\Authentication\Domain\Event\{SessionWasOpened, SessionWasRefreshed, SessionWasRevoked}; +use Webify\User\Authentication\Domain\Exception\CannotRefreshSessionException; +use Webify\User\Authentication\Domain\ValueObject\{AccessToken, RefreshToken, SessionId, UserId}; + +/** + * Tests for the Session entity. + * + * @internal + */ +#[CoversClass(Session::class)] +#[CoversMethod(Session::class, 'open')] +#[CoversMethod(Session::class, 'getId')] +#[CoversMethod(Session::class, 'getUserId')] +#[CoversMethod(Session::class, 'getAccessToken')] +#[CoversMethod(Session::class, 'getRefreshToken')] +#[CoversMethod(Session::class, 'getExpiresAt')] +#[CoversMethod(Session::class, 'getCreatedAt')] +#[CoversMethod(Session::class, 'refresh')] +#[CoversMethod(Session::class, 'revoke')] +#[CoversMethod(Session::class, 'isExpired')] +#[CoversMethod(Session::class, 'isActive')] +#[CoversMethod(Session::class, 'isRevoked')] +final class SessionTest extends TestCase +{ + private SessionId $sessionId; + + private UserId $userId; + + private AccessToken $accessToken; + + private RefreshToken $refreshToken; + + private DateTime $expiresAt; + + protected function setUp(): void + { + $this->sessionId = SessionId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $this->userId = UserId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAW'); + $this->accessToken = AccessToken::generate(); + $this->refreshToken = RefreshToken::generate(); + $this->expiresAt = DateTime::fromString('2099-01-01 00:00:00'); + } + + #[Test] + public function testOpenSessionRecordsSessionWasOpenedEvent(): void + { + $session = $this->createSession(); + $events = $session->getDomainEvents(); + + $this->assertCount(1, $events); + $this->assertInstanceOf(SessionWasOpened::class, $events[0]); + } + + #[Test] + public function testOpenSessionInitialState(): void + { + $session = $this->createSession(); + + $this->assertFalse($session->isExpired()); + $this->assertFalse($session->isRevoked()); + $this->assertTrue($session->isActive()); + } + + #[Test] + public function testSessionGetters(): void + { + $session = $this->createSession(); + + $this->assertTrue($this->sessionId->equals($session->getId())); + $this->assertTrue($this->userId->equals($session->getUserId())); + $this->assertTrue($this->accessToken->equals($session->getAccessToken())); + $this->assertTrue($this->refreshToken->equals($session->getRefreshToken())); + $this->assertTrue($this->expiresAt->equals($session->getExpiresAt())); + $this->assertInstanceOf(DateTime::class, $session->getCreatedAt()); + } + + #[Test] + public function testRefreshSession(): void + { + $session = $this->createSession(); + $newAccessToken = AccessToken::generate(); + $newRefreshToken = RefreshToken::generate(); + $newExpiresAt = DateTime::fromString('2099-06-01 00:00:00'); + + $session->refresh($newAccessToken, $newRefreshToken, $newExpiresAt); + + $this->assertTrue($newAccessToken->equals($session->getAccessToken())); + $this->assertTrue($newRefreshToken->equals($session->getRefreshToken())); + $this->assertTrue($newExpiresAt->equals($session->getExpiresAt())); + + $events = $session->getDomainEvents(); + + $this->assertCount(2, $events); + $this->assertInstanceOf(SessionWasRefreshed::class, $events[1]); + } + + #[Test] + public function testRefreshExpiredSessionThrowsException(): void + { + $session = Session::open( + $this->sessionId, + $this->userId, + $this->accessToken, + $this->refreshToken, + DateTime::fromString('2020-01-01 00:00:00') + ); + + $this->expectException(CannotRefreshSessionException::class); + $session->refresh( + AccessToken::generate(), + RefreshToken::generate(), + DateTime::fromString('2099-01-01 00:00:00') + ); + } + + #[Test] + public function testRefreshRevokedSessionThrowsException(): void + { + $session = $this->createSession(); + + $session->revoke(); + $session->releaseDomainEvents(); + $this->expectException(CannotRefreshSessionException::class); + $session->refresh( + AccessToken::generate(), + RefreshToken::generate(), + DateTime::fromString('2099-06-01 00:00:00') + ); + } + + #[Test] + public function testRevokeSession(): void + { + $session = $this->createSession(); + + $session->revoke(); + $this->assertTrue($session->isRevoked()); + $this->assertFalse($session->isActive()); + + $events = $session->getDomainEvents(); + + $this->assertCount(2, $events); + $this->assertInstanceOf(SessionWasRevoked::class, $events[1]); + } + + #[Test] + public function testRevokeSessionIsIdempotent(): void + { + $session = $this->createSession(); + + $session->revoke(); + $session->revoke(); + + $revokeEvents = array_values( + array_filter( + $session->getDomainEvents(), + static fn (object $event): bool => $event instanceof SessionWasRevoked + ) + ); + + $this->assertCount(1, $revokeEvents); + } + + #[Test] + public function testIsExpiredReturnsTrueForExpiredSession(): void + { + $session = Session::open( + $this->sessionId, + $this->userId, + $this->accessToken, + $this->refreshToken, + DateTime::fromString('2020-01-01 00:00:00') + ); + + $this->assertTrue($session->isExpired()); + } + + #[Test] + public function testIsExpiredReturnsFalseForNonExpiredSession(): void + { + $session = $this->createSession(); + + $this->assertFalse($session->isExpired()); + } + + #[Test] + public function testIsActiveReturnsFalseForExpiredSession(): void + { + $session = Session::open( + $this->sessionId, + $this->userId, + $this->accessToken, + $this->refreshToken, + DateTime::fromString('2020-01-01 00:00:00') + ); + + $this->assertFalse($session->isActive()); + } + + #[Test] + public function testIsActiveReturnsFalseForRevokedSession(): void + { + $session = $this->createSession(); + + $session->revoke(); + $this->assertFalse($session->isActive()); + } + + private function createSession(): Session + { + return Session::open( + $this->sessionId, + $this->userId, + $this->accessToken, + $this->refreshToken, + $this->expiresAt + ); + } +} diff --git a/test/Unit/Authentication/Domain/Guard/UserMustBeActiveTest.php b/test/Unit/Authentication/Domain/Guard/UserMustBeActiveTest.php new file mode 100644 index 0000000..6c65b11 --- /dev/null +++ b/test/Unit/Authentication/Domain/Guard/UserMustBeActiveTest.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Guard; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authentication\Domain\Exception\AuthenticationFailedException; +use Webify\User\Authentication\Domain\Guard\UserMustBeActive; +use Webify\User\Authentication\Domain\ValueObject\UserStatus; + +/** + * Tests for the UserMustBeActive guard. + * + * @internal + */ +#[CoversClass(UserMustBeActive::class)] +#[CoversMethod(UserMustBeActive::class, 'guard')] +final class UserMustBeActiveTest extends TestCase +{ + /** + * Tests that the guard passes when the user status is active. + */ + #[Test] + public function testGuardPassesWhenStatusIsActive(): void + { + $guard = new UserMustBeActive(); + + $this->expectNotToPerformAssertions(); + $guard->guard(UserStatus::Active); + } + + /** + * Tests that the guard throws an exception when the user status is not active. + */ + #[Test] + public function testGuardThrowsExceptionWhenStatusIsNotActive(): void + { + $guard = new UserMustBeActive(); + + $this->expectException(AuthenticationFailedException::class); + $guard->guard(UserStatus::Unverified); + } +} diff --git a/test/Unit/Authentication/Domain/Service/ChallengeBaseTest.php b/test/Unit/Authentication/Domain/Service/ChallengeBaseTest.php new file mode 100644 index 0000000..1142326 --- /dev/null +++ b/test/Unit/Authentication/Domain/Service/ChallengeBaseTest.php @@ -0,0 +1,489 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Service; + +use PHPUnit\Framework\Attributes\{AllowMockObjectsWithoutExpectations, CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Contract\Authentication\{ + AuthenticatedUser as AuthenticatedUserDto, + CredentialsInterface, + StrategyInterface, + UserCredentials, + UserCredentialsLookupInterface +}; +use Webify\Base\Domain\Contract\Authentication\Service\StrategyRegisterInterface; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\UlidGeneratorInterface; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Entity\Challenge; +use Webify\User\Authentication\Domain\Exception\{ + AuthenticationFailedException, + ChallengeNotFoundException +}; +use Webify\User\Authentication\Domain\Guard\UserMustBeActive; +use Webify\User\Authentication\Domain\Repository\{ChallengeRepositoryInterface, SessionRepositoryInterface}; +use Webify\User\Authentication\Domain\Service\{ChallengeBase, OpenSession, UserStatusTranslator, VerifyChallenge}; +use Webify\User\Authentication\Domain\ValueObject\{ChallengeCode, ChallengeId, ChallengeType, UserId}; + +use function in_array; + +/** + * Tests for the ChallengeBase authentication service. + * + * @internal + */ +#[AllowMockObjectsWithoutExpectations] +#[CoversClass(ChallengeBase::class)] +#[CoversMethod(ChallengeBase::class, 'initiate')] +#[CoversMethod(ChallengeBase::class, 'complete')] +final class ChallengeBaseTest extends TestCase +{ + /** + * The user ID used for testing. + */ + private const string USER_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + /** + * The challenge ID used for testing. + */ + private const string CHALLENGE_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAW'; + + /** + * Mock for user credentials lookup service. + */ + private MockObject&UserCredentialsLookupInterface $userLookup; + + /** + * Mock for the strategy register. + */ + private MockObject&StrategyRegisterInterface $strategyRegister; + + /** + * The ChallengeBase service instance under test. + */ + private ChallengeBase $service; + + /** + * Mock for challenge repository. + */ + private ChallengeRepositoryInterface&MockObject $challengeRepository; + + /** + * Mock for session repository. + */ + private MockObject&SessionRepositoryInterface $sessionRepository; + + /** + * Mock for ULID generator. + */ + private MockObject&UlidGeneratorInterface $idGenerator; + + /** + * Mock for domain event publisher. + */ + private DomainEventPublisherInterface&MockObject $eventPublisher; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->userLookup = $this->createMock(UserCredentialsLookupInterface::class); + $this->strategyRegister = $this->createMock(StrategyRegisterInterface::class); + $this->challengeRepository = $this->createMock(ChallengeRepositoryInterface::class); + $this->sessionRepository = $this->createMock(SessionRepositoryInterface::class); + $this->idGenerator = $this->createMock(UlidGeneratorInterface::class); + $this->eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + $verifyChallenge = new VerifyChallenge($this->challengeRepository, $this->eventPublisher); + $openSession = new OpenSession( + $this->sessionRepository, + $this->idGenerator, + $this->eventPublisher + ); + $this->service = new ChallengeBase( + $this->userLookup, + $this->strategyRegister, + new UserMustBeActive(), + new UserStatusTranslator(), + $verifyChallenge, + $openSession + ); + } + + /** + * Test initiating challenge-based authentication successfully. + */ + #[Test] + public function testInitiateSuccessfully(): void + { + $userCredentials = $this->createUserCredentials(); + $request = $this->createRequest('challenge_code'); + $strategy = $this->createMock(StrategyInterface::class); + + $strategy + ->method('getIdentifier') + ->willReturn('challenge_code') + ; + $strategy + ->method('isSupported') + ->willReturn(true) + ; + $this->userLookup + ->expects($this->once()) + ->method('findByEmail') + ->with('test@webifycms.com') + ->willReturn($userCredentials) + ; + $this->strategyRegister + ->expects($this->once()) + ->method('get') + ->with('challenge_code') + ->willReturn($strategy) + ; + $strategy + ->expects($this->once()) + ->method('initiate') + ->with($request, $userCredentials) + ; + $this->service->initiate($request); + } + + /** + * Test that initiate throws an exception when the user is not found. + */ + #[Test] + public function testInitiateThrowsExceptionWhenUserNotFound(): void + { + $request = $this->createRequest('challenge_code'); + + $this->userLookup + ->expects($this->once()) + ->method('findByEmail') + ->with('test@webifycms.com') + ->willReturn(null) + ; + $this->expectException(AuthenticationFailedException::class); + $this->service->initiate($request); + } + + /** + * Test that initiate throws an exception when the user is not active. + */ + #[Test] + public function testInitiateThrowsExceptionWhenUserNotActive(): void + { + $userCredentials = $this->createUserCredentials('unverified'); + $request = $this->createRequest('challenge_code'); + + $this->userLookup + ->expects($this->once()) + ->method('findByEmail') + ->willReturn($userCredentials) + ; + $this->expectException(AuthenticationFailedException::class); + $this->service->initiate($request); + } + + /** + * Test that initiate throws an exception when the strategy is not supported. + */ + #[Test] + public function testInitiateThrowsExceptionWhenStrategyNotSupported(): void + { + $userCredentials = $this->createUserCredentials(); + $request = $this->createRequest('unsupported_strategy'); + $strategy = $this->createMock(StrategyInterface::class); + + $strategy + ->method('getIdentifier') + ->willReturn('unsupported_strategy') + ; + $strategy + ->method('isSupported') + ->willReturn(false) + ; + $this->userLookup + ->expects($this->once()) + ->method('findByEmail') + ->willReturn($userCredentials) + ; + $this->strategyRegister + ->expects($this->once()) + ->method('get') + ->with('unsupported_strategy') + ->willReturn($strategy) + ; + $this->expectException(AuthenticationFailedException::class); + $this->service->initiate($request); + } + + /** + * Test completing a code challenge successfully. + */ + #[Test] + public function testCompleteCodeChallengeSuccessfully(): void + { + $userCredentials = $this->createUserCredentials(); + $challenge = Challenge::issue( + ChallengeId::fromString(self::CHALLENGE_ID), + UserId::fromString(self::USER_ID), + ChallengeType::Code, + DateTime::fromString('2099-01-01 00:00:00') + ); + + /** @var ChallengeCode $secret */ + $secret = $challenge->getSecret(); + + $request = $this->createRequest('challenge_code', $secret->toNative()); + + $this->userLookup + ->expects($this->once()) + ->method('findByEmail') + ->willReturn($userCredentials) + ; + + $strategy = $this->createMockStrategy('challenge_code'); + + $this->strategyRegister + ->expects($this->once()) + ->method('get') + ->with('challenge_code') + ->willReturn($strategy) + ; + $this->challengeRepository + ->expects($this->once()) + ->method('getByUser') + ->willReturn($challenge) + ; + $this->challengeRepository + ->expects($this->once()) + ->method('persist') + ->with($challenge) + ; + $this->idGenerator + ->expects($this->once()) + ->method('generate') + ->willReturn(self::CHALLENGE_ID) + ; + $this->sessionRepository + ->expects($this->once()) + ->method('persist') + ; + $this->eventPublisher + ->expects($this->exactly(2)) + ->method('publish') + ; + $result = $this->service->complete($request); + + $this->assertInstanceOf(AuthenticatedUserDto::class, $result); + $this->assertSame(self::USER_ID, $result->id); + $this->assertSame('test@webifycms.com', $result->email); + } + + /** + * Test that complete throws an exception when the secret is missing. + */ + #[Test] + public function testCompleteThrowsExceptionWhenSecretMissing(): void + { + $request = $this->createMock(CredentialsInterface::class); + + $request + ->method('has') + ->willReturnCallback(fn (string $key): bool => 'email' === $key) + ; + $this->expectException(AuthenticationFailedException::class); + $this->expectExceptionMessageIs('Secret is required for passwordless authentication.'); + $this->service->complete($request); + } + + /** + * Test that complete throws an exception when the email is missing. + */ + #[Test] + public function testCompleteThrowsExceptionWhenEmailMissing(): void + { + $request = $this->createMock(CredentialsInterface::class); + + $request + ->method('has') + ->willReturnCallback(fn (string $key): bool => 'secret' === $key) + ; + $this->expectException(AuthenticationFailedException::class); + $this->expectExceptionMessageIs('Email is required for authentication.'); + $this->service->complete($request); + } + + /** + * Test that complete throws an exception when the user is not found. + */ + #[Test] + public function testCompleteThrowsExceptionWhenUserNotFound(): void + { + $request = $this->createRequest('challenge_code'); + + $this->userLookup + ->expects($this->once()) + ->method('findByEmail') + ->willReturn(null) + ; + $this->expectException(AuthenticationFailedException::class); + $this->service->complete($request); + } + + /** + * Test that complete throws an exception when the challenge is not found. + */ + #[Test] + public function testCompleteThrowsExceptionWhenChallengeNotFound(): void + { + $userCredentials = $this->createUserCredentials(); + $request = $this->createRequest('challenge_code'); + + $this->userLookup + ->expects($this->once()) + ->method('findByEmail') + ->willReturn($userCredentials) + ; + + $strategy = $this->createMockStrategy('challenge_code'); + + $this->strategyRegister + ->expects($this->once()) + ->method('get') + ->with('challenge_code') + ->willReturn($strategy) + ; + $this->challengeRepository + ->expects($this->once()) + ->method('getByUser') + ->willThrowException( + ChallengeNotFoundException::forId(self::CHALLENGE_ID) + ) + ; + $this->expectException(AuthenticationFailedException::class); + $this->service->complete($request); + } + + /** + * Test that complete throws an exception when the secret is invalid. + */ + #[Test] + public function testCompleteThrowsExceptionWhenSecretInvalid(): void + { + $userCredentials = $this->createUserCredentials(); + $request = $this->createRequest('challenge_code', '999999'); + $challenge = Challenge::issue( + ChallengeId::fromString(self::CHALLENGE_ID), + UserId::fromString(self::USER_ID), + ChallengeType::Code, + DateTime::fromString('2099-01-01 00:00:00') + ); + + $this->userLookup + ->expects($this->once()) + ->method('findByEmail') + ->willReturn($userCredentials) + ; + + $strategy = $this->createMockStrategy('challenge_code'); + + $this->strategyRegister + ->expects($this->once()) + ->method('get') + ->with('challenge_code') + ->willReturn($strategy) + ; + $this->challengeRepository + ->expects($this->once()) + ->method('getByUser') + ->willReturn($challenge) + ; + $this->challengeRepository + ->expects($this->once()) + ->method('persist') + ; + $this->eventPublisher + ->expects($this->once()) + ->method('publish') + ; + $this->expectException(AuthenticationFailedException::class); + $this->service->complete($request); + } + + /** + * Creates user credentials for testing. + */ + private function createUserCredentials(string $status = 'active'): UserCredentials + { + return new UserCredentials( + id: self::USER_ID, + email: 'test@webifycms.com', + displayName: 'Test User', + passwordHash: 'hashed_password', + status: $status, + isTwoFactorEnabled: true + ); + } + + /** + * Creates a mock strategy stub with the given identifier. + */ + private function createMockStrategy(string $identifier): StrategyInterface + { + $strategy = $this->createStub(StrategyInterface::class); + + $strategy + ->method('getIdentifier') + ->willReturn($identifier) + ; + $strategy + ->method('isSupported') + ->willReturn(true) + ; + + return $strategy; + } + + /** + * Creates a request stub with the given strategy, secret, and email. + */ + private function createRequest( + string $strategy, + ?string $secret = '123456', + ?string $email = 'test@webifycms.com' + ): CredentialsInterface { + $request = $this->createStub(CredentialsInterface::class); + + $request + ->method('getStrategyIdentifier') + ->willReturn($strategy) + ; + $request + ->method('has') + ->willReturnCallback(fn (string $key): bool => in_array($key, ['email', 'secret'], true)) + ; + $request + ->method('get') + ->willReturnCallback(fn (string $key) => match ($key) { + 'email' => $email, + 'secret' => $secret, + default => null + }) + ; + + return $request; + } +} diff --git a/test/Unit/Authentication/Domain/Service/IssueChallengeTest.php b/test/Unit/Authentication/Domain/Service/IssueChallengeTest.php new file mode 100644 index 0000000..d44dbf4 --- /dev/null +++ b/test/Unit/Authentication/Domain/Service/IssueChallengeTest.php @@ -0,0 +1,128 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\UlidGeneratorInterface; +use Webify\User\Authentication\Domain\Entity\Challenge; +use Webify\User\Authentication\Domain\Repository\ChallengeRepositoryInterface; +use Webify\User\Authentication\Domain\Service\IssueChallenge; +use Webify\User\Authentication\Domain\ValueObject\{ChallengeType, UserId}; + +/** + * Tests for the IssueChallenge domain service. + * + * @internal + */ +#[CoversClass(IssueChallenge::class)] +#[CoversMethod(IssueChallenge::class, 'issue')] +final class IssueChallengeTest extends TestCase +{ + /** + * User ID for testing. + */ + private const string USER_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + /** + * Challenge ID for testing. + */ + private const string CHALLENGE_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAW'; + + /** + * Test issuing a code challenge for a user. + */ + #[Test] + public function testIssueCodeChallenge(): void + { + $userId = UserId::fromString(self::USER_ID); + + $repository = $this->createMock(ChallengeRepositoryInterface::class); + $repository + ->expects($this->once()) + ->method('deleteStale') + ->with($userId) + ; + $repository + ->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(Challenge::class)) + ; + + $idGenerator = $this->createMock(UlidGeneratorInterface::class); + + $idGenerator + ->expects($this->once()) + ->method('generate') + ->willReturn(self::CHALLENGE_ID) + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher + ->expects($this->once()) + ->method('publish') + ; + + $service = new IssueChallenge($idGenerator, $repository, $eventPublisher); + $challenge = $service->issue($userId, ChallengeType::Code); + + $this->assertInstanceOf(Challenge::class, $challenge); + $this->assertTrue($challenge->getUserId()->equals($userId)); + $this->assertSame(ChallengeType::Code, $challenge->getType()); + } + + /** + * Test issuing a token challenge for a user. + */ + #[Test] + public function testIssueTokenChallenge(): void + { + $userId = UserId::fromString(self::USER_ID); + $repository = $this->createMock(ChallengeRepositoryInterface::class); + + $repository + ->expects($this->once()) + ->method('deleteStale') + ->with($userId) + ; + $repository + ->expects($this->once()) + ->method('persist') + ; + + $idGenerator = $this->createMock(UlidGeneratorInterface::class); + + $idGenerator + ->expects($this->once()) + ->method('generate') + ->willReturn(self::CHALLENGE_ID) + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher + ->expects($this->once()) + ->method('publish') + ; + + $service = new IssueChallenge($idGenerator, $repository, $eventPublisher); + $challenge = $service->issue($userId, ChallengeType::Token); + + $this->assertInstanceOf(Challenge::class, $challenge); + $this->assertTrue($challenge->getUserId()->equals($userId)); + $this->assertSame(ChallengeType::Token, $challenge->getType()); + } +} diff --git a/test/Unit/Authentication/Domain/Service/OpenSessionTest.php b/test/Unit/Authentication/Domain/Service/OpenSessionTest.php new file mode 100644 index 0000000..e493995 --- /dev/null +++ b/test/Unit/Authentication/Domain/Service/OpenSessionTest.php @@ -0,0 +1,112 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Service; + +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\UlidGeneratorInterface; +use Webify\User\Authentication\Domain\Entity\Session; +use Webify\User\Authentication\Domain\Repository\SessionRepositoryInterface; +use Webify\User\Authentication\Domain\Service\OpenSession; +use Webify\User\Authentication\Domain\ValueObject\{AuthenticatedUser, UserId}; + +/** + * Tests for the OpenSession domain service. + * + * @internal + */ +#[CoversClass(OpenSession::class)] +#[CoversMethod(OpenSession::class, 'open')] +final class OpenSessionTest extends TestCase +{ + private const string USER_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + private const string SESSION_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAW'; + + /** + * Test successfully opening a session for an authenticated user. + */ + #[Test] + public function testOpenSessionSuccessfully(): void + { + $expiresAt = new DateTimeImmutable('2099-01-01 00:00:00'); + $userId = UserId::fromString(self::USER_ID); + $authenticatedUser = AuthenticatedUser::fromSuccessfulAuthentication($userId); + + $repository = $this->createMock(SessionRepositoryInterface::class); + $repository + ->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(Session::class)) + ; + + $idGenerator = $this->createMock(UlidGeneratorInterface::class); + $idGenerator + ->expects($this->once()) + ->method('generate') + ->willReturn(self::SESSION_ID) + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + $eventPublisher + ->expects($this->once()) + ->method('publish') + ; + + $service = new OpenSession($repository, $idGenerator, $eventPublisher); + $session = $service->open($authenticatedUser, $expiresAt); + + $this->assertInstanceOf(Session::class, $session); + $this->assertTrue($session->getUserId()->equals($userId)); + } + + /** + * Test that the opened session has the correct initial state. + */ + #[Test] + public function testOpenSessionInitialState(): void + { + $expiresAt = new DateTimeImmutable('2099-01-01 00:00:00'); + $userId = UserId::fromString(self::USER_ID); + $authenticatedUser = AuthenticatedUser::fromSuccessfulAuthentication($userId); + + $repository = $this->createMock(SessionRepositoryInterface::class); + $repository + ->expects($this->once()) + ->method('persist') + ; + + $idGenerator = $this->createMock(UlidGeneratorInterface::class); + $idGenerator + ->expects($this->once()) + ->method('generate') + ->willReturn(self::SESSION_ID) + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + $eventPublisher + ->expects($this->once()) + ->method('publish') + ; + + $service = new OpenSession($repository, $idGenerator, $eventPublisher); + $session = $service->open($authenticatedUser, $expiresAt); + + $this->assertFalse($session->isExpired()); + $this->assertFalse($session->isRevoked()); + $this->assertTrue($session->isActive()); + } +} diff --git a/test/Unit/Authentication/Domain/Service/PasswordBaseTest.php b/test/Unit/Authentication/Domain/Service/PasswordBaseTest.php new file mode 100644 index 0000000..d2a561e --- /dev/null +++ b/test/Unit/Authentication/Domain/Service/PasswordBaseTest.php @@ -0,0 +1,358 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Service; + +use PHPUnit\Framework\Attributes\{AllowMockObjectsWithoutExpectations, CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Contract\Authentication\{AuthenticatedUser, + CredentialsInterface, + UserCredentials, + UserCredentialsLookupInterface}; +use Webify\Base\Domain\Contract\Identity\Service\PasswordHasherInterface; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\UlidGeneratorInterface; +use Webify\User\Authentication\Domain\Entity\Challenge; +use Webify\User\Authentication\Domain\Exception\AuthenticationFailedException; +use Webify\User\Authentication\Domain\Guard\UserMustBeActive; +use Webify\User\Authentication\Domain\Repository\{ChallengeRepositoryInterface, SessionRepositoryInterface}; +use Webify\User\Authentication\Domain\Service\{ + ChallengeSecretDeliveryInterface, + IssueChallenge, + OpenSession, + PasswordBase, + UserStatusTranslator +}; +use Webify\User\Authentication\Domain\ValueObject\UserId; + +use function in_array; + +/** + * Tests for the PasswordBase authentication service. + * + * @internal + */ +#[AllowMockObjectsWithoutExpectations] +#[CoversClass(PasswordBase::class)] +#[CoversMethod(PasswordBase::class, 'authenticate')] +final class PasswordBaseTest extends TestCase +{ + /** + * The user ID used for testing. + */ + private const string USER_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + /** + * Mock for user credentials lookup. + */ + private MockObject&UserCredentialsLookupInterface $credentialLookup; + + /** + * Mock for password hasher. + */ + private MockObject&PasswordHasherInterface $passwordHasher; + + /** + * Mock for challenge secret delivery. + */ + private ChallengeSecretDeliveryInterface&MockObject $delivery; + + /** + * The PasswordBase service instance under test. + */ + private PasswordBase $service; + + /** + * Mock for challenge repository. + */ + private ChallengeRepositoryInterface&MockObject $challengeRepository; + + /** + * Mock for session repository. + */ + private MockObject&SessionRepositoryInterface $sessionRepository; + + /** + * Mock for ULID generator. + */ + private MockObject&UlidGeneratorInterface $idGenerator; + + /** + * Mock for domain event publisher. + */ + private DomainEventPublisherInterface&MockObject $eventPublisher; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->credentialLookup = $this->createMock(UserCredentialsLookupInterface::class); + $this->passwordHasher = $this->createMock(PasswordHasherInterface::class); + $this->delivery = $this->createMock(ChallengeSecretDeliveryInterface::class); + $this->challengeRepository = $this->createMock(ChallengeRepositoryInterface::class); + $this->sessionRepository = $this->createMock(SessionRepositoryInterface::class); + $this->idGenerator = $this->createMock(UlidGeneratorInterface::class); + $this->eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + $issueChallenge = new IssueChallenge( + $this->idGenerator, + $this->challengeRepository, + $this->eventPublisher + ); + $openSession = new OpenSession( + $this->sessionRepository, + $this->idGenerator, + $this->eventPublisher + ); + $this->service = new PasswordBase( + $this->credentialLookup, + $this->passwordHasher, + new UserMustBeActive(), + new UserStatusTranslator(), + $issueChallenge, + $openSession, + $this->delivery + ); + } + + /** + * Test authenticating a user successfully without 2FA. + */ + #[Test] + public function testAuthenticateSuccessfullyWithout2FA(): void + { + $userCredentials = $this->createUserCredentials(); + $request = $this->createRequest(); + + $this->credentialLookup + ->expects($this->once()) + ->method('findByEmail') + ->with('test@webifycms.com') + ->willReturn($userCredentials) + ; + $this->passwordHasher + ->expects($this->once()) + ->method('verify') + ->with('correct_password', 'hashed_password') + ->willReturn(true) + ; + $this->idGenerator + ->expects($this->once()) + ->method('generate') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FAW') + ; + $this->sessionRepository + ->expects($this->once()) + ->method('persist') + ; + $this->eventPublisher + ->expects($this->once()) + ->method('publish') + ; + + $result = $this->service->authenticate($request); + + $this->assertInstanceOf(AuthenticatedUser::class, $result); + $this->assertSame(self::USER_ID, $result->id); + } + + /** + * Test that authentication with 2FA enabled returns null and issues a challenge code. + */ + #[Test] + public function testAuthenticateWith2FAReturnsNullAndIssuesCode(): void + { + $userCredentials = $this->createUserCredentials('active', true); + $request = $this->createRequest(); + + $this->credentialLookup + ->expects($this->once()) + ->method('findByEmail') + ->willReturn($userCredentials) + ; + $this->passwordHasher + ->expects($this->once()) + ->method('verify') + ->with('correct_password', 'hashed_password') + ->willReturn(true) + ; + $this->challengeRepository + ->expects($this->once()) + ->method('deleteStale') + ->with($this->callback(fn (UserId $uid): bool => $uid->toNative() === self::USER_ID)) + ; + $this->challengeRepository + ->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(Challenge::class)) + ; + $this->idGenerator + ->expects($this->once()) + ->method('generate') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FAW') + ; + $this->delivery + ->expects($this->once()) + ->method('deliver') + ; + $this->eventPublisher + ->expects($this->once()) + ->method('publish') + ; + + $result = $this->service->authenticate($request); + + $this->assertNull($result); + } + + /** + * Test that authenticate throws an exception when the email is missing. + */ + #[Test] + public function testAuthenticateThrowsExceptionWhenEmailMissing(): void + { + $request = $this->createMock(CredentialsInterface::class); + + $request + ->method('has') + ->willReturnCallback(fn (string $key): bool => 'password' === $key) + ; + $this->expectException(AuthenticationFailedException::class); + $this->expectExceptionMessageIs('Email is required for authentication.'); + $this->service->authenticate($request); + } + + /** + * Test that authenticate throws an exception when the password is missing. + */ + #[Test] + public function testAuthenticateThrowsExceptionWhenPasswordMissing(): void + { + $request = $this->createMock(CredentialsInterface::class); + + $request + ->method('has') + ->willReturnCallback(fn (string $key): bool => 'email' === $key) + ; + $this->expectException(AuthenticationFailedException::class); + $this->expectExceptionMessageIs('Password is required for authentication.'); + $this->service->authenticate($request); + } + + /** + * Test that authenticate throws an exception when the user is not found. + */ + #[Test] + public function testAuthenticateThrowsExceptionWhenUserNotFound(): void + { + $request = $this->createRequest(); + + $this->credentialLookup + ->expects($this->once()) + ->method('findByEmail') + ->with('test@webifycms.com') + ->willReturn(null) + ; + $this->expectException(AuthenticationFailedException::class); + $this->expectExceptionMessageIs('User with email "test@webifycms.com" not found.'); + $this->service->authenticate($request); + } + + /** + * Test that authenticate throws an exception when the user is not active. + */ + #[Test] + public function testAuthenticateThrowsExceptionWhenUserNotActive(): void + { + $userCredentials = $this->createUserCredentials('unverified'); + $request = $this->createRequest(); + + $this->credentialLookup + ->expects($this->once()) + ->method('findByEmail') + ->willReturn($userCredentials) + ; + $this->passwordHasher + ->expects($this->never()) + ->method('verify') + ; + $this->expectException(AuthenticationFailedException::class); + $this->service->authenticate($request); + } + + /** + * Test that authenticate throws an exception when the password is invalid. + */ + #[Test] + public function testAuthenticateThrowsExceptionWhenPasswordInvalid(): void + { + $userCredentials = $this->createUserCredentials(); + $request = $this->createRequest('test@webifycms.com', 'wrong_password'); + + $this->credentialLookup + ->expects($this->once()) + ->method('findByEmail') + ->willReturn($userCredentials) + ; + $this->passwordHasher + ->expects($this->once()) + ->method('verify') + ->with('wrong_password', 'hashed_password') + ->willReturn(false) + ; + $this->expectException(AuthenticationFailedException::class); + $this->expectExceptionMessageIs('Invalid user credentials.'); + $this->service->authenticate($request); + } + + /** + * Creates user credentials for testing. + */ + private function createUserCredentials(string $status = 'active', bool $isTwoFactorEnabled = false): UserCredentials + { + return new UserCredentials( + id: self::USER_ID, + email: 'test@webifycms.com', + displayName: 'Test User', + passwordHash: 'hashed_password', + status: $status, + isTwoFactorEnabled: $isTwoFactorEnabled + ); + } + + /** + * Creates a request stub with the given email and password. + */ + private function createRequest( + ?string $email = 'test@webifycms.com', + ?string $password = 'correct_password' + ): CredentialsInterface { + $request = $this->createStub(CredentialsInterface::class); + + $request + ->method('has') + ->willReturnCallback(fn (string $key): bool => in_array($key, ['email', 'password'], true)) + ; + $request + ->method('get') + ->willReturnCallback(fn (string $key) => match ($key) { + 'email' => $email, + 'password' => $password, + default => null + }) + ; + + return $request; + } +} diff --git a/test/Unit/Authentication/Domain/Service/RefreshSessionTest.php b/test/Unit/Authentication/Domain/Service/RefreshSessionTest.php new file mode 100644 index 0000000..e87332a --- /dev/null +++ b/test/Unit/Authentication/Domain/Service/RefreshSessionTest.php @@ -0,0 +1,128 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Service; + +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\{AllowMockObjectsWithoutExpectations, CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Entity\Session; +use Webify\User\Authentication\Domain\Exception\{CannotRefreshSessionException, SessionNotFoundException}; +use Webify\User\Authentication\Domain\Repository\SessionRepositoryInterface; +use Webify\User\Authentication\Domain\Service\RefreshSession; +use Webify\User\Authentication\Domain\ValueObject\{AccessToken, RefreshToken, SessionId, UserId}; + +/** + * Tests for the RefreshSession domain service. + * + * @internal + */ +#[CoversClass(RefreshSession::class)] +#[CoversMethod(RefreshSession::class, 'refresh')] +final class RefreshSessionTest extends TestCase +{ + private MockObject&SessionRepositoryInterface $repository; + + private DomainEventPublisherInterface&MockObject $eventPublisher; + + private RefreshSession $refreshSession; + + protected function setUp(): void + { + $this->repository = $this->createMock(SessionRepositoryInterface::class); + $this->eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $this->refreshSession = new RefreshSession( + $this->repository, + $this->eventPublisher + ); + } + + #[Test] + public function testRefreshSessionSuccessfully(): void + { + $token = '4500ccbd09e402fe83717c67dd156512837bfc3fd244125c430260ddd8816266'; + $expiresAt = new DateTimeImmutable('2099-01-01 00:00:00'); + $originalAccess = AccessToken::generate(); + $session = Session::open( + SessionId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'), + UserId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAW'), + $originalAccess, + RefreshToken::fromString($token), + DateTime::fromString('2099-06-01 00:00:00') + ); + + $this->repository + ->expects($this->once()) + ->method('getByRefreshToken') + ->with($this->isInstanceOf(RefreshToken::class)) + ->willReturn($session) + ; + $this->repository + ->expects($this->once()) + ->method('persist') + ->with($session) + ; + $this->eventPublisher + ->expects($this->once()) + ->method('publish') + ; + + $this->refreshSession->refresh($token, $expiresAt); + + $this->assertFalse($originalAccess->equals($session->getAccessToken())); + $this->assertTrue($session->getExpiresAt()->equals(DateTime::fromNative($expiresAt))); + } + + #[Test] + #[AllowMockObjectsWithoutExpectations] + public function testRefreshThrowsExceptionWhenTokenNotFound(): void + { + $token = '4500ccbd09e402fe83717c67dd156512837bfc3fd244125c430260ddd8816266'; + + $this->repository + ->expects($this->once()) + ->method('getByRefreshToken') + ->with($this->isInstanceOf(RefreshToken::class)) + ->willThrowException(SessionNotFoundException::forRefreshToken($token)) + ; + $this->expectException(SessionNotFoundException::class); + $this->refreshSession->refresh($token, new DateTimeImmutable('2099-01-01 00:00:00')); + } + + #[Test] + #[AllowMockObjectsWithoutExpectations] + public function testRefreshThrowsExceptionWhenSessionCannotBeRefreshed(): void + { + $token = '4500ccbd09e402fe83717c67dd156512837bfc3fd244125c430260ddd8816266'; + $session = Session::open( + SessionId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'), + UserId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAW'), + AccessToken::generate(), + RefreshToken::fromString($token), + DateTime::fromString('2020-01-01 00:00:00') + ); + + $this->repository + ->expects($this->once()) + ->method('getByRefreshToken') + ->with($this->isInstanceOf(RefreshToken::class)) + ->willReturn($session) + ; + $this->expectException(CannotRefreshSessionException::class); + $this->refreshSession->refresh($token, new DateTimeImmutable('2099-01-01 00:00:00')); + } +} diff --git a/test/Unit/Authentication/Domain/Service/RevokeSessionTest.php b/test/Unit/Authentication/Domain/Service/RevokeSessionTest.php new file mode 100644 index 0000000..25f2a06 --- /dev/null +++ b/test/Unit/Authentication/Domain/Service/RevokeSessionTest.php @@ -0,0 +1,141 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Entity\Session; +use Webify\User\Authentication\Domain\Exception\SessionNotFoundException; +use Webify\User\Authentication\Domain\Repository\SessionRepositoryInterface; +use Webify\User\Authentication\Domain\Service\RevokeSession; +use Webify\User\Authentication\Domain\ValueObject\{AccessToken, RefreshToken, SessionId, UserId}; + +/** + * Tests for the RevokeSession service. + * + * @internal + */ +#[CoversClass(RevokeSession::class)] +#[CoversMethod(RevokeSession::class, 'revoke')] +final class RevokeSessionTest extends TestCase +{ + private SessionId $sessionId; + + private UserId $userId; + + private AccessToken $accessToken; + + private RefreshToken $refreshToken; + + private DateTime $expiresAt; + + protected function setUp(): void + { + $this->sessionId = SessionId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $this->userId = UserId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAW'); + $this->accessToken = AccessToken::generate(); + $this->refreshToken = RefreshToken::generate(); + $this->expiresAt = DateTime::fromString('2099-01-01 00:00:00'); + } + + #[Test] + public function testRevokeSessionSuccessfully(): void + { + $session = Session::open( + $this->sessionId, + $this->userId, + $this->accessToken, + $this->refreshToken, + $this->expiresAt + ); + $repository = $this->createMock(SessionRepositoryInterface::class); + + $repository->method('getById') + ->willReturn($session) + ; + $repository->expects($this->once()) + ->method('persist') + ->with($session) + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher->expects($this->once()) + ->method('publish') + ; + + $service = new RevokeSession($repository, $eventPublisher); + + $service->revoke($this->sessionId->toNative()); + } + + #[Test] + public function testRevokeSessionThrowsExceptionWhenSessionNotFound(): void + { + $repository = $this->createMock(SessionRepositoryInterface::class); + + $repository->method('getById') + ->willThrowException(SessionNotFoundException::forId('01ARZ3NDEKTSV4RRFFQ69G5FAV')) + ; + $repository->expects($this->never()) + ->method('persist') + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher->expects($this->never()) + ->method('publish') + ; + + $this->expectException(SessionNotFoundException::class); + + $service = new RevokeSession($repository, $eventPublisher); + + $service->revoke('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + } + + #[Test] + public function testRevokeSessionIsIdempotent(): void + { + $session = Session::open( + $this->sessionId, + $this->userId, + $this->accessToken, + $this->refreshToken, + $this->expiresAt + ); + $repository = $this->createMock(SessionRepositoryInterface::class); + + $repository->method('getById') + ->willReturn($session) + ; + $repository->expects($this->exactly(2)) + ->method('persist') + ->with($session) + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher->expects($this->exactly(2)) + ->method('publish') + ; + + $service = new RevokeSession($repository, $eventPublisher); + + $service->revoke($this->sessionId->toNative()); + $service->revoke($this->sessionId->toNative()); + } +} diff --git a/test/Unit/Authentication/Domain/Service/UserStatusTranslatorTest.php b/test/Unit/Authentication/Domain/Service/UserStatusTranslatorTest.php new file mode 100644 index 0000000..30dae8c --- /dev/null +++ b/test/Unit/Authentication/Domain/Service/UserStatusTranslatorTest.php @@ -0,0 +1,60 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authentication\Domain\Service\UserStatusTranslator; +use Webify\User\Authentication\Domain\ValueObject\UserStatus; + +/** + * UserStatusTranslatorTest tests the functionality of the UserStatusTranslator service. + * + * @internal + */ +#[CoversClass(UserStatusTranslator::class)] +#[CoversMethod(UserStatusTranslator::class, 'translate')] +final class UserStatusTranslatorTest extends TestCase +{ + private UserStatusTranslator $translator; + + protected function setUp(): void + { + $this->translator = new UserStatusTranslator(); + } + + #[Test] + public function testTranslateActive(): void + { + $this->assertTrue($this->translator->translate('active')->isActive()); + } + + #[Test] + public function testTranslateUnverified(): void + { + $result = $this->translator->translate('unverified'); + + $this->assertInstanceOf(UserStatus::class, $result); + $this->assertFalse($result->isActive()); + } + + #[Test] + public function testTranslateUnknownStatusDefaultsToUnverified(): void + { + $result = $this->translator->translate('unknown_status'); + + $this->assertInstanceOf(UserStatus::class, $result); + $this->assertFalse($result->isActive()); + } +} diff --git a/test/Unit/Authentication/Domain/Service/VerifyChallengeTest.php b/test/Unit/Authentication/Domain/Service/VerifyChallengeTest.php new file mode 100644 index 0000000..ac80a73 --- /dev/null +++ b/test/Unit/Authentication/Domain/Service/VerifyChallengeTest.php @@ -0,0 +1,212 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authentication\Domain\Entity\Challenge; +use Webify\User\Authentication\Domain\Exception\{ChallengeNotFoundException, InvalidChallengeSecretException}; +use Webify\User\Authentication\Domain\Repository\ChallengeRepositoryInterface; +use Webify\User\Authentication\Domain\Service\VerifyChallenge; +use Webify\User\Authentication\Domain\ValueObject\{ + ChallengeCode, + ChallengeId, + ChallengeToken, + ChallengeType, + UserId +}; + +/** + * Tests for the VerifyChallenge domain service. + * + * @internal + */ +#[CoversClass(VerifyChallenge::class)] +#[CoversMethod(VerifyChallenge::class, 'verify')] +final class VerifyChallengeTest extends TestCase +{ + /** + * User ID for testing. + */ + private const string USER_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + /** + * Challenge ID for testing. + */ + private const string CHALLENGE_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAW'; + + /** + * Test verifying a code challenge successfully. + */ + #[Test] + public function testVerifyCodeChallengeSuccessfully(): void + { + $userId = UserId::fromString(self::USER_ID); + $challenge = Challenge::issue( + ChallengeId::fromString(self::CHALLENGE_ID), + $userId, + ChallengeType::Code, + DateTime::fromString('2099-01-01 00:00:00') + ); + + /** @var ChallengeCode $secret */ + $secret = $challenge->getSecret(); + + $repository = $this->createMock(ChallengeRepositoryInterface::class); + + $repository + ->expects($this->once()) + ->method('getByUser') + ->with($userId) + ->willReturn($challenge) + ; + $repository + ->expects($this->once()) + ->method('persist') + ->with($challenge) + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher + ->expects($this->once()) + ->method('publish') + ; + + $service = new VerifyChallenge($repository, $eventPublisher); + + $service->verify($userId, $secret); + $this->assertTrue($challenge->isVerified()); + } + + /** + * Test verifying a token challenge successfully. + */ + #[Test] + public function testVerifyTokenChallengeSuccessfully(): void + { + $userId = UserId::fromString(self::USER_ID); + $challenge = Challenge::issue( + ChallengeId::fromString(self::CHALLENGE_ID), + $userId, + ChallengeType::Token, + DateTime::fromString('2099-01-01 00:00:00') + ); + + /** @var ChallengeToken $secret */ + $secret = $challenge->getSecret(); + + $repository = $this->createMock(ChallengeRepositoryInterface::class); + + $repository + ->expects($this->once()) + ->method('getByUser') + ->with($userId) + ->willReturn($challenge) + ; + $repository + ->expects($this->once()) + ->method('persist') + ->with($challenge) + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher + ->expects($this->once()) + ->method('publish') + ; + + $service = new VerifyChallenge($repository, $eventPublisher); + + $service->verify($userId, $secret); + $this->assertTrue($challenge->isVerified()); + } + + /** + * Test verifying a challenge with a wrong secret. + */ + #[Test] + public function testVerifyWithWrongSecretPersistsAndThrows(): void + { + $userId = UserId::fromString(self::USER_ID); + $challenge = Challenge::issue( + ChallengeId::fromString(self::CHALLENGE_ID), + $userId, + ChallengeType::Code, + DateTime::fromString('2099-01-01 00:00:00') + ); + $repository = $this->createMock(ChallengeRepositoryInterface::class); + + $repository + ->expects($this->once()) + ->method('getByUser') + ->with($userId) + ->willReturn($challenge) + ; + $repository + ->expects($this->once()) + ->method('persist') + ->with($challenge) + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher + ->expects($this->once()) + ->method('publish') + ; + $this->expectException(InvalidChallengeSecretException::class); + + $service = new VerifyChallenge($repository, $eventPublisher); + + $service->verify($userId, ChallengeCode::fromString('999999')); + } + + /** + * Test verifying a challenge that does not exist and expecting an exception. + */ + #[Test] + public function testVerifyThrowsExceptionWhenChallengeNotFound(): void + { + $userId = UserId::fromString(self::USER_ID); + $repository = $this->createMock(ChallengeRepositoryInterface::class); + + $repository + ->expects($this->once()) + ->method('getByUser') + ->with($userId) + ->willThrowException(ChallengeNotFoundException::forId(self::CHALLENGE_ID)) + ; + $repository + ->expects($this->never()) + ->method('persist') + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher + ->expects($this->never()) + ->method('publish') + ; + + $this->expectException(ChallengeNotFoundException::class); + + $service = new VerifyChallenge($repository, $eventPublisher); + + $service->verify($userId, ChallengeCode::fromString('123456')); + } +} diff --git a/test/Unit/Authentication/Domain/Strategy/ChallengeCodeStrategyTest.php b/test/Unit/Authentication/Domain/Strategy/ChallengeCodeStrategyTest.php new file mode 100644 index 0000000..c622977 --- /dev/null +++ b/test/Unit/Authentication/Domain/Strategy/ChallengeCodeStrategyTest.php @@ -0,0 +1,176 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Strategy; + +use PHPUnit\Framework\Attributes\{AllowMockObjectsWithoutExpectations, CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Contract\Authentication\{CredentialsInterface, UserCredentials}; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\UlidGeneratorInterface; +use Webify\User\Authentication\Domain\Entity\Challenge; +use Webify\User\Authentication\Domain\Repository\ChallengeRepositoryInterface; +use Webify\User\Authentication\Domain\Service\{ChallengeSecretDeliveryInterface, IssueChallenge}; +use Webify\User\Authentication\Domain\Strategy\ChallengeCodeStrategy; +use Webify\User\Authentication\Domain\ValueObject\UserId; + +/** + * Tests for the ChallengeCodeStrategy. + * + * @internal + */ +#[AllowMockObjectsWithoutExpectations] +#[CoversClass(ChallengeCodeStrategy::class)] +#[CoversMethod(ChallengeCodeStrategy::class, 'getIdentifier')] +#[CoversMethod(ChallengeCodeStrategy::class, 'isSupported')] +#[CoversMethod(ChallengeCodeStrategy::class, 'initiate')] +final class ChallengeCodeStrategyTest extends TestCase +{ + /** + * The user ID used for testing. + */ + private const string USER_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + /** + * The challenge ID used for testing. + */ + private const string CHALLENGE_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAW'; + + /** + * Mock for challenge secret delivery. + */ + private ChallengeSecretDeliveryInterface&MockObject $delivery; + + /** + * The strategy instance under test. + */ + private ChallengeCodeStrategy $strategy; + + /** + * Mock for challenge repository. + */ + private ChallengeRepositoryInterface&MockObject $challengeRepository; + + /** + * Mock for ULID generator. + */ + private MockObject&UlidGeneratorInterface $idGenerator; + + /** + * Mock for domain event publisher. + */ + private DomainEventPublisherInterface&MockObject $eventPublisher; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->delivery = $this->createMock(ChallengeSecretDeliveryInterface::class); + $this->challengeRepository = $this->createMock(ChallengeRepositoryInterface::class); + $this->idGenerator = $this->createMock(UlidGeneratorInterface::class); + $this->eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $issueChallenge = new IssueChallenge($this->idGenerator, $this->challengeRepository, $this->eventPublisher); + + $this->strategy = new ChallengeCodeStrategy($issueChallenge, $this->delivery); + } + + /** + * Test that getIdentifier returns the correct strategy identifier. + */ + #[Test] + public function testGetIdentifier(): void + { + $this->assertSame('challenge_code', $this->strategy->getIdentifier()); + } + + /** + * Test that isSupported returns true for a matching identifier. + */ + #[Test] + public function testIsSupportedWithMatchingIdentifier(): void + { + $request = $this->createStub(CredentialsInterface::class); + $request + ->method('getStrategyIdentifier') + ->willReturn('challenge_code') + ; + + $this->assertTrue($this->strategy->isSupported($request)); + } + + /** + * Test that isSupported returns false for a non-matching identifier. + */ + #[Test] + public function testIsSupportedWithNonMatchingIdentifier(): void + { + $request = $this->createStub(CredentialsInterface::class); + $request + ->method('getStrategyIdentifier') + ->willReturn('challenge_token') + ; + + $this->assertFalse($this->strategy->isSupported($request)); + } + + /** + * Test that initiate issues a challenge code and delivers it. + */ + #[Test] + public function testInitiateIssuesAndDeliversCode(): void + { + $userId = UserId::fromString(self::USER_ID); + $userCredentials = new UserCredentials( + id: self::USER_ID, + email: 'test@webifycms.com', + displayName: 'Test User', + passwordHash: 'hashed', + status: 'active', + isTwoFactorEnabled: true + ); + $request = $this->createStub(CredentialsInterface::class); + + $this->challengeRepository + ->expects($this->once()) + ->method('deleteStale') + ->with($userId) + ; + $this->challengeRepository + ->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(Challenge::class)) + ; + + $this->idGenerator + ->expects($this->once()) + ->method('generate') + ->willReturn(self::CHALLENGE_ID) + ; + + $this->delivery + ->expects($this->once()) + ->method('deliver') + ->with($userId, 'code', $this->matchesRegularExpression('/^\d{6}$/')) + ; + + $this->eventPublisher + ->expects($this->once()) + ->method('publish') + ; + + $this->strategy->initiate($request, $userCredentials); + } +} diff --git a/test/Unit/Authentication/Domain/Strategy/ChallengeTokenStrategyTest.php b/test/Unit/Authentication/Domain/Strategy/ChallengeTokenStrategyTest.php new file mode 100644 index 0000000..1c4c949 --- /dev/null +++ b/test/Unit/Authentication/Domain/Strategy/ChallengeTokenStrategyTest.php @@ -0,0 +1,173 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\Strategy; + +use PHPUnit\Framework\Attributes\{AllowMockObjectsWithoutExpectations, CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Contract\Authentication\{CredentialsInterface, UserCredentials}; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\UlidGeneratorInterface; +use Webify\User\Authentication\Domain\Entity\Challenge; +use Webify\User\Authentication\Domain\Repository\ChallengeRepositoryInterface; +use Webify\User\Authentication\Domain\Service\{ChallengeSecretDeliveryInterface, IssueChallenge}; +use Webify\User\Authentication\Domain\Strategy\ChallengeTokenStrategy; +use Webify\User\Authentication\Domain\ValueObject\UserId; + +/** + * Tests for the ChallengeTokenStrategy. + * + * @internal + */ +#[AllowMockObjectsWithoutExpectations] +#[CoversClass(ChallengeTokenStrategy::class)] +#[CoversMethod(ChallengeTokenStrategy::class, 'getIdentifier')] +#[CoversMethod(ChallengeTokenStrategy::class, 'isSupported')] +#[CoversMethod(ChallengeTokenStrategy::class, 'initiate')] +final class ChallengeTokenStrategyTest extends TestCase +{ + /** + * The user ID used for testing. + */ + private const string USER_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + /** + * The challenge ID used for testing. + */ + private const string CHALLENGE_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAW'; + + /** + * Mock for challenge secret delivery. + */ + private ChallengeSecretDeliveryInterface&MockObject $delivery; + + /** + * The strategy instance under test. + */ + private ChallengeTokenStrategy $strategy; + + /** + * Mock for challenge repository. + */ + private ChallengeRepositoryInterface&MockObject $challengeRepository; + + /** + * Mock for ULID generator. + */ + private MockObject&UlidGeneratorInterface $idGenerator; + + /** + * Mock for domain event publisher. + */ + private DomainEventPublisherInterface&MockObject $eventPublisher; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->delivery = $this->createMock(ChallengeSecretDeliveryInterface::class); + $this->challengeRepository = $this->createMock(ChallengeRepositoryInterface::class); + $this->idGenerator = $this->createMock(UlidGeneratorInterface::class); + $this->eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + $issueChallenge = new IssueChallenge( + $this->idGenerator, + $this->challengeRepository, + $this->eventPublisher + ); + $this->strategy = new ChallengeTokenStrategy($issueChallenge, $this->delivery); + } + + /** + * Test that getIdentifier returns the correct strategy identifier. + */ + #[Test] + public function testGetIdentifier(): void + { + $this->assertSame('challenge_token', $this->strategy->getIdentifier()); + } + + /** + * Test that isSupported returns true for a matching identifier. + */ + #[Test] + public function testIsSupportedWithMatchingIdentifier(): void + { + $request = $this->createStub(CredentialsInterface::class); + $request + ->method('getStrategyIdentifier') + ->willReturn('challenge_token') + ; + + $this->assertTrue($this->strategy->isSupported($request)); + } + + /** + * Test that isSupported returns false for a non-matching identifier. + */ + #[Test] + public function testIsSupportedWithNonMatchingIdentifier(): void + { + $request = $this->createStub(CredentialsInterface::class); + $request + ->method('getStrategyIdentifier') + ->willReturn('challenge_code') + ; + $this->assertFalse($this->strategy->isSupported($request)); + } + + /** + * Test that initiate issues a challenge token and delivers it. + */ + #[Test] + public function testInitiateIssuesAndDeliversToken(): void + { + $userId = UserId::fromString(self::USER_ID); + $userCredentials = new UserCredentials( + id: self::USER_ID, + email: 'test@webifycms.com', + displayName: 'Test User', + passwordHash: 'hashed', + status: 'active', + isTwoFactorEnabled: true + ); + $request = $this->createStub(CredentialsInterface::class); + + $this->challengeRepository + ->expects($this->once()) + ->method('deleteStale') + ->with($userId) + ; + $this->challengeRepository + ->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(Challenge::class)) + ; + $this->idGenerator + ->expects($this->once()) + ->method('generate') + ->willReturn(self::CHALLENGE_ID) + ; + $this->delivery + ->expects($this->once()) + ->method('deliver') + ->with($userId, 'token', $this->matchesRegularExpression('/^[0-9a-f]{64}$/')) + ; + $this->eventPublisher + ->expects($this->once()) + ->method('publish') + ; + $this->strategy->initiate($request, $userCredentials); + } +} diff --git a/test/Unit/Authentication/Domain/ValueObject/AccessTokenTest.php b/test/Unit/Authentication/Domain/ValueObject/AccessTokenTest.php new file mode 100644 index 0000000..6ad3441 --- /dev/null +++ b/test/Unit/Authentication/Domain/ValueObject/AccessTokenTest.php @@ -0,0 +1,125 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authentication\Domain\Exception\InvalidAccessTokenException; +use Webify\User\Authentication\Domain\ValueObject\AccessToken; + +/** + * Tests for the AccessToken value object. + * + * @internal + */ +#[CoversClass(AccessToken::class)] +#[CoversMethod(AccessToken::class, '__toString')] +#[CoversMethod(AccessToken::class, 'generate')] +#[CoversMethod(AccessToken::class, 'throwExceptionWhenInvalid')] +final class AccessTokenTest extends TestCase +{ + /** + * Test creating a valid access token from a 64-character hex string. + */ + #[Test] + public function testFromStringWithValidToken(): void + { + $value = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + $token = AccessToken::fromString($value); + + $this->assertSame($value, $token->toNative()); + } + + /** + * Test throws InvalidAccessTokenException for a token shorter than 64 characters. + */ + #[Test] + public function testThrowsExceptionForTooShortToken(): void + { + $this->expectException(InvalidAccessTokenException::class); + $this->expectExceptionMessageIs('The access token "short" is invalid.'); + + AccessToken::fromString('short'); + } + + /** + * Test generate always produces an access token with at least 64 characters. + */ + #[Test] + public function testGenerateProducesLongEnoughToken(): void + { + $token = AccessToken::generate(); + + $this->assertGreaterThanOrEqual(64, strlen($token->toNative())); + } + + /** + * Test generate returns an AccessToken instance. + */ + #[Test] + public function testGenerateReturnsAccessTokenInstance(): void + { + $token = AccessToken::generate(); + + $this->assertInstanceOf(AccessToken::class, $token); + } + + /** + * Test fromString returns an AccessToken instance. + */ + #[Test] + public function testFromStringReturnsAccessTokenInstance(): void + { + $token = AccessToken::fromString('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'); + + $this->assertInstanceOf(AccessToken::class, $token); + } + + /** + * Test __toString returns the token value. + */ + #[Test] + public function testToStringReturnsTokenValue(): void + { + $value = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + $token = AccessToken::fromString($value); + + $this->assertSame($value, (string) $token); + } + + /** + * Test equals returns true for two access tokens with the same value. + */ + #[Test] + public function testEqualsReturnsTrueForSameValue(): void + { + $value = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + $token1 = AccessToken::fromString($value); + $token2 = AccessToken::fromString($value); + + $this->assertTrue($token1->equals($token2)); + } + + /** + * Test equals returns false for two access tokens with different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentValues(): void + { + $token1 = AccessToken::generate(); + $token2 = AccessToken::generate(); + + $this->assertFalse($token1->equals($token2)); + } +} diff --git a/test/Unit/Authentication/Domain/ValueObject/ChallengeCodeTest.php b/test/Unit/Authentication/Domain/ValueObject/ChallengeCodeTest.php new file mode 100644 index 0000000..73434b7 --- /dev/null +++ b/test/Unit/Authentication/Domain/ValueObject/ChallengeCodeTest.php @@ -0,0 +1,143 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authentication\Domain\Exception\InvalidChallengeCodeException; +use Webify\User\Authentication\Domain\ValueObject\ChallengeCode; + +/** + * Tests for the ChallengeCode value object. + * + * @internal + */ +#[CoversClass(ChallengeCode::class)] +#[CoversMethod(ChallengeCode::class, '__toString')] +#[CoversMethod(ChallengeCode::class, 'fromNative')] +#[CoversMethod(ChallengeCode::class, 'fromString')] +#[CoversMethod(ChallengeCode::class, 'generate')] +#[CoversMethod(ChallengeCode::class, 'toNative')] +#[CoversMethod(ChallengeCode::class, 'equals')] +final class ChallengeCodeTest extends TestCase +{ + /** + * Test creating a valid challenge code from a native string. + */ + #[Test] + public function testFromNativeWithValidCode(): void + { + $code = ChallengeCode::fromNative('123456'); + + $this->assertSame('123456', $code->toNative()); + } + + /** + * Test creating a valid challenge code from a string. + */ + #[Test] + public function testFromStringWithValidCode(): void + { + $code = ChallengeCode::fromString('123456'); + + $this->assertSame('123456', $code->toNative()); + } + + /** + * Test fromNative pads short strings to six digits. + */ + #[Test] + public function testFromNativePadsShortString(): void + { + $code = ChallengeCode::fromNative('123'); + + $this->assertSame('000123', $code->toNative()); + } + + /** + * Test throws InvalidChallengeCodeException for a string with invalid length. + */ + #[Test] + public function testThrowsExceptionForStringWithInvalidLength(): void + { + $this->expectException(InvalidChallengeCodeException::class); + ChallengeCode::fromString('123'); + } + + /** + * Test throws InvalidChallengeCodeException for an empty string. + */ + #[Test] + public function testThrowsExceptionForEmptyString(): void + { + $this->expectException(InvalidChallengeCodeException::class); + ChallengeCode::fromString(''); + } + + /** + * Test creating a challenge code from a string with leading zeros. + */ + #[Test] + public function testFromStringPreservesLeadingZeros(): void + { + $code = ChallengeCode::fromString('001234'); + + $this->assertSame('001234', $code->toNative()); + $this->assertSame(6, strlen($code->toNative())); + } + + /** + * Test generate returns a ChallengeCode instance. + */ + #[Test] + public function testGenerateReturnsChallengeCodeInstance(): void + { + $this->assertInstanceOf(ChallengeCode::class, ChallengeCode::generate()); + } + + /** + * Test __toString returns the string representation of the code. + */ + #[Test] + public function testToStringReturnsStringRepresentation(): void + { + $code = ChallengeCode::fromNative('123456'); + + $this->assertSame('123456', (string) $code); + } + + /** + * Test equals returns true for two challenge codes with the same value. + */ + #[Test] + public function testEqualsReturnsTrueForSameValue(): void + { + $code1 = ChallengeCode::fromNative('123456'); + $code2 = ChallengeCode::fromNative('123456'); + + $this->assertTrue($code1->equals($code2)); + } + + /** + * Test equals returns false for two challenge codes with different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentValues(): void + { + $code1 = ChallengeCode::generate(); + $code2 = ChallengeCode::generate(); + + $this->assertFalse($code1->equals($code2)); + } +} diff --git a/test/Unit/Authentication/Domain/ValueObject/ChallengeIdTest.php b/test/Unit/Authentication/Domain/ValueObject/ChallengeIdTest.php new file mode 100644 index 0000000..b1a06c7 --- /dev/null +++ b/test/Unit/Authentication/Domain/ValueObject/ChallengeIdTest.php @@ -0,0 +1,53 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authentication\Domain\Exception\InvalidChallengeIdException; +use Webify\User\Authentication\Domain\ValueObject\ChallengeId; + +/** + * Tests for the ChallengeId value object. + * + * @internal + */ +#[CoversClass(ChallengeId::class)] +#[CoversMethod(ChallengeId::class, 'fromString')] +#[CoversMethod(ChallengeId::class, 'throwException')] +final class ChallengeIdTest extends TestCase +{ + /** + * Test can create a valid challenge ID. + */ + #[Test] + public function testValidChallengeId(): void + { + $validId = '01F8MECHZX3TBDSZ7XRADM79XV'; + $challengeId = ChallengeId::fromString($validId); + + $this->assertSame($validId, $challengeId->toNative()); + } + + /** + * Test throws an exception for invalid challenge ID. + */ + #[Test] + public function testItThrowsExceptionForInvalidChallengeId(): void + { + $this->expectException(InvalidChallengeIdException::class); + $this->expectExceptionMessageIs('The authentication challenge ID "invalid-challenge-id" is invalid.'); + ChallengeId::fromString('invalid-challenge-id'); + } +} diff --git a/test/Unit/Authentication/Domain/ValueObject/ChallengeStatusTest.php b/test/Unit/Authentication/Domain/ValueObject/ChallengeStatusTest.php new file mode 100644 index 0000000..16d17db --- /dev/null +++ b/test/Unit/Authentication/Domain/ValueObject/ChallengeStatusTest.php @@ -0,0 +1,83 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authentication\Domain\ValueObject\ChallengeStatus; + +/** + * Tests for the ChallengeStatus enum. + * + * @internal + */ +#[CoversClass(ChallengeStatus::class)] +#[CoversMethod(ChallengeStatus::class, 'isPending')] +#[CoversMethod(ChallengeStatus::class, 'isCompleted')] +final class ChallengeStatusTest extends TestCase +{ + /** + * Tests that the Pending case has the correct backing value. + */ + #[Test] + public function testPendingCaseHasCorrectValue(): void + { + $this->assertSame('pending', ChallengeStatus::Pending->value); + } + + /** + * Tests that the Completed case has the correct backing value. + */ + #[Test] + public function testCompletedCaseHasCorrectValue(): void + { + $this->assertSame('completed', ChallengeStatus::Completed->value); + } + + #[Test] + public function testIsPendingReturnsTrueForPending(): void + { + $this->assertTrue(ChallengeStatus::Pending->isPending()); + $this->assertFalse(ChallengeStatus::Completed->isPending()); + } + + /** + * Tests that `isCompleted` returns true for the Completed case and false for the Pending case. + */ + #[Test] + public function testIsCompletedReturnsTrueForCompleted(): void + { + $this->assertTrue(ChallengeStatus::Completed->isCompleted()); + $this->assertFalse(ChallengeStatus::Pending->isCompleted()); + } + + /** + * Tests that `tryFrom` returns the correct case for a valid value. + */ + #[Test] + public function testTryFromWithValidValue(): void + { + $this->assertSame(ChallengeStatus::Pending, ChallengeStatus::tryFrom('pending')); + $this->assertSame(ChallengeStatus::Completed, ChallengeStatus::tryFrom('completed')); + } + + /** + * Tests that `tryFrom` returns null for an invalid value. + */ + #[Test] + public function testTryFromWithInvalidValue(): void + { + $this->assertNull(ChallengeStatus::tryFrom('invalid')); + } +} diff --git a/test/Unit/Authentication/Domain/ValueObject/ChallengeTokenTest.php b/test/Unit/Authentication/Domain/ValueObject/ChallengeTokenTest.php new file mode 100644 index 0000000..48dd044 --- /dev/null +++ b/test/Unit/Authentication/Domain/ValueObject/ChallengeTokenTest.php @@ -0,0 +1,125 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authentication\Domain\Exception\InvalidChallengeTokenException; +use Webify\User\Authentication\Domain\ValueObject\ChallengeToken; + +/** + * Tests for the ChallengeToken value object. + * + * @internal + */ +#[CoversClass(ChallengeToken::class)] +#[CoversMethod(ChallengeToken::class, '__toString')] +#[CoversMethod(ChallengeToken::class, 'generate')] +#[CoversMethod(ChallengeToken::class, 'throwExceptionWhenInvalid')] +final class ChallengeTokenTest extends TestCase +{ + /** + * Test creating a valid challenge token from a 64-character hex string. + */ + #[Test] + public function testFromStringWithValidToken(): void + { + $value = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + $token = ChallengeToken::fromString($value); + + $this->assertSame($value, $token->toNative()); + } + + /** + * Test throws InvalidChallengeTokenException for a token shorter than 64 characters. + */ + #[Test] + public function testThrowsExceptionForTooShortToken(): void + { + $this->expectException(InvalidChallengeTokenException::class); + $this->expectExceptionMessageIs('The authentication challenge token "short" is invalid.'); + + ChallengeToken::fromString('short'); + } + + /** + * Test generate always produces a challenge token with at least 64 characters. + */ + #[Test] + public function testGenerateProducesLongEnoughToken(): void + { + $token = ChallengeToken::generate(); + + $this->assertGreaterThanOrEqual(64, strlen($token->toNative())); + } + + /** + * Test generate returns a ChallengeToken instance. + */ + #[Test] + public function testGenerateReturnsChallengeTokenInstance(): void + { + $token = ChallengeToken::generate(); + + $this->assertInstanceOf(ChallengeToken::class, $token); + } + + /** + * Test fromString returns a ChallengeToken instance. + */ + #[Test] + public function testFromStringReturnsChallengeTokenInstance(): void + { + $token = ChallengeToken::fromString('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'); + + $this->assertInstanceOf(ChallengeToken::class, $token); + } + + /** + * Test __toString returns the token value. + */ + #[Test] + public function testToStringReturnsTokenValue(): void + { + $value = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + $token = ChallengeToken::fromString($value); + + $this->assertSame($value, (string) $token); + } + + /** + * Test equals returns true for two challenge tokens with the same value. + */ + #[Test] + public function testEqualsReturnsTrueForSameValue(): void + { + $value = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + $token1 = ChallengeToken::fromString($value); + $token2 = ChallengeToken::fromString($value); + + $this->assertTrue($token1->equals($token2)); + } + + /** + * Test equals returns false for two challenge tokens with different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentValues(): void + { + $token1 = ChallengeToken::generate(); + $token2 = ChallengeToken::generate(); + + $this->assertFalse($token1->equals($token2)); + } +} diff --git a/test/Unit/Authentication/Domain/ValueObject/ChallengeTypeTest.php b/test/Unit/Authentication/Domain/ValueObject/ChallengeTypeTest.php new file mode 100644 index 0000000..49653ea --- /dev/null +++ b/test/Unit/Authentication/Domain/ValueObject/ChallengeTypeTest.php @@ -0,0 +1,86 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authentication\Domain\ValueObject\ChallengeType; + +/** + * ChallengeTypeTest tests the functionality of the ChallengeType enum. + * + * @internal + */ +#[CoversClass(ChallengeType::class)] +#[CoversMethod(ChallengeType::class, 'isCode')] +#[CoversMethod(ChallengeType::class, 'isToken')] +final class ChallengeTypeTest extends TestCase +{ + /** + * Tests that the Code case has the correct backing value. + */ + #[Test] + public function testCodeCaseHasCorrectValue(): void + { + $this->assertSame('code', ChallengeType::Code->value); + } + + /** + * Tests that the Token case has the correct backing value. + */ + #[Test] + public function testTokenCaseHasCorrectValue(): void + { + $this->assertSame('token', ChallengeType::Token->value); + } + + /** + * Tests that isCode returns true for the Code case and false for the Token case. + */ + #[Test] + public function testIsCode(): void + { + $this->assertTrue(ChallengeType::Code->isCode()); + $this->assertFalse(ChallengeType::Token->isCode()); + } + + /** + * Tests that isToken returns true for the Token case and false for the Code case. + */ + #[Test] + public function testIsToken(): void + { + $this->assertTrue(ChallengeType::Token->isToken()); + $this->assertFalse(ChallengeType::Code->isToken()); + } + + /** + * Tests that tryFrom returns the correct case for a valid value. + */ + #[Test] + public function testTryFromWithValidValue(): void + { + $this->assertSame(ChallengeType::Code, ChallengeType::tryFrom('code')); + $this->assertSame(ChallengeType::Token, ChallengeType::tryFrom('token')); + } + + /** + * Tests that tryFrom returns null for an invalid value. + */ + #[Test] + public function testTryFromWithInvalidValue(): void + { + $this->assertNull(ChallengeType::tryFrom('invalid')); + } +} diff --git a/test/Unit/Authentication/Domain/ValueObject/RefreshTokenTest.php b/test/Unit/Authentication/Domain/ValueObject/RefreshTokenTest.php new file mode 100644 index 0000000..177fb6f --- /dev/null +++ b/test/Unit/Authentication/Domain/ValueObject/RefreshTokenTest.php @@ -0,0 +1,125 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authentication\Domain\Exception\InvalidRefreshTokenException; +use Webify\User\Authentication\Domain\ValueObject\RefreshToken; + +/** + * Tests for the RefreshToken value object. + * + * @internal + */ +#[CoversClass(RefreshToken::class)] +#[CoversMethod(RefreshToken::class, '__toString')] +#[CoversMethod(RefreshToken::class, 'generate')] +#[CoversMethod(RefreshToken::class, 'throwExceptionWhenInvalid')] +final class RefreshTokenTest extends TestCase +{ + /** + * Test creating a valid refresh token from a 64-character hex string. + */ + #[Test] + public function testFromStringWithValidToken(): void + { + $value = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + $token = RefreshToken::fromString($value); + + $this->assertSame($value, $token->toNative()); + } + + /** + * Test throws InvalidRefreshTokenException for a token shorter than 64 characters. + */ + #[Test] + public function testThrowsExceptionForTooShortToken(): void + { + $this->expectException(InvalidRefreshTokenException::class); + $this->expectExceptionMessageIs('The refresh token "short" is invalid.'); + + RefreshToken::fromString('short'); + } + + /** + * Test generate always produces a refresh token with at least 64 characters. + */ + #[Test] + public function testGenerateProducesLongEnoughToken(): void + { + $token = RefreshToken::generate(); + + $this->assertGreaterThanOrEqual(64, strlen($token->toNative())); + } + + /** + * Test generate returns a RefreshToken instance. + */ + #[Test] + public function testGenerateReturnsRefreshTokenInstance(): void + { + $token = RefreshToken::generate(); + + $this->assertInstanceOf(RefreshToken::class, $token); + } + + /** + * Test fromString returns a RefreshToken instance. + */ + #[Test] + public function testFromStringReturnsRefreshTokenInstance(): void + { + $token = RefreshToken::fromString('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'); + + $this->assertInstanceOf(RefreshToken::class, $token); + } + + /** + * Test __toString returns the token value. + */ + #[Test] + public function testToStringReturnsTokenValue(): void + { + $value = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + $token = RefreshToken::fromString($value); + + $this->assertSame($value, (string) $token); + } + + /** + * Test equals returns true for two refresh tokens with the same value. + */ + #[Test] + public function testEqualsReturnsTrueForSameValue(): void + { + $value = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + $token1 = RefreshToken::fromString($value); + $token2 = RefreshToken::fromString($value); + + $this->assertTrue($token1->equals($token2)); + } + + /** + * Test equals returns false for two refresh tokens with different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentValues(): void + { + $token1 = RefreshToken::generate(); + $token2 = RefreshToken::generate(); + + $this->assertFalse($token1->equals($token2)); + } +} diff --git a/test/Unit/Authentication/Domain/ValueObject/SessionIdTest.php b/test/Unit/Authentication/Domain/ValueObject/SessionIdTest.php new file mode 100644 index 0000000..b8dc704 --- /dev/null +++ b/test/Unit/Authentication/Domain/ValueObject/SessionIdTest.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authentication\Domain\Exception\InvalidSessionIdException; +use Webify\User\Authentication\Domain\ValueObject\SessionId; + +/** + * Tests for the TokenId value object. + * + * @internal + */ +#[CoversClass(SessionId::class)] +#[CoversMethod(SessionId::class, 'fromString')] +#[CoversMethod(SessionId::class, 'throwException')] +final class SessionIdTest extends TestCase +{ + /** + * Test can create a valid token ID. + */ + #[Test] + public function testValidTokenId(): void + { + $validId = '01F8MECHZX3TBDSZ7XRADM79XV'; + $tokenId = SessionId::fromString($validId); + + $this->assertSame($validId, $tokenId->toNative()); + } + + /** + * Test throws an exception for invalid token ID. + */ + #[Test] + public function testItThrowsExceptionForInvalidTokenId(): void + { + $this->expectException(InvalidSessionIdException::class); + $this->expectExceptionMessageIs('The session ID "invalid-session-id" is invalid.'); + + SessionId::fromString('invalid-session-id'); + } +} diff --git a/test/Unit/Authentication/Infrastructure/Service/StrategyRegisterTest.php b/test/Unit/Authentication/Infrastructure/Service/StrategyRegisterTest.php new file mode 100644 index 0000000..d7ed0fc --- /dev/null +++ b/test/Unit/Authentication/Infrastructure/Service/StrategyRegisterTest.php @@ -0,0 +1,123 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authentication\Infrastructure\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Contract\Authentication\StrategyInterface; +use Webify\User\Authentication\Infrastructure\Exception\StrategyNotFoundException; +use Webify\User\Authentication\Infrastructure\Service\StrategyRegister; + +/** + * Tests for the StrategyRegister. + * + * @internal + */ +#[CoversClass(StrategyRegister::class)] +#[CoversMethod(StrategyRegister::class, 'register')] +#[CoversMethod(StrategyRegister::class, 'get')] +#[CoversMethod(StrategyRegister::class, 'getAll')] +final class StrategyRegisterTest extends TestCase +{ + /** + * Test that a strategy can be registered and retrieved by its identifier. + */ + #[Test] + public function testRegisterAndGetStrategy(): void + { + $strategy = $this->createStub(StrategyInterface::class); + + $strategy + ->method('getIdentifier') + ->willReturn('test_strategy') + ; + + $register = new StrategyRegister(); + + $register->register($strategy); + $this->assertSame($strategy, $register->get('test_strategy')); + } + + /** + * Test that get throws an exception when the strategy is not found. + */ + #[Test] + public function testGetThrowsExceptionWhenStrategyNotFound(): void + { + $register = new StrategyRegister(); + + $this->expectException(StrategyNotFoundException::class); + $register->get('non_existent'); + } + + /** + * Test that getAll returns all registered strategies. + */ + #[Test] + public function testGetAllReturnsAllRegisteredStrategies(): void + { + $strategy1 = $this->createStub(StrategyInterface::class); + + $strategy1 + ->method('getIdentifier') + ->willReturn('strategy_1') + ; + + $strategy2 = $this->createStub(StrategyInterface::class); + + $strategy2 + ->method('getIdentifier') + ->willReturn('strategy_2') + ; + + $register = new StrategyRegister(); + + $register->register($strategy1); + $register->register($strategy2); + + $all = $register->getAll(); + + $this->assertCount(2, $all); + $this->assertSame($strategy1, $all['strategy_1']); + $this->assertSame($strategy2, $all['strategy_2']); + } + + /** + * Test that registering a strategy with an existing identifier overwrites it. + */ + #[Test] + public function testRegisterOverwritesExistingStrategy(): void + { + $strategy1 = $this->createStub(StrategyInterface::class); + + $strategy1 + ->method('getIdentifier') + ->willReturn('test_strategy') + ; + + $strategy2 = $this->createStub(StrategyInterface::class); + + $strategy2 + ->method('getIdentifier') + ->willReturn('test_strategy') + ; + + $register = new StrategyRegister(); + + $register->register($strategy1); + $register->register($strategy2); + $this->assertSame($strategy2, $register->get('test_strategy')); + $this->assertCount(1, $register->getAll()); + } +} diff --git a/test/Unit/Authorization/Domain/Entity/RoleAssignmentTest.php b/test/Unit/Authorization/Domain/Entity/RoleAssignmentTest.php new file mode 100644 index 0000000..59b4f1a --- /dev/null +++ b/test/Unit/Authorization/Domain/Entity/RoleAssignmentTest.php @@ -0,0 +1,215 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\Entity; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authorization\Domain\Entity\RoleAssignment; +use Webify\User\Authorization\Domain\Event\RoleAssigned; +use Webify\User\Authorization\Domain\ValueObject\{RoleAssignmentId, RoleId, SubjectId, TenantId}; + +/** + * RoleAssignmentTest tests the functionality of the RoleAssignment aggregate root. + * + * @internal + */ +#[CoversClass(RoleAssignment::class)] +#[CoversMethod(RoleAssignment::class, 'assign')] +#[CoversMethod(RoleAssignment::class, 'getId')] +#[CoversMethod(RoleAssignment::class, 'getRoleId')] +#[CoversMethod(RoleAssignment::class, 'getSubjectId')] +#[CoversMethod(RoleAssignment::class, 'getTenantId')] +#[CoversMethod(RoleAssignment::class, 'getExpiresAt')] +#[CoversMethod(RoleAssignment::class, 'isApplicableFor')] +#[CoversMethod(RoleAssignment::class, 'isExpired')] +final class RoleAssignmentTest extends TestCase +{ + /** + * Identifier for the assignment. + */ + private RoleAssignmentId $assignmentId; + + /** + * Represents the identifier of a role. + */ + private RoleId $roleId; + + /** + * Denotes the identifier of a subject. + */ + private SubjectId $subjectId; + + /** + * Denotes the unique identifier of a tenant. + */ + private TenantId $tenantId; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->assignmentId = RoleAssignmentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $this->roleId = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FBW'); + $this->subjectId = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FCX'); + $this->tenantId = TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FDY'); + } + + /** + * Tests that the role assignment is correctly created with the given identifiers. + */ + #[Test] + public function testAssignCreatesRoleAssignment(): void + { + $assignment = RoleAssignment::assign($this->assignmentId, $this->roleId, $this->subjectId); + + $this->assertInstanceOf(RoleAssignment::class, $assignment); + $this->assertTrue($this->assignmentId->equals($assignment->getId())); + $this->assertTrue($this->roleId->equals($assignment->getRoleId())); + $this->assertTrue($this->subjectId->equals($assignment->getSubjectId())); + $this->assertNull($assignment->getTenantId()); + } + + /** + * Tests the assignment of a role with a tenant ID to ensure the tenant ID is correctly + * associated with the created role assignment. + */ + #[Test] + public function testAssignWithTenantId(): void + { + $assignment = RoleAssignment::assign($this->assignmentId, $this->roleId, $this->subjectId, $this->tenantId); + + /** @var TenantId $tenantId */ + $tenantId = $assignment->getTenantId(); + + $this->assertTrue($this->tenantId->equals($tenantId)); + } + + /** + * Tests that the isExpired method returns false when no expiration is set on the role assignment. + */ + #[Test] + public function testIsExpiredReturnsFalseWhenNoExpiration(): void + { + $assignment = RoleAssignment::assign($this->assignmentId, $this->roleId, $this->subjectId); + + $this->assertFalse($assignment->isExpired()); + } + + /** + * Tests that a role assignment with an expiration time is correctly created and the expiration time is set. + */ + #[Test] + public function testAssignWithExpiresAt(): void + { + $expiresAt = DateTime::fromTimestamp(time() + 3600); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId, + null, + $expiresAt + ); + + $this->assertNotNull($assignment->getExpiresAt()); + } + + /** + * Tests that the isExpiresAt method returns true when the role assignment has expired. + */ + #[Test] + public function testIsExpiresAtReturnTrueForExpired(): void + { + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId, + $this->tenantId, + DateTime::fromTimestamp(time() - 3600) + ); + + $this->assertTrue($assignment->isExpired()); + } + + /** + * Tests that the isExpiresAt method returns false when the role assignment has not expired. + */ + #[Test] + public function testIsExpiresAtReturnFalseForNotExpired(): void + { + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId, + $this->tenantId, + DateTime::fromTimestamp(time() + 3600) + ); + + $this->assertFalse($assignment->isExpired()); + } + + /** + * Tests the applicability of a role assignment for a global context. + */ + #[Test] + public function testIsApplicableForGlobalAssignment(): void + { + $assignment = RoleAssignment::assign($this->assignmentId, $this->roleId, $this->subjectId); + + $this->assertTrue($assignment->isApplicableFor(null)); + $this->assertTrue($assignment->isApplicableFor($this->tenantId)); + } + + /** + * Tests if a tenant-scoped role assignment is applicable to the given tenant. + */ + #[Test] + public function testIsApplicableForTenantScopedAssignment(): void + { + $assignment = RoleAssignment::assign($this->assignmentId, $this->roleId, $this->subjectId, $this->tenantId); + + $this->assertFalse($assignment->isApplicableFor(null)); + $this->assertTrue($assignment->isApplicableFor($this->tenantId)); + } + + /** + * Tests whether a role assignment is not applicable for a different tenant. + */ + #[Test] + public function testIsApplicableForDifferentTenant(): void + { + $otherTenant = TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FEZ'); + $assignment = RoleAssignment::assign($this->assignmentId, $this->roleId, $this->subjectId, $this->tenantId); + + $this->assertFalse($assignment->isApplicableFor($otherTenant)); + } + + /** + * Tests that assigning a role generates a domain event. + * + * This method verifies that when a role assignment is created, a + * corresponding domain event is recorded. It checks the number of + * generated events and ensures the correct event type is produced. + */ + #[Test] + public function testAssignRecordsDomainEvent(): void + { + $assignment = RoleAssignment::assign($this->assignmentId, $this->roleId, $this->subjectId, $this->tenantId); + $events = $assignment->getDomainEvents(); + + $this->assertCount(1, $events); + $this->assertInstanceOf(RoleAssigned::class, $events[0]); + } +} diff --git a/test/Unit/Authorization/Domain/Entity/RoleTest.php b/test/Unit/Authorization/Domain/Entity/RoleTest.php new file mode 100644 index 0000000..ab38583 --- /dev/null +++ b/test/Unit/Authorization/Domain/Entity/RoleTest.php @@ -0,0 +1,218 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\Entity; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authorization\Domain\Collection\PermissionCollection; +use Webify\User\Authorization\Domain\Entity\Role; +use Webify\User\Authorization\Domain\Event\{PermissionGranted, PermissionRevoked, RoleCreated}; +use Webify\User\Authorization\Domain\ValueObject\{Permission, RoleId, RoleName, RoleSlug}; + +/** + * RoleTest tests the functionality of the Role entity. + * + * @internal + */ +#[CoversClass(Role::class)] +#[CoversMethod(Role::class, 'getId')] +#[CoversMethod(Role::class, 'getName')] +#[CoversMethod(Role::class, 'getSlug')] +#[CoversMethod(Role::class, 'getPermissions')] +#[CoversMethod(Role::class, 'isSystemRole')] +#[CoversMethod(Role::class, 'grant')] +#[CoversMethod(Role::class, 'revoke')] +#[CoversMethod(Role::class, 'allows')] +#[CoversMethod(Role::class, 'create')] +final class RoleTest extends TestCase +{ + /** + * Role instance. + */ + private Role $role; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->role = Role::create( + RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'), + RoleName::fromString('Admin'), + RoleSlug::fromString('system.admin'), + new PermissionCollection() + ); + } + + /** + * Tests role-created event. + */ + #[Test] + public function testRoleCreatedEvent(): void + { + $events = $this->role->getDomainEvents(); + + /** @var RoleCreated $event */ + $event = $events[0]; + + $this->assertCount(1, $events); + $this->assertInstanceOf(RoleCreated::class, $event); + $this->assertCount(0, $event->permissions); + } + + /** + * Tests that role-created event payload can reconstruct permissions. + */ + #[Test] + public function testRoleCreatedEventPermissionsAreReconstructable(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'create', + 'resource' => 'article', + ]), + Permission::fromNative([ + 'scope' => 'user', + 'action' => 'read', + 'resource' => 'profile', + ]), + ]); + + $role = Role::create( + RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'), + RoleName::fromString('Editor'), + RoleSlug::fromString('system.editor'), + $permissions + ); + + /** @var RoleCreated $event */ + $event = $role->getDomainEvents()[0]; + + $this->assertCount(2, $event->permissions); + + foreach ($event->permissions as $index => $permissionData) { + $reconstructed = Permission::fromNative($permissionData); + $this->assertTrue($reconstructed->equals($permissions->get($index))); + } + } + + /** + * Tests role getters return correct values. + */ + #[Test] + public function testRoleGetters(): void + { + $this->assertSame('01ARZ3NDEKTSV4RRFFQ69G5FAV', $this->role->getId()->toNative()); + $this->assertSame('Admin', $this->role->getName()->toNative()); + $this->assertSame(['vendor' => 'system', 'slug' => 'admin'], $this->role->getSlug()->toNative()); + $this->assertFalse($this->role->isSystemRole()); + $this->assertCount(0, $this->role->getPermissions()); + } + + /** + * Tests system role flag. + */ + #[Test] + public function testSystemRoleFlag(): void + { + $systemRole = Role::create( + RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'), + RoleName::fromString('Super Admin'), + RoleSlug::fromString('system.super-admin'), + new PermissionCollection(), + true + ); + + $this->assertTrue($systemRole->isSystemRole()); + } + + /** + * Tests granting permission for a role. + */ + #[Test] + public function testGrantPermission(): void + { + $permission = Permission::fromNative([ + 'scope' => 'post', + 'action' => 'create', + 'resource' => 'article', + ]); + + $this->role->grant($permission); + + $this->assertCount(1, $this->role->getPermissions()); + $this->assertTrue($this->role->allows('post', 'create', 'article')); + + $events = $this->role->getDomainEvents(); + + $this->assertCount(2, $events); + $this->assertInstanceOf(PermissionGranted::class, $events[1]); + } + + /** + * Tests revoking permission from a role. + */ + #[Test] + public function testRevokePermission(): void + { + $permission = Permission::fromNative([ + 'scope' => 'post', + 'action' => 'create', + 'resource' => 'article', + ]); + + $this->role->grant($permission); + $this->role->revoke($permission); + $this->assertCount(0, $this->role->getPermissions()); + $this->assertFalse($this->role->allows('post', 'create', 'article')); + + $events = $this->role->getDomainEvents(); + + $this->assertCount(3, $events); + $this->assertInstanceOf(PermissionRevoked::class, $events[2]); + } + + /** + * Tests allows method returns true for matching permission. + */ + #[Test] + public function testAllowsReturnsTrueForMatchingPermission(): void + { + $permission = Permission::fromNative([ + 'scope' => 'user', + 'action' => 'read', + 'resource' => 'profile', + ]); + + $this->role->grant($permission); + $this->assertTrue($this->role->allows('user', 'read', 'profile')); + } + + /** + * Tests allows method returns false for non-matching permission. + */ + #[Test] + public function testAllowsReturnsFalseForNonMatchingPermission(): void + { + $permission = Permission::fromNative([ + 'scope' => 'user', + 'action' => 'read', + 'resource' => 'profile', + ]); + + $this->role->grant($permission); + $this->assertFalse($this->role->allows('user', 'delete', 'profile')); + } +} diff --git a/test/Unit/Authorization/Domain/Guard/DuplicateRoleAssignmentTest.php b/test/Unit/Authorization/Domain/Guard/DuplicateRoleAssignmentTest.php new file mode 100644 index 0000000..c636d0c --- /dev/null +++ b/test/Unit/Authorization/Domain/Guard/DuplicateRoleAssignmentTest.php @@ -0,0 +1,74 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\Guard; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authorization\Domain\Exception\DuplicateRoleAssignmentException; +use Webify\User\Authorization\Domain\Guard\DuplicateRoleAssignment; +use Webify\User\Authorization\Domain\Repository\RoleAssignmentRepositoryInterface; +use Webify\User\Authorization\Domain\ValueObject\{RoleId, SubjectId}; + +/** + * DuplicateRoleAssignmentTest tests the functionality of the DuplicateRoleAssignment guard. + * + * @internal + */ +#[CoversClass(DuplicateRoleAssignment::class)] +#[CoversMethod(DuplicateRoleAssignment::class, 'guard')] +final class DuplicateRoleAssignmentTest extends TestCase +{ + /** + * Tests that the guard passes when no duplicate role assignment exists. + */ + #[Test] + public function testGuardPassesWhenNoDuplicateExists(): void + { + $roleId = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $subjectId = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FBW'); + $repository = $this->createMock(RoleAssignmentRepositoryInterface::class); + + $repository->expects($this->once()) + ->method('isExists') + ->with($roleId, $subjectId) + ->willReturn(false) + ; + + $guard = new DuplicateRoleAssignment($repository); + + $guard->guard($roleId, $subjectId); + } + + /** + * Tests that the guard throws an exception when a duplicate role assignment exists. + */ + #[Test] + public function testGuardThrowsExceptionWhenDuplicateExists(): void + { + $roleId = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $subjectId = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FBW'); + $repository = $this->createMock(RoleAssignmentRepositoryInterface::class); + + $repository->expects($this->once()) + ->method('isExists') + ->with($roleId, $subjectId) + ->willReturn(true) + ; + + $guard = new DuplicateRoleAssignment($repository); + + $this->expectException(DuplicateRoleAssignmentException::class); + $guard->guard($roleId, $subjectId); + } +} diff --git a/test/Unit/Authorization/Domain/Guard/RoleAlreadyExistsTest.php b/test/Unit/Authorization/Domain/Guard/RoleAlreadyExistsTest.php new file mode 100644 index 0000000..d92329e --- /dev/null +++ b/test/Unit/Authorization/Domain/Guard/RoleAlreadyExistsTest.php @@ -0,0 +1,72 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\Guard; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authorization\Domain\Exception\RoleAlreadyExistsException; +use Webify\User\Authorization\Domain\Guard\RoleAlreadyExists; +use Webify\User\Authorization\Domain\Repository\RoleRepositoryInterface; +use Webify\User\Authorization\Domain\ValueObject\RoleSlug; + +/** + * RoleAlreadyExistsTest tests the functionality of the RoleAlreadyExists guard. + * + * @internal + */ +#[CoversClass(RoleAlreadyExists::class)] +#[CoversMethod(RoleAlreadyExists::class, 'guard')] +final class RoleAlreadyExistsTest extends TestCase +{ + /** + * Tests that the guard passes when a role with the given slug does not exist. + */ + #[Test] + public function testGuardPassesWhenRoleDoesNotExist(): void + { + $slug = RoleSlug::fromString('webify.admin'); + $repository = $this->createMock(RoleRepositoryInterface::class); + + $repository->expects($this->once()) + ->method('isExist') + ->with($slug) + ->willReturn(false) + ; + + $guard = new RoleAlreadyExists($repository); + + $guard->guard($slug); + } + + /** + * Tests that the guard throws an exception when a role with the given slug already exists. + */ + #[Test] + public function testGuardThrowsExceptionWhenRoleAlreadyExists(): void + { + $slug = RoleSlug::fromString('webify.admin'); + $repository = $this->createMock(RoleRepositoryInterface::class); + + $repository->expects($this->once()) + ->method('isExist') + ->with($slug) + ->willReturn(true) + ; + + $guard = new RoleAlreadyExists($repository); + + $this->expectException(RoleAlreadyExistsException::class); + $guard->guard($slug); + } +} diff --git a/test/Unit/Authorization/Domain/Service/AssignRoleTest.php b/test/Unit/Authorization/Domain/Service/AssignRoleTest.php new file mode 100644 index 0000000..4fdeb96 --- /dev/null +++ b/test/Unit/Authorization/Domain/Service/AssignRoleTest.php @@ -0,0 +1,291 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\Service; + +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\UlidGeneratorInterface; +use Webify\User\Authorization\Domain\Collection\PermissionCollection; +use Webify\User\Authorization\Domain\Entity\Role; +use Webify\User\Authorization\Domain\Exception\DuplicateRoleAssignmentException; +use Webify\User\Authorization\Domain\Guard\DuplicateRoleAssignment; +use Webify\User\Authorization\Domain\Repository\{RoleAssignmentRepositoryInterface, RoleRepositoryInterface}; +use Webify\User\Authorization\Domain\Service\AssignRole; +use Webify\User\Authorization\Domain\ValueObject\{RoleId, RoleName, RoleSlug, SubjectId, TenantId}; + +/** + * RoleAssignTest tests the functionality of the RoleAssign service. + * + * @internal + */ +#[CoversClass(AssignRole::class)] +#[CoversMethod(AssignRole::class, 'assign')] +final class AssignRoleTest extends TestCase +{ + /** + * Identifier for the role. + */ + private RoleId $roleId; + + /** + * Identifier for the subject. + */ + private SubjectId $subjectId; + + /** + * Identifier for the tenant. + */ + private TenantId $tenantId; + + /** + * Slug for the role. + */ + private RoleSlug $roleSlug; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->roleId = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $this->subjectId = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FBW'); + $this->tenantId = TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FCX'); + $this->roleSlug = RoleSlug::fromString('system.admin'); + } + + /** + * Tests that a role can be assigned successfully. + */ + #[Test] + public function testAssignRoleSuccessfully(): void + { + $role = Role::create( + $this->roleId, + RoleName::fromString('Admin'), + $this->roleSlug, + new PermissionCollection() + ); + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getBySlug') + ->willReturn($role) + ; + + $roleAssignmentRepository = $this->createMock(RoleAssignmentRepositoryInterface::class); + + $roleAssignmentRepository->expects($this->once()) + ->method('persist') + ; + + $idGenerator = $this->createStub(UlidGeneratorInterface::class); + + $idGenerator->method('generate') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FDY') + ; + + $duplicateRoleAssignmentRepo = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $duplicateRoleAssignmentRepo->method('isExists') + ->willReturn(false) + ; + + $duplicateRoleAssignment = new DuplicateRoleAssignment($duplicateRoleAssignmentRepo); + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher->expects($this->once()) + ->method('publish') + ; + + $service = new AssignRole( + $roleRepository, + $roleAssignmentRepository, + $idGenerator, + $duplicateRoleAssignment, + $eventPublisher + ); + + $service->assign($this->subjectId->toNative(), (string) $this->roleSlug); + } + + /** + * Tests that a role can be assigned with a tenant ID. + */ + #[Test] + public function testAssignRoleWithTenantId(): void + { + $role = Role::create( + $this->roleId, + RoleName::fromString('Admin'), + $this->roleSlug, + new PermissionCollection() + ); + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getBySlug') + ->willReturn($role) + ; + + $roleAssignmentRepository = $this->createMock(RoleAssignmentRepositoryInterface::class); + + $roleAssignmentRepository->expects($this->once()) + ->method('persist') + ; + + $idGenerator = $this->createStub(UlidGeneratorInterface::class); + + $idGenerator->method('generate') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FDY') + ; + + $duplicateRoleAssignmentRepo = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $duplicateRoleAssignmentRepo->method('isExists') + ->willReturn(false) + ; + + $duplicateRoleAssignment = new DuplicateRoleAssignment($duplicateRoleAssignmentRepo); + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher->expects($this->once()) + ->method('publish') + ; + + $service = new AssignRole( + $roleRepository, + $roleAssignmentRepository, + $idGenerator, + $duplicateRoleAssignment, + $eventPublisher + ); + + $service->assign( + $this->subjectId->toNative(), + (string) $this->roleSlug, + $this->tenantId->toNative() + ); + } + + /** + * Tests that a role can be assigned with an expiration date. + */ + #[Test] + public function testAssignRoleWithExpiresAt(): void + { + $role = Role::create( + $this->roleId, + RoleName::fromString('Admin'), + $this->roleSlug, + new PermissionCollection() + ); + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getBySlug') + ->willReturn($role) + ; + + $roleAssignmentRepository = $this->createMock(RoleAssignmentRepositoryInterface::class); + + $roleAssignmentRepository->expects($this->once()) + ->method('persist') + ; + + $idGenerator = $this->createStub(UlidGeneratorInterface::class); + + $idGenerator->method('generate') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FDY') + ; + + $duplicateRoleAssignmentRepo = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $duplicateRoleAssignmentRepo->method('isExists') + ->willReturn(false) + ; + + $duplicateRoleAssignment = new DuplicateRoleAssignment($duplicateRoleAssignmentRepo); + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher->expects($this->once()) + ->method('publish') + ; + + $service = new AssignRole( + $roleRepository, + $roleAssignmentRepository, + $idGenerator, + $duplicateRoleAssignment, + $eventPublisher + ); + + $expireAt = new DateTimeImmutable('+1 hour'); + + $service->assign( + $this->subjectId->toNative(), + (string) $this->roleSlug, + null, + $expireAt + ); + } + + /** + * Tests that duplicate role assigning throws an exception. + */ + #[Test] + public function testDuplicateRoleAssignmentThrowsException(): void + { + $role = Role::create( + $this->roleId, + RoleName::fromString('Admin'), + $this->roleSlug, + new PermissionCollection() + ); + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getBySlug') + ->willReturn($role) + ; + + $duplicateRoleAssignmentRepo = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $duplicateRoleAssignmentRepo->method('isExists') + ->willReturn(true) + ; + + $duplicateRoleAssignment = new DuplicateRoleAssignment($duplicateRoleAssignmentRepo); + $roleAssignmentRepository = $this->createMock(RoleAssignmentRepositoryInterface::class); + + $roleAssignmentRepository->expects($this->never()) + ->method('persist') + ; + + $idGenerator = $this->createStub(UlidGeneratorInterface::class); + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher->expects($this->never()) + ->method('publish') + ; + + $service = new AssignRole( + $roleRepository, + $roleAssignmentRepository, + $idGenerator, + $duplicateRoleAssignment, + $eventPublisher + ); + + $this->expectException(DuplicateRoleAssignmentException::class); + $service->assign($this->subjectId->toNative(), (string) $this->roleSlug); + } +} diff --git a/test/Unit/Authorization/Domain/Service/AuthorizationTest.php b/test/Unit/Authorization/Domain/Service/AuthorizationTest.php new file mode 100644 index 0000000..9f1bd92 --- /dev/null +++ b/test/Unit/Authorization/Domain/Service/AuthorizationTest.php @@ -0,0 +1,1232 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Contract\Authorization\{AuthorizableResourceInterface, + AuthorizableSubjectInterface, + RuleInterface}; +use Webify\Base\Domain\Contract\Authorization\Service\RuleRegistryInterface; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authorization\Domain\Collection\{PermissionCollection, RoleAssignmentCollection}; +use Webify\User\Authorization\Domain\Entity\{Role, RoleAssignment}; +use Webify\User\Authorization\Domain\Exception\RoleNotFoundException; +use Webify\User\Authorization\Domain\Repository\{RoleAssignmentRepositoryInterface, RoleRepositoryInterface}; +use Webify\User\Authorization\Domain\Service\Authorization; +use Webify\User\Authorization\Domain\ValueObject\{Permission, + RoleAssignmentId, + RoleId, + RoleName, + RoleSlug, + SubjectId, + TenantId}; + +/** + * AuthorizationTest tests the functionality of the Authorization service. + * + * @internal + */ +#[CoversClass(Authorization::class)] +#[CoversMethod(Authorization::class, '__construct')] +#[CoversMethod(Authorization::class, 'authorize')] +#[CoversMethod(Authorization::class, 'evaluate')] +#[CoversMethod(Authorization::class, 'getTenantId')] +#[CoversMethod(Authorization::class, 'generateCacheKey')] +final class AuthorizationTest extends TestCase +{ + /** + * The identifier for a specific role. + */ + private RoleId $roleId; + + /** + * The unique identifier for a specific subject. + */ + private SubjectId $subjectId; + + /** + * The unique identifier for a specific tenant. + */ + private TenantId $tenantId; + + /** + * The unique identifier for a specific role assignment. + */ + private RoleAssignmentId $assignmentId; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->roleId = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $this->subjectId = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FBW'); + $this->tenantId = TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FCX'); + $this->assignmentId = RoleAssignmentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FDY'); + } + + /** + * Tests whether the `check` method of the Authorization service returns `true` when the subject + * has a role with matching permissions to the given action and resource. + */ + #[Test] + public function testCheckReturnsTrueWhenSubjectHasMatchingRoleAndPermission(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertTrue($service->authorize('read', $subject, $resource)); + } + + /** + * Tests that the `check` method of the `Authorization` service returns `false` + * when the subject has no role assignments. + */ + #[Test] + public function testCheckReturnsFalseWhenSubjectHasNoAssignments(): void + { + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertFalse($service->authorize('read', $subject, $resource)); + } + + /** + * Tests whether the `check` method of the `Authorization` service returns `false` + * when the role assigned to the subject does not have a permission matching the + * required action, scope, and resource characteristics. + * + * The test ensures that when a role's permission collection lacks a specific + * permission for the requested operation (e.g., 'delete'), the service correctly + * denies the access check. + */ + #[Test] + public function testCheckReturnsFalseWhenRoleDoesNotHaveMatchingPermission(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'write', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Viewer'), + RoleSlug::fromString('webify.viewer'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertFalse($service->authorize('delete', $subject, $resource)); + } + + /** + * Tests whether the `check` method of the `Authorization` service returns `false` + * when the role associated with the subject's role assignment cannot be found. + * + * This test ensures that if a role query fails to retrieve a role for an + * assignment, the service correctly denies the access check. + */ + #[Test] + public function testCheckReturnsFalseWhenRoleNotFound(): void + { + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getById') + ->willThrowException(RoleNotFoundException::forId($this->roleId->toNative())) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertFalse($service->authorize('read', $subject, $resource)); + } + + /** + * Tests whether the `check` method of the `Authorization` service returns `false` + * when the role assignment for the subject has expired. + * + * The test ensures that if the role assignment's expiration date is in the past, + * the service correctly denies the access check, regardless of permissions + * associated with the role. + */ + #[Test] + public function testCheckReturnsFalseWhenAssignmentIsExpired(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $expiredDate = DateTime::fromString('-1 day'); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId, + null, + $expiredDate + ); + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertFalse($service->authorize('read', $subject, $resource)); + } + + /** + * Tests whether the `check` method of the `Authorization` service returns `false` + * when the tenant ID associated with the subject does not match the tenant ID + * assigned to the role in the role assignment. + * + * The test ensures that a mismatch in tenant IDs results in the access check + * being denied, even if the role's permissions would otherwise allow the action + * to be performed. + */ + #[Test] + public function testCheckReturnsFalseWhenTenantDoesNotMatch(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $differentTenant = TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FEZ'); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId, + $differentTenant + ); + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn($this->tenantId->toNative()) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertFalse($service->authorize('read', $subject, $resource)); + } + + /** + * Tests whether the `check` method of the `Authorization` service returns `true` + * when a globally scoped role assigned to a tenant-scoped subject contains a permission + * that matches the required action, scope, and resource characteristics. + * + * This test ensures that global roles with appropriate permissions correctly grant + * access to tenant-scoped subjects when the requested operation (e.g., 'read') + * aligns with the permissions defined in the role. + */ + #[Test] + public function testCheckReturnsTrueWhenGlobalRoleAppliesToTenantScopedSubject(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn($this->tenantId->toNative()) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertTrue($service->authorize('read', $subject, $resource)); + } + + /** + * Tests whether the `check` method of the `Authorization` service returns `false` + * when a role with scoped permissions is applied to a global context that does + * not satisfy the scope constraint of the permission. + * + * The test ensures that even if a role has a permission for the specified action, + * resource, and scope, the service denies the access check when the context in + * which the role is applied does not match the defined scope of the permission. + */ + #[Test] + public function testCheckReturnsFalseWhenScopedRoleAppliedToGlobalContext(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId, + $this->tenantId + ); + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertFalse($service->authorize('read', $subject, $resource)); + } + + /** + * Tests whether the `check` method of the `Authorization` service returns `true` + * when the role assigned to the subject has a permission matching the required + * action, scope, and resource characteristics, and the tenant associated with + * the assignment matches the tenant of the subject. + * + * The test ensures that when a role's permission collection and tenant context + * align with the requested operation (e.g., 'read'), the service correctly allows + * the access check. + */ + #[Test] + public function testCheckReturnsTrueWhenTenantMatches(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId, + $this->tenantId + ); + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn($this->tenantId->toNative()) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertTrue($service->authorize('read', $subject, $resource)); + } + + /** + * Tests whether the `check` method of the `Authorization` service returns `false` + * when the applicable authorization rule is not satisfied for the given action, + * subject, and resource. + * + * The test ensures that even if a role's permissions match the requested action, + * scope, and resource, the service correctly denies access if an applicable rule + * explicitly fails the satisfaction check. + */ + #[Test] + public function testCheckReturnsFalseWhenRuleIsNotSatisfied(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + + $rule = $this->createStub(RuleInterface::class); + + $rule->method('isSatisfied') + ->willReturn(false) + ; + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([$rule]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertFalse($service->authorize('read', $subject, $resource)); + } + + /** + * Tests whether the `check` method of the `Authorization` service returns `true` + * when all applicable rules are satisfied, and the role assigned to the subject + * has the required permissions for the requested action, scope, and resource + * characteristics. + * + * The test ensures that when a role's permission collection includes a specific + * permission for the requested operation (e.g., 'read') and all relevant rules + * validate successfully, the service correctly grants the access check. + */ + #[Test] + public function testCheckReturnsTrueWhenAllRulesAreSatisfied(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + + $rule = $this->createStub(RuleInterface::class); + + $rule->method('isSatisfied') + ->willReturn(true) + ; + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([$rule]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertTrue($service->authorize('read', $subject, $resource)); + } + + /** + * Tests whether the `check` method of the `Authorization` service caches results + * for subsequent identical authorization checks. + * + * The test ensures that the `Authorization` service performs the necessary queries + * and computations only once, and subsequent checks for the same action, subject, + * and resource reuse the cached result, thereby improving performance. + */ + #[Test] + public function testCheckCachesResults(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + + $roleRepository = $this->createMock(RoleRepositoryInterface::class); + + $roleRepository->expects($this->once()) + ->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createMock(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->expects($this->once()) + ->method('getBySubjectId') + ->willReturn( + RoleAssignmentCollection::from([$assignment]) + ) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertTrue($service->authorize('read', $subject, $resource)); + $this->assertTrue($service->authorize('read', $subject, $resource)); + } + + /** + * Tests whether the `check` method of the `Authorization` service returns `true` + * when a subject has a valid, non-expired role assignment with the required + * permission for the specified action, scope, and resource characteristics. + * + * The test ensures that if a role assignment is within its validity period + * and the assigned role contains a matching permission (e.g., 'read'), the service + * correctly grants the access check. + */ + #[Test] + public function testCheckReturnsTrueWithNonExpiredAssignment(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $futureDate = DateTime::fromString('+1 day'); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId, + null, + $futureDate + ); + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertTrue($service->authorize('read', $subject, $resource)); + } + + /** + * Tests whether the `check` method of the `Authorization` service iterates through + * multiple role assignments for a subject until a matching permission is found + * for a requested action, scope, and resource. + * + * The test ensures that: + * - If a matching permission exists in any of the assigned roles, the service + * authorizes the access (returns `true`) for the corresponding action. + * - If no matching permission exists across all assignments, the service denies + * the access (returns `false`). + */ + #[Test] + public function testCheckIteratesMultipleAssignmentsUntilMatchFound(): void + { + $roleId2 = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FEZ'); + $assignmentId2 = RoleAssignmentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FGA'); + $permissions1 = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'write', + 'resource' => 'all', + ]), + ]); + $permissions2 = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role1 = Role::create( + $this->roleId, + RoleName::fromString('Viewer'), + RoleSlug::fromString('webify.viewer'), + $permissions1, + false + ); + $role2 = Role::create( + $roleId2, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions2, + false + ); + $assignment1 = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + $assignment2 = RoleAssignment::assign( + $assignmentId2, + $roleId2, + $this->subjectId + ); + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('getById') + ->willReturnCallback( + fn (RoleId $id): Role => match ($id->toNative()) { + $this->roleId->toNative() => $role1, + $roleId2->toNative() => $role2, + default => throw RoleNotFoundException::forId($id->toNative()), + } + ) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment1, $assignment2])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('some-resource-id') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $this->assertTrue($service->authorize('read', $subject, $resource)); + $this->assertTrue($service->authorize('write', $subject, $resource)); + $this->assertFalse($service->authorize('delete', $subject, $resource)); + } +} diff --git a/test/Unit/Authorization/Domain/Service/CheckAccessTest.php b/test/Unit/Authorization/Domain/Service/CheckAccessTest.php new file mode 100644 index 0000000..a18a4e7 --- /dev/null +++ b/test/Unit/Authorization/Domain/Service/CheckAccessTest.php @@ -0,0 +1,541 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Contract\Authorization\{AuthorizableResourceInterface, AuthorizableSubjectInterface}; +use Webify\Base\Domain\Contract\Authorization\Service\RuleRegistryInterface; +use Webify\Base\Domain\Exception\AccessDeniedException; +use Webify\User\Authorization\Domain\Collection\{PermissionCollection, RoleAssignmentCollection}; +use Webify\User\Authorization\Domain\Entity\{Role, RoleAssignment}; +use Webify\User\Authorization\Domain\Repository\{RoleAssignmentRepositoryInterface, RoleRepositoryInterface}; +use Webify\User\Authorization\Domain\Service\{Authorization, CheckAccess}; +use Webify\User\Authorization\Domain\ValueObject\{Permission, RoleAssignmentId, RoleId, RoleName, RoleSlug, SubjectId}; + +/** + * CheckAccessTest tests the functionality of the CheckAccess service. + * + * @internal + */ +#[CoversClass(CheckAccess::class)] +#[CoversMethod(CheckAccess::class, '__construct')] +#[CoversMethod(CheckAccess::class, 'isAllowed')] +#[CoversMethod(CheckAccess::class, 'denyUnless')] +final class CheckAccessTest extends TestCase +{ + /** + * The identifier for a specific role. + */ + private RoleId $roleId; + + /** + * The unique identifier for a specific subject. + */ + private SubjectId $subjectId; + + /** + * The unique identifier for a specific role assignment. + */ + private RoleAssignmentId $assignmentId; + + protected function setUp(): void + { + $this->roleId = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $this->subjectId = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FBW'); + $this->assignmentId = RoleAssignmentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FDY'); + } + + /** + * Tests that the method isAllowed returns true when the authorization check passes. + */ + #[Test] + public function testIsAllowedReturnsTrueWhenAuthorizationCheckPasses(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $authorization = $this->createAuthorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FBW') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new CheckAccess($authorization); + + $this->assertTrue($service->isAllowed('read', $subject, $resource)); + } + + /** + * Tests that the method isAllowed returns false when the authorization check fails. + */ + #[Test] + public function testIsAllowedReturnsFalseWhenAuthorizationCheckFails(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'write', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $authorization = $this->createAuthorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FBW') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new CheckAccess($authorization); + + $this->assertFalse($service->isAllowed('delete', $subject, $resource)); + } + + /** + * Tests that the `isAllowed` method delegates to the `authorize` method of the injected + * `Authorization` implementation, verifying access permissions based on + * the provided action, subject, and resource. + */ + #[Test] + public function testIsAllowedDelegatesToAuthorizationCheck(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'update', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + + $roleRepository = $this->createMock(RoleRepositoryInterface::class); + $roleRepository->expects($this->once()) + ->method('getById') + ->with($this->equalTo($this->roleId)) + ->willReturn($role) + ; + + $assignmentRepository = $this->createMock(RoleAssignmentRepositoryInterface::class); + $assignmentRepository->expects($this->once()) + ->method('getBySubjectId') + ->with($this->isInstanceOf(SubjectId::class)) + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $authorization = $this->createAuthorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FBW') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new CheckAccess($authorization); + + $service->isAllowed('update', $subject, $resource); + } + + /** + * Tests that the `denyUnless` method does not throw an exception when access is allowed. + * + * This test verifies that the `CheckAccess` service correctly proceeds without error + * when the `Authorization` authorizes the requested action for the specified + * subject and resource. + */ + #[Test] + public function testDenyUnlessDoesNotThrowWhenAccessIsAllowed(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'read', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $authorization = $this->createAuthorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FBW') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new CheckAccess($authorization); + + $this->expectNotToPerformAssertions(); + $service->denyUnless('read', $subject, $resource); + } + + /** + * Tests that the `denyUnless` method throws an `AccessDeniedException` when access is denied. + * + * This test verifies that the `CheckAccess` service correctly throws an `AccessDeniedException` + * when the `Authorization` denies the requested action for the specified subject and resource. + */ + #[Test] + public function testDenyUnlessThrowsAccessDeniedExceptionWhenAccessIsDenied(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'write', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + $roleRepository->method('getById') + ->willReturn($role) + ; + + $assignmentRepository = $this->createStub(RoleAssignmentRepositoryInterface::class); + $assignmentRepository->method('getBySubjectId') + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $authorization = $this->createAuthorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $subjectId = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + $resourceId = '01ARZ3NDEKTSV4RRFFQ69G5FBW'; + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + $subject->method('subjectId') + ->willReturn($subjectId) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn($resourceId) + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new CheckAccess($authorization); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage( + sprintf( + 'Access denied for action "delete" on subject "%s" and resource "%s".', + $subjectId, + $resourceId + ) + ); + $service->denyUnless('delete', $subject, $resource); + } + + /** + * Tests that the `denyUnless` method delegates to the `Authorization` for permission checks. + * + * This test ensures that the `CheckAccess` service calls the `authorize` method on the `Authorization` + * with the expected action, subject, and resource. It verifies that the `authorize` method is invoked + * exactly once and returns `true` to allow the requested action. + */ + #[Test] + public function testDenyUnlessDelegatesToAuthorizationCheck(): void + { + $permissions = PermissionCollection::from([ + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'manage', + 'resource' => 'all', + ]), + ]); + $role = Role::create( + $this->roleId, + RoleName::fromString('Editor'), + RoleSlug::fromString('webify.editor'), + $permissions, + false + ); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId + ); + + $roleRepository = $this->createMock(RoleRepositoryInterface::class); + $roleRepository->expects($this->once()) + ->method('getById') + ->with($this->equalTo($this->roleId)) + ->willReturn($role) + ; + + $assignmentRepository = $this->createMock(RoleAssignmentRepositoryInterface::class); + $assignmentRepository->expects($this->once()) + ->method('getBySubjectId') + ->with($this->isInstanceOf(SubjectId::class)) + ->willReturn(RoleAssignmentCollection::from([$assignment])) + ; + + $ruleRegistry = $this->createStub(RuleRegistryInterface::class); + $ruleRegistry->method('getAllApplicableTo') + ->willReturn([]) + ; + + $authorization = $this->createAuthorization($roleRepository, $assignmentRepository, $ruleRegistry); + + $subject = $this->createStub(AuthorizableSubjectInterface::class); + $subject->method('subjectId') + ->willReturn($this->subjectId->toNative()) + ; + $subject->method('tenantId') + ->willReturn(null) + ; + + $resource = $this->createStub(AuthorizableResourceInterface::class); + $resource->method('resourceScope') + ->willReturn('post') + ; + $resource->method('resourceType') + ->willReturn('all') + ; + $resource->method('resourceId') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FBW') + ; + $resource->method('ownerId') + ->willReturn(null) + ; + $resource->method('tenantId') + ->willReturn(null) + ; + + $service = new CheckAccess($authorization); + + $service->denyUnless('manage', $subject, $resource); + } + + /** + * Creates an Authorization instance with the given dependencies. + */ + private function createAuthorization( + RoleRepositoryInterface $roleRepository, + RoleAssignmentRepositoryInterface $assignmentRepository, + RuleRegistryInterface $ruleRegistry + ): Authorization { + return new Authorization($roleRepository, $assignmentRepository, $ruleRegistry); + } +} diff --git a/test/Unit/Authorization/Domain/Service/CreateRoleTest.php b/test/Unit/Authorization/Domain/Service/CreateRoleTest.php new file mode 100644 index 0000000..04bcac4 --- /dev/null +++ b/test/Unit/Authorization/Domain/Service/CreateRoleTest.php @@ -0,0 +1,269 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\{SlugifyInterface, UlidGeneratorInterface}; +use Webify\User\Authorization\Domain\Entity\Role; +use Webify\User\Authorization\Domain\Exception\RoleAlreadyExistsException; +use Webify\User\Authorization\Domain\Guard\RoleAlreadyExists; +use Webify\User\Authorization\Domain\Repository\RoleRepositoryInterface; +use Webify\User\Authorization\Domain\Service\CreateRole; +use Webify\User\Authorization\Domain\ValueObject\{RoleName, RoleSlug}; + +/** + * CreateRoleTest tests the functionality of the CreateRole service. + * + * @internal + */ +#[CoversClass(CreateRole::class)] +#[CoversMethod(CreateRole::class, 'create')] +final class CreateRoleTest extends TestCase +{ + /** + * Tests that a role can be created successfully with permissions. + */ + #[Test] + public function testCreateRoleSuccessfullyWithPermissions(): void + { + $slugify = $this->createStub(SlugifyInterface::class); + $idGenerator = $this->createStub(UlidGeneratorInterface::class); + $repository = $this->createMock(RoleRepositoryInterface::class); + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $slugify->method('slugify') + ->willReturnCallback(fn (string $value): string => strtolower($value)) + ; + $idGenerator->method('generate') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FAV') + ; + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('isExist') + ->willReturn(false) + ; + + $roleAlreadyExists = new RoleAlreadyExists($roleRepository); + + $repository->expects($this->once()) + ->method('persist') + ; + $eventPublisher->expects($this->once()) + ->method('publish') + ; + + $service = new CreateRole( + $slugify, + $idGenerator, + $roleAlreadyExists, + $repository, + $eventPublisher + ); + $role = $service->create( + 'Editor', + [ + ['scope' => 'post', 'action' => 'create', 'resource' => 'own'], + ['scope' => 'post', 'action' => 'update', 'resource' => 'own'], + ], + 'webify' + ); + + $this->assertInstanceOf(Role::class, $role); + $this->assertEquals(RoleName::fromString('Editor'), $role->getName()); + $this->assertEquals(RoleSlug::fromString('webify.editor'), $role->getSlug()); + $this->assertCount(2, $role->getPermissions()); + } + + /** + * Tests that a role can be created successfully with empty permissions. + */ + #[Test] + public function testCreateRoleSuccessfullyWithEmptyPermissions(): void + { + $slugify = $this->createStub(SlugifyInterface::class); + $idGenerator = $this->createStub(UlidGeneratorInterface::class); + $repository = $this->createMock(RoleRepositoryInterface::class); + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $slugify->method('slugify') + ->willReturnCallback(fn (string $value): string => strtolower($value)) + ; + $idGenerator->method('generate') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FBW') + ; + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('isExist') + ->willReturn(false) + ; + + $roleAlreadyExists = new RoleAlreadyExists($roleRepository); + + $repository->expects($this->once()) + ->method('persist') + ; + $eventPublisher->expects($this->once()) + ->method('publish') + ; + + $service = new CreateRole( + $slugify, + $idGenerator, + $roleAlreadyExists, + $repository, + $eventPublisher + ); + $role = $service->create('Viewer', [], 'webify'); + + $this->assertInstanceOf(Role::class, $role); + $this->assertEquals(RoleName::fromString('Viewer'), $role->getName()); + $this->assertEquals(RoleSlug::fromString('webify.viewer'), $role->getSlug()); + $this->assertCount(0, $role->getPermissions()); + } + + /** + * Tests that creating a role with a duplicate slug throws an exception. + */ + #[Test] + public function testCreateRoleWithDuplicateSlugThrowsException(): void + { + $slugify = $this->createStub(SlugifyInterface::class); + $idGenerator = $this->createStub(UlidGeneratorInterface::class); + $repository = $this->createMock(RoleRepositoryInterface::class); + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $slugify->method('slugify') + ->willReturnCallback(fn (string $value): string => strtolower($value)) + ; + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('isExist') + ->willReturn(true) + ; + + $roleAlreadyExists = new RoleAlreadyExists($roleRepository); + + $repository->expects($this->never()) + ->method('persist') + ; + $eventPublisher->expects($this->never()) + ->method('publish') + ; + + $service = new CreateRole( + $slugify, + $idGenerator, + $roleAlreadyExists, + $repository, + $eventPublisher + ); + + $this->expectException(RoleAlreadyExistsException::class); + $service->create('Admin', [], 'webify'); + } + + /** + * Tests that the slug is correctly derived from vendor and role name. + */ + #[Test] + public function testSlugIsDerivedFromVendorAndRoleName(): void + { + $slugify = $this->createStub(SlugifyInterface::class); + $idGenerator = $this->createStub(UlidGeneratorInterface::class); + $repository = $this->createStub(RoleRepositoryInterface::class); + $eventPublisher = $this->createStub(DomainEventPublisherInterface::class); + + $slugify->method('slugify') + ->willReturnCallback( + fn (string $value): string => strtolower( + trim((string) preg_replace('/[^a-zA-Z0-9]+/', '-', $value), '-') + ) + ) + ; + $idGenerator->method('generate') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FCX') + ; + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('isExist') + ->willReturn(false) + ; + + $roleAlreadyExists = new RoleAlreadyExists($roleRepository); + $service = new CreateRole( + $slugify, + $idGenerator, + $roleAlreadyExists, + $repository, + $eventPublisher + ); + $role = $service->create('Super Admin', [], 'my-vendor'); + + $this->assertEquals(RoleSlug::fromString('my-vendor.super-admin'), $role->getSlug()); + } + + /** + * Tests that permissions are correctly converted to PermissionCollection. + */ + #[Test] + public function testPermissionsAreCorrectlyConverted(): void + { + $slugify = $this->createStub(SlugifyInterface::class); + $idGenerator = $this->createStub(UlidGeneratorInterface::class); + $repository = $this->createStub(RoleRepositoryInterface::class); + $eventPublisher = $this->createStub(DomainEventPublisherInterface::class); + + $slugify->method('slugify') + ->willReturnCallback(fn (string $value): string => strtolower($value)) + ; + $idGenerator->method('generate') + ->willReturn('01ARZ3NDEKTSV4RRFFQ69G5FDY') + ; + + $roleRepository = $this->createStub(RoleRepositoryInterface::class); + + $roleRepository->method('isExist') + ->willReturn(false) + ; + + $roleAlreadyExists = new RoleAlreadyExists($roleRepository); + $service = new CreateRole( + $slugify, + $idGenerator, + $roleAlreadyExists, + $repository, + $eventPublisher + ); + $permissions = [ + ['scope' => 'user', 'action' => 'read', 'resource' => 'all'], + ['scope' => 'user', 'action' => 'write', 'resource' => 'own'], + ['scope' => 'settings', 'action' => 'manage', 'resource' => 'all'], + ]; + $role = $service->create('Manager', $permissions, 'webify'); + + $this->assertCount(3, $role->getPermissions()); + + $permissionArray = $role->getPermissions()->toArray(); + + $this->assertEquals('user', $permissionArray[0]->toNative()['scope']); + $this->assertEquals('read', $permissionArray[0]->toNative()['action']); + $this->assertEquals('all', $permissionArray[0]->toNative()['resource']); + } +} diff --git a/test/Unit/Authorization/Domain/Service/RevokeRoleTest.php b/test/Unit/Authorization/Domain/Service/RevokeRoleTest.php new file mode 100644 index 0000000..e5ad907 --- /dev/null +++ b/test/Unit/Authorization/Domain/Service/RevokeRoleTest.php @@ -0,0 +1,192 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\ValueObject\DateTime; +use Webify\User\Authorization\Domain\Entity\RoleAssignment; +use Webify\User\Authorization\Domain\Event\RoleRevoked; +use Webify\User\Authorization\Domain\Exception\RoleAssignmentNotFoundException; +use Webify\User\Authorization\Domain\Repository\RoleAssignmentRepositoryInterface; +use Webify\User\Authorization\Domain\Service\RevokeRole; +use Webify\User\Authorization\Domain\ValueObject\{RoleAssignmentId, RoleId, SubjectId, TenantId}; + +/** + * RevokeRoleTest tests the functionality of the RevokeRole service. + * + * @internal + */ +#[CoversClass(RevokeRole::class)] +#[CoversMethod(RevokeRole::class, 'revoke')] +final class RevokeRoleTest extends TestCase +{ + /** + * Identifier for the assignment. + */ + private RoleAssignmentId $assignmentId; + + /** + * Represents the identifier of a role. + */ + private RoleId $roleId; + + /** + * Denotes the identifier of a subject. + */ + private SubjectId $subjectId; + + /** + * Denotes the unique identifier of a tenant. + */ + private TenantId $tenantId; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->assignmentId = RoleAssignmentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $this->roleId = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FBW'); + $this->subjectId = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FCX'); + $this->tenantId = TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FDY'); + } + + /** + * Tests that a role can be revoked successfully. + */ + #[Test] + public function testRevokeRoleSuccessfully(): void + { + $assignment = RoleAssignment::assign($this->assignmentId, $this->roleId, $this->subjectId); + $roleAssignmentRepository = $this->createMock(RoleAssignmentRepositoryInterface::class); + + $roleAssignmentRepository->method('getById') + ->willReturn($assignment) + ; + $roleAssignmentRepository->expects($this->once()) + ->method('delete') + ->with($assignment) + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher->expects($this->once()) + ->method('publish') + ->with($this->isInstanceOf(RoleRevoked::class)) + ; + + $service = new RevokeRole($roleAssignmentRepository, $eventPublisher); + + $service->revoke($this->assignmentId->toNative()); + } + + /** + * Tests that revoking a non-existent role assignment is idempotent. + */ + #[Test] + public function testRevokeNonExistentRoleIsIdempotent(): void + { + $roleAssignmentRepository = $this->createMock(RoleAssignmentRepositoryInterface::class); + + $roleAssignmentRepository->method('getById') + ->willThrowException(RoleAssignmentNotFoundException::forId('')) + ; + $roleAssignmentRepository->expects($this->never()) + ->method('delete') + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher->expects($this->never()) + ->method('publish') + ; + + $service = new RevokeRole($roleAssignmentRepository, $eventPublisher); + + $service->revoke($this->assignmentId->toNative()); + } + + /** + * Tests that a role with tenant ID can be revoked successfully. + */ + #[Test] + public function testRevokeRoleWithTenantId(): void + { + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId, + $this->tenantId + ); + $roleAssignmentRepository = $this->createMock(RoleAssignmentRepositoryInterface::class); + + $roleAssignmentRepository->method('getById') + ->willReturn($assignment) + ; + $roleAssignmentRepository->expects($this->once()) + ->method('delete') + ->with($assignment) + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher->expects($this->once()) + ->method('publish') + ->with($this->isInstanceOf(RoleRevoked::class)) + ; + + $service = new RevokeRole($roleAssignmentRepository, $eventPublisher); + + $service->revoke($this->assignmentId->toNative()); + } + + /** + * Tests that a role with an expiration date can be revoked successfully. + */ + #[Test] + public function testRevokeRoleWithExpiresAt(): void + { + $expiresAt = DateTime::fromTimestamp(time() + 3600); + $assignment = RoleAssignment::assign( + $this->assignmentId, + $this->roleId, + $this->subjectId, + null, + $expiresAt + ); + $roleAssignmentRepository = $this->createMock(RoleAssignmentRepositoryInterface::class); + + $roleAssignmentRepository->method('getById') + ->willReturn($assignment) + ; + + $roleAssignmentRepository->expects($this->once()) + ->method('delete') + ->with($assignment) + ; + + $eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $eventPublisher->expects($this->once()) + ->method('publish') + ->with($this->isInstanceOf(RoleRevoked::class)) + ; + + $service = new RevokeRole($roleAssignmentRepository, $eventPublisher); + + $service->revoke($this->assignmentId->toNative()); + } +} diff --git a/test/Unit/Authorization/Domain/ValueObject/PermissionTest.php b/test/Unit/Authorization/Domain/ValueObject/PermissionTest.php new file mode 100644 index 0000000..92c73c3 --- /dev/null +++ b/test/Unit/Authorization/Domain/ValueObject/PermissionTest.php @@ -0,0 +1,224 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authorization\Domain\Exception\PermissionValidationException; +use Webify\User\Authorization\Domain\ValueObject\Permission; + +/** + * PermissionTest tests the functionality of the Permission value object. + * + * @internal + */ +#[CoversClass(Permission::class)] +#[CoversMethod(Permission::class, 'fromNative')] +#[CoversMethod(Permission::class, 'equals')] +#[CoversMethod(Permission::class, 'toNative')] +#[CoversMethod(Permission::class, 'wildcard')] +#[CoversMethod(Permission::class, 'scopedWildcard')] +#[CoversMethod(Permission::class, 'jsonSerialize')] +final class PermissionTest extends TestCase +{ + /** + * Tests the creation of a valid Permission instance from an array and verifies its properties. + */ + #[Test] + public function testCreateValidPermissionFromArray(): void + { + $permission = Permission::fromNative([ + 'scope' => 'post', + 'action' => 'create', + 'resource' => 'article', + ]); + + $this->assertInstanceOf(Permission::class, $permission); + $this->assertSame([ + 'scope' => 'post', + 'action' => 'create', + 'resource' => 'article', + ], $permission->toNative()); + } + + /** + * Tests that an exception is thrown when attempting to create a Permission with an empty scope. + */ + #[Test] + public function testEmptyScopeThrowsException(): void + { + $this->expectException(PermissionValidationException::class); + Permission::fromNative([ + 'scope' => '', + 'action' => 'create', + 'resource' => 'article', + ]); + } + + /** + * Tests that an exception is thrown when attempting to create a Permission with an empty action. + */ + #[Test] + public function testEmptyActionThrowsException(): void + { + $this->expectException(PermissionValidationException::class); + Permission::fromNative([ + 'scope' => 'post', + 'action' => '', + 'resource' => 'article', + ]); + } + + /** + * Tests that an exception is thrown when attempting to create a Permission with an empty resource. + */ + #[Test] + public function testEmptyResourceThrowsException(): void + { + $this->expectException(PermissionValidationException::class); + Permission::fromNative([ + 'scope' => 'post', + 'action' => 'create', + 'resource' => '', + ]); + } + + /** + * Tests that the toNative method of Permission returns the expected array value. + */ + #[Test] + public function testToNativeReturnsArrayValue(): void + { + $permission = Permission::fromNative([ + 'scope' => 'user', + 'action' => 'read', + 'resource' => 'profile', + ]); + + $this->assertSame([ + 'scope' => 'user', + 'action' => 'read', + 'resource' => 'profile', + ], $permission->toNative()); + } + + /** + * Tests that jsonSerialize returns the same value as toNative. + */ + #[Test] + public function testJsonSerializeReturnsNativeArray(): void + { + $permission = Permission::fromNative([ + 'scope' => 'post', + 'action' => 'create', + 'resource' => 'article', + ]); + + $this->assertSame($permission->toNative(), $permission->jsonSerialize()); + } + + /** + * Tests that json_encode produces the expected structured JSON for a Permission. + */ + #[Test] + public function testJsonEncodeProducesStructuredObject(): void + { + $permission = Permission::fromNative([ + 'scope' => 'post', + 'action' => 'create', + 'resource' => 'article', + ]); + $encoded = json_encode($permission); + + $this->assertNotFalse($encoded); + + $decoded = json_decode($encoded, true); + + $this->assertIsArray($decoded); + + $this->assertSame('post', $decoded['scope']); + $this->assertSame('create', $decoded['action']); + $this->assertSame('article', $decoded['resource']); + } + + /** + * Tests that the equals method returns true when comparing two Permission instances with the same values. + */ + #[Test] + public function testEqualsReturnsTrueForSameValues(): void + { + $permission1 = Permission::fromNative([ + 'scope' => 'post', + 'action' => 'edit', + 'resource' => 'article', + ]); + $permission2 = Permission::fromNative([ + 'scope' => 'post', + 'action' => 'edit', + 'resource' => 'article', + ]); + + $this->assertTrue($permission1->equals($permission2)); + } + + /** + * Tests that the equals method returns false when comparing Permission instances with different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentValues(): void + { + $permission1 = Permission::fromNative([ + 'scope' => 'post', + 'action' => 'create', + 'resource' => 'article', + ]); + $permission2 = Permission::fromNative([ + 'scope' => 'post', + 'action' => 'delete', + 'resource' => 'article', + ]); + + $this->assertFalse($permission1->equals($permission2)); + } + + /** + * Tests that the wildcard method creates a Permission with wildcard values. + */ + #[Test] + public function testWildcardCreatesPermissionWithWildcardValues(): void + { + $permission = Permission::wildcard(); + + $this->assertSame([ + 'scope' => '*', + 'action' => '*', + 'resource' => '*', + ], $permission->toNative()); + } + + /** + * Tests that the scopedWildcard method creates a Permission with wildcard action and resource for a specific scope. + */ + #[Test] + public function testScopedWildcardCreatesPermissionWithScopeAndWildcards(): void + { + $permission = Permission::scopedWildcard('user'); + + $this->assertSame([ + 'scope' => 'user', + 'action' => '*', + 'resource' => '*', + ], $permission->toNative()); + } +} diff --git a/test/Unit/Authorization/Domain/ValueObject/RoleAssignmentIdTest.php b/test/Unit/Authorization/Domain/ValueObject/RoleAssignmentIdTest.php new file mode 100644 index 0000000..37156e6 --- /dev/null +++ b/test/Unit/Authorization/Domain/ValueObject/RoleAssignmentIdTest.php @@ -0,0 +1,102 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authorization\Domain\Exception\InvalidRoleAssignmentIdException; +use Webify\User\Authorization\Domain\ValueObject\RoleAssignmentId; + +/** + * RoleAssignmentIdTest tests the functionality of the RoleAssignmentId value object. + * + * @internal + */ +#[CoversClass(RoleAssignmentId::class)] +#[CoversMethod(RoleAssignmentId::class, 'fromString')] +#[CoversMethod(RoleAssignmentId::class, 'toNative')] +#[CoversMethod(RoleAssignmentId::class, 'equals')] +#[CoversMethod(RoleAssignmentId::class, '__toString')] +final class RoleAssignmentIdTest extends TestCase +{ + /** + * Tests the creation of a valid RoleAssignmentId instance from a string. + */ + #[Test] + public function testCreateValidRoleAssignmentIdFromString(): void + { + $id = RoleAssignmentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertInstanceOf(RoleAssignmentId::class, $id); + $this->assertSame('01ARZ3NDEKTSV4RRFFQ69G5FAV', $id->toNative()); + } + + /** + * Tests that an attempt to create a RoleAssignmentId with an invalid string + * throws an InvalidRoleAssignmentIdException. + */ + #[Test] + public function testInvalidRoleAssignmentIdThrowsException(): void + { + $this->expectException(InvalidRoleAssignmentIdException::class); + RoleAssignmentId::fromString('invalid-id'); + } + + /** + * Tests that an attempt to create a RoleAssignmentId with a string that is too short + * throws an InvalidRoleAssignmentIdException. + */ + #[Test] + public function testTooShortRoleAssignmentIdThrowsException(): void + { + $this->expectException(InvalidRoleAssignmentIdException::class); + RoleAssignmentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FA'); + } + + /** + * Tests that the equals method returns true when comparing two RoleAssignmentId instances + * created from the same string value. + */ + #[Test] + public function testEqualsReturnsTrueForSameValues(): void + { + $id1 = RoleAssignmentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $id2 = RoleAssignmentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertTrue($id1->equals($id2)); + } + + /** + * Tests that the equals method returns false when comparing two RoleAssignmentId objects with different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentValues(): void + { + $id1 = RoleAssignmentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $id2 = RoleAssignmentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FBW'); + + $this->assertFalse($id1->equals($id2)); + } + + /** + * Tests that the toString method of RoleAssignmentId returns the expected string value. + */ + #[Test] + public function testToStringReturnsStringValue(): void + { + $id = RoleAssignmentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertSame('01ARZ3NDEKTSV4RRFFQ69G5FAV', (string) $id); + } +} diff --git a/test/Unit/Authorization/Domain/ValueObject/RoleIdTest.php b/test/Unit/Authorization/Domain/ValueObject/RoleIdTest.php new file mode 100644 index 0000000..e68e5d8 --- /dev/null +++ b/test/Unit/Authorization/Domain/ValueObject/RoleIdTest.php @@ -0,0 +1,99 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authorization\Domain\Exception\InvalidRoleIdException; +use Webify\User\Authorization\Domain\ValueObject\RoleId; + +/** + * RoleIdTest tests the functionality of the RoleId value object. + * + * @internal + */ +#[CoversClass(RoleId::class)] +#[CoversMethod(RoleId::class, 'fromString')] +#[CoversMethod(RoleId::class, 'toNative')] +#[CoversMethod(RoleId::class, 'equals')] +#[CoversMethod(RoleId::class, '__toString')] +final class RoleIdTest extends TestCase +{ + /** + * Tests creating a valid RoleId from string. + */ + #[Test] + public function testCreateValidRoleIdFromString(): void + { + $roleId = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertInstanceOf(RoleId::class, $roleId); + $this->assertSame('01ARZ3NDEKTSV4RRFFQ69G5FAV', $roleId->toNative()); + } + + /** + * Tests that an exception is thrown when creating RoleId with invalid format. + */ + #[Test] + public function testInvalidRoleIdThrowsException(): void + { + $this->expectException(InvalidRoleIdException::class); + RoleId::fromString('invalid-id'); + } + + /** + * Tests that too short RoleId throws exception. + */ + #[Test] + public function testTooShortRoleIdThrowsException(): void + { + $this->expectException(InvalidRoleIdException::class); + RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FA'); + } + + /** + * Tests equals method returns true for same values. + */ + #[Test] + public function testEqualsReturnsTrueForSameValues(): void + { + $roleId1 = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $roleId2 = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertTrue($roleId1->equals($roleId2)); + } + + /** + * Tests equals method returns false for different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentValues(): void + { + $roleId1 = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $roleId2 = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FBW'); + + $this->assertFalse($roleId1->equals($roleId2)); + } + + /** + * Tests string representation. + */ + #[Test] + public function testToStringReturnsStringValue(): void + { + $roleId = RoleId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertSame('01ARZ3NDEKTSV4RRFFQ69G5FAV', (string) $roleId); + } +} diff --git a/test/Unit/Authorization/Domain/ValueObject/RoleNameTest.php b/test/Unit/Authorization/Domain/ValueObject/RoleNameTest.php new file mode 100644 index 0000000..b257eeb --- /dev/null +++ b/test/Unit/Authorization/Domain/ValueObject/RoleNameTest.php @@ -0,0 +1,89 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authorization\Domain\Exception\InvalidRoleNameException; +use Webify\User\Authorization\Domain\ValueObject\RoleName; + +/** + * RoleNameTest tests the functionality of the RoleName value object. + * + * @internal + */ +#[CoversClass(RoleName::class)] +#[CoversMethod(RoleName::class, 'fromString')] +#[CoversMethod(RoleName::class, 'toNative')] +#[CoversMethod(RoleName::class, 'equals')] +#[CoversMethod(RoleName::class, '__toString')] +final class RoleNameTest extends TestCase +{ + /** + * Tests creating a valid RoleName from string. + */ + #[Test] + public function testCreateValidRoleNameFromString(): void + { + $roleName = RoleName::fromString('Admin'); + + $this->assertInstanceOf(RoleName::class, $roleName); + $this->assertSame('Admin', $roleName->toNative()); + } + + /** + * Tests that an exception is thrown when creating RoleName with empty string. + */ + #[Test] + public function testEmptyRoleNameThrowsException(): void + { + $this->expectException(InvalidRoleNameException::class); + RoleName::fromString(''); + } + + /** + * Tests equals method returns true for same values. + */ + #[Test] + public function testEqualsReturnsTrueForSameValues(): void + { + $roleName1 = RoleName::fromString('Admin'); + $roleName2 = RoleName::fromString('Admin'); + + $this->assertTrue($roleName1->equals($roleName2)); + } + + /** + * Tests equals method returns false for different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentValues(): void + { + $roleName1 = RoleName::fromString('Admin'); + $roleName2 = RoleName::fromString('Editor'); + + $this->assertFalse($roleName1->equals($roleName2)); + } + + /** + * Tests string representation. + */ + #[Test] + public function testToStringReturnsStringValue(): void + { + $roleName = RoleName::fromString('Super Admin'); + + $this->assertSame('Super Admin', (string) $roleName); + } +} diff --git a/test/Unit/Authorization/Domain/ValueObject/RoleSlugTest.php b/test/Unit/Authorization/Domain/ValueObject/RoleSlugTest.php new file mode 100644 index 0000000..aadc16d --- /dev/null +++ b/test/Unit/Authorization/Domain/ValueObject/RoleSlugTest.php @@ -0,0 +1,226 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authorization\Domain\Exception\InvalidRoleSlugException; +use Webify\User\Authorization\Domain\ValueObject\RoleSlug; + +/** + * RoleSlugTest tests the functionality of the RoleSlug value object. + * + * @internal + */ +#[CoversClass(RoleSlug::class)] +#[CoversMethod(RoleSlug::class, 'fromString')] +#[CoversMethod(RoleSlug::class, 'toNative')] +#[CoversMethod(RoleSlug::class, 'equals')] +#[CoversMethod(RoleSlug::class, '__toString')] +#[CoversMethod(RoleSlug::class, 'getVendor')] +#[CoversMethod(RoleSlug::class, 'getSlug')] +final class RoleSlugTest extends TestCase +{ + /** + * Tests the creation of a valid RoleSlug instance from a string. + */ + #[Test] + public function testCreateValidRoleSlugFromString(): void + { + $roleSlug = RoleSlug::fromString('webify.admin'); + + $this->assertInstanceOf(RoleSlug::class, $roleSlug); + $this->assertSame(['vendor' => 'webify', 'slug' => 'admin'], $roleSlug->toNative()); + } + + /** + * Tests that the getVendor method correctly returns the vendor part of a role slug. + */ + #[Test] + public function testGetVendorReturnsVendorPart(): void + { + $roleSlug = RoleSlug::fromString('webify.admin'); + + $this->assertSame('webify', $roleSlug->getVendor()); + } + + /** + * Tests that the getSlug method correctly returns the slug part of a role slug. + */ + #[Test] + public function testGetSlugReturnsSlugPart(): void + { + $roleSlug = RoleSlug::fromString('webify.admin'); + + $this->assertSame('admin', $roleSlug->getSlug()); + } + + /** + * Tests that a role slug with multiple hyphens is correctly created and its native representation is returned. + */ + #[Test] + public function testCreateRoleSlugWithMultipleHyphens(): void + { + $roleSlug = RoleSlug::fromString('webify.super-admin-user'); + + $this->assertSame(['vendor' => 'webify', 'slug' => 'super-admin-user'], $roleSlug->toNative()); + } + + /** + * Tests that an exception is thrown when creating a role slug without a vendor separator. + */ + #[Test] + public function testMissingSeparatorThrowsException(): void + { + $this->expectException(InvalidRoleSlugException::class); + RoleSlug::fromString('admin'); + } + + /** + * Tests that an exception is thrown when the vendor part of a role slug is in uppercase. + */ + #[Test] + public function testUppercaseVendorThrowsException(): void + { + $this->expectException(InvalidRoleSlugException::class); + RoleSlug::fromString('WEBIFY.admin'); + } + + /** + * Tests that an exception is thrown when attempting to create a role slug with uppercase characters. + */ + #[Test] + public function testUppercaseSlugThrowsException(): void + { + $this->expectException(InvalidRoleSlugException::class); + RoleSlug::fromString('webify.Admin'); + } + + /** + * Tests that creating a RoleSlug with a vendor starting with a hyphen throws an InvalidRoleSlugException. + */ + #[Test] + public function testVendorStartingWithHyphenThrowsException(): void + { + $this->expectException(InvalidRoleSlugException::class); + RoleSlug::fromString('-webify.admin'); + } + + /** + * Tests that creating a role slug starting with a hyphen throws an InvalidRoleSlugException. + */ + #[Test] + public function testSlugStartingWithHyphenThrowsException(): void + { + $this->expectException(InvalidRoleSlugException::class); + RoleSlug::fromString('webify.-admin'); + } + + /** + * Tests that an exception is thrown when the vendor part of a role slug ends with a hyphen. + */ + #[Test] + public function testVendorEndingWithHyphenThrowsException(): void + { + $this->expectException(InvalidRoleSlugException::class); + RoleSlug::fromString('webify-.admin'); + } + + /** + * Tests that an exception is thrown when a role slug ends with a hyphen. + */ + #[Test] + public function testSlugEndingWithHyphenThrowsException(): void + { + $this->expectException(InvalidRoleSlugException::class); + RoleSlug::fromString('webify.admin-'); + } + + /** + * Tests that attempting to create a role slug with consecutive hyphens in the vendor part throws an exception. + */ + #[Test] + public function testVendorWithConsecutiveHyphensThrowsException(): void + { + $this->expectException(InvalidRoleSlugException::class); + RoleSlug::fromString('ac--me.admin'); + } + + /** + * Tests that an exception is thrown when a role slug contains consecutive hyphens. + */ + #[Test] + public function testSlugWithConsecutiveHyphensThrowsException(): void + { + $this->expectException(InvalidRoleSlugException::class); + RoleSlug::fromString('webify.admin--user'); + } + + /** + * Tests that an empty string passed to the fromString method throws an InvalidRoleSlugException. + */ + #[Test] + public function testEmptyStringThrowsException(): void + { + $this->expectException(InvalidRoleSlugException::class); + RoleSlug::fromString(''); + } + + /** + * Tests that the equals method returns true when comparing two RoleSlug instances with the same value. + */ + #[Test] + public function testEqualsReturnsTrueForSameValues(): void + { + $roleSlug1 = RoleSlug::fromString('webify.admin'); + $roleSlug2 = RoleSlug::fromString('webify.admin'); + + $this->assertTrue($roleSlug1->equals($roleSlug2)); + } + + /** + * Tests that the equals method returns false when comparing role slugs with different vendors. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentVendors(): void + { + $roleSlug1 = RoleSlug::fromString('webify.admin'); + $roleSlug2 = RoleSlug::fromString('corp.admin'); + + $this->assertFalse($roleSlug1->equals($roleSlug2)); + } + + /** + * Tests that the equals method returns false when comparing two role slugs with different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentSlugs(): void + { + $roleSlug1 = RoleSlug::fromString('webify.admin'); + $roleSlug2 = RoleSlug::fromString('webify.editor'); + + $this->assertFalse($roleSlug1->equals($roleSlug2)); + } + + /** + * Tests that the __toString method correctly returns the string representation of a role slug. + */ + #[Test] + public function testToStringReturnsStringValue(): void + { + $roleSlug = RoleSlug::fromString('webify.super-admin'); + + $this->assertSame('webify.super-admin', (string) $roleSlug); + } +} diff --git a/test/Unit/Authorization/Domain/ValueObject/SubjectIdTest.php b/test/Unit/Authorization/Domain/ValueObject/SubjectIdTest.php new file mode 100644 index 0000000..6144085 --- /dev/null +++ b/test/Unit/Authorization/Domain/ValueObject/SubjectIdTest.php @@ -0,0 +1,101 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authorization\Domain\Exception\InvalidSubjectIdException; +use Webify\User\Authorization\Domain\ValueObject\SubjectId; + +/** + * SubjectIdTest tests the functionality of the SubjectId value object. + * + * @internal + */ +#[CoversClass(SubjectId::class)] +#[CoversMethod(SubjectId::class, 'fromString')] +#[CoversMethod(SubjectId::class, 'toNative')] +#[CoversMethod(SubjectId::class, 'equals')] +#[CoversMethod(SubjectId::class, '__toString')] +final class SubjectIdTest extends TestCase +{ + /** + * Validates the creation of a SubjectId object from a valid string representation. + */ + #[Test] + public function testCreateValidSubjectIdFromString(): void + { + $id = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertInstanceOf(SubjectId::class, $id); + $this->assertSame('01ARZ3NDEKTSV4RRFFQ69G5FAV', $id->toNative()); + } + + /** + * Ensures that an InvalidSubjectIdException is thrown when attempting to create a SubjectId + * object from an invalid string representation. + */ + #[Test] + public function testInvalidSubjectIdThrowsException(): void + { + $this->expectException(InvalidSubjectIdException::class); + SubjectId::fromString('invalid-id'); + } + + /** + * Ensures that attempting to create a SubjectId object from a string that is too short + * throws an InvalidSubjectIdException. + */ + #[Test] + public function testTooShortSubjectIdThrowsException(): void + { + $this->expectException(InvalidSubjectIdException::class); + SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FA'); + } + + /** + * Tests that the equals method returns true when comparing two SubjectId objects created with the same value. + */ + #[Test] + public function testEqualsReturnsTrueForSameValues(): void + { + $id1 = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $id2 = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertTrue($id1->equals($id2)); + } + + /** + * Verifies that the equals method returns false when comparing two SubjectId objects with different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentValues(): void + { + $id1 = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $id2 = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FBW'); + + $this->assertFalse($id1->equals($id2)); + } + + /** + * Verifies that the SubjectId object correctly returns its string representation. + */ + #[Test] + public function testToStringReturnsStringValue(): void + { + $id = SubjectId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertSame('01ARZ3NDEKTSV4RRFFQ69G5FAV', (string) $id); + } +} diff --git a/test/Unit/Authorization/Domain/ValueObject/TenantIdTest.php b/test/Unit/Authorization/Domain/ValueObject/TenantIdTest.php new file mode 100644 index 0000000..f556da4 --- /dev/null +++ b/test/Unit/Authorization/Domain/ValueObject/TenantIdTest.php @@ -0,0 +1,104 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Authorization\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Authorization\Domain\Exception\InvalidTenantIdException; +use Webify\User\Authorization\Domain\ValueObject\TenantId; + +/** + * TenantIdTest tests the functionality of the TenantId value object. + * + * @internal + */ +#[CoversClass(TenantId::class)] +#[CoversMethod(TenantId::class, 'fromString')] +#[CoversMethod(TenantId::class, 'toNative')] +#[CoversMethod(TenantId::class, 'equals')] +#[CoversMethod(TenantId::class, '__toString')] +final class TenantIdTest extends TestCase +{ + /** + * Tests the creation of a valid TenantId object from a string. + */ + #[Test] + public function testCreateValidTenantIdFromString(): void + { + $id = TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertInstanceOf(TenantId::class, $id); + $this->assertSame('01ARZ3NDEKTSV4RRFFQ69G5FAV', $id->toNative()); + } + + /** + * Tests that an InvalidTenantIdException is thrown when trying to create a TenantId + * instance with an invalid ID string. + */ + #[Test] + public function testInvalidTenantIdThrowsException(): void + { + $this->expectException(InvalidTenantIdException::class); + TenantId::fromString('invalid-id'); + } + + /** + * Tests that an exception is thrown when attempting to create a TenantId + * with a string that is too short to be valid. + */ + #[Test] + public function testTooShortTenantIdThrowsException(): void + { + $this->expectException(InvalidTenantIdException::class); + TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FA'); + } + + /** + * Tests that the equals method returns true when comparing two instances + * of TenantId created with the same value. + */ + #[Test] + public function testEqualsReturnsTrueForSameValues(): void + { + $id1 = TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $id2 = TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertTrue($id1->equals($id2)); + } + + /** + * Tests that the equals method returns false when comparing two instances + * of TenantId created with different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentValues(): void + { + $id1 = TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $id2 = TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FBW'); + + $this->assertFalse($id1->equals($id2)); + } + + /** + * Tests that the toString method returns the string representation + * of the TenantId value. + */ + #[Test] + public function testToStringReturnsStringValue(): void + { + $id = TenantId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertSame('01ARZ3NDEKTSV4RRFFQ69G5FAV', (string) $id); + } +} diff --git a/test/Unit/Identity/Domain/Entity/UserTest.php b/test/Unit/Identity/Domain/Entity/UserTest.php new file mode 100644 index 0000000..c94fa56 --- /dev/null +++ b/test/Unit/Identity/Domain/Entity/UserTest.php @@ -0,0 +1,221 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Identity\Domain\Entity; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Identity\Domain\Entity\User; +use Webify\User\Identity\Domain\Event\{UserDisplayNameWasChanged, + UserEmailWasChanged, + UserPasswordWasChanged, + UserWasActivated, + UserWasDeactivated, + UserWasRegistered}; +use Webify\User\Identity\Domain\Exception\{UserAlreadyActivatedException, + UserAlreadyDeactivatedException, + UserCannotBeDeactivatedException}; +use Webify\User\Identity\Domain\ValueObject\{DisplayName, PasswordHash, UserEmail, UserId}; +use Webify\User\Identity\Infrastructure\Service\PasswordHasher; + +/** + * UserTest tests the functionality of the User entity. + * + * @internal + */ +#[CoversClass(User::class)] +#[CoversMethod(User::class, 'activate')] +#[CoversMethod(User::class, 'deactivate')] +#[CoversMethod(User::class, 'changeDisplayName')] +#[CoversMethod(User::class, 'changeEmail')] +#[CoversMethod(User::class, 'changePassword')] +#[CoversMethod(User::class, 'getDisplayName')] +final class UserTest extends TestCase +{ + /** + * User instance. + */ + private User $user; + + /** + * Password hasher instance. + */ + private PasswordHasher $passwordHasher; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->passwordHasher = new PasswordHasher(); + $this->user = User::register( + UserId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'), + UserEmail::fromString('test@example.com'), + PasswordHash::fromHash($this->passwordHasher->hash('password')), + DisplayName::fromString('Test User') + ); + } + + /** + * Tests user-registered event. + */ + #[Test] + public function testUserRegisteredEventUser(): void + { + $events = $this->user->getDomainEvents(); + + $this->assertCount(1, $events); + $this->assertInstanceOf(UserWasRegistered::class, $events[0]); + } + + /** + * Tests user initial status. + */ + #[Test] + public function testUserInitialStatusIsInactive(): void + { + $this->assertTrue($this->user->getStatus()->isInactive()); + } + + /** + * Tests activating a user. + */ + #[Test] + public function testActivateUser(): void + { + $this->user->activate(); + + $this->assertTrue($this->user->getStatus()->isActive()); + + $events = $this->user->getDomainEvents(); + + $this->assertCount(2, $events); + $this->assertInstanceOf(UserWasActivated::class, $events[1]); + } + + /** + * Tests activating an already activated user throws an exception. + */ + #[Test] + public function testActivateAlreadyActivatedUserThrowsException(): void + { + $this->user->activate(); + $this->expectException(UserAlreadyActivatedException::class); + $this->user->activate(); + } + + /** + * Tests deactivating a user. + */ + #[Test] + public function testDeactivateUser(): void + { + $this->user->activate(); + $this->user->deactivate(); + + $this->assertTrue($this->user->getStatus()->isDeactivated()); + + $events = $this->user->getDomainEvents(); + + $this->assertCount(3, $events); + $this->assertInstanceOf(UserWasDeactivated::class, $events[2]); + } + + /** + * Tests deactivating an already deactivated user throws an exception. + */ + #[Test] + public function testDeactivateAlreadyDeactivatedUserThrowsException(): void + { + $this->user->activate(); + $this->user->deactivate(); + $this->expectException(UserAlreadyDeactivatedException::class); + $this->user->deactivate(); + } + + /** + * Tests deactivating a non-active user will throw an exception. + */ + #[Test] + public function testDeactivateNonActiveUserThrowsException(): void + { + $this->expectException(UserCannotBeDeactivatedException::class); + $this->user->deactivate(); + } + + /** + * Tests changing the email address. + */ + #[Test] + public function testChangeEmail(): void + { + $newEmail = UserEmail::fromString('newemail@example.com'); + + $this->user->changeEmail($newEmail); + $this->assertSame('newemail@example.com', $this->user->getEmail()->toNative()); + + $events = $this->user->getDomainEvents(); + + $this->assertCount(2, $events); + $this->assertInstanceOf(UserEmailWasChanged::class, $events[1]); + } + + /** + * Tests changing the password. + */ + #[Test] + public function testChangePassword(): void + { + $newPassword = PasswordHash::fromHash($this->passwordHasher->hash('new_password')); + + $this->user->changePassword($newPassword); + $this->assertTrue($this->user->getPasswordHash()->equals($newPassword)); + + $events = $this->user->getDomainEvents(); + + $this->assertCount(2, $events); + $this->assertInstanceOf(UserPasswordWasChanged::class, $events[1]); + } + + #[Test] + public function testGetDisplayName(): void + { + $this->assertSame('Test User', $this->user->getDisplayName()->toNative()); + } + + #[Test] + public function testChangeDisplayName(): void + { + $newDisplayName = DisplayName::fromString('New Display Name'); + + $this->user->changeDisplayName($newDisplayName); + $this->assertTrue($this->user->getDisplayName()->equals($newDisplayName)); + + $events = $this->user->getDomainEvents(); + + $this->assertCount(2, $events); + $this->assertInstanceOf(UserDisplayNameWasChanged::class, $events[1]); + } + + #[Test] + public function testChangeDisplayNameWithSameNameDoesNotRecordEvent(): void + { + $events = $this->user->getDomainEvents(); + $this->assertCount(1, $events); + + $this->user->changeDisplayName(DisplayName::fromString('Test User')); + + $events = $this->user->getDomainEvents(); + $this->assertCount(1, $events); + } +} diff --git a/test/Unit/Identity/Domain/Guard/EmailMustBeUniqueTest.php b/test/Unit/Identity/Domain/Guard/EmailMustBeUniqueTest.php new file mode 100644 index 0000000..5da4f07 --- /dev/null +++ b/test/Unit/Identity/Domain/Guard/EmailMustBeUniqueTest.php @@ -0,0 +1,72 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Identity\Domain\Guard; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Identity\Domain\Exception\EmailMustBeUniqueException; +use Webify\User\Identity\Domain\Guard\EmailMustBeUnique; +use Webify\User\Identity\Domain\Repository\UserRepositoryInterface; +use Webify\User\Identity\Domain\ValueObject\UserEmail; + +/** + * EmailMustBeUniqueTest tests the functionality of the EmailMustBeUnique guard. + * + * @internal + */ +#[CoversClass(EmailMustBeUnique::class)] +#[CoversMethod(EmailMustBeUnique::class, 'guard')] +final class EmailMustBeUniqueTest extends TestCase +{ + /** + * Tests that the guard passes when the email does not exist in the repository. + */ + #[Test] + public function testGuardPassesWhenEmailIsUnique(): void + { + $email = UserEmail::fromString('test@example.com'); + $repository = $this->createMock(UserRepositoryInterface::class); + + $repository->expects($this->once()) + ->method('isExists') + ->with($email) + ->willReturn(false) + ; + + $guard = new EmailMustBeUnique($repository); + + $guard->guard($email); + } + + /** + * Tests that the guard throws an exception when the email already exists in the repository. + */ + #[Test] + public function testGuardThrowsExceptionWhenEmailIsNotUnique(): void + { + $email = UserEmail::fromString('test@example.com'); + $repository = $this->createMock(UserRepositoryInterface::class); + + $repository->expects($this->once()) + ->method('isExists') + ->with($email) + ->willReturn(true) + ; + + $guard = new EmailMustBeUnique($repository); + + $this->expectException(EmailMustBeUniqueException::class); + $guard->guard($email); + } +} diff --git a/test/Unit/Identity/Domain/Guard/PasswordMustBeStrongTest.php b/test/Unit/Identity/Domain/Guard/PasswordMustBeStrongTest.php new file mode 100644 index 0000000..065c0a1 --- /dev/null +++ b/test/Unit/Identity/Domain/Guard/PasswordMustBeStrongTest.php @@ -0,0 +1,114 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Identity\Domain\Guard; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Identity\Domain\Exception\WeakPasswordException; +use Webify\User\Identity\Domain\Guard\PasswordMustBeStrong; +use Webify\User\Identity\Domain\Policy\PasswordPolicy; + +/** + * PasswordMustBeStrongTest tests the PasswordMustBeStrong guard. + * + * @internal + */ +#[CoversClass(PasswordMustBeStrong::class)] +#[CoversMethod(PasswordMustBeStrong::class, 'guard')] +final class PasswordMustBeStrongTest extends TestCase +{ + /** + * Tests that the guard passes when the password meets all policy requirements. + */ + #[Test] + public function testGuardPassesWhenPasswordSatisfiesAllRequirements(): void + { + $guard = new PasswordMustBeStrong(new PasswordPolicy()); + + $this->expectNotToPerformAssertions(); + $guard->guard('StrongPass1!'); + } + + /** + * Tests that the guard throws an exception when the password is too short. + */ + #[Test] + public function testGuardThrowsExceptionWhenPasswordIsTooShort(): void + { + $guard = new PasswordMustBeStrong(new PasswordPolicy()); + + $this->expectException(WeakPasswordException::class); + $guard->guard('Ab1!'); + } + + /** + * Tests that the guard throws an exception when the password exceeds the maximum length. + */ + #[Test] + public function testGuardThrowsExceptionWhenPasswordIsTooLong(): void + { + $guard = new PasswordMustBeStrong(new PasswordPolicy()); + + $this->expectException(WeakPasswordException::class); + $guard->guard('A1!' . str_repeat('x', 130)); + } + + /** + * Tests that the guard throws an exception when the password has insufficient uppercase characters. + */ + #[Test] + public function testGuardThrowsExceptionWhenMissingUppercase(): void + { + $guard = new PasswordMustBeStrong(new PasswordPolicy()); + + $this->expectException(WeakPasswordException::class); + $guard->guard('lowercase123!'); + } + + /** + * Tests that the guard throws an exception when the password has insufficient lowercase characters. + */ + #[Test] + public function testGuardThrowsExceptionWhenMissingLowercase(): void + { + $guard = new PasswordMustBeStrong(new PasswordPolicy()); + + $this->expectException(WeakPasswordException::class); + $guard->guard('UPPERCASE12!'); + } + + /** + * Tests that the guard throws an exception when the password has no digits. + */ + #[Test] + public function testGuardThrowsExceptionWhenMissingDigit(): void + { + $guard = new PasswordMustBeStrong(new PasswordPolicy()); + + $this->expectException(WeakPasswordException::class); + $guard->guard('UPPERlower!!'); + } + + /** + * Tests that the guard throws an exception when the password has no special characters. + */ + #[Test] + public function testGuardThrowsExceptionWhenMissingSpecialCharacter(): void + { + $guard = new PasswordMustBeStrong(new PasswordPolicy()); + + $this->expectException(WeakPasswordException::class); + $guard->guard('UPPERlower12'); + } +} diff --git a/test/Unit/Identity/Domain/Service/PasswordHasherTest.php b/test/Unit/Identity/Domain/Service/PasswordHasherTest.php new file mode 100644 index 0000000..591f7e6 --- /dev/null +++ b/test/Unit/Identity/Domain/Service/PasswordHasherTest.php @@ -0,0 +1,109 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Identity\Domain\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Identity\Infrastructure\Service\PasswordHasher; + +/** + * PasswordHashTest tests the functionality of the PasswordHash service. + * + * @internal + * + * @todo Should find out how to test the password_hash failure to test expected exception. + */ +#[CoversClass(PasswordHasher::class)] +#[CoversMethod(PasswordHasher::class, 'hash')] +#[CoversMethod(PasswordHasher::class, 'verify')] +final class PasswordHasherTest extends TestCase +{ + /** + * Password hash service instance. + */ + private PasswordHasher $passwordHash; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->passwordHash = new PasswordHasher(); + } + + /** + * Tests that the hash method returns a non-empty hashed password + * for the provided input string. + */ + #[Test] + public function testHashReturnsHashedPassword(): void + { + $password = 'test_password_123'; + $hashedPassword = $this->passwordHash->hash($password); + + $this->assertNotEmpty($hashedPassword); + } + + /** + * Tests that the verify method returns true when provided with the correct password and its hashed counterpart. + */ + #[Test] + public function testVerifyReturnsTrueForCorrectPassword(): void + { + $password = 'test_password_123'; + $hashedPassword = $this->passwordHash->hash($password); + + $this->assertTrue($this->passwordHash->verify($password, $hashedPassword)); + } + + /** + * Tests that the password verification method returns false + * when a wrong password is provided for a given hashed password. + */ + #[Test] + public function testVerifyReturnsFalseForIncorrectPassword(): void + { + $correctPassword = 'correctPassword'; + $wrongPassword = 'wrongPassword'; + $hashedPassword = $this->passwordHash->hash($correctPassword); + + $this->assertFalse($this->passwordHash->verify($wrongPassword, $hashedPassword)); + } + + /** + * Tests that the `hash` method generates unique hashes for the same input password. + */ + #[Test] + public function testHashGeneratesUniqueHashes(): void + { + $password = 'test_password_123'; + $hash1 = $this->passwordHash->hash($password); + $hash2 = $this->passwordHash->hash($password); + + $this->assertNotSame($hash1, $hash2); + } + + /** + * Tests that a hashed password can be successfully verified against its plain text version. + */ + #[Test] + public function testHashedPasswordCanBeVerified(): void + { + $password = 'test_password_123'; + $hashedPassword = $this->passwordHash->hash($password); + $result = $this->passwordHash->verify($password, $hashedPassword); + + $this->assertTrue($result); + } +} diff --git a/test/Unit/Identity/Domain/Service/RegisterUserTest.php b/test/Unit/Identity/Domain/Service/RegisterUserTest.php new file mode 100644 index 0000000..be88f07 --- /dev/null +++ b/test/Unit/Identity/Domain/Service/RegisterUserTest.php @@ -0,0 +1,161 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Identity\Domain\Service; + +use PHPUnit\Framework\Attributes\{AllowMockObjectsWithoutExpectations, CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Contract\Identity\Service\PasswordHasherInterface; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Domain\Service\UlidGeneratorInterface; +use Webify\User\Identity\Domain\Entity\User; +use Webify\User\Identity\Domain\Exception\EmailMustBeUniqueException; +use Webify\User\Identity\Domain\Guard\{EmailMustBeUnique, PasswordMustBeStrong}; +use Webify\User\Identity\Domain\Policy\PasswordPolicy; +use Webify\User\Identity\Domain\Repository\UserRepositoryInterface; +use Webify\User\Identity\Domain\Service\RegisterUser; +use Webify\User\Identity\Domain\ValueObject\UserEmail; + +/** + * RegisterUserTest tests the RegisterUser domain service. + * + * @internal + */ +#[CoversClass(RegisterUser::class)] +#[CoversMethod(RegisterUser::class, 'register')] +final class RegisterUserTest extends TestCase +{ + /** + * Password hasher service instance. + */ + private MockObject&PasswordHasherInterface $passwordHasher; + + /** + * Repository instance. + */ + private MockObject&UserRepositoryInterface $repository; + + /** + * Ulid generator service instance. + */ + private MockObject&UlidGeneratorInterface $ulidGenerator; + + /** + * Domain event publisher service instance. + */ + private DomainEventPublisherInterface&MockObject $eventPublisher; + + /** + * The RegisterUser instance. + */ + private RegisterUser $registerUser; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->passwordHasher = $this->createMock(PasswordHasherInterface::class); + $this->repository = $this->createMock(UserRepositoryInterface::class); + $this->ulidGenerator = $this->createMock(UlidGeneratorInterface::class); + $this->eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + + $this->registerUser = new RegisterUser( + $this->passwordHasher, + $this->repository, + new EmailMustBeUnique($this->repository), + new PasswordMustBeStrong( + new PasswordPolicy( + minimumLength: 1, + maximumLength: 255, + minimumUppercaseChars: 0, + minimumLowercaseChars: 0, + minimumDigits: 0, + minimumSpecialChars: 0, + ) + ), + $this->ulidGenerator, + $this->eventPublisher + ); + } + + /** + * Tests the successful registration of a new user. + */ + #[Test] + public function testRegisterUserSuccessfully(): void + { + $email = 'test@example.com'; + $password = 'password123'; + $displayName = 'Test User'; + $generatedId = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + $hashedPass = password_hash($password, PASSWORD_DEFAULT); + + $this->repository + ->expects($this->once()) + ->method('isExists') + ->with($this->callback( + function (UserEmail $userEmail) use ($email): bool { + return $userEmail->toNative() === $email; + } + )) + ->willReturn(false) + ; + $this->passwordHasher + ->expects($this->once()) + ->method('hash') + ->with($password) + ->willReturn($hashedPass) + ; + $this->ulidGenerator + ->expects($this->once()) + ->method('generate') + ->willReturn($generatedId) + ; + $this->repository + ->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(User::class)) + ; + $this->eventPublisher + ->expects($this->once()) + ->method('publish') + ; + + $this->registerUser->register($email, $password, $displayName); + } + + /** + * Tests that registration throws an exception when the email already exists. + */ + #[Test] + #[AllowMockObjectsWithoutExpectations] + public function testRegisterThrowsExceptionWhenEmailAlreadyExists(): void + { + $email = 'existing@example.com'; + + $this->repository + ->expects($this->once()) + ->method('isExists') + ->with($this->callback( + function (UserEmail $userEmail) use ($email): bool { + return $userEmail->toNative() === $email; + } + )) + ->willReturn(true) + ; + $this->expectException(EmailMustBeUniqueException::class); + $this->registerUser->register($email, 'password123', 'Test User'); + } +} diff --git a/test/Unit/Identity/Domain/Service/UpdateUserEmailTest.php b/test/Unit/Identity/Domain/Service/UpdateUserEmailTest.php new file mode 100644 index 0000000..f9c5f43 --- /dev/null +++ b/test/Unit/Identity/Domain/Service/UpdateUserEmailTest.php @@ -0,0 +1,195 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Identity\Domain\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\User\Identity\Domain\Entity\User; +use Webify\User\Identity\Domain\Event\UserEmailWasChanged; +use Webify\User\Identity\Domain\Exception\EmailMustBeUniqueException; +use Webify\User\Identity\Domain\Guard\EmailMustBeUnique; +use Webify\User\Identity\Domain\Repository\UserRepositoryInterface; +use Webify\User\Identity\Domain\Service\UpdateUserEmail; +use Webify\User\Identity\Domain\ValueObject\{DisplayName, PasswordHash, UserEmail, UserId}; + +/** + * UpdateUserEmailTest tests the UpdateUserEmail domain service. + * + * @internal + */ +#[CoversClass(UpdateUserEmail::class)] +#[CoversMethod(UpdateUserEmail::class, 'update')] +final class UpdateUserEmailTest extends TestCase +{ + /** + * Repository instance. + */ + private MockObject&UserRepositoryInterface $repository; + + /** + * Domain event publisher service instance. + */ + private DomainEventPublisherInterface&MockObject $eventPublisher; + + /** + * The UpdateUserEmail instance. + */ + private UpdateUserEmail $updateUserEmail; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->repository = $this->createMock(UserRepositoryInterface::class); + $this->eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + $this->updateUserEmail = new UpdateUserEmail( + new EmailMustBeUnique($this->repository), + $this->repository, + $this->eventPublisher + ); + } + + /** + * Tests the successful update of a user's email address. + * + * This method verifies the full email update flow: + * - The user is retrieved from the repository using the provided user ID. + * - The new email is checked for uniqueness before the aggregate is changed. + * - The updated user is persisted back to the repository. + * - A UserEmailWasChanged domain event is published with the old and new email addresses. + */ + #[Test] + public function testUpdateUserEmailSuccessfully(): void + { + $userId = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + $currentEmail = 'test@example.com'; + $newEmail = 'newemail@example.com'; + $user = $this->createUser($userId, $currentEmail); + + $this->repository + ->expects($this->once()) + ->method('getById') + ->with($this->callback( + function (UserId $providedUserId) use ($userId): bool { + return $providedUserId->toNative() === $userId; + } + )) + ->willReturn($user) + ; + $this->repository + ->expects($this->once()) + ->method('isExists') + ->with($this->callback( + function (UserEmail $providedEmail) use ($newEmail): bool { + return $providedEmail->toNative() === $newEmail; + } + )) + ->willReturn(false) + ; + $this->repository + ->expects($this->once()) + ->method('persist') + ->with($this->callback( + function (User $persistedUser) use ($user, $newEmail): bool { + $this->assertSame($user, $persistedUser); + $this->assertSame($newEmail, $persistedUser->getEmail()->toNative()); + + return true; + } + )) + ; + $this->eventPublisher + ->expects($this->once()) + ->method('publish') + ->with($this->callback( + function (UserEmailWasChanged $event) use ($userId, $currentEmail, $newEmail): bool { + $this->assertSame($userId, $event->userId); + $this->assertSame($currentEmail, $event->oldEmail); + $this->assertSame($newEmail, $event->newEmail); + + return true; + } + )) + ; + $this->updateUserEmail->update($userId, $newEmail); + } + + /** + * Tests that the update method throws an exception when the new email already exists. + * + * The method verifies that the EmailMustBeUniqueException is raised before + * persisting the user or publishing domain events. + */ + #[Test] + public function testUpdateThrowsExceptionWhenEmailAlreadyExists(): void + { + $userId = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + $newEmail = 'existing@example.com'; + $user = $this->createUser($userId, 'test@example.com'); + + $this->repository + ->expects($this->once()) + ->method('getById') + ->with($this->callback( + function (UserId $providedUserId) use ($userId): bool { + return $providedUserId->toNative() === $userId; + } + )) + ->willReturn($user) + ; + $this->repository + ->expects($this->once()) + ->method('isExists') + ->with($this->callback( + function (UserEmail $providedEmail) use ($newEmail): bool { + return $providedEmail->toNative() === $newEmail; + } + )) + ->willReturn(true) + ; + $this->repository + ->expects($this->never()) + ->method('persist') + ; + $this->eventPublisher + ->expects($this->never()) + ->method('publish') + ; + $this->expectException(EmailMustBeUniqueException::class); + $this->updateUserEmail->update($userId, $newEmail); + } + + /** + * Creates a user aggregate for email update tests. + * + * The registration event is released to represent a user already + * loaded from persistence before the email update use case starts. + */ + private function createUser(string $userId, string $email): User + { + $user = User::register( + UserId::fromString($userId), + UserEmail::fromString($email), + PasswordHash::fromHash('password_hash'), + DisplayName::fromString('Test User') + ); + + $user->releaseDomainEvents(); + + return $user; + } +} diff --git a/test/Unit/Identity/Domain/Service/UpdateUserPasswordTest.php b/test/Unit/Identity/Domain/Service/UpdateUserPasswordTest.php new file mode 100644 index 0000000..be2eb10 --- /dev/null +++ b/test/Unit/Identity/Domain/Service/UpdateUserPasswordTest.php @@ -0,0 +1,211 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Identity\Domain\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Contract\Identity\Service\PasswordHasherInterface; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\User\Identity\Domain\Entity\User; +use Webify\User\Identity\Domain\Event\UserPasswordWasChanged; +use Webify\User\Identity\Domain\Exception\CurrentPasswordNotMatchedException; +use Webify\User\Identity\Domain\Guard\PasswordMustBeStrong; +use Webify\User\Identity\Domain\Policy\PasswordPolicy; +use Webify\User\Identity\Domain\Repository\UserRepositoryInterface; +use Webify\User\Identity\Domain\Service\UpdateUserPassword; +use Webify\User\Identity\Domain\ValueObject\{DisplayName, PasswordHash, UserEmail, UserId}; + +/** + * UpdateUserPasswordTest tests the UpdateUserPassword domain service. + * + * @internal + */ +#[CoversClass(UpdateUserPassword::class)] +#[CoversMethod(UpdateUserPassword::class, 'update')] +final class UpdateUserPasswordTest extends TestCase +{ + /** + * Password hasher service instance. + */ + private MockObject&PasswordHasherInterface $passwordHasher; + + /** + * Repository instance. + */ + private MockObject&UserRepositoryInterface $repository; + + /** + * Domain event publisher service instance. + */ + private DomainEventPublisherInterface&MockObject $eventPublisher; + + /** + * The UpdateUserPassword instance. + */ + private UpdateUserPassword $updateUserPassword; + + /** + * {@inheritDoc} + */ + protected function setUp(): void + { + $this->passwordHasher = $this->createMock(PasswordHasherInterface::class); + $this->repository = $this->createMock(UserRepositoryInterface::class); + $this->eventPublisher = $this->createMock(DomainEventPublisherInterface::class); + $this->updateUserPassword = new UpdateUserPassword( + $this->passwordHasher, + $this->repository, + new PasswordMustBeStrong( + new PasswordPolicy( + minimumLength: 1, + maximumLength: 255, + minimumUppercaseChars: 0, + minimumLowercaseChars: 0, + minimumDigits: 0, + minimumSpecialChars: 0, + ) + ), + $this->eventPublisher + ); + } + + /** + * Tests the successful update of a user's password. + * + * This method verifies the full password update flow: + * - The user is retrieved from the repository using the provided user ID. + * - The current password is verified against the stored password hash. + * - The new password is hashed and applied to the user aggregate. + * - The updated user is persisted back to the repository. + * - A UserPasswordWasChanged domain event is published. + */ + #[Test] + public function testUpdateUserPasswordSuccessfully(): void + { + $userId = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + $currentPassword = 'current_password'; + $newPassword = 'new_password'; + $currentPasswordHash = 'current_password_hash'; + $newPasswordHash = 'new_password_hash'; + $user = $this->createUser($userId, $currentPasswordHash); + + $this->repository + ->expects($this->once()) + ->method('getById') + ->with($this->callback( + function (UserId $providedUserId) use ($userId): bool { + return $providedUserId->toNative() === $userId; + } + )) + ->willReturn($user) + ; + $this->passwordHasher + ->expects($this->once()) + ->method('verify') + ->with($currentPassword, $currentPasswordHash) + ->willReturn(true) + ; + $this->passwordHasher + ->expects($this->once()) + ->method('hash') + ->with($newPassword) + ->willReturn($newPasswordHash) + ; + $this->repository + ->expects($this->once()) + ->method('persist') + ->with($this->callback( + function (User $persistedUser) use ($user, $newPasswordHash): bool { + $this->assertSame($user, $persistedUser); + $this->assertSame($newPasswordHash, $persistedUser->getPasswordHash()->toNative()); + + return true; + } + )) + ; + $this->eventPublisher + ->expects($this->once()) + ->method('publish') + ->with($this->isInstanceOf(UserPasswordWasChanged::class)) + ; + $this->updateUserPassword->update($userId, $currentPassword, $newPassword); + } + + /** + * Tests that the update method throws an exception when the current password is invalid. + * + * The method verifies that the CurrentPasswordNotMatchedException is raised + * before hashing the new password, persisting the user, or publishing events. + */ + #[Test] + public function testUpdateThrowsExceptionWhenCurrentPasswordIsInvalid(): void + { + $userId = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + $currentPassword = 'wrong_current_password'; + $currentPasswordHash = 'current_password_hash'; + $user = $this->createUser($userId, $currentPasswordHash); + + $this->repository + ->expects($this->once()) + ->method('getById') + ->with($this->callback( + function (UserId $providedUserId) use ($userId): bool { + return $providedUserId->toNative() === $userId; + } + )) + ->willReturn($user) + ; + $this->passwordHasher + ->expects($this->once()) + ->method('verify') + ->with($currentPassword, $currentPasswordHash) + ->willReturn(false) + ; + $this->passwordHasher + ->expects($this->never()) + ->method('hash') + ; + $this->repository + ->expects($this->never()) + ->method('persist') + ; + $this->eventPublisher + ->expects($this->never()) + ->method('publish') + ; + $this->expectException(CurrentPasswordNotMatchedException::class); + $this->updateUserPassword->update($userId, $currentPassword, 'new_password'); + } + + /** + * Creates a user aggregate for password update tests. + * + * The registration event is released to represent a user already + * loaded from persistence before the password update use case starts. + */ + private function createUser(string $userId, string $passwordHash): User + { + $user = User::register( + UserId::fromString($userId), + UserEmail::fromString('test@example.com'), + PasswordHash::fromHash($passwordHash), + DisplayName::fromString('Test User') + ); + + $user->releaseDomainEvents(); + + return $user; + } +} diff --git a/test/Unit/Identity/Domain/ValueObject/DisplayNameTest.php b/test/Unit/Identity/Domain/ValueObject/DisplayNameTest.php new file mode 100644 index 0000000..7b853bc --- /dev/null +++ b/test/Unit/Identity/Domain/ValueObject/DisplayNameTest.php @@ -0,0 +1,141 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Identity\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Identity\Domain\Exception\InvalidDisplayNameException; +use Webify\User\Identity\Domain\ValueObject\DisplayName; + +/** + * DisplayNameTest tests the functionality of the DisplayName value object. + * + * @internal + */ +#[CoversClass(DisplayName::class)] +#[CoversMethod(DisplayName::class, 'fromString')] +#[CoversMethod(DisplayName::class, 'equals')] +#[CoversMethod(DisplayName::class, '__toString')] +#[CoversMethod(DisplayName::class, 'toNative')] +final class DisplayNameTest extends TestCase +{ + /** + * Tests the creation of a valid display name from a string. + */ + #[Test] + public function testCreateValidDisplayName(): void + { + $displayName = DisplayName::fromString('Test User'); + + $this->assertInstanceOf(DisplayName::class, $displayName); + $this->assertSame('Test User', $displayName->toNative()); + } + + /** + * Tests that the display name value is trimmed of whitespace during creation. + */ + #[Test] + public function testCreateDisplayNameTrimsValue(): void + { + $displayName = DisplayName::fromString(' Test User '); + + $this->assertSame('Test User', $displayName->toNative()); + } + + /** + * Tests that creating a display name with fewer than the minimum allowed characters throws an exception. + */ + #[Test] + public function testDisplayNameTooShortThrowsException(): void + { + $this->expectException(InvalidDisplayNameException::class); + DisplayName::fromString('AB'); + } + + /** + * Tests that creating a display name with more than the maximum allowed characters throws an exception. + */ + #[Test] + public function testDisplayNameTooLongThrowsException(): void + { + $this->expectException(InvalidDisplayNameException::class); + DisplayName::fromString(str_repeat('A', 256)); + } + + /** + * Tests that creating a display name with an empty string throws an exception. + */ + #[Test] + public function testEmptyDisplayNameThrowsException(): void + { + $this->expectException(InvalidDisplayNameException::class); + DisplayName::fromString(''); + } + + /** + * Tests that creating a display name with only whitespace characters throws an exception. + */ + #[Test] + public function testWhitespaceOnlyDisplayNameThrowsException(): void + { + $this->expectException(InvalidDisplayNameException::class); + DisplayName::fromString(' '); + } + + /** + * Tests the toNative method returns the string value. + */ + #[Test] + public function testToNativeReturnsStringValue(): void + { + $displayName = DisplayName::fromString('Test User'); + + $this->assertSame('Test User', $displayName->toNative()); + } + + /** + * Tests the string representation of the display name. + */ + #[Test] + public function testStringRepresentation(): void + { + $displayName = DisplayName::fromString('Test User'); + + $this->assertSame('Test User', (string) $displayName); + } + + /** + * Tests that equals returns true for two display names with the same value. + */ + #[Test] + public function testEqualsReturnsTrueForSameValue(): void + { + $displayName1 = DisplayName::fromString('Test User'); + $displayName2 = DisplayName::fromString('Test User'); + + $this->assertTrue($displayName1->equals($displayName2)); + } + + /** + * Tests that equals returns false for two display names with different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentValue(): void + { + $displayName1 = DisplayName::fromString('User One'); + $displayName2 = DisplayName::fromString('User Two'); + + $this->assertFalse($displayName1->equals($displayName2)); + } +} diff --git a/test/Unit/Identity/Domain/ValueObject/PasswordHashTest.php b/test/Unit/Identity/Domain/ValueObject/PasswordHashTest.php new file mode 100644 index 0000000..977c8c0 --- /dev/null +++ b/test/Unit/Identity/Domain/ValueObject/PasswordHashTest.php @@ -0,0 +1,86 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Identity\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Identity\Domain\Exception\InvalidPasswordHashException; +use Webify\User\Identity\Domain\ValueObject\PasswordHash; + +/** + * PasswordHashTest tests the functionality of the HashedPassword value object. + * + * @internal + */ +#[CoversClass(PasswordHash::class)] +#[CoversMethod(PasswordHash::class, 'fromHash')] +#[CoversMethod(PasswordHash::class, 'equals')] +#[CoversMethod(PasswordHash::class, '__toString')] +#[CoversMethod(PasswordHash::class, 'toNative')] +final class PasswordHashTest extends TestCase +{ + #[Test] + public function testCreateValidHashedPassword(): void + { + $hash = password_hash('test_password', PASSWORD_DEFAULT); + $hashedPassword = PasswordHash::fromHash($hash); + + $this->assertInstanceOf(PasswordHash::class, $hashedPassword); + $this->assertSame($hash, $hashedPassword->toNative()); + } + + #[Test] + public function testEmptyHashThrowsException(): void + { + $this->expectException(InvalidPasswordHashException::class); + PasswordHash::fromHash(''); + } + + #[Test] + public function testToNativeReturnsStringValue(): void + { + $hash = password_hash('test_password', PASSWORD_DEFAULT); + $hashedPassword = PasswordHash::fromHash($hash); + + $this->assertSame($hash, $hashedPassword->toNative()); + } + + #[Test] + public function testStringRepresentation(): void + { + $hash = password_hash('test_password', PASSWORD_DEFAULT); + $hashedPassword = PasswordHash::fromHash($hash); + + $this->assertSame($hash, (string) $hashedPassword); + } + + #[Test] + public function testEqualsReturnsTrueForSameHash(): void + { + $hash = password_hash('test_password', PASSWORD_DEFAULT); + $hashedPassword1 = PasswordHash::fromHash($hash); + $hashedPassword2 = PasswordHash::fromHash($hash); + + $this->assertTrue($hashedPassword1->equals($hashedPassword2)); + } + + #[Test] + public function testEqualsReturnsFalseForDifferentHash(): void + { + $hashedPassword1 = PasswordHash::fromHash('hash1'); + $hashedPassword2 = PasswordHash::fromHash('hash2'); + + $this->assertFalse($hashedPassword1->equals($hashedPassword2)); + } +} diff --git a/test/Unit/Identity/Domain/ValueObject/UserEmailTest.php b/test/Unit/Identity/Domain/ValueObject/UserEmailTest.php new file mode 100644 index 0000000..9820f20 --- /dev/null +++ b/test/Unit/Identity/Domain/ValueObject/UserEmailTest.php @@ -0,0 +1,50 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Identity\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Identity\Domain\Exception\InvalidUserEmailException; +use Webify\User\Identity\Domain\ValueObject\UserEmail; + +/** + * UserEmailTest tests the functionality of the UserEmail value object. + * + * @internal + */ +#[CoversClass(UserEmail::class)] +#[CoversMethod(UserEmail::class, 'fromString')] +final class UserEmailTest extends TestCase +{ + /** + * Tests the creation of a valid user ID from a string. + */ + #[Test] + public function testCreateValidUserEmail(): void + { + $userEmail = UserEmail::fromString('test@example.com'); + + $this->assertInstanceOf(UserEmail::class, $userEmail); + } + + /** + * Test user ID with an invalid format. + */ + #[Test] + public function testInvalidUserIdFormatThrowsException(): void + { + $this->expectException(InvalidUserEmailException::class); + UserEmail::fromString('invalid-email'); + } +} diff --git a/test/Unit/Identity/Domain/ValueObject/UserIdTest.php b/test/Unit/Identity/Domain/ValueObject/UserIdTest.php new file mode 100644 index 0000000..435d06d --- /dev/null +++ b/test/Unit/Identity/Domain/ValueObject/UserIdTest.php @@ -0,0 +1,50 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Identity\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\User\Identity\Domain\Exception\InvalidUserIdException; +use Webify\User\Identity\Domain\ValueObject\UserId; + +/** + * UserIdTest tests the functionality of the UserId value object. + * + * @internal + */ +#[CoversClass(UserId::class)] +#[CoversMethod(UserId::class, 'fromString')] +final class UserIdTest extends TestCase +{ + /** + * Tests the creation of a valid user ID from a string. + */ + #[Test] + public function testCreateValidUserId(): void + { + $userId = UserId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertInstanceOf(UserId::class, $userId); + } + + /** + * Test user ID with an invalid format. + */ + #[Test] + public function testInvalidUserIdFormatThrowsException(): void + { + $this->expectException(InvalidUserIdException::class); + UserId::fromString('invalid-id-format'); + } +} diff --git a/test/Unit/Identity/Domain/ValueObject/UserStatusTest.php b/test/Unit/Identity/Domain/ValueObject/UserStatusTest.php new file mode 100644 index 0000000..2d03d97 --- /dev/null +++ b/test/Unit/Identity/Domain/ValueObject/UserStatusTest.php @@ -0,0 +1,102 @@ + + */ +declare(strict_types=1); + +namespace Webify\User\Test\Unit\Identity\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use ValueError; +use Webify\User\Identity\Domain\ValueObject\UserStatus; + +/** + * UserStatusTest tests the functionality of the UserStatus value object. + * + * @internal + */ +#[CoversClass(UserStatus::class)] +#[CoversMethod(UserStatus::class, 'isActive')] +#[CoversMethod(UserStatus::class, 'isInactive')] +#[CoversMethod(UserStatus::class, 'isDeactivated')] +final class UserStatusTest extends TestCase +{ + /** + * Tests whether the `UserStatus::Active` status is correctly identified as active. + */ + #[Test] + public function testActiveStatusIsActive(): void + { + $status = UserStatus::Active; + + $this->assertTrue($status->isActive()); + $this->assertFalse($status->isInactive()); + $this->assertFalse($status->isDeactivated()); + } + + /** + * Tests whether the `UserStatus::Inactive` status is correctly identified as inactive. + */ + #[Test] + public function testInactiveStatusIsInactive(): void + { + $status = UserStatus::Inactive; + + $this->assertFalse($status->isActive()); + $this->assertTrue($status->isInactive()); + $this->assertFalse($status->isDeactivated()); + } + + /** + * Tests whether the `UserStatus::Deactivated` status is correctly identified as deactivated. + */ + #[Test] + public function testDeactivatedStatusIsDeactivated(): void + { + $status = UserStatus::Deactivated; + + $this->assertFalse($status->isActive()); + $this->assertFalse($status->isInactive()); + $this->assertTrue($status->isDeactivated()); + } + + /** + * Tests status values. + */ + #[Test] + public function testStatusValues(): void + { + $this->assertSame('active', UserStatus::Active->value); + $this->assertSame('inactive', UserStatus::Inactive->value); + $this->assertSame('deactivated', UserStatus::Deactivated->value); + } + + /** + * Tests weather can create status from value. + */ + #[Test] + public function testStatusFromValue(): void + { + $this->assertSame(UserStatus::Active, UserStatus::from('active')); + $this->assertSame(UserStatus::Inactive, UserStatus::from('inactive')); + $this->assertSame(UserStatus::Deactivated, UserStatus::from('deactivated')); + } + + /** + * Tests throwing exception for invalid value. + */ + #[Test] + public function testInvalidValueThrowsException(): void + { + $this->expectException(ValueError::class); + UserStatus::from('invalid'); + } +}