diff --git a/.laminas-ci/pre-run.sh b/.laminas-ci/pre-run.sh index f6351b84..3a38c16a 100755 --- a/.laminas-ci/pre-run.sh +++ b/.laminas-ci/pre-run.sh @@ -9,7 +9,7 @@ if [[ ${COMMAND} =~ phpunit ]];then apt-get install php"${PHP_VERSION}"-sqlite3 cp config/autoload/local.php.dist config/autoload/local.php - cp config/autoload/mail.local.php.dist config/autoload/mail.local.php + cp config/autoload/mail.global.php.dist config/autoload/mail.global.php cp config/autoload/local.test.php.dist config/autoload/local.test.php fi diff --git a/composer.json b/composer.json index 68266c08..5bdc09c6 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,7 @@ "dotkernel/dot-data-fixtures": "^1.2.2", "dotkernel/dot-dependency-injection": "^1.0", "dotkernel/dot-errorhandler": "^4.0.0", - "dotkernel/dot-mail": "^4.1.1", + "dotkernel/dot-mail": "^5.1.0", "dotkernel/dot-response-header": "^3.2.3", "laminas/laminas-component-installer": "^3.4.0", "laminas/laminas-config-aggregator": "^1.14.0", @@ -78,7 +78,7 @@ "zircote/swagger-php": "^4.10" }, "require-dev": { - "laminas/laminas-coding-standard": "^2.5", + "laminas/laminas-coding-standard": "^3.0", "laminas/laminas-development-mode": "^3.12.0", "mezzio/mezzio-tooling": "^2.9.0", "phpstan/phpstan": "^2.0", diff --git a/config/autoload/mail.global.php.dist b/config/autoload/mail.global.php.dist new file mode 100644 index 00000000..9ef3b308 --- /dev/null +++ b/config/autoload/mail.global.php.dist @@ -0,0 +1,80 @@ + [ + //the key is the mail service name, this is the default one, which does not extend any configuration + 'default' => [ + //message configuration + 'message_options' => [ + //from email address of the email + 'from' => '', + //from name to be displayed instead of from address + 'from_name' => '', + //reply-to email address of the email + 'reply_to' => '', + //replyTo name to be displayed instead of the address + 'reply_to_name' => '', + //destination email address as string or a list of email addresses + 'to' => [], + //copy destination addresses + 'cc' => [], + //hidden copy destination addresses + 'bcc' => [], + //email subject + 'subject' => '', + //body options - content can be plain text, HTML + 'body' => [ + 'content' => '', + 'charset' => 'utf-8', + ], + //attachments config + 'attachments' => [ + 'files' => [], + 'dir' => [ + 'iterate' => false, + 'path' => 'data/mail/attachments', + 'recursive' => false, + ], + ], + ], + /** + * the mail transport to use can be any class implementing + * Symfony\Component\Mailer\Transport\TransportInterface + * + * for standard mail transports, you can use these aliases: + * - sendmail => Symfony\Component\Mailer\Transport\SendmailTransport + * - esmtp => Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport + * + * defaults to sendmail + **/ + 'transport' => 'sendmail', + //options that will be used only if esmtp adapter is used + 'smtp_options' => [ + //hostname or IP address of the mail server + 'host' => '', + //port of the mail server - 587 or 465 for secure connections + 'port' => 587, + 'connection_config' => [ + //the smtp authentication identity + 'username' => '', + //the smtp authentication credential + 'password' => '', + //to disable auto_tls set tls key to false + //it's not recommended to disable TLS while connecting to an SMTP server + 'tls' => null, + ], + ], + ], + // option to log the SENT emails + 'log' => [ + 'sent' => getcwd() . '/log/mail/sent.log', + ], + ], +]; diff --git a/config/config.php b/config/config.php index 136ba5d2..513ff9d2 100644 --- a/config/config.php +++ b/config/config.php @@ -25,7 +25,6 @@ Laminas\Filter\ConfigProvider::class, Laminas\HttpHandlerRunner\ConfigProvider::class, Laminas\Hydrator\ConfigProvider::class, - Laminas\Mail\ConfigProvider::class, Laminas\Validator\ConfigProvider::class, // Include cache configuration new Laminas\ConfigAggregator\ArrayProvider($cacheConfig), diff --git a/data/doctrine/fixtures/UserLoader.php b/data/doctrine/fixtures/UserLoader.php index 0c956927..af373ea8 100644 --- a/data/doctrine/fixtures/UserLoader.php +++ b/data/doctrine/fixtures/UserLoader.php @@ -34,7 +34,6 @@ public function load(ObjectManager $manager): void ->setIdentity('test@dotkernel.com') ->usePassword('dotkernel') ->setStatus(UserStatusEnum::Active) - ->setIsDeleted(false) ->setHash(User::generateHash()) ->addRole($guestRole) ->addRole($userRole); diff --git a/data/doctrine/migrations/Version20241030082958.php b/data/doctrine/migrations/Version20241030082958.php index 86ad9ea5..7342c67c 100644 --- a/data/doctrine/migrations/Version20241030082958.php +++ b/data/doctrine/migrations/Version20241030082958.php @@ -30,7 +30,7 @@ public function up(Schema $schema): void $this->addSql('CREATE TABLE oauth_clients (id INT UNSIGNED AUTO_INCREMENT NOT NULL, name VARCHAR(40) NOT NULL, secret VARCHAR(100) DEFAULT NULL, redirect VARCHAR(191) NOT NULL, revoked TINYINT(1) DEFAULT 0 NOT NULL, isConfidential TINYINT(1) DEFAULT 0 NOT NULL, user_id BINARY(16) DEFAULT NULL, INDEX IDX_13CE8101A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4'); $this->addSql('CREATE TABLE oauth_refresh_tokens (id INT UNSIGNED AUTO_INCREMENT NOT NULL, revoked TINYINT(1) DEFAULT 0 NOT NULL, expires_at DATETIME NOT NULL, access_token_id INT UNSIGNED DEFAULT NULL, INDEX IDX_5AB6872CCB2688 (access_token_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4'); $this->addSql('CREATE TABLE oauth_scopes (id INT UNSIGNED AUTO_INCREMENT NOT NULL, scope VARCHAR(191) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4'); - $this->addSql('CREATE TABLE user (uuid BINARY(16) NOT NULL, identity VARCHAR(191) NOT NULL, password VARCHAR(191) NOT NULL, status ENUM(\'active\', \'pending\') DEFAULT \'pending\' NOT NULL, isDeleted TINYINT(1) NOT NULL, hash VARCHAR(64) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_8D93D6496A95E9C4 (identity), UNIQUE INDEX UNIQ_8D93D649D1B862B8 (hash), PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE user (uuid BINARY(16) NOT NULL, identity VARCHAR(191) NOT NULL, password VARCHAR(191) NOT NULL, status ENUM(\'active\', \'pending\', \'deleted\') DEFAULT \'pending\' NOT NULL, hash VARCHAR(64) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_8D93D6496A95E9C4 (identity), UNIQUE INDEX UNIQ_8D93D649D1B862B8 (hash), PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4'); $this->addSql('CREATE TABLE user_roles (userUuid BINARY(16) NOT NULL, roleUuid BINARY(16) NOT NULL, INDEX IDX_54FCD59FD73087E9 (userUuid), INDEX IDX_54FCD59F88446210 (roleUuid), PRIMARY KEY(userUuid, roleUuid)) DEFAULT CHARACTER SET utf8mb4'); $this->addSql('CREATE TABLE user_avatar (uuid BINARY(16) NOT NULL, name VARCHAR(191) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, userUuid BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_73256912D73087E9 (userUuid), PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4'); $this->addSql('CREATE TABLE user_detail (uuid BINARY(16) NOT NULL, firstName VARCHAR(191) DEFAULT NULL, lastName VARCHAR(191) DEFAULT NULL, email VARCHAR(191) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, userUuid BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_4B5464AED73087E9 (userUuid), PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4'); diff --git a/phpcs.xml b/phpcs.xml index fa6e19d8..52504edb 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -19,5 +19,8 @@ test - + + + + diff --git a/src/User/src/Entity/User.php b/src/User/src/Entity/User.php index 0a54feab..5caad643 100644 --- a/src/User/src/Entity/User.php +++ b/src/User/src/Entity/User.php @@ -51,9 +51,6 @@ class User extends AbstractEntity implements UserEntityInterface #[ORM\Column(type: 'user_status_enum', options: ['default' => UserStatusEnum::Pending])] protected UserStatusEnum $status = UserStatusEnum::Pending; - #[ORM\Column(name: "isDeleted", type: "boolean")] - protected bool $isDeleted = false; - #[ORM\Column(name: "hash", type: "string", length: 64, unique: true)] protected string $hash; @@ -103,18 +100,6 @@ public function setStatus(UserStatusEnum $status): self return $this; } - public function isDeleted(): bool - { - return $this->isDeleted; - } - - public function setIsDeleted(bool $isDeleted): self - { - $this->isDeleted = $isDeleted; - - return $this; - } - public function getHash(): string { return $this->hash; @@ -273,11 +258,9 @@ public function isPending(): bool return $this->status === UserStatusEnum::Pending; } - public function markAsDeleted(): self + public function isDeleted(): bool { - $this->isDeleted = true; - - return $this; + return $this->status === UserStatusEnum::Deleted; } public function renewHash(): self @@ -311,7 +294,6 @@ public function getArrayCopy(): array 'hash' => $this->getHash(), 'identity' => $this->getIdentity(), 'status' => $this->getStatus(), - 'isDeleted' => $this->isDeleted(), 'avatar' => $this->getAvatar()?->getArrayCopy(), 'detail' => $this->getDetail()->getArrayCopy(), 'roles' => $this->getRoles()->map(function (UserRole $userRole) { diff --git a/src/User/src/Enum/UserStatusEnum.php b/src/User/src/Enum/UserStatusEnum.php index a2a30afa..a3863515 100644 --- a/src/User/src/Enum/UserStatusEnum.php +++ b/src/User/src/Enum/UserStatusEnum.php @@ -8,4 +8,5 @@ enum UserStatusEnum: string { case Active = 'active'; case Pending = 'pending'; + case Deleted = 'deleted'; } diff --git a/src/User/src/OpenAPI.php b/src/User/src/OpenAPI.php index 25390d2f..5b5ad2f9 100644 --- a/src/User/src/OpenAPI.php +++ b/src/User/src/OpenAPI.php @@ -1067,7 +1067,6 @@ new OA\Property(property: 'hash', type: 'string'), new OA\Property(property: 'identity', type: 'string'), new OA\Property(property: 'status', type: 'string', example: UserStatusEnum::Active), - new OA\Property(property: 'isDeleted', type: 'boolean', example: false), new OA\Property(property: 'avatar', ref: '#/components/schemas/UserAvatar', nullable: true), new OA\Property(property: 'detail', ref: '#/components/schemas/UserDetail'), new OA\Property( diff --git a/src/User/src/Repository/UserRepository.php b/src/User/src/Repository/UserRepository.php index 9bee50ad..f1590bdd 100644 --- a/src/User/src/Repository/UserRepository.php +++ b/src/User/src/Repository/UserRepository.php @@ -52,17 +52,6 @@ public function getUsers(array $filters = []): UserCollection $qb->andWhere('user.status = :status')->setParameter('status', $filters['status']); } - if (isset($filters['deleted'])) { - switch ($filters['deleted']) { - case 'true': - $qb->andWhere('user.isDeleted = :isDeleted')->setParameter('isDeleted', true); - break; - case 'false': - $qb->andWhere('user.isDeleted = :isDeleted')->setParameter('isDeleted', false); - break; - } - } - if (! empty($filters['search'])) { $qb->andWhere( $qb->expr()->orX( @@ -78,6 +67,8 @@ public function getUsers(array $filters = []): UserCollection $qb->andWhere('roles.name = :role')->setParameter('role', $filters['role']); } + //ignore deleted users + $qb->andWhere('user.status != :status')->setParameter('status', UserStatusEnum::Deleted); $qb->getQuery()->useQueryCache(true); return new UserCollection($qb, false); @@ -115,8 +106,9 @@ public function getUserEntityByUserCredentials( $qb->select(['u.password', 'u.status']) ->from(User::class, 'u') ->andWhere('u.identity = :identity') - ->andWhere('u.isDeleted = 0') - ->setParameter('identity', $username); + ->andWhere('u.status != :status') + ->setParameter('identity', $username) + ->setParameter('status', UserStatusEnum::Deleted); break; default: throw new OAuthServerException(Message::INVALID_CLIENT_ID, 6, 'invalid_client', 401); diff --git a/src/User/src/Service/UserService.php b/src/User/src/Service/UserService.php index 2fbb5d00..f6c2d312 100644 --- a/src/User/src/Service/UserService.php +++ b/src/User/src/Service/UserService.php @@ -115,7 +115,7 @@ public function deleteUser(User $user): User { $this->revokeTokens($user); - return $this->anonymizeUser($user->markAsDeleted()); + return $this->anonymizeUser($user->setStatus(UserStatusEnum::Deleted)); } /** @@ -198,7 +198,7 @@ public function findResetPasswordByHash(?string $hash): UserResetPassword public function findByEmail(string $email): User { $user = $this->userDetailRepository->findOneBy(['email' => $email])?->getUser(); - if (! $user instanceof User) { + if (! $user instanceof User || $user->isDeleted()) { throw new NotFoundException(Message::USER_NOT_FOUND); } @@ -219,7 +219,7 @@ public function findByIdentity(string $identity): ?User public function findOneBy(array $params = []): User { $user = $this->userRepository->findOneBy($params); - if (! $user instanceof User) { + if (! $user instanceof User || $user->isDeleted()) { throw new NotFoundException(Message::USER_NOT_FOUND); } @@ -370,10 +370,6 @@ public function updateUser(User $user, array $data = []): User $user->setStatus($data['status']); } - if (isset($data['isDeleted'])) { - $user->setIsDeleted($data['isDeleted']); - } - if (isset($data['hash'])) { $user->setHash($data['hash']); } diff --git a/test/Functional/AdminTest.php b/test/Functional/AdminTest.php index e946e2e9..a00f7b8c 100644 --- a/test/Functional/AdminTest.php +++ b/test/Functional/AdminTest.php @@ -401,7 +401,6 @@ public function testAdminCanCreateUserAccount(): void $this->assertArrayHasKey('hash', $data); $this->assertArrayHasKey('identity', $data); $this->assertArrayHasKey('status', $data); - $this->assertArrayHasKey('isDeleted', $data); $this->assertArrayHasKey('avatar', $data); $this->assertArrayHasKey('detail', $data); $this->assertArrayHasKey('roles', $data); @@ -409,7 +408,6 @@ public function testAdminCanCreateUserAccount(): void $this->assertNotEmpty($data['hash']); $this->assertSame($userData['identity'], $data['identity']); $this->assertSame(UserStatusEnum::Pending->value, $data['status']); - $this->assertFalse($data['isDeleted']); $this->assertEmpty($data['avatar']); $this->assertEmpty($data['resetPasswords']); $this->assertArrayHasKey('firstName', $data['detail']); diff --git a/test/Functional/UserTest.php b/test/Functional/UserTest.php index 75d216ae..b74fff00 100644 --- a/test/Functional/UserTest.php +++ b/test/Functional/UserTest.php @@ -108,7 +108,6 @@ public function testRegisterAccount(): void $this->assertSame($user['identity'], $data['identity']); $this->assertSame(UserStatusEnum::Pending->value, $data['status']); - $this->assertFalse($data['isDeleted']); $this->assertArrayHasKey('detail', $data); $this->assertArrayHasKey('email', $data['detail']); $this->assertArrayHasKey('firstName', $data['detail']); diff --git a/test/Unit/App/Middleware/AuthorizationMiddlewareTest.php b/test/Unit/App/Middleware/AuthorizationMiddlewareTest.php index 5d6cc3fe..cc6dfd11 100644 --- a/test/Unit/App/Middleware/AuthorizationMiddlewareTest.php +++ b/test/Unit/App/Middleware/AuthorizationMiddlewareTest.php @@ -13,6 +13,7 @@ use Api\App\UserIdentity; use Api\User\Entity\User; use Api\User\Entity\UserRole; +use Api\User\Enum\UserStatusEnum; use Api\User\Repository\UserRepository; use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\ServerRequest; @@ -108,7 +109,7 @@ public function testAuthorizationInactiveUser(): void public function testAuthorizationUserNotFoundOrDeleted(): void { - $user = (new User())->markAsDeleted(); + $user = (new User())->setStatus(UserStatusEnum::Deleted); $this->userRepository->method('findOneBy')->willReturn($user); $this->authorization->method('isGranted')->willReturn(false); diff --git a/test/Unit/User/Service/UserServiceTest.php b/test/Unit/User/Service/UserServiceTest.php index e07b8c57..5d1228e9 100644 --- a/test/Unit/User/Service/UserServiceTest.php +++ b/test/Unit/User/Service/UserServiceTest.php @@ -147,7 +147,6 @@ public function testCreateUser(): void $this->assertSame($data['detail']['lastName'], $user->getDetail()->getLastName()); $this->assertSame($data['detail']['email'], $user->getDetail()->getEmail()); $this->assertSame(UserStatusEnum::Pending, $user->getStatus()); - $this->assertFalse($user->isDeleted()); $this->assertFalse($user->isActive()); }