diff --git a/.env.dist b/.env.dist index 6064e45..62619d6 100644 --- a/.env.dist +++ b/.env.dist @@ -1,11 +1,11 @@ APP_BUILDER_API=\Aphiria\Framework\Api\SynchronousApiApplicationBuilder APP_BUILDER_CONSOLE=\Aphiria\Framework\Console\ConsoleApplicationBuilder +APP_COOKIE_DOMAIN= +APP_COOKIE_SECURE=0 APP_ENV=development APP_URL=http://localhost:8080 -DB_DRIVER=postgres DB_HOST=localhost -DB_USER=myuser -DB_PASSWORD=mypassword -DB_NAME=public -DB_PORT=5432 +DB_PATH=/database/database.sqlite LOG_LEVEL=debug +USER_DEFAULT_EMAIL=admin@example.com +USER_DEFAULT_PASSWORD=abc123 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a030801..b5b055d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ name: ci on: push: + branches: + - '*.x' pull_request: jobs: ci: @@ -19,7 +21,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: curl, dom, libxml, mbstring, pcntl, xdebug, zip + extensions: curl, dom, libxml, mbstring, pcntl, sqlite3, xdebug, zip tools: composer:v2 coverage: xdebug - name: Install Dependencies @@ -29,4 +31,10 @@ jobs: - name: Run Linter run: composer phpcs-test - name: Run Psalm Static Analysis - run: composer psalm + run: composer psalm -- --shepherd + - name: Upload Coverage Results To Coveralls + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + composer global require php-coveralls/php-coveralls + php-coveralls --coverage_clover=./.coverage/clover.xml --json_path=./coveralls-upload.json -v diff --git a/.gitignore b/.gitignore index b3c8e0b..94ec7c1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /.phpunit.result.cache /composer.lock /composer.phar +/database/database.sqlite /nbproject/ /phpunit.phar /vendor/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index b73a19f..1b2885c 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -58,7 +58,8 @@ 'method_protected', 'method_private_static', 'method_private' - ] + ], + 'sort_algorithm' => 'alpha' ], 'ordered_imports' => true, 'return_type_declaration' => ['space_before' => 'none'], diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3f3ba..2943e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [v1.0.0-alpha5](https://github.com/aphiria/app/compare/v1.0.0-alpha4...v1.0.0-alpha5) (?) +### Added + +- Added auth and user scaffolding, backed by a SQLite database ([#34](https://github.com/aphiria/app/pull/34)) + ### Changed - Updated PHPUnit and Psalm ([#33](https://github.com/aphiria/app/pull/33)) diff --git a/README.md b/README.md index 138698a..571f4c3 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@

- +Coverage Status +

