From 4344bf30467ec66b13d9c57d7416ca305e171bc3 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:46:18 -0600 Subject: [PATCH 01/22] [#.x] - new routes for user crud and auth --- .../Components/Enums/Http/RoutesEnum.php | 67 +++++++++++++---- .../Components/Enums/Http/RoutesEnumTest.php | 74 +++++++++++++++++-- .../Providers/RouterProviderTest.php | 20 +++++ 3 files changed, 142 insertions(+), 19 deletions(-) diff --git a/src/Domain/Components/Enums/Http/RoutesEnum.php b/src/Domain/Components/Enums/Http/RoutesEnum.php index 3c6fcac..e28552c 100644 --- a/src/Domain/Components/Enums/Http/RoutesEnum.php +++ b/src/Domain/Components/Enums/Http/RoutesEnum.php @@ -20,17 +20,29 @@ /** * @phpstan-type TMiddleware array */ -enum RoutesEnum: string +enum RoutesEnum: int { - public const DELETE = 'delete'; + /** + * Methods + */ + public const DELETE = 'delete'; + /** + * Events + */ public const EVENT_BEFORE = 'before'; public const EVENT_FINISH = 'finish'; - public const GET = 'get'; - public const POST = 'post'; - public const PUT = 'put'; + public const GET = 'get'; + public const POST = 'post'; + public const PUT = 'put'; + + case authLoginPost = 11; + case authLogoutPost = 12; + case authRefreshPost = 13; - case authLoginPost = 'auth/login'; - case userGet = 'user'; + case userDelete = 21; + case userGet = 22; + case userPost = 23; + case userPut = 24; /** * @return string @@ -46,8 +58,13 @@ public function endpoint(): string public function method(): string { return match ($this) { - self::authLoginPost => self::POST, - self::userGet => self::GET, + self::authLoginPost, + self::authLogoutPost, + self::authRefreshPost, + self::userPost => self::POST, + self::userDelete => self::DELETE, + self::userGet => self::GET, + self::userPut => self::PUT, }; } @@ -63,6 +80,7 @@ public static function middleware(): array Container::MIDDLEWARE_VALIDATE_TOKEN_STRUCTURE => self::EVENT_BEFORE, Container::MIDDLEWARE_VALIDATE_TOKEN_USER => self::EVENT_BEFORE, Container::MIDDLEWARE_VALIDATE_TOKEN_CLAIMS => self::EVENT_BEFORE, + Container::MIDDLEWARE_VALIDATE_TOKEN_REVOKED => self::EVENT_BEFORE, ]; } @@ -71,14 +89,29 @@ public static function middleware(): array */ public function prefix(): string { - return '/' . str_replace('-', '/', $this->value); + $endpoint = match ($this) { + self::authLoginPost, + self::authLogoutPost, + self::authRefreshPost => 'auth', + self::userDelete, + self::userGet, + self::userPost, + self::userPut => 'user', + }; + + return '/' . str_replace('-', '/', $endpoint); } public function service(): string { return match ($this) { - self::authLoginPost => Container::AUTH_LOGIN_POST_SERVICE, - self::userGet => Container::USER_GET_SERVICE, + self::authLoginPost => Container::AUTH_LOGIN_POST_SERVICE, + self::authLogoutPost => Container::AUTH_LOGOUT_POST_SERVICE, + self::authRefreshPost => Container::AUTH_REFRESH_POST_SERVICE, + self::userDelete => Container::USER_DELETE_SERVICE, + self::userGet => Container::USER_GET_SERVICE, + self::userPost => Container::USER_POST_SERVICE, + self::userPut => Container::USER_PUT_SERVICE, }; } @@ -87,6 +120,14 @@ public function service(): string */ public function suffix(): string { - return ''; + return match ($this) { + self::authLoginPost => '/login', + self::authLogoutPost => '/logout', + self::authRefreshPost => '/refresh', + self::userDelete, + self::userGet, + self::userPost, + self::userPut => '', + }; } } diff --git a/tests/Unit/Domain/Components/Enums/Http/RoutesEnumTest.php b/tests/Unit/Domain/Components/Enums/Http/RoutesEnumTest.php index c8057ea..42592d4 100644 --- a/tests/Unit/Domain/Components/Enums/Http/RoutesEnumTest.php +++ b/tests/Unit/Domain/Components/Enums/Http/RoutesEnumTest.php @@ -25,24 +25,66 @@ public static function getExamples(): array return [ [ RoutesEnum::authLoginPost, - 'auth/login', + '/auth', + '/login', '/auth/login', RoutesEnum::POST, Container::AUTH_LOGIN_POST_SERVICE, ], + [ + RoutesEnum::authLogoutPost, + '/auth', + '/logout', + '/auth/logout', + RoutesEnum::POST, + Container::AUTH_LOGOUT_POST_SERVICE, + ], + [ + RoutesEnum::authRefreshPost, + '/auth', + '/refresh', + '/auth/refresh', + RoutesEnum::POST, + Container::AUTH_REFRESH_POST_SERVICE, + ], + [ + RoutesEnum::userDelete, + '/user', + '', + '/user', + RoutesEnum::DELETE, + Container::USER_DELETE_SERVICE, + ], [ RoutesEnum::userGet, - 'user', + '/user', + '', '/user', RoutesEnum::GET, Container::USER_GET_SERVICE, ], + [ + RoutesEnum::userPost, + '/user', + '', + '/user', + RoutesEnum::POST, + Container::USER_POST_SERVICE, + ], + [ + RoutesEnum::userPut, + '/user', + '', + '/user', + RoutesEnum::PUT, + Container::USER_PUT_SERVICE, + ], ]; } public function testCheckCount(): void { - $expected = 2; + $expected = 7; $actual = RoutesEnum::cases(); $this->assertCount($expected, $actual); } @@ -50,13 +92,18 @@ public function testCheckCount(): void #[DataProvider('getExamples')] public function testCheckItems( RoutesEnum $element, - string $value, + string $prefix, + string $suffix, string $endpoint, string $method, string $service ) { - $expected = $value; - $actual = $element->value; + $expected = $prefix; + $actual = $element->prefix(); + $this->assertSame($expected, $actual); + + $expected = $suffix; + $actual = $element->suffix(); $this->assertSame($expected, $actual); $expected = $endpoint; @@ -71,4 +118,19 @@ public function testCheckItems( $actual = $element->service(); $this->assertSame($expected, $actual); } + + public function testMiddleware(): void + { + $expected = [ + Container::MIDDLEWARE_NOT_FOUND => RoutesEnum::EVENT_BEFORE, + Container::MIDDLEWARE_HEALTH => RoutesEnum::EVENT_BEFORE, + Container::MIDDLEWARE_VALIDATE_TOKEN_PRESENCE => RoutesEnum::EVENT_BEFORE, + Container::MIDDLEWARE_VALIDATE_TOKEN_STRUCTURE => RoutesEnum::EVENT_BEFORE, + Container::MIDDLEWARE_VALIDATE_TOKEN_USER => RoutesEnum::EVENT_BEFORE, + Container::MIDDLEWARE_VALIDATE_TOKEN_CLAIMS => RoutesEnum::EVENT_BEFORE, + Container::MIDDLEWARE_VALIDATE_TOKEN_REVOKED => RoutesEnum::EVENT_BEFORE, + ]; + $actual = RoutesEnum::middleware(); + $this->assertSame($expected, $actual); + } } diff --git a/tests/Unit/Domain/Components/Providers/RouterProviderTest.php b/tests/Unit/Domain/Components/Providers/RouterProviderTest.php index 28fa717..0429c1b 100644 --- a/tests/Unit/Domain/Components/Providers/RouterProviderTest.php +++ b/tests/Unit/Domain/Components/Providers/RouterProviderTest.php @@ -36,10 +36,30 @@ public function testCheckRegistration(): void 'method' => 'POST', 'pattern' => '/auth/login', ], + [ + 'method' => 'POST', + 'pattern' => '/auth/logout', + ], + [ + 'method' => 'POST', + 'pattern' => '/auth/refresh', + ], + [ + 'method' => 'DELETE', + 'pattern' => '/user', + ], [ 'method' => 'GET', 'pattern' => '/user', ], + [ + 'method' => 'POST', + 'pattern' => '/user', + ], + [ + 'method' => 'PUT', + 'pattern' => '/user', + ], [ 'method' => 'GET', 'pattern' => '/health', From c2aeec38456187b0114ef71d9594d104420c2869 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:46:36 -0600 Subject: [PATCH 02/22] [#.x] - new validate token revoked middleware --- .../ValidateTokenRevokedMiddleware.php | 67 ++++++++++ .../ValidateTokenRevokedMiddlewareTest.php | 119 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 src/Domain/Components/Middleware/ValidateTokenRevokedMiddleware.php create mode 100644 tests/Unit/Domain/Components/Middleware/ValidateTokenRevokedMiddlewareTest.php diff --git a/src/Domain/Components/Middleware/ValidateTokenRevokedMiddleware.php b/src/Domain/Components/Middleware/ValidateTokenRevokedMiddleware.php new file mode 100644 index 0000000..81fddca --- /dev/null +++ b/src/Domain/Components/Middleware/ValidateTokenRevokedMiddleware.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Components\Middleware; + +use Phalcon\Api\Domain\Components\Cache\Cache; +use Phalcon\Api\Domain\Components\Container; +use Phalcon\Api\Domain\Components\DataSource\TransportRepository; +use Phalcon\Api\Domain\Components\DataSource\User\UserTransport; +use Phalcon\Api\Domain\Components\Enums\Http\HttpCodesEnum; +use Phalcon\Api\Domain\Components\Env\EnvManager; +use Phalcon\Http\RequestInterface; +use Phalcon\Mvc\Micro; + +final class ValidateTokenRevokedMiddleware extends AbstractMiddleware +{ + /** + * @param Micro $application + * + * @return bool + */ + public function call(Micro $application): bool + { + /** @var RequestInterface $request */ + $request = $application->getSharedService(Container::REQUEST); + /** @var Cache $cache */ + $cache = $application->getSharedService(Container::CACHE); + /** @var EnvManager $env */ + $env = $application->getSharedService(Container::ENV); + /** @var TransportRepository $userTransport */ + $userTransport = $application->getSharedService(Container::REPOSITORY_TRANSPORT); + + /** @var UserTransport $user */ + $user = $userTransport->getSessionUser(); + + /** + * Get the token object + */ + $token = $this->getBearerTokenFromHeader($request, $env); + $cacheKey = $cache->getCacheTokenKey($user, $token); + $exists = $cache->has($cacheKey); + + if (true !== $exists) { + $this->halt( + $application, + HttpCodesEnum::Unauthorized->value, + HttpCodesEnum::Unauthorized->text(), + [], + [HttpCodesEnum::AppTokenNotValid->error()] + ); + + return false; + } + + return true; + } +} diff --git a/tests/Unit/Domain/Components/Middleware/ValidateTokenRevokedMiddlewareTest.php b/tests/Unit/Domain/Components/Middleware/ValidateTokenRevokedMiddlewareTest.php new file mode 100644 index 0000000..6c65fdd --- /dev/null +++ b/tests/Unit/Domain/Components/Middleware/ValidateTokenRevokedMiddlewareTest.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Components\Middleware; + +use Phalcon\Api\Domain\Components\Cache\Cache; +use Phalcon\Api\Domain\Components\Container; +use Phalcon\Api\Domain\Components\DataSource\User\UserTransport; +use Phalcon\Api\Domain\Components\Enums\Http\HttpCodesEnum; +use Phalcon\Api\Tests\AbstractUnitTestCase; +use Phalcon\Api\Tests\Fixtures\Domain\Migrations\UsersMigration; +use Phalcon\Mvc\Micro; +use PHPUnit\Framework\Attributes\BackupGlobals; + +#[BackupGlobals(true)] +final class ValidateTokenRevokedMiddlewareTest extends AbstractUnitTestCase +{ + public function testValidateTokenRevokedFailureInvalidToken(): void + { + $migration = new UsersMigration($this->getConnection()); + $user = $this->getNewUser($migration); + $tokenUser = $user; + + [$micro, $middleware] = $this->setupTest(); + + $token = $this->getUserToken($tokenUser); + + /** + * Store the user in the session + */ + /** @var UserTransport $userRepository */ + $userRepository = $micro->getSharedService(Container::REPOSITORY_TRANSPORT); + $userRepository->setSessionUser($user); + + // There is no entry in the cache for this token, so this should fail. + $time = $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_TIME_FLOAT' => $time, + 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, + 'REQUEST_URI' => '/user?id=1234', + ]; + + ob_start(); + $middleware->call($micro); + $contents = ob_get_clean(); + + $contents = json_decode($contents, true); + + $data = $contents['data']; + $errors = $contents['errors']; + + $this->assertSame([], $data); + + $expected = [HttpCodesEnum::AppTokenNotValid->error()]; + $this->assertSame($expected, $errors); + } + + public function testValidateTokenRevokedSuccess(): void + { + $migration = new UsersMigration($this->getConnection()); + $user = $this->getNewUser($migration); + $tokenUser = $user; + + [$micro, $middleware] = $this->setupTest(); + + $token = $this->getUserToken($tokenUser); + + /** + * Store the user in the session + */ + /** @var UserTransport $userRepository */ + $userRepository = $micro->getSharedService(Container::REPOSITORY_TRANSPORT); + $userRepository->setSessionUser($user); + /** @var Cache $cache */ + $cache = $micro->getSharedService(Container::CACHE); + $sessionUser = $userRepository->getSessionUser(); + $cacheKey = $cache->getCacheTokenKey($sessionUser, $token); + $payload = [ + 'token' => $token, + ]; + $actual = $cache->set($cacheKey, $payload, 2); + $this->assertTrue($actual); + + // There is no entry in the cache for this token, so this should fail. + $time = $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_TIME_FLOAT' => $time, + 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, + 'REQUEST_URI' => '/user?id=1234', + ]; + + $contents = $middleware->call($micro); + + $this->assertTrue($contents); + } + + /** + * @return array + */ + private function setupTest(): array + { + $micro = new Micro($this->container); + $middleware = $this->container->get(Container::MIDDLEWARE_VALIDATE_TOKEN_REVOKED); + + return [$micro, $middleware]; + } +} From 3ea800dedd384536a51eae86d89aec44f59b8dcf Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:47:17 -0600 Subject: [PATCH 03/22] [#.x] - docblocks and minor corrections --- tests/AbstractUnitTestCase.php | 36 +++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/tests/AbstractUnitTestCase.php b/tests/AbstractUnitTestCase.php index 8b5f766..23fbede 100644 --- a/tests/AbstractUnitTestCase.php +++ b/tests/AbstractUnitTestCase.php @@ -26,6 +26,10 @@ use Phalcon\Encryption\Security\JWT\Signer\Hmac; use PHPUnit\Framework\TestCase; +use function base64_encode; +use function random_bytes; +use function substr; + abstract class AbstractUnitTestCase extends TestCase { /** @@ -50,6 +54,12 @@ public function assertFileContentsContains(string $fileName, string $stream): vo $this->assertStringContainsString($stream, $contents); } + /** + * @param string $table + * @param array $criteria + * + * @return void + */ public function assertInDatabase(string $table, array $criteria = []): void { $records = $this->getFromDatabase($table, $criteria); @@ -57,6 +67,12 @@ public function assertInDatabase(string $table, array $criteria = []): void $this->assertNotEmpty($records); } + /** + * @param string $table + * @param array $criteria + * + * @return void + */ public function assertNotInDatabase(string $table, array $criteria = []): void { $records = $this->getFromDatabase($table, $criteria); @@ -64,6 +80,11 @@ public function assertNotInDatabase(string $table, array $criteria = []): void $this->assertEmpty($records); } + /** + * @param Connection $connection + * + * @return void + */ public function getConnection(): Connection { if (null === $this->connection) { @@ -115,12 +136,17 @@ public function getNewUser(UsersMigration $migration, array $fields = []): array return $dbUser[0]; } + /** + * @param array $fields + * + * @return array + */ public function getNewUserData(array $fields = []): array { $faker = Factory::create(); $password = $fields['usr_password'] ?? $this->getStrongPassword(); /** @var Security $security */ - $security = $this->container->get(Container::SECURITY); + $security = $this->container->getShared(Container::SECURITY); $password = $security->hash($password); @@ -235,10 +261,10 @@ protected function getFromDatabase( if (!empty($where)) { $sql .= ' WHERE ' . implode(' AND ', $where); } - $stmt = $this->connection?->prepare($sql); - $stmt?->execute($params); - $records = $stmt?->fetchAll(PDO::FETCH_ASSOC); - return $records; + $stmt = $this->getConnection()->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); } } From 1649bac20539d62950f18e2804a21e80af0d29af Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:47:45 -0600 Subject: [PATCH 04/22] [#.x] - adding support for refresh token --- .../DataSource/TransportRepository.php | 7 ++- src/Domain/Components/Encryption/JWTToken.php | 63 ++++++++++++------- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/Domain/Components/DataSource/TransportRepository.php b/src/Domain/Components/DataSource/TransportRepository.php index 870bdf4..c7edf02 100644 --- a/src/Domain/Components/DataSource/TransportRepository.php +++ b/src/Domain/Components/DataSource/TransportRepository.php @@ -56,12 +56,14 @@ public function getSessionUser(): ?UserTransport /** * @param UserTransport $user * @param string $token + * @param string $refreshToken * * @return TLoginResponsePayload */ public function newLoginUser( UserTransport $user, - string $token + string $token, + string $refreshToken ): array { return [ 'authenticated' => true, @@ -71,7 +73,8 @@ public function newLoginUser( 'email' => $user->getEmail(), ], 'jwt' => [ - 'token' => $token, + 'token' => $token, + 'refreshToken' => $refreshToken, ], ]; } diff --git a/src/Domain/Components/Encryption/JWTToken.php b/src/Domain/Components/Encryption/JWTToken.php index 3134e4a..c4c067b 100644 --- a/src/Domain/Components/Encryption/JWTToken.php +++ b/src/Domain/Components/Encryption/JWTToken.php @@ -32,10 +32,13 @@ /** * @phpstan-import-type TUserRecord from UserTypes - * @phpstan-import-type TUserDbRecord from UserTypes + * @phpstan-import-type TUserTokenDbRecord from UserTypes * @phpstan-type TValidatorErrors array{}|array + * + * Removed the final declaration so that this class can be mocked. This + * class should not be extended */ -final class JWTToken +class JWTToken { /** * @var Parser|null @@ -50,23 +53,13 @@ public function __construct( /** * Returns the string token * - * @param TUserDbRecord $user + * @param TUserTokenDbRecord $user * * @return string */ public function getForUser(array $user): string { - /** @var int $expiration */ - $expiration = $this->env->get( - 'TOKEN_EXPIRATION', - Cache::CACHE_TOKEN_EXPIRY, - 'int' - ); - - $now = new DateTimeImmutable(); - $expiresAt = $now->modify('+' . $expiration . ' seconds'); - - return $this->generateTokenForUser($user, $now, $expiresAt); + return $this->generateTokenForUser($user); } /** @@ -91,6 +84,18 @@ public function getObject(string $token): Token return $tokenObject; } + /** + * Returns the string token + * + * @param TUserTokenDbRecord $user + * + * @return string + */ + public function getRefreshForUser(array $user): string + { + return $this->generateTokenForUser($user, true); + } + /** * @param QueryRepository $repository * @param Token $token @@ -122,6 +127,8 @@ public function getUser( } /** + * Returns an array with the validation errors for this token + * * @param Token $tokenObject * @param UserTransport $user * @@ -153,19 +160,31 @@ public function validate( } /** - * @param TUserDbRecord $user - * @param DateTimeImmutable $now - * @param DateTimeImmutable $expiresAt - * @param bool $isRefresh + * Returns the string token + * + * @param TUserTokenDbRecord $user + * @param bool $isRefresh * * @return string */ private function generateTokenForUser( array $user, - DateTimeImmutable $now, - DateTimeImmutable $expiresAt, - bool $isRefresh = true + bool $isRefresh = false ): string { + /** @var int $expiration */ + $expiration = $this->env->get( + 'TOKEN_EXPIRATION', + Cache::CACHE_TOKEN_EXPIRY, + 'int' + ); + + $now = new DateTimeImmutable(); + $expiresAt = $now->modify('+' . $expiration . ' seconds'); + /** + * This is to ensure that the token is valid the minute we issue it + */ + $now = $now->modify('-1 second'); + $tokenBuilder = new Builder(new Hmac()); /** @var string $issuer */ $issuer = $user['usr_issuer']; @@ -194,6 +213,8 @@ private function generateTokenForUser( /** + * Returns the default audience for the tokens + * * @return string */ private function getTokenAudience(): string From aa12d78b2cd411eb24f3f297dea72194640afb95 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:48:15 -0600 Subject: [PATCH 05/22] [#.x] - Changing exception throwing --- src/Domain/Components/Env/Adapters/DotEnv.php | 2 +- src/Domain/Components/Env/EnvFactory.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Domain/Components/Env/Adapters/DotEnv.php b/src/Domain/Components/Env/Adapters/DotEnv.php index 4793353..0133a82 100644 --- a/src/Domain/Components/Env/Adapters/DotEnv.php +++ b/src/Domain/Components/Env/Adapters/DotEnv.php @@ -35,7 +35,7 @@ public function load(array $options): array /** @var string|null $filePath */ $filePath = $options['filePath'] ?? null; if (true === empty($filePath) || true !== file_exists($filePath)) { - throw new InvalidConfigurationArgumentException( + throw InvalidConfigurationArgumentException::new( 'The .env file does not exist at the specified path: ' . (string)$filePath ); diff --git a/src/Domain/Components/Env/EnvFactory.php b/src/Domain/Components/Env/EnvFactory.php index cb000ec..4ce3ba1 100644 --- a/src/Domain/Components/Env/EnvFactory.php +++ b/src/Domain/Components/Env/EnvFactory.php @@ -29,7 +29,7 @@ public function newInstance(string $name, mixed ...$parameters): AdapterInterfac $adapters = $this->getAdapters(); if (true !== isset($this->instances[$name])) { if (true !== isset($adapters[$name])) { - throw new InvalidConfigurationArgumentException( + throw InvalidConfigurationArgumentException::new( 'Service ' . $name . ' is not registered' ); } From 962f02f92bd03af54d1d46ea78a2af933d0fc25e Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:48:36 -0600 Subject: [PATCH 06/22] [#.x] - minor corrections --- .../Components/Middleware/ValidateTokenUserMiddleware.php | 2 +- .../Middleware/ValidateTokenClaimsMiddlewareTest.php | 4 ++-- .../Middleware/ValidateTokenPresenceMiddlewareTest.php | 6 +++--- .../Middleware/ValidateTokenStructureMiddlewareTest.php | 6 +++--- .../Middleware/ValidateTokenUserMiddlewareTest.php | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Domain/Components/Middleware/ValidateTokenUserMiddleware.php b/src/Domain/Components/Middleware/ValidateTokenUserMiddleware.php index ce2ea63..9720a05 100644 --- a/src/Domain/Components/Middleware/ValidateTokenUserMiddleware.php +++ b/src/Domain/Components/Middleware/ValidateTokenUserMiddleware.php @@ -52,7 +52,7 @@ public function call(Micro $application): bool /** @var Token $tokenObject */ $tokenObject = $transport->getSessionToken(); /** @var TUserRecord $dbUser */ - $dbUser = $jwtToken->getUser($repository, $tokenObject); + $dbUser = $jwtToken->getUser($repository, $tokenObject); if (true === empty($dbUser)) { $this->halt( diff --git a/tests/Unit/Domain/Components/Middleware/ValidateTokenClaimsMiddlewareTest.php b/tests/Unit/Domain/Components/Middleware/ValidateTokenClaimsMiddlewareTest.php index 5535a13..453701a 100644 --- a/tests/Unit/Domain/Components/Middleware/ValidateTokenClaimsMiddlewareTest.php +++ b/tests/Unit/Domain/Components/Middleware/ValidateTokenClaimsMiddlewareTest.php @@ -93,7 +93,7 @@ public function testValidateTokenClaimsFailure( 'REQUEST_METHOD' => 'GET', 'REQUEST_TIME_FLOAT' => $time, 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, - 'REQUEST_URI' => '/user?userId=1234', + 'REQUEST_URI' => '/user?id=1234', ]; ob_start(); @@ -135,7 +135,7 @@ public function testValidateTokenClaimsSuccess(): void 'REQUEST_METHOD' => 'GET', 'REQUEST_TIME_FLOAT' => $time, 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, - 'REQUEST_URI' => '/user?userId=1234', + 'REQUEST_URI' => '/user?id=1234', ]; ob_start(); diff --git a/tests/Unit/Domain/Components/Middleware/ValidateTokenPresenceMiddlewareTest.php b/tests/Unit/Domain/Components/Middleware/ValidateTokenPresenceMiddlewareTest.php index 1bea4b3..bbc0517 100644 --- a/tests/Unit/Domain/Components/Middleware/ValidateTokenPresenceMiddlewareTest.php +++ b/tests/Unit/Domain/Components/Middleware/ValidateTokenPresenceMiddlewareTest.php @@ -34,7 +34,7 @@ public function testValidateTokenPresenceFailure(): void $_SERVER = [ 'REQUEST_METHOD' => 'GET', 'REQUEST_TIME_FLOAT' => $time, - 'REQUEST_URI' => '/user?userId=1234', + 'REQUEST_URI' => '/user?id=1234', ]; ob_start(); @@ -63,7 +63,7 @@ public function testValidateTokenPresenceSuccess(): void $_SERVER = [ 'REQUEST_METHOD' => 'GET', 'REQUEST_TIME_FLOAT' => $time, - 'REQUEST_URI' => '/user?userId=1234', + 'REQUEST_URI' => '/user?id=1234', ]; ob_start(); @@ -79,7 +79,7 @@ public function testValidateTokenPresenceSuccess(): void 'REQUEST_METHOD' => 'GET', 'REQUEST_TIME_FLOAT' => $time, 'HTTP_AUTHORIZATION' => 'Bearer 123.456.789', - 'REQUEST_URI' => '/user?userId=1234', + 'REQUEST_URI' => '/user?id=1234', ]; ob_start(); diff --git a/tests/Unit/Domain/Components/Middleware/ValidateTokenStructureMiddlewareTest.php b/tests/Unit/Domain/Components/Middleware/ValidateTokenStructureMiddlewareTest.php index 7eece51..539aa48 100644 --- a/tests/Unit/Domain/Components/Middleware/ValidateTokenStructureMiddlewareTest.php +++ b/tests/Unit/Domain/Components/Middleware/ValidateTokenStructureMiddlewareTest.php @@ -31,7 +31,7 @@ public function testValidateTokenStructureFailureBadSignature(): void 'REQUEST_METHOD' => 'GET', 'REQUEST_TIME_FLOAT' => $time, 'HTTP_AUTHORIZATION' => 'Bearer abcd.abcd.abcd', - 'REQUEST_URI' => '/user?userId=1234', + 'REQUEST_URI' => '/user?id=1234', ]; ob_start(); @@ -60,7 +60,7 @@ public function testValidateTokenStructureFailureNoDots(): void 'REQUEST_METHOD' => 'GET', 'REQUEST_TIME_FLOAT' => $time, 'HTTP_AUTHORIZATION' => 'Bearer abcd.abcd', - 'REQUEST_URI' => '/user?userId=1234', + 'REQUEST_URI' => '/user?id=1234', ]; ob_start(); @@ -94,7 +94,7 @@ public function testValidateTokenStructureSuccess(): void 'REQUEST_METHOD' => 'GET', 'REQUEST_TIME_FLOAT' => $time, 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, - 'REQUEST_URI' => '/user?userId=1234', + 'REQUEST_URI' => '/user?id=1234', ]; ob_start(); diff --git a/tests/Unit/Domain/Components/Middleware/ValidateTokenUserMiddlewareTest.php b/tests/Unit/Domain/Components/Middleware/ValidateTokenUserMiddlewareTest.php index d991e2e..827d519 100644 --- a/tests/Unit/Domain/Components/Middleware/ValidateTokenUserMiddlewareTest.php +++ b/tests/Unit/Domain/Components/Middleware/ValidateTokenUserMiddlewareTest.php @@ -38,7 +38,7 @@ public function testValidateTokenUserFailureRecordNotFound(): void 'REQUEST_METHOD' => 'GET', 'REQUEST_TIME_FLOAT' => $time, 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, - 'REQUEST_URI' => '/user?userId=1234', + 'REQUEST_URI' => '/user?id=1234', ]; ob_start(); @@ -73,7 +73,7 @@ public function testValidateTokenUserSuccess(): void 'REQUEST_METHOD' => 'GET', 'REQUEST_TIME_FLOAT' => $time, 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, - 'REQUEST_URI' => '/user?userId=1234', + 'REQUEST_URI' => '/user?id=1234', ]; ob_start(); From 0774788c78602d4b3dbae09aba0f4584f9c9536e Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:48:50 -0600 Subject: [PATCH 07/22] [#.x] - new types for users/input --- src/Domain/ADR/InputTypes.php | 67 ++++++++++++++++++- .../Components/DataSource/User/UserTypes.php | 47 ++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/Domain/ADR/InputTypes.php b/src/Domain/ADR/InputTypes.php index 40b8806..b68da4d 100644 --- a/src/Domain/ADR/InputTypes.php +++ b/src/Domain/ADR/InputTypes.php @@ -18,10 +18,75 @@ * email?: string, * password?: string * } + * + * @phpstan-type TLogoutInput array{ + * token?: string + * } + * + * @phpstan-type TRefreshInput array{ + * token?: string + * } + * * @phpstan-type TUserInput array{ - * userId?: int + * id?: int, + * status?: int, + * email?: string, + * password?: string, + * namePrefix?: string, + * nameFirst?: string, + * nameMiddle?: string, + * nameLast?: string, + * nameSuffix?: string, + * issuer?: string, + * tokenPassword?: string, + * tokenId?: string, + * preferences?: string, + * createdDate?: string, + * createdUserId?: int, + * updatedDate?: string, + * updatedUserId?: int, * } + * + * @phpstan-type TUserSanitizedInsertInput array{ + * status: int, + * email: string, + * password: string, + * namePrefix: string, + * nameFirst: string, + * nameMiddle: string, + * nameLast: string, + * nameSuffix: string, + * issuer: string, + * tokenPassword: string, + * tokenId: string, + * preferences: string, + * createdDate: string, + * createdUserId: int, + * updatedDate: string, + * updatedUserId: int, + * } + * + * @phpstan-type TUserSanitizedUpdateInput array{ + * id: int, + * status: int, + * email: string, + * password: string, + * namePrefix: string, + * nameFirst: string, + * nameMiddle: string, + * nameLast: string, + * nameSuffix: string, + * issuer: string, + * tokenPassword: string, + * tokenId: string, + * preferences: string, + * updatedDate: string, + * updatedUserId: int, + * } + * * @phpstan-type TRequestQuery array + * + * @phpstan-type TValidationErrors array> */ final class InputTypes { diff --git a/src/Domain/Components/DataSource/User/UserTypes.php b/src/Domain/Components/DataSource/User/UserTypes.php index a9825b3..d42f2e4 100644 --- a/src/Domain/Components/DataSource/User/UserTypes.php +++ b/src/Domain/Components/DataSource/User/UserTypes.php @@ -22,7 +22,8 @@ * email: string * }, * jwt: array{ - * token: string + * token: string, + * refreshToken: string, * } * } * @@ -46,6 +47,13 @@ * usr_updated_usr_id: int, * } * + * @phpstan-type TUserTokenDbRecord array{ + * usr_id: int, + * usr_issuer: string, + * usr_token_password: string, + * usr_token_id: string + * } + * * @phpstan-type TUserRecord array{}|TUserDbRecord * * @phpstan-type TUserTransport array{ @@ -67,6 +75,43 @@ * updatedDate: string, * updatedUserId: int, * } + * + * @phpstan-type TUserInsert array{ + * status: int, + * email: string, + * password: string, + * namePrefix: string, + * nameFirst: string, + * nameMiddle: string, + * nameLast: string, + * nameSuffix: string, + * issuer: string, + * tokenPassword: string, + * tokenId: string, + * preferences: string, + * createdDate: string, + * createdUserId: int, + * updatedDate: string, + * updatedUserId: int, + * } + * + * @phpstan-type TUserUpdate array{ + * id: int, + * status: int, + * email: string, + * password: string, + * namePrefix: string, + * nameFirst: string, + * nameMiddle: string, + * nameLast: string, + * nameSuffix: string, + * issuer: string, + * tokenPassword: string, + * tokenId: string, + * preferences: string, + * updatedDate: string, + * updatedUserId: int, + * } */ final class UserTypes { From 5cec63803d977c46ba0352adcdaf1166f308d5da Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:49:40 -0600 Subject: [PATCH 08/22] [#.x] - refactoring constructor --- .../Services/Auth/AbstractAuthService.php | 61 +++++++++++++++++++ src/Domain/Services/Auth/LoginPostService.php | 56 +++++------------ .../Services/Auth/LoginPostServiceTest.php | 4 +- 3 files changed, 78 insertions(+), 43 deletions(-) create mode 100644 src/Domain/Services/Auth/AbstractAuthService.php diff --git a/src/Domain/Services/Auth/AbstractAuthService.php b/src/Domain/Services/Auth/AbstractAuthService.php new file mode 100644 index 0000000..3094540 --- /dev/null +++ b/src/Domain/Services/Auth/AbstractAuthService.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Auth; + +use PayloadInterop\DomainStatus; +use Phalcon\Api\Domain\ADR\DomainInterface; +use Phalcon\Api\Domain\ADR\InputTypes; +use Phalcon\Api\Domain\Components\Cache\Cache; +use Phalcon\Api\Domain\Components\DataSource\QueryRepository; +use Phalcon\Api\Domain\Components\DataSource\TransportRepository; +use Phalcon\Api\Domain\Components\DataSource\User\UserTypes; +use Phalcon\Api\Domain\Components\Encryption\JWTToken; +use Phalcon\Api\Domain\Components\Encryption\Security; +use Phalcon\Api\Domain\Components\Env\EnvManager; +use Phalcon\Domain\Payload; +use Phalcon\Filter\Filter; + +/** + * @phpstan-import-type TUserDbRecord from UserTypes + * @phpstan-import-type TLoginInput from InputTypes + * @phpstan-import-type TValidationErrors from InputTypes + */ +abstract class AbstractAuthService implements DomainInterface +{ + public function __construct( + protected readonly QueryRepository $repository, + protected readonly TransportRepository $transport, + protected readonly Cache $cache, + protected readonly EnvManager $env, + protected readonly JWTToken $jwtToken, + protected readonly Filter $filter, + protected readonly Security $security, + ) { + } + + /** + * @param TValidationErrors $errors + * + * @return Payload + */ + protected function getUnauthorizedPayload(array $errors): Payload + { + return new Payload( + DomainStatus::UNAUTHORIZED, + [ + 'errors' => $errors, + ] + ); + } +} diff --git a/src/Domain/Services/Auth/LoginPostService.php b/src/Domain/Services/Auth/LoginPostService.php index 306005f..d6f9a4f 100644 --- a/src/Domain/Services/Auth/LoginPostService.php +++ b/src/Domain/Services/Auth/LoginPostService.php @@ -14,36 +14,17 @@ namespace Phalcon\Api\Domain\Services\Auth; use PayloadInterop\DomainStatus; -use Phalcon\Api\Domain\ADR\DomainInterface; use Phalcon\Api\Domain\ADR\InputTypes; -use Phalcon\Api\Domain\Components\Cache\Cache; -use Phalcon\Api\Domain\Components\DataSource\QueryRepository; -use Phalcon\Api\Domain\Components\DataSource\TransportRepository; use Phalcon\Api\Domain\Components\DataSource\User\UserTypes; -use Phalcon\Api\Domain\Components\Encryption\JWTToken; -use Phalcon\Api\Domain\Components\Encryption\Security; use Phalcon\Api\Domain\Components\Enums\Http\HttpCodesEnum; -use Phalcon\Api\Domain\Components\Env\EnvManager; use Phalcon\Domain\Payload; -use Phalcon\Filter\Filter; /** * @phpstan-import-type TUserDbRecord from UserTypes * @phpstan-import-type TLoginInput from InputTypes */ -final readonly class LoginPostService implements DomainInterface +final class LoginPostService extends AbstractAuthService { - public function __construct( - private QueryRepository $repository, - private TransportRepository $transport, - private Cache $cache, - private EnvManager $env, - private JWTToken $jwtToken, - private Filter $filter, - private Security $security, - ) { - } - /** * @param TLoginInput $input * @@ -63,7 +44,9 @@ public function __invoke(array $input): Payload * Check if email or password are empty */ if (true === empty($email) || true === empty($password)) { - return $this->getUnauthorizedPayload(); + return $this->getUnauthorizedPayload( + [HttpCodesEnum::AppIncorrectCredentials->error()] + ); } /** @@ -80,24 +63,30 @@ public function __invoke(array $input): Payload $dbUserId < 1 || true !== $this->security->verify($password, $dbPassword) ) { - return $this->getUnauthorizedPayload(); + return $this->getUnauthorizedPayload( + [HttpCodesEnum::AppIncorrectCredentials->error()] + ); } /** * Get a new token for this user */ - /** @var TUserDbRecord $dbUser $token */ - $token = $this->jwtToken->getForUser($dbUser); - $domainUser = $this->transport->newUser($dbUser); - $results = $this->transport->newLoginUser( + /** @var TUserDbRecord $dbUser */ + $token = $this->jwtToken->getForUser($dbUser); + $refreshToken = $this->jwtToken->getRefreshForUser($dbUser); + $domainUser = $this->transport->newUser($dbUser); + $results = $this->transport->newLoginUser( $domainUser, $token, + $refreshToken ); /** * Store the token in cache */ $this->cache->storeTokenInCache($this->env, $domainUser, $token); + $this->cache->storeTokenInCache($this->env, $domainUser, $refreshToken); + /** * Send the payload back */ @@ -108,19 +97,4 @@ public function __invoke(array $input): Payload ] ); } - - /** - * @return Payload - */ - private function getUnauthorizedPayload(): Payload - { - return new Payload( - DomainStatus::UNAUTHORIZED, - [ - 'errors' => [ - HttpCodesEnum::AppIncorrectCredentials->error(), - ], - ] - ); - } } diff --git a/tests/Unit/Domain/Services/Auth/LoginPostServiceTest.php b/tests/Unit/Domain/Services/Auth/LoginPostServiceTest.php index c37c0b9..2049fb1 100644 --- a/tests/Unit/Domain/Services/Auth/LoginPostServiceTest.php +++ b/tests/Unit/Domain/Services/Auth/LoginPostServiceTest.php @@ -103,10 +103,10 @@ public function testServiceWithCredentials(): void $this->assertSame($expected, $actual); $this->assertArrayHasKey('token', $jwt); -// $this->assertArrayHasKey('refreshToken', $jwt); + $this->assertArrayHasKey('refreshToken', $jwt); $this->assertNotEmpty($jwt['token']); -// $this->assertNotEmpty($jwt['refreshToken']); + $this->assertNotEmpty($jwt['refreshToken']); } public function testServiceWrongCredentials(): void From 3dd2c5b9707eab4333207d25e1411e8080993d11 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:49:53 -0600 Subject: [PATCH 09/22] [#.x] - added put in the vars --- src/Domain/ADR/Input.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Domain/ADR/Input.php b/src/Domain/ADR/Input.php index ed98253..079057e 100644 --- a/src/Domain/ADR/Input.php +++ b/src/Domain/ADR/Input.php @@ -33,7 +33,9 @@ public function __invoke(RequestInterface $request): array $query = $request->getQuery(); /** @var TRequestQuery $post */ $post = $request->getPost(); + /** @var TRequestQuery $put */ + $put = $request->getPut(); - return array_merge($query, $post); + return array_merge($query, $post, $put); } } From a50c0d08432564c359ef4ff589a5c3bbfb52ce51 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:50:21 -0600 Subject: [PATCH 10/22] [#.x] - abstracting constructor --- .../Services/User/AbstractUserService.php | 31 +++++++++++++++++++ src/Domain/Services/User/UserGetService.php | 15 ++------- 2 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 src/Domain/Services/User/AbstractUserService.php diff --git a/src/Domain/Services/User/AbstractUserService.php b/src/Domain/Services/User/AbstractUserService.php new file mode 100644 index 0000000..f6d5c84 --- /dev/null +++ b/src/Domain/Services/User/AbstractUserService.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\User; + +use Phalcon\Api\Domain\ADR\DomainInterface; +use Phalcon\Api\Domain\Components\DataSource\QueryRepository; +use Phalcon\Api\Domain\Components\DataSource\TransportRepository; +use Phalcon\Api\Domain\Components\Encryption\Security; +use Phalcon\Filter\Filter; + +abstract class AbstractUserService implements DomainInterface +{ + public function __construct( + protected readonly QueryRepository $repository, + protected readonly TransportRepository $transport, + protected readonly Filter $filter, + protected readonly Security $security, + ) { + } +} diff --git a/src/Domain/Services/User/UserGetService.php b/src/Domain/Services/User/UserGetService.php index cb79dfa..c71735b 100644 --- a/src/Domain/Services/User/UserGetService.php +++ b/src/Domain/Services/User/UserGetService.php @@ -14,25 +14,14 @@ namespace Phalcon\Api\Domain\Services\User; use PayloadInterop\DomainStatus; -use Phalcon\Api\Domain\ADR\DomainInterface; use Phalcon\Api\Domain\ADR\InputTypes; -use Phalcon\Api\Domain\Components\DataSource\QueryRepository; -use Phalcon\Api\Domain\Components\DataSource\TransportRepository; use Phalcon\Domain\Payload; -use Phalcon\Filter\Filter; /** * @phpstan-import-type TUserInput from InputTypes */ -final readonly class UserGetService implements DomainInterface +final class UserGetService extends AbstractUserService { - public function __construct( - private QueryRepository $repository, - private TransportRepository $transport, - private Filter $filter - ) { - } - /** * @param TUserInput $input * @@ -40,7 +29,7 @@ public function __construct( */ public function __invoke(array $input): Payload { - $userId = $this->filter->absint($input['userId'] ?? 0); + $userId = $this->filter->absint($input['id'] ?? 0); /** * Success From 2ba6955f90f3b37182ad203e008d850024583f4f Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:50:44 -0600 Subject: [PATCH 11/22] [#.x] - abstracting constructor and common methods --- .../DataSource/AbstractRepository.php | 110 +++++++++++++++++ .../DataSource/User/UserRepository.php | 113 +++++++++++++----- 2 files changed, 195 insertions(+), 28 deletions(-) create mode 100644 src/Domain/Components/DataSource/AbstractRepository.php diff --git a/src/Domain/Components/DataSource/AbstractRepository.php b/src/Domain/Components/DataSource/AbstractRepository.php new file mode 100644 index 0000000..34c6314 --- /dev/null +++ b/src/Domain/Components/DataSource/AbstractRepository.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Components\DataSource; + +use Phalcon\Api\Domain\Components\DataSource\User\UserTypes; +use Phalcon\DataMapper\Pdo\Connection; +use Phalcon\DataMapper\Query\Delete; +use Phalcon\DataMapper\Query\Select; + +/** + * @phpstan-import-type TUserRecord from UserTypes + */ +abstract class AbstractRepository +{ + /** + * @var string + */ + protected string $idField = ''; + /** + * @var string + */ + protected string $table = ''; + + public function __construct( + protected readonly Connection $connection, + ) { + } + + /** + * @param array $criteria + * + * @return int + */ + public function deleteBy(array $criteria): int + { + $delete = Delete::new($this->connection); + + $statement = $delete + ->table($this->table) + ->whereEquals($criteria) + ->perform() + ; + + return $statement->rowCount(); + } + + /** + * @param int $recordId + * + * @return int + */ + public function deleteById(int $recordId): int + { + return $this->deleteBy( + [ + $this->idField => $recordId, + ] + ); + } + + /** + * @param int $recordId + * + * @return TUserRecord + */ + public function findById(int $recordId): array + { + $result = []; + if ($recordId > 0) { + return $this->findOneBy( + [ + $this->idField => $recordId, + ] + ); + } + + return $result; + } + + + /** + * @param array $criteria + * + * @return TUserRecord + */ + public function findOneBy(array $criteria): array + { + $select = Select::new($this->connection); + + /** @var TUserRecord $result */ + $result = $select + ->from($this->table) + ->whereEquals($criteria) + ->fetchOne() + ; + + return $result; + } +} diff --git a/src/Domain/Components/DataSource/User/UserRepository.php b/src/Domain/Components/DataSource/User/UserRepository.php index a841ea0..8ddc986 100644 --- a/src/Domain/Components/DataSource/User/UserRepository.php +++ b/src/Domain/Components/DataSource/User/UserRepository.php @@ -13,24 +13,31 @@ namespace Phalcon\Api\Domain\Components\DataSource\User; +use Phalcon\Api\Domain\Components\DataSource\AbstractRepository; use Phalcon\Api\Domain\Components\Enums\Common\FlagsEnum; -use Phalcon\DataMapper\Pdo\Connection; -use Phalcon\DataMapper\Query\Select; +use Phalcon\DataMapper\Query\Insert; +use Phalcon\DataMapper\Query\Update; /** * @phpstan-import-type TUserRecord from UserTypes + * @phpstan-import-type TUserInsert from UserTypes + * @phpstan-import-type TUserUpdate from UserTypes * * The 'final' keyword was intentionally removed from this class to allow * extension for testing purposes (e.g., mocking in unit tests). * * Please avoid extending this class in production code unless absolutely necessary. */ -class UserRepository +class UserRepository extends AbstractRepository { - public function __construct( - private readonly Connection $connection, - ) { - } + /** + * @var string + */ + protected string $idField = 'usr_id'; + /** + * @var string + */ + protected string $table = 'co_users'; /** * @param string $email @@ -53,40 +60,90 @@ public function findByEmail(string $email): array } /** - * @param int $userId + * @param TUserInsert $userData * - * @return TUserRecord + * @return int */ - public function findById(int $userId): array + public function insert(array $userData): int { - $result = []; - if ($userId > 0) { - return $this->findOneBy( - [ - 'usr_id' => $userId, - ] - ); + $createdUserId = $userData['createdUserId']; + $updatedUserId = $userData['updatedUserId']; + + $columns = [ + 'usr_status_flag' => $userData['status'], + 'usr_email' => $userData['email'], + 'usr_password' => $userData['password'], + 'usr_name_prefix' => $userData['namePrefix'], + 'usr_name_first' => $userData['nameFirst'], + 'usr_name_middle' => $userData['nameMiddle'], + 'usr_name_last' => $userData['nameLast'], + 'usr_name_suffix' => $userData['nameSuffix'], + 'usr_issuer' => $userData['issuer'], + 'usr_token_password' => $userData['tokenPassword'], + 'usr_token_id' => $userData['tokenId'], + 'usr_preferences' => $userData['preferences'], + 'usr_created_date' => $userData['createdDate'], + 'usr_updated_date' => $userData['updatedDate'], + ]; + + $insert = Insert::new($this->connection); + + $insert + ->into($this->table) + ->columns($columns) + ; + + if ($createdUserId > 0) { + $insert->column('usr_created_usr_id', $createdUserId); + } + if ($updatedUserId > 0) { + $insert->column('usr_updated_usr_id', $updatedUserId); } - return $result; + $insert->perform(); + + return (int)$insert->getLastInsertId(); } /** - * @param array $criteria + * @param TUserUpdate $userData * - * @return TUserRecord + * @return int */ - public function findOneBy(array $criteria): array + public function update(array $userData): int { - $select = Select::new($this->connection); + $userId = $userData['id']; + $updatedUserId = $userData['updatedUserId']; + + $columns = [ + 'usr_status_flag' => $userData['status'], + 'usr_email' => $userData['email'], + 'usr_password' => $userData['password'], + 'usr_name_prefix' => $userData['namePrefix'], + 'usr_name_first' => $userData['nameFirst'], + 'usr_name_middle' => $userData['nameMiddle'], + 'usr_name_last' => $userData['nameLast'], + 'usr_name_suffix' => $userData['nameSuffix'], + 'usr_issuer' => $userData['issuer'], + 'usr_token_password' => $userData['tokenPassword'], + 'usr_token_id' => $userData['tokenId'], + 'usr_preferences' => $userData['preferences'], + 'usr_updated_date' => $userData['updatedDate'], + ]; - /** @var TUserRecord $result */ - $result = $select - ->from('co_users') - ->whereEquals($criteria) - ->fetchOne() + $update = Update::new($this->connection); + $update + ->table($this->table) + ->columns($columns) + ->where('usr_id = ', $userId) ; - return $result; + if ($updatedUserId > 1) { + $update->column('usr_updated_usr_id', $updatedUserId); + } + + $update->perform(); + + return (int)$userId; } } From 02f7a3d26a681a6718b9bc2f050c9bb3c014eb9e Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:51:17 -0600 Subject: [PATCH 12/22] [#.x] - invalidate user tokens --- src/Domain/Components/Cache/Cache.php | 44 ++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/Domain/Components/Cache/Cache.php b/src/Domain/Components/Cache/Cache.php index 6a2f963..1d1dcbd 100644 --- a/src/Domain/Components/Cache/Cache.php +++ b/src/Domain/Components/Cache/Cache.php @@ -17,6 +17,7 @@ use Phalcon\Api\Domain\Components\Constants\Dates; use Phalcon\Api\Domain\Components\DataSource\User\UserTransport; use Phalcon\Api\Domain\Components\Env\EnvManager; +use Phalcon\Cache\Adapter\Redis; use Phalcon\Cache\Cache as PhalconCache; use Psr\SimpleCache\InvalidArgumentException; @@ -54,13 +55,54 @@ class Cache extends PhalconCache */ public function getCacheTokenKey(UserTransport $domainUser, string $token): string { + $tokenString = ''; + if (true !== empty($token)) { + $tokenString = sha1($token); + } + return sprintf( self::MASK_TOKEN_USER, $domainUser->getId(), - sha1($token) + $tokenString ); } + /** + * @param EnvManager $env + * @param UserTransport $domainUser + * + * @return bool + * @throws InvalidArgumentException + */ + public function invalidateForUser( + EnvManager $env, + UserTransport $domainUser + ): bool { + /** + * We could store the tokens in the database but this way is faster + * and Redis also has a TTL which auto expires elements. + * + * To get all the keys for a user, we use the underlying adapter + * of the cache which is Redis and call the `getKeys()` on it. The + * keys will come back with the prefix defined in the adapter. In order + * to delete them, we need to remove the prefix because `delete()` will + * automatically prepend each key with it. + */ + /** @var Redis $redis */ + $redis = $this->getAdapter(); + $pattern = $this->getCacheTokenKey($domainUser, ''); + $keys = $redis->getKeys($pattern); + /** @var string $prefix */ + $prefix = $env->get('CACHE_PREFIX', '-rest-', 'string'); + $newKeys = []; + /** @var string $key */ + foreach ($keys as $key) { + $newKeys[] = str_replace($prefix, '', $key); + } + + return $this->deleteMultiple($newKeys); + } + /** * @param EnvManager $env * @param UserTransport $domainUser From 8a61f3c02662e57e4c34825c7b56f9ac0c8c8124 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:51:26 -0600 Subject: [PATCH 13/22] [#.x] - added new services --- src/Domain/Components/Container.php | 76 +++++++++++++++++++---------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/src/Domain/Components/Container.php b/src/Domain/Components/Container.php index 265f950..a8e97c5 100644 --- a/src/Domain/Components/Container.php +++ b/src/Domain/Components/Container.php @@ -23,10 +23,16 @@ use Phalcon\Api\Domain\Components\Middleware\NotFoundMiddleware; use Phalcon\Api\Domain\Components\Middleware\ValidateTokenClaimsMiddleware; use Phalcon\Api\Domain\Components\Middleware\ValidateTokenPresenceMiddleware; +use Phalcon\Api\Domain\Components\Middleware\ValidateTokenRevokedMiddleware; use Phalcon\Api\Domain\Components\Middleware\ValidateTokenStructureMiddleware; use Phalcon\Api\Domain\Components\Middleware\ValidateTokenUserMiddleware; use Phalcon\Api\Domain\Services\Auth\LoginPostService; +use Phalcon\Api\Domain\Services\Auth\LogoutPostService; +use Phalcon\Api\Domain\Services\Auth\RefreshPostService; +use Phalcon\Api\Domain\Services\User\UserDeleteService; use Phalcon\Api\Domain\Services\User\UserGetService; +use Phalcon\Api\Domain\Services\User\UserPostService; +use Phalcon\Api\Domain\Services\User\UserPutService; use Phalcon\Api\Responder\JsonResponder; use Phalcon\Cache\AdapterFactory; use Phalcon\DataMapper\Pdo\Connection; @@ -45,10 +51,6 @@ class Container extends Di { /** @var string */ public const APPLICATION = 'application'; - /** - * Services - */ - public const AUTH_LOGIN_POST_SERVICE = 'service.auth.login.post'; /** @var string */ public const CACHE = 'cache'; /** @var string */ @@ -63,13 +65,34 @@ class Container extends Di public const JWT_TOKEN = 'jwt.token'; /** @var string */ public const LOGGER = 'logger'; + /** @var string */ + public const REQUEST = 'request'; + /** @var string */ + public const RESPONSE = 'response'; + /** @var string */ + public const ROUTER = 'router'; + /** @var string */ + public const SECURITY = Security::class; + /** @var string */ + public const TIME = 'time'; + /** + * Services + */ + public const AUTH_LOGIN_POST_SERVICE = 'service.auth.login.post'; + public const AUTH_LOGOUT_POST_SERVICE = 'service.auth.logout.post'; + public const AUTH_REFRESH_POST_SERVICE = 'service.auth.refresh.post'; + public const USER_DELETE_SERVICE = 'service.user.delete'; + public const USER_GET_SERVICE = 'service.user.get'; + public const USER_POST_SERVICE = 'service.user.post'; + public const USER_PUT_SERVICE = 'service.user.put'; /** * Middleware */ - public const MIDDLEWARE_HEALTH = HealthMiddleware::class; - public const MIDDLEWARE_NOT_FOUND = NotFoundMiddleware::class; - public const MIDDLEWARE_VALIDATE_TOKEN_CLAIMS = ValidateTokenClaimsMiddleware::class; - public const MIDDLEWARE_VALIDATE_TOKEN_PRESENCE = ValidateTokenPresenceMiddleware::class; + public const MIDDLEWARE_HEALTH = HealthMiddleware::class; + public const MIDDLEWARE_NOT_FOUND = NotFoundMiddleware::class; + public const MIDDLEWARE_VALIDATE_TOKEN_CLAIMS = ValidateTokenClaimsMiddleware::class; + public const MIDDLEWARE_VALIDATE_TOKEN_PRESENCE = ValidateTokenPresenceMiddleware::class; + public const MIDDLEWARE_VALIDATE_TOKEN_REVOKED = ValidateTokenRevokedMiddleware::class; public const MIDDLEWARE_VALIDATE_TOKEN_STRUCTURE = ValidateTokenStructureMiddleware::class; public const MIDDLEWARE_VALIDATE_TOKEN_USER = ValidateTokenUserMiddleware::class; /** @@ -77,22 +100,10 @@ class Container extends Di */ public const REPOSITORY = 'repository'; public const REPOSITORY_TRANSPORT = TransportRepository::class; - /** @var string */ - public const REQUEST = 'request'; /** * Responders */ public const RESPONDER_JSON = JsonResponder::class; -// public const MIDDLEWARE_VALIDATE_TOKEN_REVOKED = ValidateTokenRevokedMiddleware::class; - /** @var string */ - public const RESPONSE = 'response'; - /** @var string */ - public const ROUTER = 'router'; - /** @var string */ - public const SECURITY = Security::class; - /** @var string */ - public const TIME = 'time'; - public const USER_GET_SERVICE = 'service.user.get'; public function __construct() { @@ -110,21 +121,28 @@ public function __construct() self::REPOSITORY => $this->getServiceRepository(), - self::AUTH_LOGIN_POST_SERVICE => $this->getServiceAuthLoginPost(), - self::USER_GET_SERVICE => $this->getServiceUserGet(), + self::AUTH_LOGIN_POST_SERVICE => $this->getServiceAuthPost(LoginPostService::class), + self::AUTH_LOGOUT_POST_SERVICE => $this->getServiceAuthPost(LogoutPostService::class), + self::AUTH_REFRESH_POST_SERVICE => $this->getServiceAuthPost(RefreshPostService::class), + self::USER_DELETE_SERVICE => $this->getServiceUser(UserDeleteService::class), + self::USER_GET_SERVICE => $this->getServiceUser(UserGetService::class), + self::USER_POST_SERVICE => $this->getServiceUser(UserPostService::class), + self::USER_PUT_SERVICE => $this->getServiceUser(UserPutService::class), ]; parent::__construct(); } /** + * @param class-string $className + * * @return Service */ - private function getServiceAuthLoginPost(): Service + private function getServiceAuthPost(string $className): Service { return new Service( [ - 'className' => LoginPostService::class, + 'className' => $className, 'arguments' => [ [ 'type' => 'service', @@ -368,13 +386,15 @@ private function getServiceRouter(): Service } /** + * @param class-string $className + * * @return Service */ - private function getServiceUserGet(): Service + private function getServiceUser(string $className): Service { return new Service( [ - 'className' => UserGetService::class, + 'className' => $className, 'arguments' => [ [ 'type' => 'service', @@ -388,6 +408,10 @@ private function getServiceUserGet(): Service 'type' => 'service', 'name' => self::FILTER, ], + [ + 'type' => 'service', + 'name' => self::SECURITY, + ], ], ] ); From 79d828964b49a108db810cd1384af6825f6c8669 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:51:43 -0600 Subject: [PATCH 14/22] [#.x] - added user put service --- src/Domain/Services/User/UserPutService.php | 182 ++++++++ .../Services/User/UserServicePutTest.php | 409 ++++++++++++++++++ 2 files changed, 591 insertions(+) create mode 100644 src/Domain/Services/User/UserPutService.php create mode 100644 tests/Unit/Domain/Services/User/UserServicePutTest.php diff --git a/src/Domain/Services/User/UserPutService.php b/src/Domain/Services/User/UserPutService.php new file mode 100644 index 0000000..61b5431 --- /dev/null +++ b/src/Domain/Services/User/UserPutService.php @@ -0,0 +1,182 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\User; + +use PayloadInterop\DomainStatus; +use PDOException; +use Phalcon\Api\Domain\ADR\InputTypes; +use Phalcon\Api\Domain\Components\Constants\Dates; +use Phalcon\Api\Domain\Components\Enums\Http\HttpCodesEnum; +use Phalcon\Domain\Payload; + +/** + * @phpstan-import-type TUserSanitizedUpdateInput from InputTypes + * @phpstan-import-type TUserInput from InputTypes + * @phpstan-import-type TValidationErrors from InputTypes + */ +final class UserPutService extends AbstractUserService +{ + /** + * @param TUserInput $input + * + * @return Payload + */ + public function __invoke(array $input): Payload + { + $inputData = $this->sanitizeInput($input); + $errors = $this->validateInput($inputData); + + /** + * Errors exist - return early + */ + if (true !== empty($errors)) { + return new Payload( + DomainStatus::INVALID, + [ + 'errors' => $errors, + ] + ); + } + + /** + * The password needs to be hashed + */ + $password = $inputData['password']; + $hashed = $this->security->hash($password); + + $inputData['password'] = $hashed; + + /** + * Update the record + */ + /** + * @todo get the user from the database to make sure that it is valid + */ + + try { + $userId = $this + ->repository + ->user() + ->update($inputData) + ; + } catch (PDOException $ex) { + /** + * @todo send generic response and log the error + */ + return $this->getErrorPayload($ex->getMessage()); + } + + if ($userId < 1) { + return $this->getErrorPayload('No id returned'); + } + + /** + * Get the user from the database + */ + $dbUser = $this->repository->user()->findById($userId); + $domainUser = $this->transport->newUser($dbUser); + + /** + * Return the user back + */ + return new Payload( + DomainStatus::UPDATED, + [ + 'data' => $domainUser->toArray(), + ] + ); + } + + /** + * @param string $message + * + * @return Payload + */ + private function getErrorPayload(string $message): Payload + { + return new Payload( + DomainStatus::ERROR, + [ + 'errors' => [ + HttpCodesEnum::AppCannotUpdateDatabaseRecord->text() + . $message, + ], + ] + ); + } + + /** + * @param TUserInput $input + * + * @return TUserSanitizedUpdateInput + */ + private function sanitizeInput(array $input): array + { + /** + * Only the fields we want + * + * @todo add sanitizers here + * @todo maybe this is another domain object? + */ + $sanitized = [ + 'id' => $input['id'] ?? 0, + 'status' => $input['status'] ?? 0, + 'email' => $input['email'] ?? '', + 'password' => $input['password'] ?? '', + 'namePrefix' => $input['namePrefix'] ?? '', + 'nameFirst' => $input['nameFirst'] ?? '', + 'nameLast' => $input['nameLast'] ?? '', + 'nameMiddle' => $input['nameMiddle'] ?? '', + 'nameSuffix' => $input['nameSuffix'] ?? '', + 'issuer' => $input['issuer'] ?? '', + 'tokenPassword' => $input['tokenPassword'] ?? '', + 'tokenId' => $input['tokenId'] ?? '', + 'preferences' => $input['preferences'] ?? '', + 'updatedDate' => $input['updatedDate'] ?? null, + 'updatedUserId' => $input['updatedUserId'] ?? 0, + ]; + + if (empty($sanitized['updatedDate'])) { + $sanitized['updatedDate'] = Dates::toUTC('now', Dates::DATE_TIME_FORMAT); + } + + return $sanitized; + } + + /** + * @param TUserSanitizedUpdateInput $inputData + * + * @return TValidationErrors|array{} + */ + private function validateInput(array $inputData): array + { + $errors = []; + $required = [ + 'email', + 'password', + 'issuer', + 'tokenPassword', + 'tokenId', + ]; + + foreach ($required as $name) { + $field = $inputData[$name]; + if (true === empty($field)) { + $errors[] = ['Field ' . $name . ' cannot be empty.']; + } + } + + return $errors; + } +} diff --git a/tests/Unit/Domain/Services/User/UserServicePutTest.php b/tests/Unit/Domain/Services/User/UserServicePutTest.php new file mode 100644 index 0000000..7fe18af --- /dev/null +++ b/tests/Unit/Domain/Services/User/UserServicePutTest.php @@ -0,0 +1,409 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Services\User; + +use PayloadInterop\DomainStatus; +use PDOException; +use Phalcon\Api\Domain\Components\Container; +use Phalcon\Api\Domain\Components\DataSource\QueryRepository; +use Phalcon\Api\Domain\Components\DataSource\TransportRepository; +use Phalcon\Api\Domain\Components\DataSource\User\UserRepository; +use Phalcon\Api\Domain\Components\Encryption\Security; +use Phalcon\Api\Domain\Services\User\UserPutService; +use Phalcon\Api\Tests\AbstractUnitTestCase; +use Phalcon\Api\Tests\Fixtures\Domain\Migrations\UsersMigration; +use Phalcon\Filter\Filter; +use PHPUnit\Framework\Attributes\BackupGlobals; + +#[BackupGlobals(true)] +final class UserServicePutTest extends AbstractUnitTestCase +{ + public function testServiceFailureNoIdReturned(): void + { + $userRepository = $this + ->getMockBuilder(UserRepository::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'update', + ] + ) + ->getMock() + ; + $userRepository->method('update')->willReturn(0); + + $repository = $this + ->getMockBuilder(QueryRepository::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'user', + ] + ) + ->getMock() + ; + $repository->method('user')->willReturn($userRepository); + + /** @var Filter $filter */ + $filter = $this->container->get(Container::FILTER); + /** @var Security $security */ + $security = $this->container->get(Container::SECURITY); + /** @var TransportRepository $transport */ + $transport = $this->container->get(Container::REPOSITORY_TRANSPORT); + + $service = new UserPutService($repository, $transport, $filter, $security); + + $userData = $this->getNewUserData(); + + /** + * $userData is a db record. We need a domain object here + */ + $domainUser = $transport->newUser($userData); + $domainData = $domainUser->toArray(); + $domainData = $domainData[0]; + + $payload = $service->__invoke($domainData); + + $expected = DomainStatus::ERROR; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $errors = $actual['errors']; + + $expected = ['Cannot update database record: No id returned']; + $actual = $errors; + $this->assertSame($expected, $actual); + } + + public function testServiceFailurePdoError(): void + { + $userRepository = $this + ->getMockBuilder(UserRepository::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'update', + ] + ) + ->getMock() + ; + $userRepository + ->method('update') + ->willThrowException(new PDOException('abcde')) + ; + + $repository = $this + ->getMockBuilder(QueryRepository::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'user', + ] + ) + ->getMock() + ; + $repository + ->method('user') + ->willReturn($userRepository) + ; + + /** @var Filter $filter */ + $filter = $this->container->get(Container::FILTER); + /** @var Security $security */ + $security = $this->container->get(Container::SECURITY); + /** @var TransportRepository $transport */ + $transport = $this->container->get(Container::REPOSITORY_TRANSPORT); + + $service = new UserPutService($repository, $transport, $filter, $security); + + $userData = $this->getNewUserData(); + + /** + * $userData is a db record. We need a domain object here + */ + $domainUser = $transport->newUser($userData); + $domainData = $domainUser->toArray(); + $domainData = $domainData[0]; + + $payload = $service->__invoke($domainData); + + $expected = DomainStatus::ERROR; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $errors = $actual['errors']; + + $expected = ['Cannot update database record: abcde']; + $actual = $errors; + $this->assertSame($expected, $actual); + } + + public function testServiceFailureValidation(): void + { + /** @var UserPutService $service */ + $service = $this->container->get(Container::USER_PUT_SERVICE); + /** @var TransportRepository $transport */ + $transport = $this->container->get(Container::REPOSITORY_TRANSPORT); + + $userData = $this->getNewUserData(); + + unset( + $userData['usr_email'], + $userData['usr_password'], + $userData['usr_issuer'], + $userData['usr_token_password'], + $userData['usr_token_id'] + ); + + /** + * $userData is a db record. We need a domain object here + */ + $domainUser = $transport->newUser($userData); + $domainData = $domainUser->toArray(); + $domainData = $domainData[0]; + + $payload = $service->__invoke($domainData); + + $expected = DomainStatus::INVALID; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $errors = $actual['errors']; + + $expected = [ + ['Field email cannot be empty.'], + ['Field password cannot be empty.'], + ['Field issuer cannot be empty.'], + ['Field tokenPassword cannot be empty.'], + ['Field tokenId cannot be empty.'], + ]; + $actual = $errors; + $this->assertSame($expected, $actual); + } + + public function testServiceSuccess(): void + { + /** @var UserPutService $service */ + $service = $this->container->get(Container::USER_PUT_SERVICE); + /** @var TransportRepository $transport */ + $transport = $this->container->get(Container::REPOSITORY_TRANSPORT); + + $migration = new UsersMigration($this->getConnection()); + $dbUser = $this->getNewUser($migration); + $userId = $dbUser['usr_id']; + $userData = $this->getNewUserData(); + + $userData['usr_created_usr_id'] = 4; + $userData['usr_updated_usr_id'] = 5; + + /** + * $userData is a db record. We need a domain object here + */ + $domainUser = $transport->newUser($userData); + $domainData = $domainUser->toArray(); + $domainData = $domainData[0]; + + $domainData['id'] = $userId; + + $payload = $service->__invoke($domainData); + + $expected = DomainStatus::UPDATED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('data', $actual); + + $data = $actual['data']; + + $this->assertArrayHasKey($userId, $data); + + $data = $data[$userId]; + + $expected = $domainData['status']; + $actual = $data['status']; + $this->assertSame($expected, $actual); + + $expected = $domainData['email']; + $actual = $data['email']; + $this->assertSame($expected, $actual); + + $actual = str_starts_with($data['password'], '$argon2i$'); + $this->assertTrue($actual); + + $expected = $domainData['namePrefix']; + $actual = $data['namePrefix']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameFirst']; + $actual = $data['nameFirst']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameMiddle']; + $actual = $data['nameMiddle']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameLast']; + $actual = $data['nameLast']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameSuffix']; + $actual = $data['nameSuffix']; + $this->assertSame($expected, $actual); + + $expected = $domainData['issuer']; + $actual = $data['issuer']; + $this->assertSame($expected, $actual); + + $expected = $domainData['tokenPassword']; + $actual = $data['tokenPassword']; + $this->assertSame($expected, $actual); + + $expected = $domainData['tokenId']; + $actual = $data['tokenId']; + $this->assertSame($expected, $actual); + + $expected = $domainData['preferences']; + $actual = $data['preferences']; + $this->assertSame($expected, $actual); + + $expected = $dbUser['usr_created_date']; + $actual = $data['createdDate']; + $this->assertSame($expected, $actual); + + $expected = 0; + $actual = $data['createdUserId']; + $this->assertSame($expected, $actual); + + $expected = $dbUser['usr_updated_date']; + $actual = $data['updatedDate']; + $this->assertNotSame($expected, $actual); + + $expected = 5; + $actual = $data['updatedUserId']; + $this->assertSame($expected, $actual); + } + + public function testServiceSuccessEmptyDates(): void + { + /** @var UserPutService $service */ + $service = $this->container->get(Container::USER_PUT_SERVICE); + /** @var TransportRepository $transport */ + $transport = $this->container->get(Container::REPOSITORY_TRANSPORT); + + $migration = new UsersMigration($this->getConnection()); + $dbUser = $this->getNewUser($migration); + $userId = $dbUser['usr_id']; + $userData = $this->getNewUserData(); + unset( + $userData['usr_updated_date'], + ); + + /** + * $userData is a db record. We need a domain object here + */ + $domainUser = $transport->newUser($userData); + $domainData = $domainUser->toArray(); + $domainData = $domainData[0]; + + $domainData['id'] = $userId; + + $payload = $service->__invoke($domainData); + + $expected = DomainStatus::UPDATED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('data', $actual); + + $data = $actual['data']; + + $this->assertArrayHasKey($userId, $data); + + $data = $data[$userId]; + + $expected = $domainData['status']; + $actual = $data['status']; + $this->assertSame($expected, $actual); + + $expected = $domainData['email']; + $actual = $data['email']; + $this->assertSame($expected, $actual); + + $actual = str_starts_with($data['password'], '$argon2i$'); + $this->assertTrue($actual); + + $expected = $domainData['namePrefix']; + $actual = $data['namePrefix']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameFirst']; + $actual = $data['nameFirst']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameMiddle']; + $actual = $data['nameMiddle']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameLast']; + $actual = $data['nameLast']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameSuffix']; + $actual = $data['nameSuffix']; + $this->assertSame($expected, $actual); + + $expected = $domainData['issuer']; + $actual = $data['issuer']; + $this->assertSame($expected, $actual); + + $expected = $domainData['tokenPassword']; + $actual = $data['tokenPassword']; + $this->assertSame($expected, $actual); + + $expected = $domainData['tokenId']; + $actual = $data['tokenId']; + $this->assertSame($expected, $actual); + + $expected = $domainData['preferences']; + $actual = $data['preferences']; + $this->assertSame($expected, $actual); + + $expected = $dbUser['usr_created_date']; + $actual = $data['createdDate']; + $this->assertSame($expected, $actual); + + $expected = 0; + $actual = $data['createdUserId']; + $this->assertSame($expected, $actual); + + $expected = $dbUser['usr_updated_date']; + $actual = $data['updatedDate']; + $this->assertNotSame($expected, $actual); + + $expected = $dbUser['usr_updated_usr_id']; + $actual = $data['updatedUserId']; + $this->assertSame($expected, $actual); + } +} From 897807a4994841c32df9b52b61cebd05e3aede1d Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:51:55 -0600 Subject: [PATCH 15/22] [#.x] - added user post service --- src/Domain/Services/User/UserPostService.php | 183 ++++++++ .../Services/User/UserServicePostTest.php | 411 ++++++++++++++++++ 2 files changed, 594 insertions(+) create mode 100644 src/Domain/Services/User/UserPostService.php create mode 100644 tests/Unit/Domain/Services/User/UserServicePostTest.php diff --git a/src/Domain/Services/User/UserPostService.php b/src/Domain/Services/User/UserPostService.php new file mode 100644 index 0000000..d17c75b --- /dev/null +++ b/src/Domain/Services/User/UserPostService.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\User; + +use PayloadInterop\DomainStatus; +use PDOException; +use Phalcon\Api\Domain\ADR\InputTypes; +use Phalcon\Api\Domain\Components\Constants\Dates; +use Phalcon\Api\Domain\Components\Enums\Http\HttpCodesEnum; +use Phalcon\Domain\Payload; + +/** + * @phpstan-import-type TUserSanitizedInsertInput from InputTypes + * @phpstan-import-type TUserInput from InputTypes + * @phpstan-import-type TValidationErrors from InputTypes + */ +final class UserPostService extends AbstractUserService +{ + /** + * @param TUserInput $input + * + * @return Payload + */ + public function __invoke(array $input): Payload + { + $inputData = $this->sanitizeInput($input); + $errors = $this->validateInput($inputData); + + /** + * Errors exist - return early + */ + if (true !== empty($errors)) { + return new Payload( + DomainStatus::INVALID, + [ + 'errors' => $errors, + ] + ); + } + + /** + * The password needs to be hashed + */ + $password = $inputData['password']; + $hashed = $this->security->hash($password); + + $inputData['password'] = $hashed; + + /** + * Insert the record + */ + try { + $userId = $this + ->repository + ->user() + ->insert($inputData) + ; + } catch (PDOException $ex) { + /** + * @todo send generic response and log the error + */ + return $this->getErrorPayload($ex->getMessage()); + } + + if ($userId < 1) { + return $this->getErrorPayload('No id returned'); + } + + /** + * Get the user from the database + */ + $dbUser = $this->repository->user()->findById($userId); + $domainUser = $this->transport->newUser($dbUser); + + /** + * Return the user back + */ + return new Payload( + DomainStatus::CREATED, + [ + 'data' => $domainUser->toArray(), + ] + ); + } + + /** + * @param string $message + * + * @return Payload + */ + private function getErrorPayload(string $message): Payload + { + return new Payload( + DomainStatus::ERROR, + [ + 'errors' => [ + HttpCodesEnum::AppCannotCreateDatabaseRecord->text() + . $message, + ], + ] + ); + } + + /** + * @param TUserInput $input + * + * @return TUserSanitizedInsertInput + */ + private function sanitizeInput(array $input): array + { + /** + * Only the fields we want + * + * @todo add sanitizers here + * @todo maybe this is another domain object? + */ + $sanitized = [ + 'status' => $input['status'] ?? 0, + 'email' => $input['email'] ?? '', + 'password' => $input['password'] ?? '', + 'namePrefix' => $input['namePrefix'] ?? '', + 'nameFirst' => $input['nameFirst'] ?? '', + 'nameLast' => $input['nameLast'] ?? '', + 'nameMiddle' => $input['nameMiddle'] ?? '', + 'nameSuffix' => $input['nameSuffix'] ?? '', + 'issuer' => $input['issuer'] ?? '', + 'tokenPassword' => $input['tokenPassword'] ?? '', + 'tokenId' => $input['tokenId'] ?? '', + 'preferences' => $input['preferences'] ?? '', + 'createdDate' => $input['createdDate'] ?? null, + 'createdUserId' => $input['createdUserId'] ?? 0, + 'updatedDate' => $input['updatedDate'] ?? null, + 'updatedUserId' => $input['updatedUserId'] ?? 0, + ]; + + if (empty($sanitized['createdDate'])) { + $sanitized['createdDate'] = Dates::toUTC('now', Dates::DATE_TIME_FORMAT); + } + + if (empty($sanitized['updatedDate'])) { + $sanitized['updatedDate'] = Dates::toUTC('now', Dates::DATE_TIME_FORMAT); + } + + return $sanitized; + } + + /** + * @param TUserSanitizedInsertInput $inputData + * + * @return TValidationErrors|array{} + */ + private function validateInput(array $inputData): array + { + $errors = []; + $required = [ + 'email', + 'password', + 'issuer', + 'tokenPassword', + 'tokenId', + ]; + + foreach ($required as $name) { + $field = $inputData[$name]; + if (true === empty($field)) { + $errors[] = ['Field ' . $name . ' cannot be empty.']; + } + } + + return $errors; + } +} diff --git a/tests/Unit/Domain/Services/User/UserServicePostTest.php b/tests/Unit/Domain/Services/User/UserServicePostTest.php new file mode 100644 index 0000000..0cbd8f4 --- /dev/null +++ b/tests/Unit/Domain/Services/User/UserServicePostTest.php @@ -0,0 +1,411 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Services\User; + +use DateTimeImmutable; +use PayloadInterop\DomainStatus; +use PDOException; +use Phalcon\Api\Domain\Components\Container; +use Phalcon\Api\Domain\Components\DataSource\QueryRepository; +use Phalcon\Api\Domain\Components\DataSource\TransportRepository; +use Phalcon\Api\Domain\Components\DataSource\User\UserRepository; +use Phalcon\Api\Domain\Components\Encryption\Security; +use Phalcon\Api\Domain\Services\User\UserPostService; +use Phalcon\Api\Tests\AbstractUnitTestCase; +use Phalcon\Filter\Filter; +use PHPUnit\Framework\Attributes\BackupGlobals; + +#[BackupGlobals(true)] +final class UserServicePostTest extends AbstractUnitTestCase +{ + public function testServiceFailureNoIdReturned(): void + { + $userRepository = $this + ->getMockBuilder(UserRepository::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'insert', + ] + ) + ->getMock() + ; + $userRepository + ->method('insert') + ->willReturn(0) + ; + + $repository = $this + ->getMockBuilder(QueryRepository::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'user', + ] + ) + ->getMock() + ; + $repository + ->method('user') + ->willReturn($userRepository) + ; + + /** + * Difference of achieving the same thing - see test above on + * how the class is used without getting it from the DI container + */ + $this->container->setShared(Container::REPOSITORY, $repository); + + /** @var UserPostService $service */ + $service = $this->container->get(Container::USER_POST_SERVICE); + /** @var TransportRepository $service */ + $transport = $this->container->get(Container::REPOSITORY_TRANSPORT); + + $userData = $this->getNewUserData(); + + /** + * $userData is a db record. We need a domain object here + */ + $domainUser = $transport->newUser($userData); + $domainData = $domainUser->toArray(); + $domainData = $domainData[0]; + + $payload = $service->__invoke($domainData); + + $expected = DomainStatus::ERROR; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $errors = $actual['errors']; + + $expected = ['Cannot create database record: No id returned']; + $actual = $errors; + $this->assertSame($expected, $actual); + } + + public function testServiceFailurePdoError(): void + { + $userRepository = $this + ->getMockBuilder(UserRepository::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'insert', + ] + ) + ->getMock() + ; + $userRepository + ->method('insert') + ->willThrowException(new PDOException('abcde')) + ; + + $repository = $this + ->getMockBuilder(QueryRepository::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'user', + ] + ) + ->getMock() + ; + $repository + ->method('user') + ->willReturn($userRepository) + ; + + /** @var Filter $filter */ + $filter = $this->container->get(Container::FILTER); + /** @var Security $security */ + $security = $this->container->get(Container::SECURITY); + /** @var TransportRepository $transport */ + $transport = $this->container->get(Container::REPOSITORY_TRANSPORT); + + $service = new UserPostService($repository, $transport, $filter, $security); + + $userData = $this->getNewUserData(); + + /** + * $userData is a db record. We need a domain object here + */ + $domainUser = $transport->newUser($userData); + $domainData = $domainUser->toArray(); + $domainData = $domainData[0]; + + $payload = $service->__invoke($domainData); + + $expected = DomainStatus::ERROR; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $errors = $actual['errors']; + + $expected = ['Cannot create database record: abcde']; + $actual = $errors; + $this->assertSame($expected, $actual); + } + + public function testServiceFailureValidation(): void + { + /** @var UserPostService $service */ + $service = $this->container->get(Container::USER_POST_SERVICE); + /** @var TransportRepository $transport */ + $transport = $this->container->get(Container::REPOSITORY_TRANSPORT); + + $userData = $this->getNewUserData(); + + unset( + $userData['usr_email'], + $userData['usr_password'], + $userData['usr_issuer'], + $userData['usr_token_password'], + $userData['usr_token_id'] + ); + + /** + * $userData is a db record. We need a domain object here + */ + $domainUser = $transport->newUser($userData); + $domainData = $domainUser->toArray(); + $domainData = $domainData[0]; + + $payload = $service->__invoke($domainData); + + $expected = DomainStatus::INVALID; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $errors = $actual['errors']; + + $expected = [ + ['Field email cannot be empty.'], + ['Field password cannot be empty.'], + ['Field issuer cannot be empty.'], + ['Field tokenPassword cannot be empty.'], + ['Field tokenId cannot be empty.'], + ]; + $actual = $errors; + $this->assertSame($expected, $actual); + } + + public function testServiceSuccess(): void + { + /** @var UserPostService $service */ + $service = $this->container->get(Container::USER_POST_SERVICE); + /** @var TransportRepository $transport */ + $transport = $this->container->get(Container::REPOSITORY_TRANSPORT); + + $userData = $this->getNewUserData(); + $userData['usr_created_usr_id'] = 4; + $userData['usr_updated_usr_id'] = 5; + + /** + * $userData is a db record. We need a domain object here + */ + $domainUser = $transport->newUser($userData); + $domainData = $domainUser->toArray(); + $domainData = $domainData[0]; + + $payload = $service->__invoke($domainData); + + $expected = DomainStatus::CREATED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('data', $actual); + + $data = $actual['data']; + + $userId = array_key_first($data); + + $this->assertGreaterThan(0, $userId); + + $data = $data[$userId]; + + $expected = $domainData['status']; + $actual = $data['status']; + $this->assertSame($expected, $actual); + + $expected = $domainData['email']; + $actual = $data['email']; + $this->assertSame($expected, $actual); + + $actual = str_starts_with($data['password'], '$argon2i$'); + $this->assertTrue($actual); + + $expected = $domainData['namePrefix']; + $actual = $data['namePrefix']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameFirst']; + $actual = $data['nameFirst']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameMiddle']; + $actual = $data['nameMiddle']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameLast']; + $actual = $data['nameLast']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameSuffix']; + $actual = $data['nameSuffix']; + $this->assertSame($expected, $actual); + + $expected = $domainData['issuer']; + $actual = $data['issuer']; + $this->assertSame($expected, $actual); + + $expected = $domainData['tokenPassword']; + $actual = $data['tokenPassword']; + $this->assertSame($expected, $actual); + + $expected = $domainData['tokenId']; + $actual = $data['tokenId']; + $this->assertSame($expected, $actual); + + $expected = $domainData['preferences']; + $actual = $data['preferences']; + $this->assertSame($expected, $actual); + + $expected = $domainData['createdDate']; + $actual = $data['createdDate']; + $this->assertSame($expected, $actual); + + $expected = 4; + $actual = $data['createdUserId']; + $this->assertSame($expected, $actual); + + $expected = $domainData['updatedDate']; + $actual = $data['updatedDate']; + $this->assertSame($expected, $actual); + + $expected = 5; + $actual = $data['updatedUserId']; + $this->assertSame($expected, $actual); + } + + public function testServiceSuccessEmptyDates(): void + { + $now = new DateTimeImmutable(); + $today = $now->format('Y-m-d'); + /** @var UserPostService $service */ + $service = $this->container->get(Container::USER_POST_SERVICE); + /** @var TransportRepository $transport */ + $transport = $this->container->get(Container::REPOSITORY_TRANSPORT); + + $userData = $this->getNewUserData(); + unset( + $userData['usr_created_date'], + $userData['usr_updated_date'], + ); + + /** + * $userData is a db record. We need a domain object here + */ + $domainUser = $transport->newUser($userData); + $domainData = $domainUser->toArray(); + $domainData = $domainData[0]; + + $payload = $service->__invoke($domainData); + + $expected = DomainStatus::CREATED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('data', $actual); + + $data = $actual['data']; + + $userId = array_key_first($data); + + $this->assertGreaterThan(0, $userId); + + $data = $data[$userId]; + + $expected = $domainData['status']; + $actual = $data['status']; + $this->assertSame($expected, $actual); + + $expected = $domainData['email']; + $actual = $data['email']; + $this->assertSame($expected, $actual); + + $actual = str_starts_with($data['password'], '$argon2i$'); + $this->assertTrue($actual); + + $expected = $domainData['namePrefix']; + $actual = $data['namePrefix']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameFirst']; + $actual = $data['nameFirst']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameMiddle']; + $actual = $data['nameMiddle']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameLast']; + $actual = $data['nameLast']; + $this->assertSame($expected, $actual); + + $expected = $domainData['nameSuffix']; + $actual = $data['nameSuffix']; + $this->assertSame($expected, $actual); + + $expected = $domainData['issuer']; + $actual = $data['issuer']; + $this->assertSame($expected, $actual); + + $expected = $domainData['tokenPassword']; + $actual = $data['tokenPassword']; + $this->assertSame($expected, $actual); + + $expected = $domainData['tokenId']; + $actual = $data['tokenId']; + $this->assertSame($expected, $actual); + + $expected = $domainData['preferences']; + $actual = $data['preferences']; + $this->assertSame($expected, $actual); + + $actual = $data['createdDate']; + $this->assertStringContainsString($today, $actual); + + $expected = 0; + $actual = $data['createdUserId']; + $this->assertSame($expected, $actual); + + $actual = $data['updatedDate']; + $this->assertStringContainsString($today, $actual); + + $expected = 0; + $actual = $data['updatedUserId']; + $this->assertSame($expected, $actual); + } +} From 0a5e19cf6bab427876b53761862532abad7b3721 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:52:09 -0600 Subject: [PATCH 16/22] [#.x] - added user delete service --- .../Services/User/UserDeleteService.php | 64 ++++++++++ .../Services/User/UserServiceDeleteTest.php | 112 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/Domain/Services/User/UserDeleteService.php create mode 100644 tests/Unit/Domain/Services/User/UserServiceDeleteTest.php diff --git a/src/Domain/Services/User/UserDeleteService.php b/src/Domain/Services/User/UserDeleteService.php new file mode 100644 index 0000000..77b45bd --- /dev/null +++ b/src/Domain/Services/User/UserDeleteService.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\User; + +use PayloadInterop\DomainStatus; +use Phalcon\Api\Domain\ADR\InputTypes; +use Phalcon\Domain\Payload; + +/** + * @phpstan-import-type TUserInput from InputTypes + */ +final class UserDeleteService extends AbstractUserService +{ + /** + * @param TUserInput $input + * + * @return Payload + */ + public function __invoke(array $input): Payload + { + $userId = $this->filter->absint($input['id'] ?? 0); + + /** + * Success + */ + if ($userId > 0) { + $rows = $this->repository->user()->deleteById($userId); + + if ($rows > 0) { + return new Payload( + DomainStatus::DELETED, + [ + 'data' => [ + 'Record deleted successfully [#' . $userId . '].', + ], + ] + ); + } + } + + /** + * 404 + */ + return new Payload( + DomainStatus::NOT_FOUND, + [ + 'errors' => [ + 'Record(s) not found', + ], + ] + ); + } +} diff --git a/tests/Unit/Domain/Services/User/UserServiceDeleteTest.php b/tests/Unit/Domain/Services/User/UserServiceDeleteTest.php new file mode 100644 index 0000000..05807f5 --- /dev/null +++ b/tests/Unit/Domain/Services/User/UserServiceDeleteTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Services\User; + +use PayloadInterop\DomainStatus; +use Phalcon\Api\Domain\Components\Container; +use Phalcon\Api\Domain\Services\User\UserDeleteService; +use Phalcon\Api\Tests\AbstractUnitTestCase; +use Phalcon\Api\Tests\Fixtures\Domain\Migrations\UsersMigration; +use PHPUnit\Framework\Attributes\BackupGlobals; + +#[BackupGlobals(true)] +final class UserServiceDeleteTest extends AbstractUnitTestCase +{ + public function testServiceWithUserId(): void + { + /** @var UserDeleteService $service */ + $service = $this->container->get(Container::USER_DELETE_SERVICE); + + /** + * We need to ask for a user to be deleted with an ID that does not + * exist in the database. To ensure that, we will create a user, + * delete it and then try to delete the same user with that ID + */ + $migration = new UsersMigration($this->getConnection()); + $dbUser = $this->getNewUser($migration); + $userId = $dbUser['usr_id']; + + $payload = $service->__invoke( + [ + 'id' => $userId, + ] + ); + + $expected = DomainStatus::DELETED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('data', $actual); + + $data = $actual['data']; + + $expected = [ + 'Record deleted successfully [#' . $userId . '].', + ]; + $actual = $data; + $this->assertSame($expected, $actual); + + /** + * Now deleting it again, will result in a 404 + */ + $payload = $service->__invoke( + [ + 'id' => $userId, + ] + ); + + $expected = DomainStatus::NOT_FOUND; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $errors = $actual['errors']; + + $expected = [ + 'Record(s) not found', + ]; + $actual = $errors; + $this->assertSame($expected, $actual); + } + + public function testServiceZeroUserId(): void + { + /** @var UserDeleteService $service */ + $service = $this->container->get(Container::USER_DELETE_SERVICE); + + $payload = $service->__invoke( + [ + 'id' => 0, + ] + ); + + $expected = DomainStatus::NOT_FOUND; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $errors = $actual['errors']; + + $expected = [ + 'Record(s) not found', + ]; + $actual = $errors; + $this->assertSame($expected, $actual); + } +} From b5931487a72b8bb575896f33e4c6949395905222 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:52:26 -0600 Subject: [PATCH 17/22] [#.x] - added auth refresh service --- .../Services/Auth/RefreshPostService.php | 120 ++++++++ .../Services/Auth/RefreshPostServiceTest.php | 291 ++++++++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 src/Domain/Services/Auth/RefreshPostService.php create mode 100644 tests/Unit/Domain/Services/Auth/RefreshPostServiceTest.php diff --git a/src/Domain/Services/Auth/RefreshPostService.php b/src/Domain/Services/Auth/RefreshPostService.php new file mode 100644 index 0000000..2431936 --- /dev/null +++ b/src/Domain/Services/Auth/RefreshPostService.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Auth; + +use PayloadInterop\DomainStatus; +use Phalcon\Api\Domain\ADR\InputTypes; +use Phalcon\Api\Domain\Components\DataSource\User\UserTypes; +use Phalcon\Api\Domain\Components\Enums\Common\JWTEnum; +use Phalcon\Api\Domain\Components\Enums\Http\HttpCodesEnum; +use Phalcon\Domain\Payload; + +/** + * @phpstan-import-type TUserDbRecord from UserTypes + * @phpstan-import-type TRefreshInput from InputTypes + * @phpstan-import-type TValidationErrors from InputTypes + */ +final class RefreshPostService extends AbstractAuthService +{ + /** + * @param TRefreshInput $input + * + * @return Payload + */ + public function __invoke(array $input): Payload + { + /** + * Get email and password from the input and sanitize them + */ + $token = (string)($input['token'] ?? ''); + $token = $this->filter->string($token); + + /** + * Validation + * + * Empty token + */ + if (true === empty($token)) { + return $this->getUnauthorizedPayload( + [HttpCodesEnum::AppTokenNotPresent->error()] + ); + } + + /** + * @todo catch any exceptions here + * + * Is this the refresh token + */ + $tokenObject = $this->jwtToken->getObject($token); + $isRefresh = $tokenObject->getClaims()->get(JWTEnum::Refresh->value); + if (false === $isRefresh) { + return $this->getUnauthorizedPayload( + [HttpCodesEnum::AppTokenNotValid->error()] + ); + } + + /** + * Get the user - if empty return error + */ + $user = $this + ->jwtToken + ->getUser($this->repository, $tokenObject) + ; + if (true === empty($user)) { + return $this->getUnauthorizedPayload( + [HttpCodesEnum::AppTokenInvalidUser->error()] + ); + } + + $domainUser = $this->transport->newUser($user); + + /** @var TValidationErrors $errors */ + $errors = $this->jwtToken->validate($tokenObject, $domainUser); + if (true !== empty($errors)) { + return $this->getUnauthorizedPayload($errors); + } + + /** + * @todo change this to be the domain user + */ + $userPayload = [ + 'usr_issuer' => $domainUser->getIssuer(), + 'usr_token_password' => $domainUser->getTokenPassword(), + 'usr_token_id' => $domainUser->getTokenId(), + 'usr_id' => $domainUser->getId(), + ]; + $newToken = $this->jwtToken->getForUser($userPayload); + $newRefreshToken = $this->jwtToken->getRefreshForUser($userPayload); + + /** + * Invalidate old tokens, store new tokens in cache + */ + $this->cache->invalidateForUser($this->env, $domainUser); + $this->cache->storeTokenInCache($this->env, $domainUser, $newToken); + $this->cache->storeTokenInCache($this->env, $domainUser, $newRefreshToken); + + /** + * Send the payload back + */ + return new Payload( + DomainStatus::SUCCESS, + [ + 'data' => [ + 'token' => $newToken, + 'refreshToken' => $newRefreshToken, + ], + ] + ); + } +} diff --git a/tests/Unit/Domain/Services/Auth/RefreshPostServiceTest.php b/tests/Unit/Domain/Services/Auth/RefreshPostServiceTest.php new file mode 100644 index 0000000..f274adb --- /dev/null +++ b/tests/Unit/Domain/Services/Auth/RefreshPostServiceTest.php @@ -0,0 +1,291 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Services\Auth; + +use PayloadInterop\DomainStatus; +use Phalcon\Api\Domain\Components\Container; +use Phalcon\Api\Domain\Components\Encryption\JWTToken; +use Phalcon\Api\Domain\Components\Enums\Http\HttpCodesEnum; +use Phalcon\Api\Domain\Services\Auth\LoginPostService; +use Phalcon\Api\Domain\Services\Auth\RefreshPostService; +use Phalcon\Api\Tests\AbstractUnitTestCase; +use Phalcon\Api\Tests\Fixtures\Domain\Migrations\UsersMigration; +use Phalcon\Encryption\Security\JWT\Token\Item; +use Phalcon\Encryption\Security\JWT\Token\Token; +use PHPUnit\Framework\Attributes\BackupGlobals; + +#[BackupGlobals(true)] +final class RefreshPostServiceTest extends AbstractUnitTestCase +{ + public function testServiceEmptyToken(): void + { + /** @var RefreshPostService $service */ + $service = $this->container->get(Container::AUTH_REFRESH_POST_SERVICE); + + $payload = $service->__invoke([]); + + $expected = DomainStatus::UNAUTHORIZED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $expected = HttpCodesEnum::AppTokenNotPresent->error(); + $actual = $actual['errors'][0]; + $this->assertSame($expected, $actual); + } + + public function testServiceInvalidToken(): void + { + $user = $this->getNewUserData(); + $errors = [ + ['Incorrect token data'], + ]; + + /** + * Set up mock services + */ + $mockItem = $this->getMockBuilder(Item::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'get', + ] + ) + ->getMock() + ; + $mockItem->method('get')->willReturn(true); + + $mockToken = $this->getMockBuilder(Token::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'getClaims', + ] + ) + ->getMock() + ; + $mockToken->method('getClaims')->willReturn($mockItem); + + $mockJWT = $this->getMockBuilder(JWTToken::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'getObject', + 'getUser', + 'validate', + ] + ) + ->getMock() + ; + $mockJWT->method('getObject')->willReturn($mockToken); + $mockJWT->method('getUser')->willReturn($user); + $mockJWT->method('validate')->willReturn($errors); + + + /** + * Replace the service with the mocked one + */ + $this->container->set(Container::JWT_TOKEN, $mockJWT); + + /** @var RefreshPostService $service */ + $service = $this->container->get(Container::AUTH_REFRESH_POST_SERVICE); + + $payload = $service->__invoke(['token' => '1234']); + + $expected = DomainStatus::UNAUTHORIZED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $expected = ['Incorrect token data']; + $actual = $actual['errors'][0]; + $this->assertSame($expected, $actual); + } + + public function testServiceNotRefreshToken(): void + { + /** + * Set up mock services + */ + $mockItem = $this->getMockBuilder(Item::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'get', + ] + ) + ->getMock() + ; + $mockItem->method('get')->willReturn(false); + + $mockToken = $this->getMockBuilder(Token::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'getClaims', + ] + ) + ->getMock() + ; + $mockToken->method('getClaims')->willReturn($mockItem); + + $mockJWT = $this->getMockBuilder(JWTToken::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'getObject', + ] + ) + ->getMock() + ; + $mockJWT->method('getObject')->willReturn($mockToken); + + /** + * Replace the service with the mocked one + */ + $this->container->set(Container::JWT_TOKEN, $mockJWT); + + /** @var RefreshPostService $service */ + $service = $this->container->get(Container::AUTH_REFRESH_POST_SERVICE); + + $payload = $service->__invoke(['token' => '1234']); + + $expected = DomainStatus::UNAUTHORIZED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $expected = HttpCodesEnum::AppTokenNotValid->error(); + $actual = $actual['errors'][0]; + $this->assertSame($expected, $actual); + } + + public function testServiceWithCredentials(): void + { + /** @var LoginPostService $service */ + $loginService = $this->container->get(Container::AUTH_LOGIN_POST_SERVICE); + /** @var RefreshPostService $service */ + $service = $this->container->get(Container::AUTH_REFRESH_POST_SERVICE); + $migration = new UsersMigration($this->getConnection()); + + /** + * Setting the password to something we know + */ + $password = 'password'; + + $dbUser = $this->getNewUser($migration, ['usr_password' => $password]); + $email = $dbUser['usr_email']; + $payload = [ + 'email' => $email, + 'password' => $password, + ]; + + $payload = $loginService->__invoke($payload); + + $expected = DomainStatus::SUCCESS; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $result = $payload->getResult(); + $jwt = $result['data']['jwt']; + + $payload = $service->__invoke(['token' => $jwt['refreshToken']]); + + $expected = DomainStatus::SUCCESS; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('data', $actual); + + $data = $actual['data']; + + $this->assertArrayHasKey('token', $data); + $this->assertArrayHasKey('refreshToken', $data); + + $this->assertIsString($data['token']); + $this->assertIsString($data['refreshToken']); + $this->assertNotEmpty($data['token']); + $this->assertNotEmpty($data['refreshToken']); + } + + public function testServiceWrongUser(): void + { + /** + * Set up mock services + */ + $mockItem = $this->getMockBuilder(Item::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'get', + ] + ) + ->getMock() + ; + $mockItem->method('get')->willReturn(true); + + $mockToken = $this->getMockBuilder(Token::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'getClaims', + ] + ) + ->getMock() + ; + $mockToken->method('getClaims')->willReturn($mockItem); + + $mockJWT = $this->getMockBuilder(JWTToken::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'getObject', + 'getUser', + ] + ) + ->getMock() + ; + $mockJWT->method('getObject')->willReturn($mockToken); + $mockJWT->method('getUser')->willReturn([]); + + + /** + * Replace the service with the mocked one + */ + $this->container->set(Container::JWT_TOKEN, $mockJWT); + + /** @var RefreshPostService $service */ + $service = $this->container->get(Container::AUTH_REFRESH_POST_SERVICE); + + $payload = $service->__invoke(['token' => '1234']); + + $expected = DomainStatus::UNAUTHORIZED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $expected = HttpCodesEnum::AppTokenInvalidUser->error(); + $actual = $actual['errors'][0]; + $this->assertSame($expected, $actual); + } +} From 76dfc8c998679830631a577e6a496337dc644131 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:52:37 -0600 Subject: [PATCH 18/22] [#.x] - added auth logout service --- .../Services/Auth/LogoutPostService.php | 108 ++++++ .../Services/Auth/LogoutPostServiceTest.php | 339 ++++++++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 src/Domain/Services/Auth/LogoutPostService.php create mode 100644 tests/Unit/Domain/Services/Auth/LogoutPostServiceTest.php diff --git a/src/Domain/Services/Auth/LogoutPostService.php b/src/Domain/Services/Auth/LogoutPostService.php new file mode 100644 index 0000000..1bec8d1 --- /dev/null +++ b/src/Domain/Services/Auth/LogoutPostService.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Domain\Services\Auth; + +use PayloadInterop\DomainStatus; +use Phalcon\Api\Domain\ADR\InputTypes; +use Phalcon\Api\Domain\Components\DataSource\User\UserTypes; +use Phalcon\Api\Domain\Components\Enums\Common\JWTEnum; +use Phalcon\Api\Domain\Components\Enums\Http\HttpCodesEnum; +use Phalcon\Domain\Payload; + +/** + * @phpstan-import-type TUserDbRecord from UserTypes + * @phpstan-import-type TLogoutInput from InputTypes + * @phpstan-import-type TValidationErrors from InputTypes + */ +final class LogoutPostService extends AbstractAuthService +{ + /** + * @param TLogoutInput $input + * + * @return Payload + */ + public function __invoke(array $input): Payload + { + /** + * @todo common code with refresh + */ + /** + * Get the token + */ + $token = (string)($input['token'] ?? ''); + $token = $this->filter->string($token); + + /** + * Validation + * + * Empty token + */ + if (true === empty($token)) { + return $this->getUnauthorizedPayload( + [HttpCodesEnum::AppTokenNotPresent->error()] + ); + } + + /** + * @todo catch any exceptions here + * + * Is this the refresh token + */ + $tokenObject = $this->jwtToken->getObject($token); + $isRefresh = $tokenObject->getClaims()->get(JWTEnum::Refresh->value); + if (false === $isRefresh) { + return $this->getUnauthorizedPayload( + [HttpCodesEnum::AppTokenNotValid->error()] + ); + } + + /** + * Get the user - if empty return error + */ + $user = $this + ->jwtToken + ->getUser($this->repository, $tokenObject) + ; + if (true === empty($user)) { + return $this->getUnauthorizedPayload( + [HttpCodesEnum::AppTokenInvalidUser->error()] + ); + } + + $domainUser = $this->transport->newUser($user); + + /** @var TValidationErrors $errors */ + $errors = $this->jwtToken->validate($tokenObject, $domainUser); + if (true !== empty($errors)) { + return $this->getUnauthorizedPayload($errors); + } + + /** + * Invalidate old tokens + */ + $this->cache->invalidateForUser($this->env, $domainUser); + + /** + * Send the payload back + */ + return new Payload( + DomainStatus::SUCCESS, + [ + 'data' => [ + 'authenticated' => false, + ], + ] + ); + } +} diff --git a/tests/Unit/Domain/Services/Auth/LogoutPostServiceTest.php b/tests/Unit/Domain/Services/Auth/LogoutPostServiceTest.php new file mode 100644 index 0000000..75f9637 --- /dev/null +++ b/tests/Unit/Domain/Services/Auth/LogoutPostServiceTest.php @@ -0,0 +1,339 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Services\Auth; + +use PayloadInterop\DomainStatus; +use Phalcon\Api\Domain\Components\Cache\Cache; +use Phalcon\Api\Domain\Components\Container; +use Phalcon\Api\Domain\Components\DataSource\TransportRepository; +use Phalcon\Api\Domain\Components\Encryption\JWTToken; +use Phalcon\Api\Domain\Components\Enums\Http\HttpCodesEnum; +use Phalcon\Api\Domain\Services\Auth\LoginPostService; +use Phalcon\Api\Domain\Services\Auth\LogoutPostService; +use Phalcon\Api\Tests\AbstractUnitTestCase; +use Phalcon\Api\Tests\Fixtures\Domain\Migrations\UsersMigration; +use Phalcon\Encryption\Security\JWT\Token\Item; +use Phalcon\Encryption\Security\JWT\Token\Token; +use PHPUnit\Framework\Attributes\BackupGlobals; + +#[BackupGlobals(true)] +final class LogoutPostServiceTest extends AbstractUnitTestCase +{ + public function testServiceEmptyToken(): void + { + /** @var LogoutPostService $service */ + $service = $this->container->get(Container::AUTH_LOGOUT_POST_SERVICE); + + $payload = $service->__invoke([]); + + $expected = DomainStatus::UNAUTHORIZED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $expected = HttpCodesEnum::AppTokenNotPresent->error(); + $actual = $actual['errors'][0]; + $this->assertSame($expected, $actual); + } + + public function testServiceInvalidToken(): void + { + $user = $this->getNewUserData(); + $errors = [ + ['Incorrect token data'], + ]; + + /** + * Set up mock services + */ + $mockItem = $this->getMockBuilder(Item::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'get', + ] + ) + ->getMock() + ; + $mockItem->method('get')->willReturn(true); + + $mockToken = $this->getMockBuilder(Token::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'getClaims', + ] + ) + ->getMock() + ; + $mockToken->method('getClaims')->willReturn($mockItem); + + $mockJWT = $this->getMockBuilder(JWTToken::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'getObject', + 'getUser', + 'validate', + ] + ) + ->getMock() + ; + $mockJWT->method('getObject')->willReturn($mockToken); + $mockJWT->method('getUser')->willReturn($user); + $mockJWT->method('validate')->willReturn($errors); + + + /** + * Replace the service with the mocked one + */ + $this->container->set(Container::JWT_TOKEN, $mockJWT); + + /** @var LogoutPostService $service */ + $service = $this->container->get(Container::AUTH_LOGOUT_POST_SERVICE); + + $payload = $service->__invoke(['token' => '1234']); + + $expected = DomainStatus::UNAUTHORIZED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $expected = ['Incorrect token data']; + $actual = $actual['errors'][0]; + $this->assertSame($expected, $actual); + } + + public function testServiceNotRefreshToken(): void + { + /** + * Set up mock services + */ + $mockItem = $this->getMockBuilder(Item::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'get', + ] + ) + ->getMock() + ; + $mockItem->method('get')->willReturn(false); + + $mockToken = $this->getMockBuilder(Token::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'getClaims', + ] + ) + ->getMock() + ; + $mockToken->method('getClaims')->willReturn($mockItem); + + $mockJWT = $this->getMockBuilder(JWTToken::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'getObject', + ] + ) + ->getMock() + ; + $mockJWT->method('getObject')->willReturn($mockToken); + + /** + * Replace the service with the mocked one + */ + $this->container->set(Container::JWT_TOKEN, $mockJWT); + /** @var LogoutPostService $service */ + $logoutService = $this->container->get(Container::AUTH_LOGOUT_POST_SERVICE); + + /** + * Logout now + */ + $payload = $logoutService->__invoke(['token' => '1234']); + + $expected = DomainStatus::UNAUTHORIZED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $errors = $actual['errors']; + + $expected = [HttpCodesEnum::AppTokenNotValid->error()]; + $actual = $errors; + $this->assertSame($expected, $actual); + } + + public function testServiceSuccess(): void + { + /** @var LogoutPostService $service */ + $logoutService = $this->container->get(Container::AUTH_LOGOUT_POST_SERVICE); + /** @var Cache $cache */ + $cache = $this->container->getShared(Container::CACHE); + /** @var LoginPostService $service */ + $service = $this->container->get(Container::AUTH_LOGIN_POST_SERVICE); + /** @var TransportRepository $transport */ + $transport = $this->container->getShared(Container::REPOSITORY_TRANSPORT); + $migration = new UsersMigration($this->getConnection()); + + /** + * Setting the password to something we know + */ + $password = 'password'; + + $dbUser = $this->getNewUser($migration, ['usr_password' => $password]); + $email = $dbUser['usr_email']; + $input = [ + 'email' => $email, + 'password' => $password, + ]; + + $payload = $service->__invoke($input); + + $expected = DomainStatus::SUCCESS; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('data', $actual); + + $data = $actual['data']; + + $this->assertArrayHasKey('authenticated', $data); + + $authenticated = $data['authenticated']; + + $actual = $authenticated; + $this->assertTrue($actual); + + $token = $data['jwt']['token']; + $domainUser = $transport->newUser($dbUser); + $tokenKey = $cache->getCacheTokenKey($domainUser, $token); + + $actual = $cache->has($tokenKey); + $this->assertTrue($actual); + + $refreshToken = $data['jwt']['refreshToken']; + $tokenKey = $cache->getCacheTokenKey($domainUser, $refreshToken); + + $actual = $cache->has($tokenKey); + $this->assertTrue($actual); + + $refreshToken = $data['jwt']['refreshToken']; + + /** + * Logout now + */ + $input = [ + 'token' => $refreshToken, + ]; + $payload = $logoutService->__invoke($input); + + $expected = DomainStatus::SUCCESS; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + + $this->assertArrayHasKey('data', $actual); + + $data = $actual['data']; + + $this->assertArrayHasKey('authenticated', $data); + + $authenticated = $data['authenticated']; + + $actual = $authenticated; + $this->assertFalse($actual); + + $actual = $cache->has($tokenKey); + $this->assertFalse($actual); + } + + public function testServiceWrongUser(): void + { + /** + * Set up mock services + */ + $mockItem = $this->getMockBuilder(Item::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'get', + ] + ) + ->getMock() + ; + $mockItem->method('get')->willReturn(true); + + $mockToken = $this->getMockBuilder(Token::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'getClaims', + ] + ) + ->getMock() + ; + $mockToken->method('getClaims')->willReturn($mockItem); + + $mockJWT = $this->getMockBuilder(JWTToken::class) + ->disableOriginalConstructor() + ->onlyMethods( + [ + 'getObject', + 'getUser', + ] + ) + ->getMock() + ; + $mockJWT->method('getObject')->willReturn($mockToken); + $mockJWT->method('getUser')->willReturn([]); + + + /** + * Replace the service with the mocked one + */ + $this->container->set(Container::JWT_TOKEN, $mockJWT); + + /** @var LogoutPostService $service */ + $logoutService = $this->container->get(Container::AUTH_LOGOUT_POST_SERVICE); + + /** + * Logout now + */ + $payload = $logoutService->__invoke(['token' => '1234']); + + $expected = DomainStatus::UNAUTHORIZED; + $actual = $payload->getStatus(); + $this->assertSame($expected, $actual); + + $actual = $payload->getResult(); + $this->assertArrayHasKey('errors', $actual); + + $errors = $actual['errors']; + + $expected = [HttpCodesEnum::AppTokenInvalidUser->error()]; + $actual = $errors; + $this->assertSame($expected, $actual); + } +} From 309784a17d09be06fbe89a36238c1be383f4a81b Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 5 Nov 2025 11:53:02 -0600 Subject: [PATCH 19/22] [#.x] - reworking tests - moving dispatch tests to own file --- .../Services/User/UserServiceDispatchTest.php | 83 +++++++++++++++++++ ...ServiceTest.php => UserServiceGetTest.php} | 56 +------------ 2 files changed, 86 insertions(+), 53 deletions(-) create mode 100644 tests/Unit/Domain/Services/User/UserServiceDispatchTest.php rename tests/Unit/Domain/Services/User/{UserServiceTest.php => UserServiceGetTest.php} (73%) diff --git a/tests/Unit/Domain/Services/User/UserServiceDispatchTest.php b/tests/Unit/Domain/Services/User/UserServiceDispatchTest.php new file mode 100644 index 0000000..2557457 --- /dev/null +++ b/tests/Unit/Domain/Services/User/UserServiceDispatchTest.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Api\Tests\Unit\Domain\Services\User; + +use Phalcon\Api\Domain\Components\Cache\Cache; +use Phalcon\Api\Domain\Components\Container; +use Phalcon\Api\Domain\Components\DataSource\TransportRepository; +use Phalcon\Api\Domain\Components\Enums\Http\RoutesEnum; +use Phalcon\Api\Domain\Components\Env\EnvManager; +use Phalcon\Api\Tests\AbstractUnitTestCase; +use Phalcon\Api\Tests\Fixtures\Domain\Migrations\UsersMigration; +use PHPUnit\Framework\Attributes\BackupGlobals; + +#[BackupGlobals(true)] +final class UserServiceDispatchTest extends AbstractUnitTestCase +{ + public function testDispatchGet(): void + { + /** @var EnvManager $env */ + $env = $this->container->getShared(Container::ENV); + /** @var Cache $cache */ + $cache = $this->container->getShared(Container::CACHE); + /** @var TransportRepository $transport */ + $transport = $this->container->get(Container::REPOSITORY_TRANSPORT); + + $migration = new UsersMigration($this->getConnection()); + $dbUser = $this->getNewUser($migration); + $userId = $dbUser['usr_id']; + $token = $this->getUserToken($dbUser); + $domainUser = $transport->newUser($dbUser); + + /** + * Store the token in the cache + */ + $cache->storeTokenInCache($env, $domainUser, $token); + + $time = $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_TIME_FLOAT' => $time, + 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, + 'REQUEST_URI' => RoutesEnum::userGet->endpoint(), + ]; + + $_GET = [ + 'id' => $userId, + ]; + + ob_start(); + require_once $env->appPath('public/index.php'); + $response = ob_get_clean(); + + $contents = json_decode($response, true); + + restore_error_handler(); + + $this->assertArrayHasKey('data', $contents); + $this->assertArrayHasKey('errors', $contents); + + $data = $contents['data']; + $errors = $contents['errors']; + + $expected = []; + $actual = $errors; + $this->assertSame($expected, $actual); + + $user = $transport->newUser($dbUser); + $expected = $user->toArray(); + $actual = $data; + $this->assertSame($expected, $actual); + } +} diff --git a/tests/Unit/Domain/Services/User/UserServiceTest.php b/tests/Unit/Domain/Services/User/UserServiceGetTest.php similarity index 73% rename from tests/Unit/Domain/Services/User/UserServiceTest.php rename to tests/Unit/Domain/Services/User/UserServiceGetTest.php index 9140759..d84e820 100644 --- a/tests/Unit/Domain/Services/User/UserServiceTest.php +++ b/tests/Unit/Domain/Services/User/UserServiceGetTest.php @@ -15,64 +15,14 @@ use PayloadInterop\DomainStatus; use Phalcon\Api\Domain\Components\Container; -use Phalcon\Api\Domain\Components\DataSource\TransportRepository; -use Phalcon\Api\Domain\Components\Env\EnvManager; use Phalcon\Api\Domain\Services\User\UserGetService; use Phalcon\Api\Tests\AbstractUnitTestCase; use Phalcon\Api\Tests\Fixtures\Domain\Migrations\UsersMigration; use PHPUnit\Framework\Attributes\BackupGlobals; #[BackupGlobals(true)] -final class UserServiceTest extends AbstractUnitTestCase +final class UserServiceGetTest extends AbstractUnitTestCase { - public function testDispatch(): void - { - /** @var EnvManager $env */ - $env = $this->container->getShared(Container::ENV); - /** @var TransportRepository $transport */ - $transport = $this->container->get(Container::REPOSITORY_TRANSPORT); - - $migration = new UsersMigration($this->getConnection()); - $dbUser = $this->getNewUser($migration); - $userId = $dbUser['usr_id']; - $token = $this->getUserToken($dbUser); - - $time = $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_TIME_FLOAT' => $time, - 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, - 'REQUEST_URI' => '/user', - ]; - - $_GET = [ - 'userId' => $userId, - ]; - - ob_start(); - require_once $env->appPath('public/index.php'); - $response = ob_get_clean(); - - $contents = json_decode($response, true); - - restore_error_handler(); - - $this->assertArrayHasKey('data', $contents); - $this->assertArrayHasKey('errors', $contents); - - $data = $contents['data']; - $errors = $contents['errors']; - - $expected = []; - $actual = $errors; - $this->assertSame($expected, $actual); - - $user = $transport->newUser($dbUser); - $expected = $user->toArray(); - $actual = $data; - $this->assertSame($expected, $actual); - } - public function testServiceEmptyUserId(): void { /** @var UserGetService $service */ @@ -103,7 +53,7 @@ public function testServiceWithUserId(): void $payload = $service->__invoke( [ - 'userId' => $userId, + 'id' => $userId, ] ); @@ -194,7 +144,7 @@ public function testServiceWrongUserId(): void $payload = $service->__invoke( [ - 'userId' => 999999, + 'id' => 999999, ] ); From f3d2b985810f478f6350110bb03b1d92c2a90b90 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Thu, 6 Nov 2025 13:35:30 +0000 Subject: [PATCH 20/22] Update src/Domain/Components/DataSource/User/UserRepository.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Domain/Components/DataSource/User/UserRepository.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Domain/Components/DataSource/User/UserRepository.php b/src/Domain/Components/DataSource/User/UserRepository.php index 8ddc986..36d903a 100644 --- a/src/Domain/Components/DataSource/User/UserRepository.php +++ b/src/Domain/Components/DataSource/User/UserRepository.php @@ -138,7 +138,7 @@ public function update(array $userData): int ->where('usr_id = ', $userId) ; - if ($updatedUserId > 1) { + if ($updatedUserId > 0) { $update->column('usr_updated_usr_id', $updatedUserId); } From 6ea7c8a9122e25e4a0a6f03166f5842e5c894627 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Thu, 6 Nov 2025 13:35:45 +0000 Subject: [PATCH 21/22] Update src/Domain/Services/User/UserPostService.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Domain/Services/User/UserPostService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Domain/Services/User/UserPostService.php b/src/Domain/Services/User/UserPostService.php index d17c75b..499cc04 100644 --- a/src/Domain/Services/User/UserPostService.php +++ b/src/Domain/Services/User/UserPostService.php @@ -40,7 +40,7 @@ public function __invoke(array $input): Payload /** * Errors exist - return early */ - if (true !== empty($errors)) { + if (!empty($errors)) { return new Payload( DomainStatus::INVALID, [ From 6d4cfc694841dda76891cb201115bf289d68b3f7 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Thu, 6 Nov 2025 13:35:54 +0000 Subject: [PATCH 22/22] Update src/Domain/Services/User/UserPutService.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Domain/Services/User/UserPutService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Domain/Services/User/UserPutService.php b/src/Domain/Services/User/UserPutService.php index 61b5431..22ad499 100644 --- a/src/Domain/Services/User/UserPutService.php +++ b/src/Domain/Services/User/UserPutService.php @@ -40,7 +40,7 @@ public function __invoke(array $input): Payload /** * Errors exist - return early */ - if (true !== empty($errors)) { + if (!empty($errors)) { return new Payload( DomainStatus::INVALID, [