diff --git a/config/config.php b/config/config.php index 8b8f8ad..c156dc1 100644 --- a/config/config.php +++ b/config/config.php @@ -8,4 +8,21 @@ return [ 'default_owner_key' => 'user_id', 'default_override_key' => 'author_id', + + 'cache' => [ + 'enabled' => true, + 'ttl' => 3600, // 1 hour + 'prefix' => 'fortress:auth:', + ], + + 'rate_limiting' => [ + 'enabled' => true, + 'max_attempts' => 60, + 'decay_minutes' => 1, + ], + + 'audit_logging' => [ + 'enabled' => true, + 'channel' => 'stack', + ], ]; diff --git a/src/Adapters/DefaultAdapter.php b/src/Adapters/DefaultAdapter.php new file mode 100644 index 0000000..1158fb4 --- /dev/null +++ b/src/Adapters/DefaultAdapter.php @@ -0,0 +1,60 @@ +user = $user; + } + + public function hasPermissionTo(string $permission): bool + { + return method_exists($this->user, 'hasPermissionTo') + ? $this->user->hasPermissionTo($permission) + : false; + } + + public function hasAnyPermission(array $permissions): bool + { + return false; + } + + public function hasAllPermissions(array $permissions): bool + { + return method_exists($this->user, 'hasAllPermissions') + ? $this->user->hasAllPermissions($permissions) + : false; + } + + public function hasRole(string $role): bool + { + return method_exists($this->user, 'hasRole') + ? $this->user->hasRole($role) + : false; + } + + public function hasAnyRole(array $roles): bool + { + return false; + } + + public function hasAllRoles(array $roles): bool + { + return method_exists($this->user, 'hasAllRoles') + ? $this->user->hasAllRoles($roles) + : false; + } +} \ No newline at end of file diff --git a/src/Contracts/PermissionAdapter.php b/src/Contracts/PermissionAdapter.php new file mode 100644 index 0000000..5c9afc8 --- /dev/null +++ b/src/Contracts/PermissionAdapter.php @@ -0,0 +1,23 @@ +permissionAdapter = new DefaultAdapter(); + $this->roleAdapter = new DefaultAdapter(); + } + + public function getConfig(): Config + { + return $this->config; + } + + public function getCache(): Cache + { + return $this->cache; + } + + public function getPermissionAdapter(): PermissionAdapter + { + return $this->permissionAdapter; + } + + public function getRoleAdapter(): RoleAdapter + { + return $this->roleAdapter; + } + + public function setPermissionAdapter(PermissionAdapter $adapter): void + { + $this->permissionAdapter = $adapter; + } + + public function setRoleAdapter(RoleAdapter $adapter): void + { + $this->roleAdapter = $adapter; + } + + public function authorize( + ?Authenticatable $user, + bool $public = false, + array|string $permissions = [], + array|string $roles = [], ?string $owner = null, ?string $overrideKey = null ): bool { - $user = Auth::user(); + if ($public === true) { + return true; + } + + if (!$user) { + throw new AuthenticationException('User not authenticated'); + } - abort_if(!$user, 401, 'User not authenticated.'); + $permissions = is_string($permissions) ? [$permissions] : $permissions; + $roles = is_string($roles) ? [$roles] : $roles; - // Roles check - abort_if(!empty($roles) && !$user->hasAnyRole($roles), 403, 'Unauthorized role.'); + if (!empty($roles)) { + $userId = $user->getAuthIdentifier(); + $cacheKey = "fortress:auth:roles:{$userId}"; + $ttl = $this->config->get('fortress.cache.ttl', 3600); - // Permissions check - abort_if($permissions && !$user->hasPermissionTo($permissions), 403, 'Unauthorized permission.'); + $hasRole = $this->cache->remember($cacheKey, $ttl, function () use ($roles) { + return $this->roleAdapter->hasAnyRole($roles); + }); - // Ownership check - if (!$owner) { - return true; + if (!$hasRole) { + throw new UnauthorizedException('User does not have required roles'); + } + } + + if (!empty($permissions)) { + $userId = $user->getAuthIdentifier(); + $cacheKey = "fortress:auth:permissions:{$userId}"; + $ttl = $this->config->get('fortress.cache.ttl', 3600); + + $hasPermission = $this->cache->remember($cacheKey, $ttl, function () use ($permissions) { + return $this->permissionAdapter->hasAnyPermission($permissions); + }); + + if (!$hasPermission) { + throw new UnauthorizedException('User does not have required permissions'); + } } - $key = $overrideKey ?? config('fortress.default_override_key'); - $ownerInstance = app($owner); + if ($owner) { + $model = app($owner); + $key = $overrideKey ?? $this->config->get('fortress.ownership_key', 'user_id'); - abort_if(!property_exists($ownerInstance, $key), 500, "Key '{$key}' does not exist on the model."); - abort_if($ownerInstance->{$key} !== $user->id, 403, 'Unauthorized ownership.'); + if (!property_exists($model, $key)) { + throw new InvalidConfigurationException("Invalid ownership key: {$key}"); + } + + if ($model->{$key} !== $user->getAuthIdentifier()) { + throw new UnauthorizedException('User does not own this resource'); + } + } return true; } + + public function batchAuthorize(Authenticatable $user, array $resources): array + { + $results = []; + + foreach ($resources as $resource) { + try { + $this->authorize( + user: $user, + public: $resource['public'] ?? false, + permissions: $resource['permissions'] ?? [], + roles: $resource['roles'] ?? [], + owner: $resource['owner'] ?? null, + overrideKey: $resource['overrideKey'] ?? null + ); + + $results[$resource['id']] = true; + } catch (AuthenticationException|UnauthorizedException) { + $results[$resource['id']] = false; + } + } + + return $results; + } } diff --git a/src/FortressServiceProvider.php b/src/FortressServiceProvider.php index 4bc9061..bc8b996 100644 --- a/src/FortressServiceProvider.php +++ b/src/FortressServiceProvider.php @@ -4,8 +4,14 @@ namespace Laravelplus\Fortress; +use Illuminate\Cache\Repository as Cache; +use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Contracts\Config\Repository as Config; use Illuminate\Support\ServiceProvider; use Laravelplus\Fortress\Commands\InstallFortressCommand; +use Laravelplus\Fortress\Contracts\PermissionAdapter; +use Laravelplus\Fortress\Contracts\RoleAdapter; +use Laravelplus\Fortress\Adapters\DefaultAdapter; final class FortressServiceProvider extends ServiceProvider { @@ -25,7 +31,7 @@ public function boot(): void if ($this->app->runningInConsole()) { $this->publishes([ __DIR__.'/../config/config.php' => config_path('fortress.php'), - ], 'config'); + ], 'fortress-config'); // Publishing the views. /*$this->publishes([ @@ -56,10 +62,24 @@ public function boot(): void */ public function register(): void { - // Automatically apply the package configuration - $this->mergeConfigFrom(__DIR__.'/../config/config.php', 'fortress'); + $this->mergeConfigFrom( + __DIR__ . '/../config/config.php', + 'fortress' + ); - // Register the main class to use with the facade - $this->app->singleton('fortress', fn () => new Fortress()); + $this->app->singleton(Fortress::class, function ($app) { + return new Fortress( + $app->make('config'), + $app->make('cache.store') + ); + }); + + $this->app->singleton(PermissionAdapter::class, function () { + return new DefaultAdapter(); + }); + + $this->app->singleton(RoleAdapter::class, function () { + return new DefaultAdapter(); + }); } } diff --git a/src/Middleware/FortressMiddleware.php b/src/Middleware/FortressMiddleware.php new file mode 100644 index 0000000..17c9082 --- /dev/null +++ b/src/Middleware/FortressMiddleware.php @@ -0,0 +1,57 @@ +route(); + if (!$route) { + return $next($request); + } + + $fortressConfig = $route->getAction('fortress'); + if (!$fortressConfig) { + return $next($request); + } + + try { + $this->fortress->authorize( + user: $request->user(), + public: $fortressConfig['public'] ?? false, + permissions: $fortressConfig['permissions'] ?? [], + roles: $fortressConfig['roles'] ?? [], + owner: $fortressConfig['owner'] ?? null, + overrideKey: $fortressConfig['overrideKey'] ?? null + ); + + return $next($request); + } catch (AuthenticationException $e) { + if ($request->expectsJson()) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + throw $e; + } catch (UnauthorizedException $e) { + if ($request->expectsJson()) { + return response()->json(['message' => 'Unauthorized.'], 403); + } + + throw $e; + } + } +} \ No newline at end of file diff --git a/tests/Feature/FortressMiddlewareTest.php b/tests/Feature/FortressMiddlewareTest.php new file mode 100644 index 0000000..67fe1cc --- /dev/null +++ b/tests/Feature/FortressMiddlewareTest.php @@ -0,0 +1,285 @@ +config = Mockery::mock(Config::class); + $this->cache = Mockery::mock(Cache::class); + + $this->config->shouldReceive('get')->with('fortress.cache.ttl', 3600)->andReturn(3600); + $this->config->shouldReceive('get')->with('fortress.ownership_key', 'user_id')->andReturn('user_id'); + + $this->fortress = new Fortress($this->config, $this->cache); + $this->middleware = new FortressMiddleware($this->fortress); + $this->request = Mockery::mock(Request::class); + + $this->permissionAdapter = Mockery::mock(PermissionAdapter::class); + $this->roleAdapter = Mockery::mock(RoleAdapter::class); + + $this->fortress->setPermissionAdapter($this->permissionAdapter); + $this->fortress->setRoleAdapter($this->roleAdapter); + + $this->user = $this->createUser(); + } + + private function createUser(): Authenticatable + { + return new class extends User { + public function getAuthIdentifier(): int + { + return 1; + } + }; + } + + public function test_public_route_passes_without_authentication(): void + { + $route = Mockery::mock(Route::class); + $route->shouldReceive('getAction') + ->with('fortress') + ->andReturn([ + 'public' => true, + ]); + + $this->request->shouldReceive('route')->andReturn($route); + $this->request->shouldReceive('user')->andReturn(null); + $this->request->shouldReceive('expectsJson')->andReturn(false); + + $response = $this->middleware->handle($this->request, function ($request) { + return response('OK'); + }); + + $this->assertEquals('OK', $response->getContent()); + } + + public function test_throws_authentication_exception_for_unauthenticated_user(): void + { + $route = Mockery::mock(Route::class); + $route->shouldReceive('getAction') + ->with('fortress') + ->andReturn([ + 'public' => false, + ]); + + $this->request->shouldReceive('route')->andReturn($route); + $this->request->shouldReceive('user')->andReturn(null); + $this->request->shouldReceive('expectsJson')->andReturn(false); + + $this->expectException(AuthenticationException::class); + + $this->middleware->handle($this->request, function ($request) { + return response('OK'); + }); + } + + public function test_throws_unauthorized_exception_for_invalid_roles(): void + { + $this->cache->shouldReceive('remember') + ->with("fortress:auth:roles:1", 3600, Mockery::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); + + $this->roleAdapter->shouldReceive('hasAnyRole')->with(['admin'])->andReturn(false); + + $route = Mockery::mock(Route::class); + $route->shouldReceive('getAction') + ->with('fortress') + ->andReturn([ + 'public' => false, + 'roles' => ['admin'], + ]); + + $this->request->shouldReceive('route')->andReturn($route); + $this->request->shouldReceive('user')->andReturn($this->user); + $this->request->shouldReceive('expectsJson')->andReturn(false); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('User does not have required roles'); + + $this->middleware->handle($this->request, function ($request) { + return response('OK'); + }); + } + + public function test_passes_with_valid_roles(): void + { + $this->cache->shouldReceive('remember') + ->with("fortress:auth:roles:1", 3600, Mockery::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); + + $this->roleAdapter->shouldReceive('hasAnyRole')->with(['admin'])->andReturn(true); + + $route = Mockery::mock(Route::class); + $route->shouldReceive('getAction') + ->with('fortress') + ->andReturn([ + 'public' => false, + 'roles' => ['admin'], + ]); + + $this->request->shouldReceive('route')->andReturn($route); + $this->request->shouldReceive('user')->andReturn($this->user); + $this->request->shouldReceive('expectsJson')->andReturn(false); + + $response = $this->middleware->handle($this->request, function ($request) { + return response('OK'); + }); + + $this->assertEquals('OK', $response->getContent()); + } + + public function test_throws_unauthorized_exception_for_invalid_permissions(): void + { + $this->cache->shouldReceive('remember') + ->with("fortress:auth:permissions:1", 3600, Mockery::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); + + $this->permissionAdapter->shouldReceive('hasAnyPermission')->with(['create-post'])->andReturn(false); + + $route = Mockery::mock(Route::class); + $route->shouldReceive('getAction') + ->with('fortress') + ->andReturn([ + 'public' => false, + 'permissions' => ['create-post'], + ]); + + $this->request->shouldReceive('route')->andReturn($route); + $this->request->shouldReceive('user')->andReturn($this->user); + $this->request->shouldReceive('expectsJson')->andReturn(false); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('User does not have required permissions'); + + $this->middleware->handle($this->request, function ($request) { + return response('OK'); + }); + } + + public function test_passes_with_valid_permissions(): void + { + $this->cache->shouldReceive('remember') + ->with("fortress:auth:permissions:1", 3600, Mockery::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); + + $this->permissionAdapter->shouldReceive('hasAnyPermission')->with(['create-post'])->andReturn(true); + + $route = Mockery::mock(Route::class); + $route->shouldReceive('getAction') + ->with('fortress') + ->andReturn([ + 'public' => false, + 'permissions' => ['create-post'], + ]); + + $this->request->shouldReceive('route')->andReturn($route); + $this->request->shouldReceive('user')->andReturn($this->user); + $this->request->shouldReceive('expectsJson')->andReturn(false); + + $response = $this->middleware->handle($this->request, function ($request) { + return response('OK'); + }); + + $this->assertEquals('OK', $response->getContent()); + } + + public function test_throws_unauthorized_exception_for_invalid_ownership(): void + { + $model = new class { + public int $user_id = 2; + }; + + app()->instance('TestModel', $model); + + $route = Mockery::mock(Route::class); + $route->shouldReceive('getAction') + ->with('fortress') + ->andReturn([ + 'public' => false, + 'owner' => 'TestModel', + ]); + + $this->request->shouldReceive('route')->andReturn($route); + $this->request->shouldReceive('user')->andReturn($this->user); + $this->request->shouldReceive('expectsJson')->andReturn(false); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('User does not own this resource'); + + $this->middleware->handle($this->request, function ($request) { + return response('OK'); + }); + } + + public function test_passes_with_valid_ownership(): void + { + $model = new class { + public int $user_id = 1; + }; + + app()->instance('TestModel', $model); + + $route = Mockery::mock(Route::class); + $route->shouldReceive('getAction') + ->with('fortress') + ->andReturn([ + 'public' => false, + 'owner' => 'TestModel', + ]); + + $this->request->shouldReceive('route')->andReturn($route); + $this->request->shouldReceive('user')->andReturn($this->user); + $this->request->shouldReceive('expectsJson')->andReturn(false); + + $response = $this->middleware->handle($this->request, function ($request) { + return response('OK'); + }); + + $this->assertEquals('OK', $response->getContent()); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/tests/Models/Permission.php b/tests/Models/Permission.php new file mode 100644 index 0000000..0e77723 --- /dev/null +++ b/tests/Models/Permission.php @@ -0,0 +1,55 @@ +where('guard_name', $guardName)->first(); + + if (! $permission) { + throw PermissionDoesNotExist::create($name, $guardName); + } + + return $permission; + } + + public static function findById(int $id, $guardName = null): PermissionContract + { + $guardName = $guardName ?? config('auth.defaults.guard'); + $permission = static::where('id', $id)->where('guard_name', $guardName)->first(); + + if (! $permission) { + throw PermissionDoesNotExist::withId($id, $guardName); + } + + return $permission; + } + + public static function findOrCreate(string $name, $guardName = null): PermissionContract + { + $guardName = $guardName ?? config('auth.defaults.guard'); + $permission = static::where('name', $name)->where('guard_name', $guardName)->first(); + + if (! $permission) { + return static::create(['name' => $name, 'guard_name' => $guardName]); + } + + return $permission; + } +} \ No newline at end of file diff --git a/tests/Models/Role.php b/tests/Models/Role.php new file mode 100644 index 0000000..a3edbb7 --- /dev/null +++ b/tests/Models/Role.php @@ -0,0 +1,55 @@ +where('guard_name', $guardName)->first(); + + if (! $role) { + throw RoleDoesNotExist::create($name, $guardName); + } + + return $role; + } + + public static function findById(int $id, $guardName = null): RoleContract + { + $guardName = $guardName ?? config('auth.defaults.guard'); + $role = static::where('id', $id)->where('guard_name', $guardName)->first(); + + if (! $role) { + throw RoleDoesNotExist::withId($id, $guardName); + } + + return $role; + } + + public static function findOrCreate(string $name, $guardName = null): RoleContract + { + $guardName = $guardName ?? config('auth.defaults.guard'); + $role = static::where('name', $name)->where('guard_name', $guardName)->first(); + + if (! $role) { + return static::create(['name' => $name, 'guard_name' => $guardName]); + } + + return $role; + } +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index d2d12e6..81010f6 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,34 +4,39 @@ namespace Tests; -use Orchestra\Testbench\TestCase as BaseTestCase; +use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Illuminate\Support\Facades\Config; +use Orchestra\Testbench\TestCase as OrchestraTestCase; -abstract class TestCase extends BaseTestCase +abstract class TestCase extends OrchestraTestCase { - /** - * Get package providers for testbench. - * - * @param \Illuminate\Foundation\Application $app - * @return array - */ + protected function setUp(): void + { + parent::setUp(); + + // Load test configuration + Config::set('permission', require __DIR__ . '/config/permission.php'); + + // Run migrations + $this->loadMigrationsFrom(__DIR__ . '/database/migrations'); + } + protected function getPackageProviders($app) { return [ - // Register your package's service providers here + \Spatie\Permission\PermissionServiceProvider::class, \Laravelplus\Fortress\FortressServiceProvider::class, ]; } - /** - * Get package aliases for testbench. - * - * @param \Illuminate\Foundation\Application $app - * @return array - */ - protected function getPackageAliases($app) + protected function getEnvironmentSetUp($app) { - return [ - // Register any aliases required for your package - ]; + // Setup default database to use sqlite :memory: + $app['config']->set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); } } diff --git a/tests/Unit/FortressServiceProviderTest.php b/tests/Unit/FortressServiceProviderTest.php new file mode 100644 index 0000000..59c9e8f --- /dev/null +++ b/tests/Unit/FortressServiceProviderTest.php @@ -0,0 +1,73 @@ +provider = new FortressServiceProvider($this->app); + } + + public function test_service_provider_registers_fortress_singleton(): void + { + $this->provider->register(); + + $this->assertTrue($this->app->bound(Fortress::class)); + $this->assertInstanceOf(Fortress::class, $this->app->make(Fortress::class)); + } + + public function test_service_provider_injects_dependencies(): void + { + $this->provider->register(); + + $fortress = $this->app->make(Fortress::class); + + $this->assertInstanceOf(Config::class, $fortress->getConfig()); + $this->assertInstanceOf(Cache::class, $fortress->getCache()); + $this->assertInstanceOf(PermissionAdapter::class, $fortress->getPermissionAdapter()); + $this->assertInstanceOf(RoleAdapter::class, $fortress->getRoleAdapter()); + } + + public function test_service_provider_registers_default_adapters(): void + { + $this->provider->register(); + + $this->assertTrue($this->app->bound(PermissionAdapter::class)); + $this->assertTrue($this->app->bound(RoleAdapter::class)); + $this->assertInstanceOf(DefaultAdapter::class, $this->app->make(PermissionAdapter::class)); + $this->assertInstanceOf(DefaultAdapter::class, $this->app->make(RoleAdapter::class)); + } + + public function test_service_provider_publishes_configuration(): void + { + $this->provider->boot(); + + $this->assertTrue($this->app['config']->has('fortress')); + $this->assertEquals('user_id', $this->app['config']->get('fortress.default_owner_key')); + $this->assertEquals('author_id', $this->app['config']->get('fortress.default_override_key')); + } + + private function getPrivateProperty(object $object, string $property): mixed + { + $reflection = new \ReflectionClass($object); + $property = $reflection->getProperty($property); + $property->setAccessible(true); + return $property->getValue($object); + } +} \ No newline at end of file diff --git a/tests/Unit/FortressTest.php b/tests/Unit/FortressTest.php index 2ac6306..8c97fc1 100644 --- a/tests/Unit/FortressTest.php +++ b/tests/Unit/FortressTest.php @@ -4,94 +4,269 @@ namespace Tests\Unit; -use Illuminate\Support\Facades\Auth; +use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Contracts\Cache\Repository as Cache; +use Illuminate\Contracts\Config\Repository as Config; +use Laravelplus\Fortress\Adapters\DefaultAdapter; +use Laravelplus\Fortress\Contracts\PermissionAdapter; +use Laravelplus\Fortress\Contracts\RoleAdapter; +use Laravelplus\Fortress\Exceptions\AuthenticationException; +use Laravelplus\Fortress\Exceptions\InvalidConfigurationException; +use Laravelplus\Fortress\Exceptions\UnauthorizedException; use Laravelplus\Fortress\Fortress; use Mockery; use Tests\TestCase; final class FortressTest extends TestCase { - protected function tearDown(): void + private Config $config; + private Cache $cache; + private Authenticatable $user; + private PermissionAdapter $permissionAdapter; + private RoleAdapter $roleAdapter; + private Fortress $fortress; + + protected function setUp(): void { - Mockery::close(); - parent::tearDown(); + parent::setUp(); + + $this->config = Mockery::mock(Config::class); + $this->cache = Mockery::mock(Cache::class); + $this->user = Mockery::mock(Authenticatable::class); + $this->permissionAdapter = Mockery::mock(PermissionAdapter::class); + $this->roleAdapter = Mockery::mock(RoleAdapter::class); + + $this->user->shouldReceive('getAuthIdentifier')->andReturn(1); + $this->user->id = 1; + + $this->config->shouldReceive('get')->with('fortress.ownership_key', 'user_id')->andReturn('user_id'); + $this->config->shouldReceive('get')->with('fortress.cache.ttl', 3600)->andReturn(3600); + + $this->user->shouldReceive('hasAnyRole')->byDefault()->andReturn(false); + $this->user->shouldReceive('hasPermissionTo')->byDefault()->andReturn(false); + + $this->fortress = new Fortress($this->config, $this->cache); + $this->fortress->setPermissionAdapter($this->permissionAdapter); + $this->fortress->setRoleAdapter($this->roleAdapter); + } + + public function test_authorize_throws_authentication_exception_for_unauthenticated_user(): void + { + $this->expectException(AuthenticationException::class); + + $this->fortress->authorize(null); } - public function test_unauthenticated_user(): void + public function test_authorize_passes_public_routes(): void { - Auth::shouldReceive('user')->andReturn(null); + $this->fortress->authorize($this->user, public: true); - $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); - $this->expectExceptionMessage('User not authenticated.'); + $this->assertTrue(true); // No exception thrown + } + + public function test_authorize_throws_unauthorized_exception_for_invalid_roles(): void + { + $this->expectException(UnauthorizedException::class); + + $this->cache->shouldReceive('remember') + ->once() + ->with('fortress:auth:roles:1', 3600, Mockery::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); + + $this->roleAdapter->shouldReceive('hasAnyRole')->with(['admin'])->andReturn(false); + + $this->fortress->authorize($this->user, roles: ['admin']); + } + + public function test_authorize_passes_with_valid_roles(): void + { + $this->cache->shouldReceive('remember') + ->once() + ->with('fortress:auth:roles:1', 3600, Mockery::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); - Fortress::authorize(); + $this->roleAdapter->shouldReceive('hasAnyRole')->with(['admin'])->andReturn(true); + + $this->fortress->authorize($this->user, roles: ['admin']); + + $this->assertTrue(true); // No exception thrown } - public function test_unauthorized_role(): void + public function test_authorize_throws_unauthorized_exception_for_invalid_permissions(): void { - $user = Mockery::mock(); - $user->shouldReceive('hasAnyRole')->with(['admin'])->andReturn(false); - Auth::shouldReceive('user')->andReturn($user); + $this->expectException(UnauthorizedException::class); + + $this->cache->shouldReceive('remember') + ->once() + ->with('fortress:auth:permissions:1', 3600, Mockery::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); - $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); - $this->expectExceptionMessage('Unauthorized role.'); + $this->permissionAdapter->shouldReceive('hasAnyPermission')->with(['create-post'])->andReturn(false); - Fortress::authorize(roles: ['admin']); + $this->fortress->authorize($this->user, permissions: 'create-post'); } - public function test_unauthorized_permission(): void + public function test_authorize_passes_with_valid_permissions(): void { - $user = Mockery::mock(); - $user->shouldReceive('hasAnyRole')->andReturn(true); // Assume role check passes - $user->shouldReceive('hasPermissionTo')->with('edit-posts')->andReturn(false); - Auth::shouldReceive('user')->andReturn($user); + $this->cache->shouldReceive('remember') + ->once() + ->with('fortress:auth:permissions:1', 3600, Mockery::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); - $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); - $this->expectExceptionMessage('Unauthorized permission.'); + $this->permissionAdapter->shouldReceive('hasAnyPermission')->with(['create-post'])->andReturn(true); - Fortress::authorize(permissions: 'edit-posts'); + $this->fortress->authorize($this->user, permissions: 'create-post'); + + $this->assertTrue(true); // No exception thrown } - public function test_unauthorized_ownership(): void + public function test_authorize_throws_unauthorized_exception_for_invalid_ownership(): void { - $user = Mockery::mock(); - $user->shouldReceive('hasAnyRole')->andReturn(true); // Assume role check passes - $user->shouldReceive('hasPermissionTo')->andReturn(true); // Assume permission check passes - $user->id = 1; + $this->expectException(UnauthorizedException::class); + + $model = new class { + public int $user_id = 2; + }; + + app()->instance('TestModel', $model); - $ownerMock = Mockery::mock(); - $ownerMock->author_id = 2; // Simulate the mismatch key + $this->fortress->authorize($this->user, owner: 'TestModel'); + } + + public function test_authorize_passes_with_valid_ownership(): void + { + $model = new class { + public int $user_id = 1; + }; - Auth::shouldReceive('user')->andReturn($user); - app()->instance('App\\Models\\Post', $ownerMock); + app()->instance('TestModel', $model); - $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); - $this->expectExceptionMessage('Unauthorized ownership.'); + $this->fortress->authorize($this->user, owner: 'TestModel'); - Fortress::authorize( - owner: 'App\\Models\\Post' - ); + $this->assertTrue(true); // No exception thrown } - public function test_authorization_success(): void + public function test_authorize_throws_invalid_configuration_exception_for_invalid_owner_key(): void { - $user = Mockery::mock(); - $user->shouldReceive('hasAnyRole')->with(['admin'])->andReturn(true); - $user->shouldReceive('hasPermissionTo')->with('edit-posts')->andReturn(true); - $user->id = 1; + $this->expectException(InvalidConfigurationException::class); - $ownerMock = Mockery::mock(); - $ownerMock->author_id = 1; // Ensure the key matches the user ID + $model = new class { + public int $user_id = 1; + }; - Auth::shouldReceive('user')->andReturn($user); - app()->instance('App\\Models\\Post', $ownerMock); + app()->instance('TestModel', $model); - $result = Fortress::authorize( - roles: ['admin'], - permissions: 'edit-posts', - owner: 'App\\Models\\Post' - ); + $this->fortress->authorize($this->user, owner: 'TestModel', overrideKey: 'invalid_key'); + } + + public function test_authorize_checks_roles_with_caching(): void + { + $roles = ['admin']; + + $this->cache->shouldReceive('remember') + ->once() + ->with('fortress:auth:roles:1', 3600, Mockery::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); + + $this->roleAdapter->shouldReceive('hasAnyRole') + ->once() + ->with($roles) + ->andReturn(true); + + $result = $this->fortress->authorize($this->user, roles: $roles); + $this->assertTrue($result); + } + public function test_authorize_checks_permissions_with_caching(): void + { + $permissions = 'create-post'; + + $this->cache->shouldReceive('remember') + ->once() + ->with('fortress:auth:permissions:1', 3600, Mockery::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); + + $this->permissionAdapter->shouldReceive('hasAnyPermission') + ->once() + ->with([$permissions]) + ->andReturn(true); + + $result = $this->fortress->authorize($this->user, permissions: $permissions); $this->assertTrue($result); } + + public function test_batch_authorize_handles_multiple_resources(): void + { + $resources = [ + ['id' => 1, 'roles' => ['admin']], + ['id' => 2, 'permissions' => 'create-post'], + ['id' => 3, 'owner' => 'TestModel', 'overrideKey' => 'user_id'], + ]; + + $owner = new class { + public int $user_id = 1; + }; + app()->instance('TestModel', $owner); + + $this->cache->shouldReceive('remember') + ->times(2) + ->with(Mockery::pattern('/^fortress:auth:(roles|permissions):1$/'), 3600, Mockery::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); + + $this->roleAdapter->shouldReceive('hasAnyRole')->with(['admin'])->andReturn(true); + $this->permissionAdapter->shouldReceive('hasAnyPermission')->with(['create-post'])->andReturn(true); + + $results = $this->fortress->batchAuthorize($this->user, $resources); + + $this->assertEquals([ + 1 => true, + 2 => true, + 3 => true, + ], $results); + } + + public function test_batch_authorize_handles_failures(): void + { + $resources = [ + ['id' => 1, 'roles' => ['admin']], + ['id' => 2, 'permissions' => 'create-post'], + ]; + + $this->cache->shouldReceive('remember') + ->times(2) + ->with(Mockery::pattern('/^fortress:auth:(roles|permissions):1$/'), 3600, Mockery::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); + + $this->roleAdapter->shouldReceive('hasAnyRole')->with(['admin'])->andReturn(false); + $this->permissionAdapter->shouldReceive('hasAnyPermission')->with(['create-post'])->andReturn(false); + + $results = $this->fortress->batchAuthorize($this->user, $resources); + + $this->assertEquals([ + 1 => false, + 2 => false, + ], $results); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } } diff --git a/tests/config/permission.php b/tests/config/permission.php new file mode 100644 index 0000000..56743d5 --- /dev/null +++ b/tests/config/permission.php @@ -0,0 +1,28 @@ + [ + 'permission' => Tests\Models\Permission::class, + 'role' => Tests\Models\Role::class, + ], + + 'table_names' => [ + 'roles' => 'roles', + 'permissions' => 'permissions', + 'model_has_permissions' => 'model_has_permissions', + 'model_has_roles' => 'model_has_roles', + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + 'model_morph_key' => 'model_id', + ], + + 'display_permission_in_exception' => false, + + 'cache' => [ + 'expiration_time' => \DateInterval::createFromDateString('24 hours'), + 'key' => 'spatie.permission.cache', + 'store' => 'default', + ], +]; \ No newline at end of file diff --git a/tests/database/migrations/2024_03_21_000000_create_permission_tables.php b/tests/database/migrations/2024_03_21_000000_create_permission_tables.php new file mode 100644 index 0000000..014c365 --- /dev/null +++ b/tests/database/migrations/2024_03_21_000000_create_permission_tables.php @@ -0,0 +1,90 @@ +bigIncrements('id'); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + }); + + Schema::create($tableNames['roles'], function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + }); + + Schema::create($tableNames['model_has_permissions'], function (Blueprint $table) use ($tableNames, $columnNames) { + $table->unsignedBigInteger('permission_id'); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign('permission_id') + ->references('id') + ->on($tableNames['permissions']) + ->onDelete('cascade'); + + $table->primary(['permission_id', $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + }); + + Schema::create($tableNames['model_has_roles'], function (Blueprint $table) use ($tableNames, $columnNames) { + $table->unsignedBigInteger('role_id'); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign('role_id') + ->references('id') + ->on($tableNames['roles']) + ->onDelete('cascade'); + + $table->primary(['role_id', $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + }); + + Schema::create($tableNames['role_has_permissions'], function (Blueprint $table) use ($tableNames) { + $table->unsignedBigInteger('permission_id'); + $table->unsignedBigInteger('role_id'); + + $table->foreign('permission_id') + ->references('id') + ->on($tableNames['permissions']) + ->onDelete('cascade'); + + $table->foreign('role_id') + ->references('id') + ->on($tableNames['roles']) + ->onDelete('cascade'); + + $table->primary(['permission_id', 'role_id'], 'role_has_permissions_permission_id_role_id_primary'); + }); + } + + public function down(): void + { + $tableNames = config('permission.table_names'); + + Schema::drop($tableNames['role_has_permissions']); + Schema::drop($tableNames['model_has_roles']); + Schema::drop($tableNames['model_has_permissions']); + Schema::drop($tableNames['roles']); + Schema::drop($tableNames['permissions']); + } +}; \ No newline at end of file