diff --git a/.gitignore b/.gitignore index 3c311a89a..b041b84e1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,10 @@ vendor/ src/Tempest/database.sqlite tests/Fixtures/database.sqlite tests/Unit/Console/test-console.log +src/Tempest/Database/src/database.sqlite .env composer.lock debug.log tempest.log public/static -tests/Unit/Log \ No newline at end of file +tests/Unit/Log diff --git a/composer.json b/composer.json index 5772329d8..56ca25312 100644 --- a/composer.json +++ b/composer.json @@ -50,6 +50,7 @@ "symplify/monorepo-builder": "^11.2" }, "replace": { + "tempest/auth": "self.version", "tempest/cache": "self.version", "tempest/clock": "self.version", "tempest/command-bus": "self.version", @@ -71,6 +72,7 @@ "minimum-stability": "dev", "autoload": { "psr-4": { + "Tempest\\Auth\\": "src/Tempest/Auth/src/", "Tempest\\Cache\\": "src/Tempest/Cache/src/", "Tempest\\Clock\\": "src/Tempest/Clock/src/", "Tempest\\CommandBus\\": "src/Tempest/CommandBus/src", @@ -105,6 +107,7 @@ }, "autoload-dev": { "psr-4": { + "Tempest\\Auth\\Tests\\": "src/Tempest/Auth/tests", "Tempest\\Cache\\Tests\\": "src/Tempest/Cache/tests", "Tempest\\Clock\\Tests\\": "src/Tempest/Clock/tests", "Tempest\\CommandBus\\Tests\\": "src/Tempest/CommandBus/tests", diff --git a/src/Tempest/Auth/.gitattributes b/src/Tempest/Auth/.gitattributes new file mode 100644 index 000000000..b007887e3 --- /dev/null +++ b/src/Tempest/Auth/.gitattributes @@ -0,0 +1,10 @@ +# Exclude build/test files from the release +.github/ export-ignore +tests/ export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpunit.xml export-ignore +README.md export-ignore + +# Configure diff output for .php and .phar files. +*.php diff=php diff --git a/src/Tempest/Auth/LICENCE.md b/src/Tempest/Auth/LICENCE.md new file mode 100644 index 000000000..e403836c2 --- /dev/null +++ b/src/Tempest/Auth/LICENCE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2024 Brent Roose brendt@stitcher.io + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/src/Tempest/Auth/composer.json b/src/Tempest/Auth/composer.json new file mode 100644 index 000000000..784bd9011 --- /dev/null +++ b/src/Tempest/Auth/composer.json @@ -0,0 +1,17 @@ +{ + "name": "tempest/auth", + "description": "A flexible authentication package for Tempest, providing user authentication and authorization.", + "require": { + "php": "^8.3", + "tempest/core": "dev-main", + "tempest/http": "dev-main", + "tempest/database": "dev-main" + }, + "autoload": { + "psr-4": { + "Tempest\\Auth\\": "src" + } + }, + "license": "MIT", + "minimum-stability": "dev" +} diff --git a/src/Tempest/Auth/phpunit.xml b/src/Tempest/Auth/phpunit.xml new file mode 100644 index 000000000..764ae9e30 --- /dev/null +++ b/src/Tempest/Auth/phpunit.xml @@ -0,0 +1,13 @@ + + + + + tests + + + + + src + + + diff --git a/src/Tempest/Auth/src/Allow.php b/src/Tempest/Auth/src/Allow.php new file mode 100644 index 000000000..8bdd84cd6 --- /dev/null +++ b/src/Tempest/Auth/src/Allow.php @@ -0,0 +1,18 @@ + $permission */ + public string|UnitEnum $permission, + ) { + } +} diff --git a/src/Tempest/Auth/src/AuthBootstrap.php b/src/Tempest/Auth/src/AuthBootstrap.php new file mode 100644 index 000000000..3a165c81e --- /dev/null +++ b/src/Tempest/Auth/src/AuthBootstrap.php @@ -0,0 +1,23 @@ +router->addMiddleware(AuthorizerMiddleware::class); + } +} diff --git a/src/Tempest/Auth/src/AuthConfig.php b/src/Tempest/Auth/src/AuthConfig.php new file mode 100644 index 000000000..6679537a2 --- /dev/null +++ b/src/Tempest/Auth/src/AuthConfig.php @@ -0,0 +1,17 @@ + */ + public string $authenticatorClass = SessionAuthenticator::class, + + /** @var class-string<\Tempest\Database\DatabaseModel> */ + public string $userModelClass = User::class, + ) { + } +} diff --git a/src/Tempest/Auth/src/Authenticator.php b/src/Tempest/Auth/src/Authenticator.php new file mode 100644 index 000000000..ea2adb30d --- /dev/null +++ b/src/Tempest/Auth/src/Authenticator.php @@ -0,0 +1,14 @@ +get(AuthConfig::class); + + return $container->get($authConfig->authenticatorClass); + } +} diff --git a/src/Tempest/Auth/src/Authorizer.php b/src/Tempest/Auth/src/Authorizer.php new file mode 100644 index 000000000..0e12325a8 --- /dev/null +++ b/src/Tempest/Auth/src/Authorizer.php @@ -0,0 +1,10 @@ +matchedRoute + ->route + ->handler + ->getAttribute(Allow::class); + + if ($attribute === null) { + return $next($request); + } + + $user = $this->authenticator->currentUser(); + + if (! $user instanceof CanAuthorize) { + return new Forbidden(); + } + + $permission = $attribute->permission; + + if (is_a($permission, Authorizer::class, true)) { + /** @var class-string<\Tempest\Auth\Authorizer> $permission */ + /** @var Authorizer $authorizer */ + $authorizer = $this->container->get($permission); + + $isAllowed = $authorizer->authorize($user); + } else { + $isAllowed = $user->hasPermission($permission); + } + + if (! $isAllowed) { + return new Forbidden(); + } + + return $next($request); + } +} diff --git a/src/Tempest/Auth/src/CanAuthenticate.php b/src/Tempest/Auth/src/CanAuthenticate.php new file mode 100644 index 000000000..c211d4bab --- /dev/null +++ b/src/Tempest/Auth/src/CanAuthenticate.php @@ -0,0 +1,11 @@ +primary() + ->varchar('name'); + } + + public function down(): DropTableStatement + { + return DropTableStatement::forModel(Permission::class); + } +} diff --git a/src/Tempest/Auth/src/CreateUserPermissionTable.php b/src/Tempest/Auth/src/CreateUserPermissionTable.php new file mode 100644 index 000000000..686c29c73 --- /dev/null +++ b/src/Tempest/Auth/src/CreateUserPermissionTable.php @@ -0,0 +1,30 @@ +primary() + ->belongsTo('user_permissions.user_id', 'users.id') + ->belongsTo('user_permissions.permission_id', 'permissions.id'); + } + + public function down(): DropTableStatement + { + return DropTableStatement::forModel(Permission::class); + } +} diff --git a/src/Tempest/Auth/src/CreateUsersTable.php b/src/Tempest/Auth/src/CreateUsersTable.php new file mode 100644 index 000000000..6000b30e1 --- /dev/null +++ b/src/Tempest/Auth/src/CreateUsersTable.php @@ -0,0 +1,32 @@ +primary() + ->varchar('name') + ->varchar('email') + ->datetime('emailValidatedAt', nullable: true) + ->text('password'); + } + + public function down(): DropTableStatement + { + return DropTableStatement::forModel(User::class); + } +} diff --git a/src/Tempest/Auth/src/CurrentUserInitializer.php b/src/Tempest/Auth/src/CurrentUserInitializer.php new file mode 100644 index 000000000..203e0278f --- /dev/null +++ b/src/Tempest/Auth/src/CurrentUserInitializer.php @@ -0,0 +1,28 @@ +implements(CanAuthenticate::class); + } + + public function initialize(ClassReflector $class, Container $container): object + { + $user = $container->get(Authenticator::class)->currentUser(); + + if (! $user) { + throw new CurrentUserNotLoggedIn(); + } + + return $user; + } +} diff --git a/src/Tempest/Auth/src/CurrentUserNotLoggedIn.php b/src/Tempest/Auth/src/CurrentUserNotLoggedIn.php new file mode 100644 index 000000000..096445aca --- /dev/null +++ b/src/Tempest/Auth/src/CurrentUserNotLoggedIn.php @@ -0,0 +1,15 @@ + $match, + $match instanceof BackedEnum => $match->value, + $match instanceof UnitEnum => $match->name, + }; + + return $this->name === $match; + } +} diff --git a/src/Tempest/Auth/src/SessionAuthenticator.php b/src/Tempest/Auth/src/SessionAuthenticator.php new file mode 100644 index 000000000..9fd73ebcd --- /dev/null +++ b/src/Tempest/Auth/src/SessionAuthenticator.php @@ -0,0 +1,46 @@ +session->set(self::USER_KEY, $user->getId()); + } + + public function logout(): void + { + $this->session->remove(self::USER_KEY); + $this->session->destroy(); + } + + public function currentUser(): ?CanAuthenticate + { + $id = $this->session->get(self::USER_KEY); + + if (! $id) { + return null; + } + + $userModelClass = new ClassReflector($this->authConfig->userModelClass); + + /** @var \Tempest\Database\Builder\ModelQueryBuilder<\Tempest\Auth\CanAuthenticate> $query */ + $query = $userModelClass->callStatic('query'); + + return $query->with('userPermissions.permission')->find($id); + } +} diff --git a/src/Tempest/Auth/src/User.php b/src/Tempest/Auth/src/User.php new file mode 100644 index 000000000..0823ee60d --- /dev/null +++ b/src/Tempest/Auth/src/User.php @@ -0,0 +1,71 @@ +password = password_hash($password, PASSWORD_BCRYPT); + + return $this; + } + + public function grantPermission(string|UnitEnum $permission): self + { + $permission = match(true) { + is_string($permission) => $permission, + $permission instanceof BackedEnum => $permission->value, + $permission instanceof UnitEnum => $permission->name, + }; + + (new UserPermission( + user: $this, + permission: new Permission($permission) + ))->save(); + + return $this->load('userPermissions.permission'); + } + + public function revokePermission(string|UnitEnum $permission): self + { + $this->getPermission($permission)?->delete(); + + return $this->load('userPermissions.permission'); + } + + public function hasPermission(UnitEnum|string $permission): bool + { + return $this->getPermission($permission) !== null; + } + + public function getPermission(UnitEnum|string $permission): ?UserPermission + { + return arr($this->userPermissions) + ->first(fn (UserPermission $userPermission) => $userPermission->permission->matches($permission)); + } +} diff --git a/src/Tempest/Auth/src/UserPermission.php b/src/Tempest/Auth/src/UserPermission.php new file mode 100644 index 000000000..ad95342ce --- /dev/null +++ b/src/Tempest/Auth/src/UserPermission.php @@ -0,0 +1,19 @@ +container->get(EventBus::class)->dispatch($event); } diff --git a/src/Tempest/Database/src/Builder/ModelDefinition.php b/src/Tempest/Database/src/Builder/ModelDefinition.php index ca68dd6e0..7f2160754 100644 --- a/src/Tempest/Database/src/Builder/ModelDefinition.php +++ b/src/Tempest/Database/src/Builder/ModelDefinition.php @@ -6,7 +6,9 @@ use Tempest\Database\Builder\Relations\BelongsToRelation; use Tempest\Database\Builder\Relations\HasManyRelation; +use Tempest\Database\Builder\Relations\HasOneRelation; use Tempest\Database\Eager; +use Tempest\Database\HasOne; use function Tempest\reflect; use Tempest\Reflection\ClassReflector; @@ -33,12 +35,16 @@ public function getRelations(string $relationName): array if ($property->getType()->isIterable()) { $relations[] = new HasManyRelation($property, $alias); $class = $property->getIterableType()->asClass(); + $alias .= ".{$property->getName()}[]"; + } elseif ($property->hasAttribute(HasOne::class)) { + $relations[] = new HasOneRelation($property, $alias); + $class = $property->getType()->asClass(); + $alias .= ".{$property->getName()}"; } else { $relations[] = new BelongsToRelation($property, $alias); $class = $property->getType()->asClass(); + $alias .= ".{$property->getName()}"; } - - $alias .= ".{$property->getName()}"; } return $relations; diff --git a/src/Tempest/Database/src/Builder/Relations/BelongsToRelation.php b/src/Tempest/Database/src/Builder/Relations/BelongsToRelation.php index d83364e35..9d39fffc3 100644 --- a/src/Tempest/Database/src/Builder/Relations/BelongsToRelation.php +++ b/src/Tempest/Database/src/Builder/Relations/BelongsToRelation.php @@ -31,7 +31,7 @@ public function __construct(PropertyReflector $property, string $alias) public function getStatement(): string { return sprintf( - 'INNER JOIN %s ON %s = %s', + 'LEFT JOIN %s ON %s = %s', $this->joinField->tableName, $this->localField, $this->joinField, diff --git a/src/Tempest/Database/src/Builder/Relations/HasManyRelation.php b/src/Tempest/Database/src/Builder/Relations/HasManyRelation.php index 67236275e..e8d562606 100644 --- a/src/Tempest/Database/src/Builder/Relations/HasManyRelation.php +++ b/src/Tempest/Database/src/Builder/Relations/HasManyRelation.php @@ -41,7 +41,7 @@ public function __construct(PropertyReflector $property, string $alias) public function getStatement(): string { return sprintf( - 'INNER JOIN %s ON %s = %s', + 'LEFT JOIN %s ON %s = %s', $this->joinField->tableName, $this->localField, $this->joinField, diff --git a/src/Tempest/Database/src/Builder/Relations/HasOneRelation.php b/src/Tempest/Database/src/Builder/Relations/HasOneRelation.php new file mode 100644 index 000000000..2025ada06 --- /dev/null +++ b/src/Tempest/Database/src/Builder/Relations/HasOneRelation.php @@ -0,0 +1,67 @@ +getClass(); + + foreach ($property->getType()->asClass()->getPublicProperties() as $possibleInverseProperty) { + if ($possibleInverseProperty->getType()->matches($currentModelClass->getName())) { + $inverseProperty = $possibleInverseProperty; + + break; + } + } + + if ($inverseProperty === null) { + // TODO exception + } + + $this->relationModelClass = $property->getType()->asClass(); + + $localTable = TableName::for($property->getClass(), $alias); + $this->localField = new FieldName($localTable, 'id'); + + $joinTable = TableName::for($property->getType()->asClass(), "{$alias}.{$property->getName()}"); + $this->joinField = new FieldName($joinTable, $inverseProperty->getName() . '_id'); + } + + public function getStatement(): string + { + return sprintf( + 'LEFT JOIN %s ON %s = %s', + $this->joinField->tableName, + $this->localField, + $this->joinField, + ); + } + + public function getRelationName(): string + { + return $this->joinField->tableName->as; + } + + public function getFieldNames(): array + { + return FieldName::make($this->relationModelClass, $this->joinField->tableName); + } +} diff --git a/src/Tempest/Database/src/HasOne.php b/src/Tempest/Database/src/HasOne.php new file mode 100644 index 000000000..150c6841c --- /dev/null +++ b/src/Tempest/Database/src/HasOne.php @@ -0,0 +1,12 @@ +id = $id instanceof self ? $id->id : $id; + $id = $id instanceof self ? $id->id : $id; + + $this->id = is_numeric($id) ? (int) $id : $id; } public function __toString(): string { return "{$this->id}"; } + + public function equals(self $other): bool + { + return $this->id === $other->id; + } } diff --git a/src/Tempest/Database/src/IsDatabaseModel.php b/src/Tempest/Database/src/IsDatabaseModel.php index 8b9d8abe8..125cb378d 100644 --- a/src/Tempest/Database/src/IsDatabaseModel.php +++ b/src/Tempest/Database/src/IsDatabaseModel.php @@ -157,5 +157,17 @@ public function update(mixed ...$params): self return $this; } - // TODO: delete + public function delete(): void + { + $table = self::table(); + + $query = new Query(sprintf( + "DELETE FROM %s WHERE `id` = :id", + $table, + ), [ + 'id' => $this->getId()->id, + ]); + + $query->execute(); + } } diff --git a/src/Tempest/Database/src/Mappers/QueryToModelMapper.php b/src/Tempest/Database/src/Mappers/QueryToModelMapper.php index 840bb3cf4..8b79cbdb1 100644 --- a/src/Tempest/Database/src/Mappers/QueryToModelMapper.php +++ b/src/Tempest/Database/src/Mappers/QueryToModelMapper.php @@ -51,24 +51,48 @@ private function parse(ClassReflector $class, DatabaseModel $model, array $row): $count = count($keyParts); + // TODO: clean up and document if ($count > 3) { - $property = $class->getProperty($propertyName); + $property = $class->getProperty(rtrim($propertyName, '[]')); + + if ($property->isIterable()) { + $collection = $property->get($model, []); + $childId = $row[$keyParts[0] . '.' . $keyParts[1] . '.id']; + + if ($childId) { + $iterableType = $property->getIterableType(); + + $childModel = $collection[$childId] ?? $iterableType->asClass()->newInstanceWithoutConstructor(); - $childModel = $property->get($model, $property->getType()->asClass()->newInstanceWithoutConstructor()); + unset($keyParts[0]); - unset($keyParts[0]); + $collection[$childId] = $this->parse( + $iterableType->asClass(), + $childModel, + [implode('.', $keyParts) => $value], + ); + } - $property->set($model, $this->parse( - $class->getProperty($propertyName)->getType()->asClass(), - $childModel, - [implode('.', $keyParts) => $value], - )); + $property->set($model, $collection); + } else { + $childModelType = $property->getType(); + + $childModel = $property->get($model, $childModelType->asClass()->newInstanceWithoutConstructor()); + + unset($keyParts[0]); + + $property->set($model, $this->parse( + $childModelType->asClass(), + $childModel, + [implode('.', $keyParts) => $value], + )); + } } elseif ($count === 3) { + $childId = $row[$keyParts[0] . '.' . $keyParts[1] . '.id'] ?? null; + if (str_contains($keyParts[1], '[]')) { $property = $class->getProperty(rtrim($propertyName, '[]')); - $childId = $row[$keyParts[0] . '.' . $keyParts[1] . '.id']; - $model = $this->parseHasMany( $property, $model, @@ -98,17 +122,25 @@ private function parse(ClassReflector $class, DatabaseModel $model, array $row): private function parseProperty(PropertyReflector $property, DatabaseModel $model, mixed $value): DatabaseModel { - if (($caster = $this->casterFactory->forProperty($property)) !== null) { + if ($value && ($caster = $this->casterFactory->forProperty($property)) !== null) { $value = $caster->cast($value); } + if ($value === null && ! $property->isNullable()) { + return $model; + } + $property->set($model, $value); return $model; } - private function parseBelongsTo(PropertyReflector $property, DatabaseModel $model, string $childProperty, mixed $value): DatabaseModel - { + private function parseBelongsTo( + PropertyReflector $property, + DatabaseModel $model, + string $childProperty, + mixed $value, + ): DatabaseModel { $childModel = $property->get( $model, $property->getType()->asClass()->newInstanceWithoutConstructor(), @@ -128,10 +160,21 @@ private function parseBelongsTo(PropertyReflector $property, DatabaseModel $mode return $model; } - private function parseHasMany(PropertyReflector $property, DatabaseModel $model, string $childId, string $childProperty, mixed $value): DatabaseModel - { + private function parseHasMany( + PropertyReflector $property, + DatabaseModel $model, + ?string $childId, + string $childProperty, + mixed $value, + ): DatabaseModel { $collection = $property->get($model, []); + if (! $childId) { + $property->set($model, $collection); + + return $model; + } + $childModel = $collection[$childId] ?? $property->getIterableType()->asClass()->newInstanceWithoutConstructor(); $childProperty = (new ClassReflector($childModel))->getProperty($childProperty); diff --git a/src/Tempest/Database/src/database.sqlite b/src/Tempest/Database/src/database.sqlite deleted file mode 100644 index 3dd57d00e..000000000 Binary files a/src/Tempest/Database/src/database.sqlite and /dev/null differ diff --git a/src/Tempest/Framework/Testing/Http/TestResponseHelper.php b/src/Tempest/Framework/Testing/Http/TestResponseHelper.php index e0f0225e6..4d88eecc8 100644 --- a/src/Tempest/Framework/Testing/Http/TestResponseHelper.php +++ b/src/Tempest/Framework/Testing/Http/TestResponseHelper.php @@ -11,11 +11,14 @@ use Tempest\Http\Response; use Tempest\Http\Session\Session; use Tempest\Http\Status; +use Tempest\View\View; +use Tempest\View\ViewRenderer; final readonly class TestResponseHelper { - public function __construct(private Response $response) - { + public function __construct( + private Response $response, + ) { } public function getResponse(): Response @@ -83,6 +86,11 @@ public function assertOk(): self return $this->assertStatus(Status::OK); } + public function assertForbidden(): self + { + return $this->assertStatus(Status::FORBIDDEN); + } + public function assertNotFound(): self { return $this->assertStatus(Status::NOT_FOUND); @@ -177,9 +185,35 @@ public function assertHasNoValidationsErrors(): self sprintf( "There should be no validation errors, but there were: %s", implode(', ', array_keys($validationErrors)), - ) + ), ); return $this; } + + public function assertSee(string $search): self + { + $body = $this->response->getBody(); + + if ($body instanceof View) { + $body = get(ViewRenderer::class)->render($body); + } + + Assert::assertStringContainsString($search, $body); + + return $this; + } + + public function assertNotSee(string $search): self + { + $body = $this->response->getBody(); + + if ($body instanceof View) { + $body = get(ViewRenderer::class)->render($body); + } + + Assert::assertStringNotContainsString($search, $body); + + return $this; + } } diff --git a/src/Tempest/Http/src/Responses/Forbidden.php b/src/Tempest/Http/src/Responses/Forbidden.php new file mode 100644 index 000000000..10bf23b82 --- /dev/null +++ b/src/Tempest/Http/src/Responses/Forbidden.php @@ -0,0 +1,19 @@ +status = Status::FORBIDDEN; + } +} diff --git a/src/Tempest/Mapper/src/CastWith.php b/src/Tempest/Mapper/src/CastWith.php index 38f96d0c6..58c62c656 100644 --- a/src/Tempest/Mapper/src/CastWith.php +++ b/src/Tempest/Mapper/src/CastWith.php @@ -10,6 +10,7 @@ final readonly class CastWith { public function __construct( + /** @var class-string<\Tempest\Mapper\Caster> */ public string $className, ) { } diff --git a/src/Tempest/Reflection/src/PropertyReflector.php b/src/Tempest/Reflection/src/PropertyReflector.php index 66e95d7d0..5e61a13c4 100644 --- a/src/Tempest/Reflection/src/PropertyReflector.php +++ b/src/Tempest/Reflection/src/PropertyReflector.php @@ -66,6 +66,17 @@ public function isPromoted(): bool return $this->reflectionProperty->isPromoted(); } + public function isNullable(): bool + { + $type = $this->getType(); + + if ($type === null) { + return true; + } + + return $type->isNullable(); + } + public function getIterableType(): ?TypeReflector { $doc = $this->reflectionProperty->getDocComment(); diff --git a/src/Tempest/Reflection/src/TypeReflector.php b/src/Tempest/Reflection/src/TypeReflector.php index 644a2fe13..f7d28b44f 100644 --- a/src/Tempest/Reflection/src/TypeReflector.php +++ b/src/Tempest/Reflection/src/TypeReflector.php @@ -104,6 +104,12 @@ public function isIterable(): bool ]); } + public function isNullable(): bool + { + return str_contains('?', $this->definition) + || str_contains('null', $this->definition); + } + /** @return self[] */ public function split(): array { diff --git a/src/Tempest/Support/src/ArrayHelper.php b/src/Tempest/Support/src/ArrayHelper.php index 073c14ede..4d9dea505 100644 --- a/src/Tempest/Support/src/ArrayHelper.php +++ b/src/Tempest/Support/src/ArrayHelper.php @@ -207,6 +207,11 @@ public function has(string $key): bool return true; } + public function contains(mixed $search): bool + { + return $this->first(fn ($value) => $value === $search) !== null; + } + public function set(string $key, mixed $value): self { $array = $this->array; diff --git a/src/Tempest/Support/tests/ArrayHelperTest.php b/src/Tempest/Support/tests/ArrayHelperTest.php index 4eb4ffb4f..f6efa13a2 100644 --- a/src/Tempest/Support/tests/ArrayHelperTest.php +++ b/src/Tempest/Support/tests/ArrayHelperTest.php @@ -249,4 +249,10 @@ public function test_each(): void $this->assertSame('012', $string); } + + public function test_contains(): void + { + $this->assertTrue(arr(['a', 'b', 'c'])->contains('b')); + $this->assertFalse(arr(['a', 'b', 'c'])->contains('d')); + } } diff --git a/tests/Fixtures/Controllers/AdminController.php b/tests/Fixtures/Controllers/AdminController.php new file mode 100644 index 000000000..669896757 --- /dev/null +++ b/tests/Fixtures/Controllers/AdminController.php @@ -0,0 +1,36 @@ +migrate( + CreateMigrationsTable::class, + CreateUsersTable::class, + CreatePermissionsTable::class, + CreateUserPermissionTable::class, + ); + + $this->path = __DIR__ . '/sessions'; + + $this->container->config(new SessionConfig(path: $this->path)); + $this->container->singleton( + SessionManager::class, + fn () => new FileSessionManager( + $this->container->get(Clock::class), + $this->container->get(SessionConfig::class) + ) + ); + } + + protected function tearDown(): void + { + array_map(unlink(...), glob("{$this->path}/*")); + rmdir($this->path); + } + + public function test_authorize(): void + { + $user = (new User( + name: 'Brent', + email: 'brendt@stitcher.io', + )) + ->setPassword('password') + ->save() + ->grantPermission(UserPermissionUnitEnum::ADMIN); + + $this->http + ->get(uri([AdminController::class, 'admin'])) + ->assertForbidden(); + + $authenticator = $this->container->get(Authenticator::class); + + $authenticator->login($user); + + $this->http + ->get(uri([AdminController::class, 'admin'])) + ->assertOk(); + + $this->http + ->get(uri([AdminController::class, 'guest'])) + ->assertForbidden(); + + $this->http + ->get(uri([AdminController::class, 'customAuthorizer'])) + ->assertForbidden(); + + $user->name = 'test'; + $user->save(); + + $this->http + ->get(uri([AdminController::class, 'customAuthorizer'])) + ->assertOk(); + } +} diff --git a/tests/Integration/Auth/Fixtures/CustomAuthorizer.php b/tests/Integration/Auth/Fixtures/CustomAuthorizer.php new file mode 100644 index 000000000..19488d26f --- /dev/null +++ b/tests/Integration/Auth/Fixtures/CustomAuthorizer.php @@ -0,0 +1,21 @@ +name === 'test'; + } +} diff --git a/tests/Integration/Auth/Fixtures/UserPermissionBackedEnum.php b/tests/Integration/Auth/Fixtures/UserPermissionBackedEnum.php new file mode 100644 index 000000000..58776f9f2 --- /dev/null +++ b/tests/Integration/Auth/Fixtures/UserPermissionBackedEnum.php @@ -0,0 +1,11 @@ +path = __DIR__ . '/sessions'; + + $this->container->config(new SessionConfig(path: $this->path)); + $this->container->singleton( + SessionManager::class, + fn () => new FileSessionManager( + $this->container->get(Clock::class), + $this->container->get(SessionConfig::class) + ) + ); + + $this->migrate( + CreateMigrationsTable::class, + CreateUsersTable::class, + CreatePermissionsTable::class, + CreateUserPermissionTable::class + ); + } + + protected function tearDown(): void + { + array_map(unlink(...), glob("{$this->path}/*")); + rmdir($this->path); + } + + public function test_authenticator(): void + { + $auth = $this->container->get(Authenticator::class); + + $this->assertInstanceOf(SessionAuthenticator::class, $auth); + + $this->assertNull($auth->currentUser()); + + $user = (new User('Brent', 'brendt@stitcher.io')) + ->setPassword('password') + ->save(); + + $auth->login($user); + + // Current user via authenticator + $this->assertTrue($auth->currentUser()->getId()->equals($user->id)); + + // Current user via session + $session = $this->container->get(Session::class); + $this->assertTrue($auth->currentUser()->getId()->equals($session->get('tempest_session_user'))); + + // Current user via container + $this->assertTrue($auth->currentUser()->getId()->equals($this->container->get(User::class)->id)); + + $auth->logout(); + + // Auth is empty + $this->assertNull($auth->currentUser()); + + // Session is empty + $this->assertNull($session->get('tempest_session_user')); + + // Container user throws + $this->expectException(CurrentUserNotLoggedIn::class); + $this->assertNull($this->container->get(User::class)); + } +} diff --git a/tests/Integration/Auth/UserModelTest.php b/tests/Integration/Auth/UserModelTest.php new file mode 100644 index 000000000..0510e32ee --- /dev/null +++ b/tests/Integration/Auth/UserModelTest.php @@ -0,0 +1,90 @@ +migrate( + CreateMigrationsTable::class, + CreateUsersTable::class, + CreatePermissionsTable::class, + CreateUserPermissionTable::class, + ); + } + + public function test_grant_permission_string(): void + { + $user = (new User( + name: 'Brent', + email: 'brendt@stitcher.io', + )) + ->setPassword('password') + ->save() + ->grantPermission('admin'); + + $this->assertTrue($user->hasPermission('admin')); + $this->assertFalse($user->hasPermission('guest')); + } + + public function test_grant_permission_backed_enum(): void + { + $user = (new User( + name: 'Brent', + email: 'brendt@stitcher.io', + )) + ->setPassword('password') + ->save() + ->grantPermission(UserPermissionBackedEnum::ADMIN); + + $this->assertTrue($user->hasPermission(UserPermissionBackedEnum::ADMIN)); + $this->assertFalse($user->hasPermission(UserPermissionBackedEnum::GUEST)); + } + + public function test_grant_permission_unit_enum(): void + { + $user = (new User( + name: 'Brent', + email: 'brendt@stitcher.io', + )) + ->setPassword('password') + ->save() + ->grantPermission(UserPermissionUnitEnum::ADMIN); + + $this->assertTrue($user->hasPermission(UserPermissionUnitEnum::ADMIN)); + $this->assertFalse($user->hasPermission(UserPermissionUnitEnum::GUEST)); + } + + public function test_revoke_permission(): void + { + $user = (new User( + name: 'Brent', + email: 'brendt@stitcher.io', + )) + ->setPassword('password') + ->save() + ->grantPermission(UserPermissionBackedEnum::ADMIN) + ->grantPermission(UserPermissionBackedEnum::GUEST) + ->revokePermission(UserPermissionBackedEnum::ADMIN); + + $this->assertFalse($user->hasPermission(UserPermissionBackedEnum::ADMIN)); + $this->assertTrue($user->hasPermission(UserPermissionBackedEnum::GUEST)); + } +} diff --git a/tests/Integration/Database/MigrationManagerTest.php b/tests/Integration/Database/MigrationManagerTest.php index eaf4e859d..081d7e423 100644 --- a/tests/Integration/Database/MigrationManagerTest.php +++ b/tests/Integration/Database/MigrationManagerTest.php @@ -20,13 +20,13 @@ public function test_migration(): void $migrationManager->up(); $migrations = Migration::all(); - $this->assertCount(5, $migrations); + $this->assertNotEmpty($migrations); + $oldCount = count($migrations); $migrationManager->up(); - $migrations = Migration::all(); - $this->assertCount(5, $migrations); - $this->assertSame('2024-08-16-create_publishers_table_0', $migrations[3]->name); - $this->assertSame('2024-08-16-create_publishers_table_1', $migrations[4]->name); + $migrations = Migration::all(); + $this->assertNotEmpty($migrations); + $this->assertSame($oldCount, count($migrations)); } } diff --git a/tests/Integration/ORM/IsDatabaseModelTest.php b/tests/Integration/ORM/IsDatabaseModelTest.php index f59e23a34..a36ece40d 100644 --- a/tests/Integration/ORM/IsDatabaseModelTest.php +++ b/tests/Integration/ORM/IsDatabaseModelTest.php @@ -24,6 +24,12 @@ use Tests\Tempest\Integration\ORM\Migrations\CreateATable; use Tests\Tempest\Integration\ORM\Migrations\CreateBTable; use Tests\Tempest\Integration\ORM\Migrations\CreateCTable; +use Tests\Tempest\Integration\ORM\Migrations\CreateHasManyChildTable; +use Tests\Tempest\Integration\ORM\Migrations\CreateHasManyParentTable; +use Tests\Tempest\Integration\ORM\Migrations\CreateHasManyThroughTable; +use Tests\Tempest\Integration\ORM\Models\ChildModel; +use Tests\Tempest\Integration\ORM\Models\ParentModel; +use Tests\Tempest\Integration\ORM\Models\ThroughModel; /** * @internal @@ -242,6 +248,66 @@ public function test_has_many_relations(): void $this->assertCount(2, $author->books); } + public function test_has_many_through_relation(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateHasManyParentTable::class, + CreateHasManyChildTable::class, + CreateHasManyThroughTable::class, + ); + + $parent = (new ParentModel(name: 'parent'))->save(); + + $childA = (new ChildModel(name: 'A'))->save(); + $childB = (new ChildModel(name: 'B'))->save(); + + (new ThroughModel(parent: $parent, child: $childA))->save(); + (new ThroughModel(parent: $parent, child: $childB))->save(); + + $parent = ParentModel::find($parent->id, ['through.child']); + + $this->assertSame('A', $parent->through[1]->child->name); + $this->assertSame('B', $parent->through[2]->child->name); + } + + public function test_empty_has_many_relation(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateHasManyParentTable::class, + CreateHasManyChildTable::class, + CreateHasManyThroughTable::class, + ); + + $parent = (new ParentModel(name: 'parent'))->save(); + + $parent = ParentModel::find($parent->id, ['through.child']); + + $this->assertInstanceOf(ParentModel::class, $parent); + $this->assertEmpty($parent->through); + } + + public function test_has_one_relation(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateHasManyParentTable::class, + CreateHasManyChildTable::class, + CreateHasManyThroughTable::class, + ); + + $parent = (new ParentModel(name: 'parent'))->save(); + + $childA = (new ChildModel(name: 'A'))->save(); + + (new ThroughModel(parent: $parent, child: $childA))->save(); + + $child = ChildModel::find($childA->id, ['through.parent']); + + $this->assertSame('parent', $child->through->parent->name); + } + public function test_lazy_load(): void { $this->migrate( @@ -316,4 +382,25 @@ public function test_update_or_create(): void $this->assertNull(Book::query()->whereField('title', 'A')->first()); $this->assertNotNull(Book::query()->whereField('title', 'B')->first()); } + + public function test_delete(): void + { + $this->migrate( + CreateMigrationsTable::class, + FooMigration::class, + ); + + $foo = Foo::create( + bar: 'baz', + ); + + $bar = Foo::create( + bar: 'baz', + ); + + $foo->delete(); + + $this->assertNull(Foo::find($foo->getId())); + $this->assertNotNull(Foo::find($bar->getId())); + } } diff --git a/tests/Integration/ORM/Migrations/CreateHasManyChildTable.php b/tests/Integration/ORM/Migrations/CreateHasManyChildTable.php new file mode 100644 index 000000000..2dd529ed5 --- /dev/null +++ b/tests/Integration/ORM/Migrations/CreateHasManyChildTable.php @@ -0,0 +1,29 @@ +primary() + ->varchar('name'); + } + + public function down(): QueryStatement|null + { + return null; + } +} diff --git a/tests/Integration/ORM/Migrations/CreateHasManyParentTable.php b/tests/Integration/ORM/Migrations/CreateHasManyParentTable.php new file mode 100644 index 000000000..04552894e --- /dev/null +++ b/tests/Integration/ORM/Migrations/CreateHasManyParentTable.php @@ -0,0 +1,29 @@ +primary() + ->varchar('name'); + } + + public function down(): QueryStatement|null + { + return null; + } +} diff --git a/tests/Integration/ORM/Migrations/CreateHasManyThroughTable.php b/tests/Integration/ORM/Migrations/CreateHasManyThroughTable.php new file mode 100644 index 000000000..a3ff5e17c --- /dev/null +++ b/tests/Integration/ORM/Migrations/CreateHasManyThroughTable.php @@ -0,0 +1,30 @@ +primary() + ->belongsTo('through.parent_id', 'parent.id') + ->belongsTo('through.child_id', 'child.id'); + } + + public function down(): QueryStatement|null + { + return null; + } +} diff --git a/tests/Integration/ORM/Models/ChildModel.php b/tests/Integration/ORM/Models/ChildModel.php new file mode 100644 index 000000000..50f26e37f --- /dev/null +++ b/tests/Integration/ORM/Models/ChildModel.php @@ -0,0 +1,28 @@ +