-> **Note:** This library is not stable yet. +> **Note:** This framework is not stable yet. This application is a useful starting point for projects that use the Aphiria framework. Check out this repository, and get started building your own REST API. @@ -29,11 +30,7 @@ php aphiria app:serve ## Demo -This app comes with an extremely simple demo that can store and retrieve users from local file storage. It should not be used in production - it is simply a demo of some Aphiria features. The demo routes can be found as PHP attributes in [_src/Demo/Api/Controllers/UserController.php_](src/Demo/Api/Controllers/UserController.php). - -### Removing Demo Code - -To remove the built-in demo code, simply delete the [_src/Demo_](src/Demo) and [_tests/Integration/Demo_](tests/Integration/Demo) directories, and remove the `DemoModule` from [_src/GlobalModule.php_](src/GlobalModule.php). +This app comes with a simple demo that can store, retrieve, and authenticate users from a local SQLite database. ## Learn More diff --git a/composer.json b/composer.json index 1a8597f..57d6a93 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,8 @@ "require": { "aphiria/aphiria": "1.x-dev", "ext-mbstring": "*", + "ext-pdo": "*", + "ext-sqlite3": "*", "php": ">=8.2", "symfony/dotenv": "^6.1" }, @@ -43,7 +45,9 @@ "php -r \"file_exists('.env') || copy('.env.dist', '.env');\"" ], "post-create-project-cmd": [ - "php -r \"echo 'Important: make ' . __DIR__ . DIRECTORY_SEPARATOR . 'tmp writable' . PHP_EOL;\"" + "php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", + "php -r \"echo 'Important: make ' . __DIR__ . DIRECTORY_SEPARATOR . 'tmp writable' . PHP_EOL;\"", + "php aphiria user:generate-default-credentials" ], "post-install-cmd": [ "php -r \"shell_exec((file_exists(getcwd() . '/composer.phar') ? PHP_BINARY . ' composer.phar' : 'composer') . ' dump-autoload -o');\"", diff --git a/database/.gitkeep b/database/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4e650c3..f5905d9 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -20,5 +20,6 @@ + diff --git a/psalm.xml.dist b/psalm.xml.dist index 953e004..e7251a6 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -21,6 +21,11 @@ + + + + + diff --git a/src/Auth/Api/Controllers/AuthController.php b/src/Auth/Api/Controllers/AuthController.php new file mode 100644 index 0000000..0c61f1b --- /dev/null +++ b/src/Auth/Api/Controllers/AuthController.php @@ -0,0 +1,63 @@ +authenticator->logIn($this->getUser(), $this->request, $response, 'cookie'); + + return $response; + } + + /** + * Logs out the user + * + * @return IResponse The logout response + * @throws AuthenticationSchemeNotFoundException|UnsupportedAuthenticationHandlerException Thrown if there was an issue with authentication + */ + #[Post('/logout')] + public function logOut(): IResponse + { + $response = new Response(); + /** @psalm-suppress PossiblyNullArgument The request will be set */ + $this->authenticator->logOut($this->request, $response, 'cookie'); + + return $response; + } +} diff --git a/src/Auth/AuthModule.php b/src/Auth/AuthModule.php new file mode 100644 index 0000000..374164f --- /dev/null +++ b/src/Auth/AuthModule.php @@ -0,0 +1,86 @@ +withBinders($appBuilder, new AuthServiceBinder()) + ->withDatabaseSeeders($appBuilder, SqlTokenSeeder::class) + // Add our default authentication scheme + ->withAuthenticationScheme( + $appBuilder, + new AuthenticationScheme( + 'cookie', + CookieAuthenticationHandler::class, + new CookieAuthenticationOptions( + cookieName: 'authToken', + cookieMaxAge: 3600, + cookiePath: '/', + cookieDomain: (string)\getenv('APP_COOKIE_DOMAIN'), + cookieIsSecure: (bool)\getenv('APP_COOKIE_SECURE'), + cookieIsHttpOnly: true, + cookieSameSite: SameSiteMode::Strict, + loginPagePath: '/login', + forbiddenPagePath: '/access-denied', + claimsIssuer: (string)\getenv('APP_COOKIE_DOMAIN') + ) + ), + true + ) + ->withAuthenticationScheme( + $appBuilder, + new AuthenticationScheme( + 'basic', + BasicAuthenticationHandler::class, + new BasicAuthenticationOptions((string)\getenv('APP_URL')) + ) + ) + ->withAuthorizationRequirementHandler( + $appBuilder, + AuthorizedUserDeleterRequirement::class, + new AuthorizedUserDeleterRequirementHandler() + ) + ->withAuthorizationRequirementHandler( + $appBuilder, + RolesRequirement::class, + new RolesRequirementHandler() + ) + ->withAuthorizationPolicy( + $appBuilder, + new AuthorizationPolicy( + 'authorized-user-deleter', + new AuthorizedUserDeleterRequirement('admin') + ) + ) + ->withProblemDetails( + $appBuilder, + InvalidCredentialsException::class, + status: HttpStatusCode::BadRequest + ); + } +} diff --git a/src/Auth/AuthorizedUserDeleterRequirement.php b/src/Auth/AuthorizedUserDeleterRequirement.php new file mode 100644 index 0000000..49045ba --- /dev/null +++ b/src/Auth/AuthorizedUserDeleterRequirement.php @@ -0,0 +1,26 @@ + The list of roles authorized to delete other users' accounts */ + public array $authorizedRoles; + + /** + * @param list|string $authorizedRoles The role or list of roles that are authorized to delete other users' accounts + */ + public function __construct(array|string $authorizedRoles) + { + if (!\is_array($authorizedRoles)) { + $authorizedRoles = [$authorizedRoles]; + } + + $this->authorizedRoles = $authorizedRoles; + } +} diff --git a/src/Auth/AuthorizedUserDeleterRequirementHandler.php b/src/Auth/AuthorizedUserDeleterRequirementHandler.php new file mode 100644 index 0000000..a2b219a --- /dev/null +++ b/src/Auth/AuthorizedUserDeleterRequirementHandler.php @@ -0,0 +1,54 @@ + + */ +final class AuthorizedUserDeleterRequirementHandler implements IAuthorizationRequirementHandler +{ + /** + * @inheritdoc + */ + public function handle(IPrincipal $user, object $requirement, AuthorizationContext $authorizationContext): void + { + if (!$requirement instanceof AuthorizedUserDeleterRequirement) { + throw new InvalidArgumentException('Requirement must be of type ' . AuthorizedUserDeleterRequirement::class); + } + + $userToDelete = $authorizationContext->resource; + + if (!$userToDelete instanceof User) { + throw new InvalidArgumentException('Resource must be of type ' . User::class); + } + + if ($userToDelete->id === (int)$user->getPrimaryIdentity()?->getNameIdentifier()) { + // The user being deleted is the current user + $authorizationContext->requirementPassed($requirement); + + return; + } + + foreach ($requirement->authorizedRoles as $authorizedRole) { + if ($user->hasClaim(ClaimType::Role, $authorizedRole)) { + // The user is authorized to delete the user's account + $authorizationContext->requirementPassed($requirement); + + return; + } + } + + $authorizationContext->fail(); + } +} diff --git a/src/Auth/BasicAuthenticationHandler.php b/src/Auth/BasicAuthenticationHandler.php new file mode 100644 index 0000000..aae4931 --- /dev/null +++ b/src/Auth/BasicAuthenticationHandler.php @@ -0,0 +1,48 @@ +users->getUserByEmailAndPassword($username, $password)) === null) { + return AuthenticationResult::fail('Invalid credentials', $scheme->name); + } + + return AuthenticationResult::pass( + (new PrincipalBuilder($scheme->options->claimsIssuer ?? $scheme->name))->withNameIdentifier($user->id) + ->withEmail($user->email) + ->withRoles($user->roles) + ->withAuthenticationSchemeName($scheme->name) + ->build(), + $scheme->name + ); + } +} diff --git a/src/Auth/Binders/AuthServiceBinder.php b/src/Auth/Binders/AuthServiceBinder.php new file mode 100644 index 0000000..aed8014 --- /dev/null +++ b/src/Auth/Binders/AuthServiceBinder.php @@ -0,0 +1,25 @@ +bindClass(ITokenService::class, SqlTokenService::class); + } +} diff --git a/src/Auth/CookieAuthenticationHandler.php b/src/Auth/CookieAuthenticationHandler.php new file mode 100644 index 0000000..e00b692 --- /dev/null +++ b/src/Auth/CookieAuthenticationHandler.php @@ -0,0 +1,116 @@ + $scheme The scheme + * @throws InvalidCredentialsException Thrown if there was an error decoding the cookie value + */ + public function logOut(IRequest $request, IResponse $response, AuthenticationScheme $scheme): void + { + $cookieValue = $this->getCookieValueFromRequest($request, $scheme); + + if ($cookieValue !== null) { + try { + /** @var array{userId: int, token: string} $decodedCookieValue */ + $decodedCookieValue = \json_decode(\base64_decode($cookieValue), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $ex) { + throw new InvalidCredentialsException('Cookie contained invalid JSON', 0, $ex); + } + + $userId = isset($decodedCookieValue['userId']) ? (int)$decodedCookieValue['userId'] : null; + $token = $decodedCookieValue['token'] ?? null; + + if ($userId !== null && $token !== null) { + $this->tokens->expireToken($userId, $token); + } + } + + parent::logOut($request, $response, $scheme); + } + + /** + * @inheritdoc + * @param AuthenticationScheme $scheme The scheme + * @throws UserNotFoundException Thrown if no user was found with the retrieved user ID + */ + protected function createAuthenticationResultFromCookie(string $cookieValue, IRequest $request, AuthenticationScheme $scheme): AuthenticationResult + { + try { + /** @var array{userId: int, token: string} $decodedCookieValue */ + $decodedCookieValue = \json_decode(\base64_decode($cookieValue), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return AuthenticationResult::fail('Token format is invalid', $scheme->name); + } + + if (!isset($decodedCookieValue['userId'], $decodedCookieValue['token'])) { + return AuthenticationResult::fail('Token format is invalid', $scheme->name); + } + + $userId = (int)$decodedCookieValue['userId']; + $token = (string)$decodedCookieValue['token']; + + if (!$this->tokens->validateToken($userId, $token)) { + return AuthenticationResult::fail('Invalid token', $scheme->name); + } + + $user = $this->users->getUserById($userId); + + return AuthenticationResult::pass( + (new PrincipalBuilder($scheme->options->claimsIssuer ?? $scheme->name))->withNameIdentifier($user->id) + ->withEmail($user->email) + ->withRoles($user->roles) + ->withAuthenticationSchemeName($scheme->name) + ->build(), + $scheme->name + ); + } + + /** + * @inheritdoc + * @param AuthenticationScheme $scheme The scheme + * @throws JsonException Thrown if there was an error encoding the JSON (very unlikely) + */ + protected function createCookieValueForUser(IPrincipal $user, AuthenticationScheme $scheme): string|int|float + { + $cookieTtlSeconds = $scheme->options->cookieMaxAge ?? self::DEFAULT_COOKIE_TTL_SECONDS; + $userId = (int)$user->getPrimaryIdentity()?->getNameIdentifier(); + $token = $this->tokens->createToken($userId, $cookieTtlSeconds); + + return \base64_encode(\json_encode(['userId' => $userId, 'token' => $token], JSON_THROW_ON_ERROR)); + } +} diff --git a/src/Auth/ITokenService.php b/src/Auth/ITokenService.php new file mode 100644 index 0000000..f93f578 --- /dev/null +++ b/src/Auth/ITokenService.php @@ -0,0 +1,37 @@ +pdo->exec( + <<pdo->exec( + <<pdo->exec( + <<pdo->prepare( + <<execute([ + 'userId' => $userId, + 'hashedToken' => self::hashToken($token), + 'expiration' => \time() + $ttlSeconds + ]); + + return $token; + } + + /** + * @inheritdoc + */ + public function expireToken(int $userId, string $token): void + { + $statement = $this->pdo->prepare( + <<execute([ + 'expiration' => 0, + 'userId' => $userId, + 'hashToken' => self::hashToken($token) + ]); + } + + /** + * @inheridoc + */ + public function validateToken(int $userId, string $token): bool + { + $statement = $this->pdo->prepare( + << :time +SQL + ); + $statement->execute([ + 'userId' => $userId, + 'hashedToken' => self::hashToken($token), + 'time' => \time() + ]); + + return \count($statement->fetchAll()) === 1; + } + + /** + * Hashes a token for storage + * + * @param string $token The token to hash + * @return string The hashed token + */ + private static function hashToken(string $token): string + { + return \hash('sha256', $token); + } +} diff --git a/src/Database/Binders/DatabaseBinder.php b/src/Database/Binders/DatabaseBinder.php new file mode 100644 index 0000000..dcf68a6 --- /dev/null +++ b/src/Database/Binders/DatabaseBinder.php @@ -0,0 +1,29 @@ +bindFactory( + PDO::class, + fn () => new PDO($dsn), + true + ); + } +} diff --git a/src/Database/Components/DatabaseComponents.php b/src/Database/Components/DatabaseComponents.php new file mode 100644 index 0000000..1d5257d --- /dev/null +++ b/src/Database/Components/DatabaseComponents.php @@ -0,0 +1,36 @@ +|list> $classNames The name or names of database seeder classes to add + * @return static For chaining + * @throws RuntimeException Thrown if the global container instance was not set + */ + protected function withDatabaseSeeders(IApplicationBuilder $appBuilder, string|array $classNames): static + { + if (!$appBuilder->hasComponent(DatabaseSeederComponent::class)) { + $appBuilder->withComponent(new DatabaseSeederComponent(Container::$globalInstance ?? throw new RuntimeException('Global instance of container not set'))); + } + + $appBuilder->getComponent(DatabaseSeederComponent::class) + ->withDatabaseSeeders($classNames); + + return $this; + } +} diff --git a/src/Database/Components/DatabaseSeederComponent.php b/src/Database/Components/DatabaseSeederComponent.php new file mode 100644 index 0000000..cc825a3 --- /dev/null +++ b/src/Database/Components/DatabaseSeederComponent.php @@ -0,0 +1,54 @@ +> The list of registered database seeders */ + private array $databaseSeeders = []; + + /** + * @param IContainer $container The DI container + */ + public function __construct(private readonly IContainer $container) + { + } + + /** + * @inheritdoc + */ + public function build(): void + { + $globalDatabaseSeeder = new GlobalDatabaseSeeder( + \array_map( + fn (string $classString) => $this->container->resolve($classString), + $this->databaseSeeders + ) + ); + $this->container->bindInstance(GlobalDatabaseSeeder::class, $globalDatabaseSeeder); + } + + /** + * Adds a database seeder or seeders to the application + * + * @param class-string|list> $classNames The name or names of database seeders to register + * @return static For chaining + */ + public function withDatabaseSeeders(array|string $classNames): static + { + $classNames = \is_string($classNames) ? [$classNames] : $classNames; + $this->databaseSeeders = [...$this->databaseSeeders, ...$classNames]; + + return $this; + } +} diff --git a/src/Database/Console/DatabaseSeederCommandHandler.php b/src/Database/Console/DatabaseSeederCommandHandler.php new file mode 100644 index 0000000..834e954 --- /dev/null +++ b/src/Database/Console/DatabaseSeederCommandHandler.php @@ -0,0 +1,35 @@ +writeln('Seeding database...'); + $this->globalDatabaseSeeder->seed(); + $output->writeln('Database seeded'); + } +} diff --git a/src/Database/DatabaseModule.php b/src/Database/DatabaseModule.php new file mode 100644 index 0000000..f595493 --- /dev/null +++ b/src/Database/DatabaseModule.php @@ -0,0 +1,23 @@ +withBinders($appBuilder, new DatabaseBinder()); + } +} diff --git a/src/Database/GlobalDatabaseSeeder.php b/src/Database/GlobalDatabaseSeeder.php new file mode 100644 index 0000000..159c6d2 --- /dev/null +++ b/src/Database/GlobalDatabaseSeeder.php @@ -0,0 +1,28 @@ + $databaseSeeders The list of database seeders + */ + public function __construct(private readonly array $databaseSeeders) + { + } + + /** + * @inheritdoc + */ + public function seed(): void + { + foreach ($this->databaseSeeders as $databaseSeeder) { + $databaseSeeder->seed(); + } + } +} diff --git a/src/Database/IDatabaseSeeder.php b/src/Database/IDatabaseSeeder.php new file mode 100644 index 0000000..93e8815 --- /dev/null +++ b/src/Database/IDatabaseSeeder.php @@ -0,0 +1,20 @@ + The list of created users - * @throws HttpException Thrown if the request body could not be read - */ - #[Post('many')] - public function createManyUsers(): array - { - // Demonstrate how to read the body as an array of models - /** @var list $users */ - $users = $this->readRequestBodyAs(User::class . '[]'); - - return $this->userService->createManyUsers($users); - } - - /** - * Creates a single user - * - * @param User $user The user to create - * @return User The created user - */ - #[Post('')] - public function createUser(User $user): User - { - // Demonstrate how to use content negotiation on request and response bodies - return $this->userService->createUser($user); - } - - /** - * Deletes all the users - */ - #[Delete('')] - public function deleteAllUsers(): void - { - $this->userService->deleteAllUsers(); - } - - /** - * Gets all users - * - * @return IResponse The response containing all users - * @throws HttpException Thrown if there was an error creating the response - */ - #[Get(''), Authenticate('dummy')] - public function getAllUsers(): IResponse - { - // Demonstrate how to use controller helper methods to create a response - return $this->ok($this->userService->getAllUsers()); - } - - /** - * Gets a random user - * - * @return IResponse The response containing the user - * @throws HttpException Thrown if there was an error creating the response - */ - #[Get('random')] - public function getRandomUser(): IResponse - { - $user = $this->userService->getRandomUser(); - - if ($user === null) { - return $this->notFound(); - } - - // Demonstrate how to manually create a response - $headers = new Headers(); - $headers->add('Content-Type', 'application/json'); - $body = new StringBody('{"id":' . $user->id . ',"email":"' . $user->email . '"}'); - - return new Response(200, $headers, $body); - } - - /** - * Gets a user with the input ID - * - * @param int $id The ID of the user to get - * @return User The user with the input ID - * @throws UserNotFoundException Thrown if there was no user with the input ID - */ - #[Get(':id(int)')] - public function getUserById(int $id): User - { - // Demonstrate how to use route variables and response body negotiation - return $this->userService->getUserById($id); - } -} diff --git a/src/Demo/Auth/DummyAuthenticationHandler.php b/src/Demo/Auth/DummyAuthenticationHandler.php deleted file mode 100644 index fdadb67..0000000 --- a/src/Demo/Auth/DummyAuthenticationHandler.php +++ /dev/null @@ -1,88 +0,0 @@ - - */ -final class DummyAuthenticationHandler implements IAuthenticationSchemeHandler -{ - /** - * @param IUserService $users The user service to retrieve users from - * @param RequestParser $requestParser The request parser to use - */ - public function __construct( - private readonly IUserService $users, - private readonly RequestParser $requestParser = new RequestParser() - ) { - } - - /** - * @inheritdoc - * @throws RuntimeException Thrown if this was used in production - */ - public function authenticate(IRequest $request, AuthenticationScheme $scheme): AuthenticationResult - { - if (\getenv('APP_ENV') === 'production') { - throw new RuntimeException('Do not use this handler in production - it is for demo purposes only'); - } - - // Note: This is just a really silly demo - do not use this in production - $queryString = $this->requestParser->parseQueryString($request); - - if (!$queryString->containsKey('letMeIn') || !$queryString->containsKey('userId')) { - return AuthenticationResult::fail('Could not authenticate user', $scheme->name); - } - - try { - $currUser = $this->users->getUserById((int)$queryString->get('userId')); - } catch (UserNotFoundException) { - return AuthenticationResult::fail('No user with this ID found', $scheme->name); - } - - $claimsIssuer = $scheme->options->claimsIssuer ?? $scheme->name; - /** @var list> $claims */ - $claims = [ - new Claim(ClaimType::NameIdentifier, $currUser->id, $claimsIssuer), - new Claim(ClaimType::Email, $currUser->email, $claimsIssuer) - ]; - - return AuthenticationResult::pass(new User(new Identity($claims)), $scheme->name); - } - - /** - * @inheritdoc - */ - public function challenge(IRequest $request, IResponse $response, AuthenticationScheme $scheme): void - { - $response->setStatusCode(HttpStatusCode::Unauthorized); - } - - /** - * @inheritdoc - */ - public function forbid(IRequest $request, IResponse $response, AuthenticationScheme $scheme): void - { - $response->setStatusCode(HttpStatusCode::Forbidden); - } -} diff --git a/src/Demo/Binders/UserServiceBinder.php b/src/Demo/Binders/UserServiceBinder.php deleted file mode 100644 index ce99795..0000000 --- a/src/Demo/Binders/UserServiceBinder.php +++ /dev/null @@ -1,26 +0,0 @@ -bindInstance(IUserService::class, $userService); - } -} diff --git a/src/Demo/Console/Commands/UserCountCommandHandler.php b/src/Demo/Console/Commands/UserCountCommandHandler.php deleted file mode 100644 index 2fe121e..0000000 --- a/src/Demo/Console/Commands/UserCountCommandHandler.php +++ /dev/null @@ -1,33 +0,0 @@ -writeln("Number of users: {$this->userService->getNumUsers()}"); - } -} diff --git a/src/Demo/FileUserService.php b/src/Demo/FileUserService.php deleted file mode 100644 index cca7942..0000000 --- a/src/Demo/FileUserService.php +++ /dev/null @@ -1,123 +0,0 @@ -createUser($user); - } - - return $createdUsers; - } - - /** - * @inheritdoc - */ - public function createUser(User $user): User - { - $users = $this->readUsersFromFile(); - $users[] = $user; - $encodedUsers = []; - - foreach ($users as $decodedUser) { - $encodedUsers[] = ['id' => $decodedUser->id, 'email' => $decodedUser->email]; - } - - \file_put_contents($this->filePath, \json_encode($encodedUsers)); - - return $user; - } - - public function deleteAllUsers(): void - { - @\unlink($this->filePath); - } - - /** - * @inheritdoc - */ - public function getAllUsers(): array - { - return $this->readUsersFromFile(); - } - - public function getNumUsers(): int - { - return \count($this->getAllUsers()); - } - - /** - * @inheritdoc - */ - public function getRandomUser(): ?User - { - $users = $this->getAllUsers(); - - if (\count($users) === 0) { - return null; - } - - return $users[\random_int(0, \count($users) - 1)]; - } - - /** - * @inheritdoc - */ - public function getUserById(int $id): User - { - foreach ($this->getAllUsers() as $user) { - if ($user->id === $id) { - return $user; - } - } - - throw new UserNotFoundException("No user with ID $id found"); - } - - /** - * Reads the users from the local file - * - * @return list The list of users - */ - private function readUsersFromFile(): array - { - if (!\file_exists($this->filePath)) { - return []; - } - - /** @var array|false $encodedUsers */ - $encodedUsers = \json_decode(\file_get_contents($this->filePath), true); - - if (!\is_array($encodedUsers)) { - return []; - } - - $decodedUsers = []; - - foreach ($encodedUsers as $encodedUser) { - $decodedUsers[] = new User($encodedUser['id'], $encodedUser['email']); - } - - return $decodedUsers; - } -} diff --git a/src/Demo/IUserService.php b/src/Demo/IUserService.php deleted file mode 100644 index 36b38e5..0000000 --- a/src/Demo/IUserService.php +++ /dev/null @@ -1,62 +0,0 @@ - $users The users to create - * @return list The users that were created - */ - public function createManyUsers(array $users): array; - - /** - * Creates a user - * - * @param User $user The user to create - * @return User The created user - */ - public function createUser(User $user): User; - - /** - * Deletes all users - */ - public function deleteAllUsers(): void; - - /** - * Gets all the users - * - * @return list All the users - */ - public function getAllUsers(): array; - - /** - * Gets the number of users - * - * @return int The number of users - */ - public function getNumUsers(): int; - - /** - * Gets a random user - * - * @return User|null The random user, or null if there are no users - */ - public function getRandomUser(): ?User; - - /** - * Gets a user by ID - * - * @param int $id The ID to search for - * @return User The user with the corresponding ID - * @throws UserNotFoundException Thrown if no user with that ID was found - */ - public function getUserById(int $id): User; -} diff --git a/src/Demo/User.php b/src/Demo/User.php deleted file mode 100644 index 01c0ad2..0000000 --- a/src/Demo/User.php +++ /dev/null @@ -1,21 +0,0 @@ -response->getStatusCode()->value >= 500 ? LogLevel::ERROR : LogLevel::DEBUG; }) ->withModules($appBuilder, [ - new DemoModule() + new DatabaseModule(), + new UserModule(), + new AuthModule() ]); } @@ -117,10 +121,6 @@ private function getBinderDispatcher(): IBinderDispatcher $cache = new FileBinderMetadataCollectionCache($cachePath); $this->container->bindInstance(IBinderMetadataCollectionCache::class, $cache); - if (\getenv('APP_ENV') === 'production') { - return new LazyBinderDispatcher($cache); - } - - return new LazyBinderDispatcher(); + return new LazyBinderDispatcher(\getenv('APP_ENV') === 'production' ? $cache : null); } } diff --git a/src/Users/Api/Controllers/UserController.php b/src/Users/Api/Controllers/UserController.php new file mode 100644 index 0000000..402a3d3 --- /dev/null +++ b/src/Users/Api/Controllers/UserController.php @@ -0,0 +1,107 @@ +users->createUser($user); + } + + /** + * Deletes a user + * + * @param int $id The ID of the user to delete + * @return IResponse The response + * @throws HttpException Thrown if the content could not be negotiated + * @throws PolicyNotFoundException|RequirementHandlerNotFoundException Thrown if there was an error authorizing this request + */ + #[Delete('/:id'), Authenticate()] + public function deleteUser(int $id): IResponse + { + try { + $userToDelete = $this->users->getUserById($id); + } catch (UserNotFoundException $ex) { + return $this->notFound(); + } + + /** @psalm-suppress PossiblyNullArgument The user will be set */ + if (!$this->authority->authorize($this->getUser(), 'authorized-user-deleter', $userToDelete)->passed) { + return $this->forbidden(); + } + + $this->users->deleteUser($id); + + return $this->noContent(); + } + + /** + * Gets a page of users + * + * @param int $pageNumber The page number to retrieve + * @param int $pageSize The page size + * @return IResponse The response containing all users + * @throws HttpException Thrown if there was an error creating the response + * @throws InvalidPageException Thrown if the pagination parameters were invalid + */ + #[Get(''), AuthorizeRoles('admin')] + public function getPagedUsers(int $pageNumber = 1, int $pageSize = 100): IResponse + { + return $this->ok($this->users->getPagedUsers($pageNumber, $pageSize)); + } + + /** + * Gets a user with the input ID + * + * @param int $id The ID of the user to get + * @return User The user with the input ID + * @throws UserNotFoundException Thrown if there was no user with the input ID + */ + #[Get(':id'), Authenticate()] + public function getUserById(int $id): User + { + return $this->users->getUserById($id); + } +} diff --git a/src/Users/Binders/UserServiceBinder.php b/src/Users/Binders/UserServiceBinder.php new file mode 100644 index 0000000..a58c07e --- /dev/null +++ b/src/Users/Binders/UserServiceBinder.php @@ -0,0 +1,35 @@ +bindClass([IUserService::class, SqlUserService::class], SqlUserService::class, resolveAsSingleton: true); + $container->bindInstance( + SqlUserSeeder::class, + new SqlUserSeeder( + $container->resolve(SqlUserService::class), + $container->resolve(PDO::class), + (string)\getenv('USER_DEFAULT_EMAIL'), + (string)\getenv('USER_DEFAULT_PASSWORD') + ) + ); + } +} diff --git a/src/Users/Console/DefaultCredentialGeneratorCommandHandler.php b/src/Users/Console/DefaultCredentialGeneratorCommandHandler.php new file mode 100644 index 0000000..7aacf08 --- /dev/null +++ b/src/Users/Console/DefaultCredentialGeneratorCommandHandler.php @@ -0,0 +1,66 @@ +ask(new Confirmation('Would you like to update the default user credentials in your .env file? [Y/N] '), $output)) { + $output->writeln('For security, remember to update these credentials before deploying to production'); + + return; + } + + $output->writeln('Generating default user credentials...'); + $defaultUserEmail = ''; + + while (\strlen(\trim($defaultUserEmail)) === 0) { + $output->write('Enter the email address of the default admin user: '); + $defaultUserEmail = $output->readLine(); + } + + $defaultUserPassword = \bin2hex(\random_bytes(self::DEFAULT_USER_PASSWORD_LENGTH)); + $dotEnvFilePath = __DIR__ . '/../../../../.env'; + + if (!\file_exists($dotEnvFilePath)) { + throw new RuntimeException("No .env file found at $dotEnvFilePath"); + } + + $dotEnvContents = \file_get_contents($dotEnvFilePath); + $dotEnvContents = \preg_replace('/^USER_DEFAULT_EMAIL=.*$/m', "USER_DEFAULT_EMAIL=$defaultUserEmail", $dotEnvContents); + $dotEnvContents = \preg_replace('/^USER_DEFAULT_PASSWORD=.*$/m', "USER_DEFAULT_PASSWORD=$defaultUserPassword", $dotEnvContents); + \file_put_contents($dotEnvFilePath, $dotEnvContents); + + $output->writeln('.env file updated'); + + if (\array_key_exists('show-password', $input->options)) { + $output->writeln("Password: $defaultUserPassword"); + } + } +} diff --git a/src/Users/IUserService.php b/src/Users/IUserService.php new file mode 100644 index 0000000..3b6407e --- /dev/null +++ b/src/Users/IUserService.php @@ -0,0 +1,63 @@ + The page of users + * @throws InvalidPageException Thrown if the page number or size was invalid + */ + public function getPagedUsers(int $pageNumber = 1, int $pageSize = 100): array; + + /** + * Gets a user with the input email address + * + * @param string $email The email address to look up + * @return User|null The user if one was found, otherwise null + */ + public function getUserByEmail(string $email): ?User; + + /** + * Gets a user with the input credentials + * + * @param string $email The email address to look up + * @param string $password The user password + * @return User|null The user if one was found, otherwise null + */ + public function getUserByEmailAndPassword(string $email, string $password): ?User; + + /** + * Gets a user by ID + * + * @param int $id The ID to search for + * @return User The user with the corresponding ID + * @throws UserNotFoundException Thrown if no user with that ID was found + */ + public function getUserById(int $id): User; +} diff --git a/src/Users/InvalidPageException.php b/src/Users/InvalidPageException.php new file mode 100644 index 0000000..af61b7f --- /dev/null +++ b/src/Users/InvalidPageException.php @@ -0,0 +1,14 @@ + $roles The user's roles + */ + public function __construct( + #[Email] public string $email, + public string $password, + public array $roles = [] + ) { + } +} diff --git a/src/Users/SqlUserSeeder.php b/src/Users/SqlUserSeeder.php new file mode 100644 index 0000000..4b5432e --- /dev/null +++ b/src/Users/SqlUserSeeder.php @@ -0,0 +1,77 @@ +createTables(); + + // Only create the user if they do not exist already + if ($this->users->getUserByEmail($this->defaultUserEmail) === null) { + $this->users->createUser( + new NewUser($this->defaultUserEmail, $this->defaultUserPassword, ['admin']), + true + ); + } + } + + /** + * Creates the database tables needed to store user data + * + * @throws PDOException Thrown if there was an error creating the tables + */ + private function createTables(): void + { + // Create the user table and index + $this->pdo->exec( + <<pdo->exec( + <<pdo->exec( + <<pdo->exec( + <<email, $newUser->password); + } + + $normalizedEmail = self::normalizeEmail($newUser->email); + + $this->pdo->beginTransaction(); + // Insert the user + $createUserStatement = $this->pdo->prepare( + <<execute([ + 'email' => $normalizedEmail, + 'hashedPassword' => self::hashPassword($newUser->password) + ]); + $createdUser = new User( + (int)$this->pdo->lastInsertId(), + $normalizedEmail, + $newUser->roles + ); + + // Insert the roles + foreach ($newUser->roles as $role) { + $createRoleStatement = $this->pdo->prepare( + <<execute([ + 'userId' => $createdUser->id, + 'role' => $role + ]); + } + + $this->pdo->commit(); + + return $createdUser; + } + + /** + * @inheritdoc + */ + public function deleteUser(int $id): void + { + $this->pdo->beginTransaction(); + + // Delete the user roles + $deleteRolesStatement = $this->pdo->prepare( + <<execute(['userId' => $id]); + + // Delete the user + $deleteUserStatement = $this->pdo->prepare( + <<execute(['userId' => $id]); + + $this->pdo->commit(); + } + + /** + * @inheritdoc + */ + public function getPagedUsers(int $pageNumber = 1, int $pageSize = 100): array + { + if ($pageNumber < 0) { + throw new InvalidPageException('Page number must begin at 0'); + } + + if ($pageSize < 1) { + throw new InvalidPageException('Page size must be greater than 0'); + } + + if ($pageSize > self::MAX_PAGE_SIZE) { + throw new InvalidPageException('Page size cannot exceed ' . self::MAX_PAGE_SIZE); + } + + $statement = $this->pdo->prepare( + <<execute(['start' => ($pageNumber - 1) * $pageSize, 'limit' => $pageSize]); + $users = []; + + /** @var array{id: int, email: string, roles: string} $row */ + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + $users[] = self::createUserFromRow($row); + } + + return $users; + } + + /** + * @inheritdoc + */ + public function getUserByEmail(string $email): ?User + { + $rows = $this->queryUserByEmail($email); + + if (\count($rows) !== 1) { + return null; + } + + return self::createUserFromRow($rows[0]); + } + + /** + * @inheritdoc + */ + public function getUserByEmailAndPassword(string $email, string $password): ?User + { + $rows = $this->queryUserByEmail($email); + + if (\count($rows) !== 1 || !\password_verify($password, $rows[0]['hashed_password'])) { + return null; + } + + return self::createUserFromRow($rows[0]); + } + + /** + * @inheritdoc + */ + public function getUserById(int $id): User + { + $statement = $this->pdo->prepare( + <<execute(['id' => $id]); + $row = $statement->fetch(PDO::FETCH_ASSOC); + + if (empty($row)) { + throw new UserNotFoundException("No user found with ID $id"); + } + + /** @var array{id: int, email: string, roles: string} $row */ + return self::createUserFromRow($row); + } + + /** + * Creates a user from an entity + * + * @param array{id: int, email: string, roles: ?string, hashed_password?: string} $userRow The row of user data to create the user from + * @return User The created user + */ + private static function createUserFromRow(array $userRow): User + { + if (isset($userRow['roles'])) { + $roles = \explode(',', $userRow['roles'] ?? ''); + } else { + $roles = []; + } + + return new User( + (int)$userRow['id'], + (string)$userRow['email'], + $roles + ); + } + + /** + * Hashes a password for storage + * + * @param string $password The password to hash + * @return string The hashed password + */ + private static function hashPassword(string $password): string + { + return \password_hash($password, PASSWORD_ARGON2ID); + } + + /** + * Normalizes the email address so that casing and string padding do not affect lookups + * + * @param string $email The email address to normalize + * @return string The normalized email address + */ + private static function normalizeEmail(string $email): string + { + return \strtolower(\trim($email)); + } + + /** + * Queries for a user by email address + * + * @param string $email The email address to query for + * @return list The row of data for the user + */ + private function queryUserByEmail(string $email): array + { + $statement = $this->pdo->prepare( + <<execute(['email' => self::normalizeEmail($email)]); + /** @var list $rows */ + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + + return $rows; + } +} diff --git a/src/Users/User.php b/src/Users/User.php new file mode 100644 index 0000000..5143486 --- /dev/null +++ b/src/Users/User.php @@ -0,0 +1,25 @@ + $roles The user's roles + */ + public function __construct( + public int $id, + #[Email] public string $email, + public array $roles + ) { + } +} diff --git a/src/Demo/DemoModule.php b/src/Users/UserModule.php similarity index 66% rename from src/Demo/DemoModule.php rename to src/Users/UserModule.php index 88f35bf..6481eed 100644 --- a/src/Demo/DemoModule.php +++ b/src/Users/UserModule.php @@ -2,36 +2,39 @@ declare(strict_types=1); -namespace App\Demo; +namespace App\Users; use Aphiria\Application\IApplicationBuilder; -use Aphiria\Authentication\AuthenticationScheme; use Aphiria\Framework\Application\AphiriaModule; use Aphiria\Net\Http\HttpStatusCode; -use App\Demo\Auth\DummyAuthenticationHandler; -use App\Demo\Binders\UserServiceBinder; +use App\Database\Components\DatabaseComponents; +use App\Users\Binders\UserServiceBinder; use Psr\Log\LogLevel; /** - * Defines the demo module + * Defines the user */ -final class DemoModule extends AphiriaModule +final class UserModule extends AphiriaModule { + use DatabaseComponents; + /** * @inheritdoc */ public function configure(IApplicationBuilder $appBuilder): void { $this->withBinders($appBuilder, new UserServiceBinder()) - ->withAuthenticationScheme( - $appBuilder, - new AuthenticationScheme('dummy', DummyAuthenticationHandler::class) - ) + ->withDatabaseSeeders($appBuilder, SqlUserSeeder::class) ->withProblemDetails( $appBuilder, UserNotFoundException::class, status: HttpStatusCode::NotFound ) + ->withProblemDetails( + $appBuilder, + InvalidPageException::class, + status: HttpStatusCode::BadRequest + ) ->withLogLevelFactory( $appBuilder, UserNotFoundException::class, diff --git a/src/Demo/UserNotFoundException.php b/src/Users/UserNotFoundException.php similarity index 90% rename from src/Demo/UserNotFoundException.php rename to src/Users/UserNotFoundException.php index 2b3b2aa..974516d 100644 --- a/src/Demo/UserNotFoundException.php +++ b/src/Users/UserNotFoundException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Demo; +namespace App\Users; use Exception; diff --git a/tests/Integration/Auth/AuthTest.php b/tests/Integration/Auth/AuthTest.php new file mode 100644 index 0000000..8e6fe80 --- /dev/null +++ b/tests/Integration/Auth/AuthTest.php @@ -0,0 +1,60 @@ +seedDatabase(); + } + + public function testLoggingInAndOutUnsetsTokenCookie(): void + { + $user = $this->createUser(password: 'foo'); + $loginResponse = $this->post( + '/auth/login', + ['Authorization' => 'Basic ' . \base64_encode("$user->email:foo")] + ); + $this->assertHasCookie($loginResponse, 'authToken'); + $logoutResponse = $this->post( + '/auth/logout', + ['Cookie' => (string)$this->responseParser->parseCookies($loginResponse)->get('authToken')->value] + ); + $this->assertStatusCodeEquals(HttpStatusCode::Ok, $logoutResponse); + $this->assertCookieIsUnset($logoutResponse, 'authToken'); + } + + public function testLoggingInWithInvalidCredentialsReturnsUnauthorizedResponse(): void + { + $user = $this->createUser(password: 'foo'); + $response = $this->post('/auth/login', ['Authorization' => 'Basic ' . \base64_encode("$user->email:bar")]); + $this->assertStatusCodeEquals(HttpStatusCode::Unauthorized, $response); + } + + public function testLoggingInWithValidCredentialsSetsTokenCookie(): void + { + $user = $this->createUser(password: 'foo'); + $response = $this->post('/auth/login', ['Authorization' => 'Basic ' . \base64_encode("$user->email:foo")]); + $this->assertStatusCodeEquals(HttpStatusCode::Ok, $response); + $this->assertHasCookie($response, 'authToken'); + } + + public function testLoggingOutWithoutTokenCookieSetStillUnsetsTokenCookie(): void + { + $response = $this->post('/auth/logout'); + $this->assertStatusCodeEquals(HttpStatusCode::Ok, $response); + $this->assertCookieIsUnset($response, 'authToken'); + } +} diff --git a/tests/Integration/CreatesUser.php b/tests/Integration/CreatesUser.php new file mode 100644 index 0000000..3ea8a66 --- /dev/null +++ b/tests/Integration/CreatesUser.php @@ -0,0 +1,36 @@ +readResponseBodyAs(User::class, $this->post('/users', body: $newUser)); + + return $createdUser; + } + +} diff --git a/tests/Integration/Demo/UserTest.php b/tests/Integration/Demo/UserTest.php deleted file mode 100644 index 8fc64cc..0000000 --- a/tests/Integration/Demo/UserTest.php +++ /dev/null @@ -1,40 +0,0 @@ -delete('/demo/users'); - } - - public function testGettingAllUsers(): void - { - // Seed some users - $this->post('/demo/users', [], new User(123, 'foo@bar.com')); - $this->post('/demo/users', [], new User(456, 'baz@qux.com')); - // Get the users - $response = $this->get('/demo/users?letMeIn=1&userId=123'); - $this->assertStatusCodeEquals(200, $response); - $this->assertParsedBodyEquals( - [new User(123, 'foo@bar.com'), new User(456, 'baz@qux.com')], - $response - ); - } - - public function testGettingInvalidUserReturns404(): void - { - $response = $this->get('/demo/users/-1'); - $this->assertStatusCodeEquals(HttpStatusCode::NotFound, $response); - } -} diff --git a/tests/Integration/SeedsDatabase.php b/tests/Integration/SeedsDatabase.php new file mode 100644 index 0000000..9973582 --- /dev/null +++ b/tests/Integration/SeedsDatabase.php @@ -0,0 +1,31 @@ +resolve(GlobalDatabaseSeeder::class)->seed(); + } +} diff --git a/tests/Integration/Users/UserTest.php b/tests/Integration/Users/UserTest.php new file mode 100644 index 0000000..316a044 --- /dev/null +++ b/tests/Integration/Users/UserTest.php @@ -0,0 +1,132 @@ +seedDatabase(); + } + + /** + * Provides invalid page size and number parameters + * + * @return list The invalid page size and numbers + */ + public static function provideInvalidPageSizes(): array + { + return [ + [0, 1], + [101, 1], + [1, -1] + ]; + } + + public function testCreatingUsersMakesThemRetrievableAsAdminUser(): void + { + $createdUser = $this->createUser(); + $adminUser = (new PrincipalBuilder('example.com'))->withRoles('admin') + ->build(); + $response = $this->actingAs($adminUser, fn () => $this->get("/users/$createdUser->id")); + $this->assertStatusCodeEquals(HttpStatusCode::Ok, $response); + $this->assertParsedBodyEquals($createdUser, $response); + } + + public function testDeletingAnotherUserAsAdminReturns204(): void + { + $createdUserId = $this->createUser()->id; + $adminUser = (new PrincipalBuilder('example.com'))->withRoles('admin') + ->build(); + $response = $this->actingAs($adminUser, fn () => $this->delete("/users/$createdUserId")); + $this->assertStatusCodeEquals(HttpStatusCode::NoContent, $response); + } + + public function testDeletingAnotherUserAsNonAdminReturns403(): void + { + $createdUserId = $this->createUser()->id; + $nonAdminUser = (new PrincipalBuilder('example.com'))->withNameIdentifier(1) + ->build(); + $response = $this->actingAs($nonAdminUser, fn () => $this->delete("/users/$createdUserId")); + $this->assertStatusCodeEquals(HttpStatusCode::Forbidden, $response); + } + + public function testDeletingNonExistentUserReturns404(): void + { + $adminUser = (new PrincipalBuilder('example.com'))->withRoles('admin') + ->build(); + $response = $this->actingAs($adminUser, fn () => $this->get('/users/0')); + $this->assertStatusCodeEquals(HttpStatusCode::NotFound, $response); + } + + public function testDeletingYourOwnUserReturns204(): void + { + $createdUserId = $this->createUser()->id; + $createdUser = (new PrincipalBuilder('example.com'))->withNameIdentifier($createdUserId) + ->build(); + $response = $this->actingAs($createdUser, fn () => $this->delete("/users/$createdUserId")); + $this->assertStatusCodeEquals(HttpStatusCode::NoContent, $response); + } + + public function testGettingInvalidUserReturns404(): void + { + $user = new Principal(new Identity()); + $response = $this->actingAs($user, fn () => $this->get('/users/0')); + $this->assertStatusCodeEquals(HttpStatusCode::NotFound, $response); + } + + public function testGettingPagedUsersRedirectsToForbiddenPageForNonAdmins(): void + { + $nonAdminUser = new Principal(new Identity()); + $response = $this->actingAs($nonAdminUser, fn () => $this->get('/users')); + $this->assertStatusCodeEquals(HttpStatusCode::Found, $response); + $this->assertHeaderEquals('/access-denied', $response, 'Location'); + } + + public function testGettingPagedUsersReturnsSuccessfullyForAdmins(): void + { + $this->createUser(); + $adminUser = (new PrincipalBuilder('example.com'))->withRoles('admin') + ->build(); + $response = $this->actingAs($adminUser, fn () => $this->get('/users')); + $this->assertStatusCodeEquals(HttpStatusCode::Ok, $response); + // Integration tests may have created many users, so just check that the endpoint returns a non-empty list + $this->assertParsedBodyPassesCallback( + $response, + User::class . '[]', + fn (array $users): bool => \count($users) > 0 + ); + } + + /** + * @param int $pageSize The page size to test + * @param int $pageNumber The page number to test + */ + #[DataProvider('provideInvalidPageSizes')] + public function testGettingPagedUsersWithInvalidPageSizesReturnsBadRequests(int $pageSize, int $pageNumber): void + { + $adminUser = (new PrincipalBuilder('example.com'))->withRoles('admin') + ->build(); + $response = $this->actingAs( + $adminUser, + fn () => $this->get("/users?pageSize=$pageSize&pageNumber=$pageNumber") + ); + $this->assertStatusCodeEquals(HttpStatusCode::BadRequest, $response); + } +}