diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..76faa73 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,23 @@ +# Base image is from Microsoft +FROM mcr.microsoft.com/vscode/devcontainers/base:ubuntu-22.04 + +# Avoid interactive prompts (e.g. tzdata) during installation +ENV DEBIAN_FRONTEND=noninteractive + +RUN mkdir -p /var/www/html + +# Install 8.3 from Ondrej’s PPA +RUN apt-get update && apt-get install -y \ + software-properties-common \ + && add-apt-repository ppa:ondrej/php -y \ + && apt-get update && apt-get install -y sqlite3 \ + && apt-get install -y php8.3-cli php8.3-dev \ + php8.3-pgsql php8.3-sqlite3 php8.3-gd \ + php8.3-curl \ + php8.3-imap php8.3-mysql php8.3-mbstring \ + php8.3-xml php8.3-zip php8.3-bcmath php8.3-soap \ + php8.3-intl php8.3-readline \ + php8.3-ldap \ + php8.3-msgpack php8.3-igbinary php8.3-redis php8.3-swoole \ + php8.3-memcached php8.3-pcov php8.3-imagick php8.3-xdebug \ + && apt-get clean && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/devcontainer.bash b/.devcontainer/devcontainer.bash new file mode 100644 index 0000000..5d8fe63 --- /dev/null +++ b/.devcontainer/devcontainer.bash @@ -0,0 +1,46 @@ +alias art='php artisan --ansi' +alias tinker='art tinker' +alias format='php vendor/bin/pint' +alias analyze='php vendor/bin/phpstan analyse' +alias test='php vendor/bin/paratest --coverage-html coverage' +alias stf='php vendor/bin/phpunit --filter' + +# commit AI +function commit() { + commitMessage="$*" + + git add . + + if [ "$commitMessage" = "" ]; then + aicommits + return + fi + + eval "git commit -a -m '${commitMessage}'" +} + +# function gfind +function gfind() { + local excludeVendor="--exclude-dir=vendor" # Default to excluding the vendor directory + local searchString="" + local searchPath="./" + + # Process all arguments + for arg in "$@"; do + if [[ "$arg" == "-w" || "$arg" == "--with-vendor" ]]; then + excludeVendor="" # Remove the exclude directive to include vendor + elif [[ -z "$searchString" && "$arg" != -* ]]; then + searchString="$arg" # Set the search string if it's not a flag and is the first non-flag argument + fi + done + + # Check if a search string was provided + if [[ -z "$searchString" ]]; then + echo -e "${RED}Error: Missing required search string.${NC}" + echo -e "${YELLOW}Usage: ${NC}gfind searchString [-w|--with-vendor]" + return 1 + fi + + # Execute grep command + grep --include=\*.php $excludeVendor -rnw $searchPath -e "$searchString" +} \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..6c97125 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,27 @@ +{ + "name": "Laravel RBAC", + "build": { + "dockerfile": "Dockerfile", + "context": "." + }, + "features": {}, + "customizations": { + "vscode": { + "extensions": [ + "bmewburn.vscode-intelephense-client", + "calebporzio.better-phpunit", + "laravel.vscode-laravel", + "mikestead.dotenv", + "ms-azuretools.vscode-docker", + "php.intelephense" + ], + "settings": { + "intelephense.environment.phpVersion": "8.3" + } + } + }, + "remoteUser": "vscode", + "postCreateCommand": "", + "forwardPorts": [], + "portsAttributes": {} + } diff --git a/.gitignore b/.gitignore index 7e3540c..d8fb497 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.cursor .idea .php-cs-fixer.cache .phpunit.cache @@ -5,8 +6,10 @@ build composer.lock coverage docs +vendor +workbench +node_modules phpunit.xml phpstan.neon +repomix-output.* testbench.yaml -vendor -node_modules diff --git a/README.md b/README.md index f9aff89..c23f74d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![run-tests](https://img.shields.io/github/actions/workflow/status/binary-cats/laravel-rbac/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/binary-cats/laravel-rbac/actions/workflows/run-tests.yml) [![GitHub Code Style Action Status](https://github.styleci.io/repos/773171043/shield?branch=main)](https://github.com/binary-cats/laravel-rbac/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) -Enhance Laravel 11 with opinionated extension for [spatie/laravel-permissions](https://spatie.be/docs/laravel-permission/v6/introduction). +Enhance your Laravel with opinionated extension for [spatie/laravel-permissions](https://spatie.be/docs/laravel-permission/v6/introduction). Before your permission list grows and maintenance becomes an issue, this package offers simple way of defining roles and their permissions. ## Installation diff --git a/composer.json b/composer.json index a5551a4..5de57a0 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,6 @@ "php": "^8.2", "illuminate/contracts": "^11.0|^12.0", "lorisleiva/laravel-actions": "^2.8", - "spatie/laravel-collection-macros": "^7.0|^8.0", "spatie/laravel-package-tools": "^1.16", "spatie/laravel-permission": "^6.4" }, @@ -36,7 +35,10 @@ }, "autoload-dev": { "psr-4": { - "BinaryCats\\LaravelRbac\\Tests\\": "tests/" + "BinaryCats\\LaravelRbac\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" } }, "suggest": { @@ -44,7 +46,23 @@ "binary-cats/laravel-sku": "Generate SKUs for Eloquent models" }, "scripts": { - "test": "vendor/bin/phpunit" + "test": "vendor/bin/phpunit", + "post-autoload-dump": [ + "@clear", + "@prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": "@php vendor/bin/testbench workbench:build --ansi", + "serve": [ + "Composer\\Config::disableProcessTimeout", + "@build", + "@php vendor/bin/testbench serve" + ], + "lint": [ + "@php vendor/bin/pint", + "@php vendor/bin/phpstan analyse" + ] }, "config": { "sort-packages": true @@ -57,11 +75,11 @@ "aliases": { "Rbac": "BinaryCats\\LaravelRbac\\Facades\\Rbac" } - }, + }, "branch-alias": { "dev-master": "1.x-dev" - } + } }, "minimum-stability": "dev", "prefer-stable": true -} +} \ No newline at end of file diff --git a/src/Actions/StorePermission.php b/src/Actions/StorePermission.php index f8acb49..cce54b8 100644 --- a/src/Actions/StorePermission.php +++ b/src/Actions/StorePermission.php @@ -3,22 +3,25 @@ namespace BinaryCats\LaravelRbac\Actions; use BackedEnum; -use Illuminate\Support\Facades\Artisan; use Lorisleiva\Actions\Action; +use Spatie\Permission\Contracts\Permission; class StorePermission extends Action { + public function __construct( + protected readonly Permission $permission + ) { + } + /** - * @param \BackedEnum $permission - * @param string $guard - * - * @return void + * Handle storing a permission. */ - public function handle(BackedEnum $permission, string $guard): void + public function handle(BackedEnum|string $permission, string $guard): void { - Artisan::call('permission:create-permission', [ - 'name' => $permission->value, - 'guard' => $guard, - ]); + if ($permission instanceof BackedEnum) { + $permission = $permission->value; + } + + $this->permission::findOrCreate($permission, $guard); } } diff --git a/src/Actions/SyncDefinedRole.php b/src/Actions/SyncDefinedRole.php index af045f9..2278aee 100644 --- a/src/Actions/SyncDefinedRole.php +++ b/src/Actions/SyncDefinedRole.php @@ -3,13 +3,18 @@ namespace BinaryCats\LaravelRbac\Actions; use BackedEnum; -use Illuminate\Support\Facades\Artisan; use Lorisleiva\Actions\Action; +use Spatie\Permission\Contracts\Role; class SyncDefinedRole extends Action { + public function __construct( + protected readonly Role $role + ) { + } + /** - * @return void + * Handle syncing a defined role. */ public function handle(string $name, string $guard, array $permissions): void { @@ -17,12 +22,9 @@ public function handle(string $name, string $guard, array $permissions): void ->map(fn ($permission) => match (true) { $permission instanceof BackedEnum => $permission->value, default => (string) $permission - })->implode('|'); + }); - Artisan::call('permission:create-role', [ - 'name' => $name, - 'guard' => $guard, - 'permissions' => $permissions, - ]); + $this->role::findOrCreate($name, $guard) + ->syncPermissions($permissions); } } diff --git a/src/Commands/RbacResetCommand.php b/src/Commands/RbacResetCommand.php index e9f2393..31c67f9 100644 --- a/src/Commands/RbacResetCommand.php +++ b/src/Commands/RbacResetCommand.php @@ -24,45 +24,45 @@ class RbacResetCommand extends Command /** * Execute the console command. - * - * @return int */ - public function handle() + public function handle(): int { - if ($this->databaseReady()) { - $this->withProgressBar($this->jobs(), fn ($job) => $job->dispatch()); - $this->newLine(); - $this->info('RBAC Reset Complete'); - } else { - $this->error('DB is not ready'); + if (!$this->databaseReady()) { + $this->error('DB is not ready. Please run migrations.'); + + return self::INVALID; } + $this->jobs()->each(function (string $job) { + $this->components->task( + $job, + fn () => $this->laravel->make($job)->dispatchSync() + ); + }); + return self::SUCCESS; } /** * Create the jobs. - * - * @return \Illuminate\Support\Collection */ protected function jobs(): Collection { $value = config('rbac.jobs'); - return collect($value) - ->map(fn ($job) => app()->make($job)); + return collect($value); } /** * True if the Database is prepared. - * - * @return bool */ protected function databaseReady(): bool { - $value = config('permission.table_names'); + $tables = config('permission.table_names', []); - return collect($value) - ->validate(fn ($table) => Schema::hasTable($table)); + return collect($tables) + ->map(fn (string $table) => Schema::hasTable($table)) + ->reject() + ->isEmpty(); } } diff --git a/src/Jobs/ResetPermissions.php b/src/Jobs/ResetPermissions.php index 9c8c5ae..0d53b52 100644 --- a/src/Jobs/ResetPermissions.php +++ b/src/Jobs/ResetPermissions.php @@ -23,7 +23,7 @@ class ResetPermissions /** * @param string|null $guard */ - public function __construct(string $guard = null) + public function __construct(?string $guard = null) { $this->guard = $guard ?? config('auth.defaults.guard'); } diff --git a/tests/Actions/StorePermissionTest.php b/tests/Actions/StorePermissionTest.php index 039c33e..6388d91 100644 --- a/tests/Actions/StorePermissionTest.php +++ b/tests/Actions/StorePermissionTest.php @@ -5,7 +5,6 @@ use BinaryCats\LaravelRbac\Actions\StorePermission; use BinaryCats\LaravelRbac\Tests\Fixtures\Abilities\FooAbility; use BinaryCats\LaravelRbac\Tests\TestCase; -use Illuminate\Support\Facades\Artisan; use PHPUnit\Framework\Attributes\Test; class StorePermissionTest extends TestCase @@ -13,16 +12,11 @@ class StorePermissionTest extends TestCase #[Test] public function it_will_handle_creating_permission(): void { - Artisan::expects('call') - ->once() - ->with( - 'permission:create-permission', - [ - 'name' => 'una', - 'guard' => 'web', - ] - ); - StorePermission::run(FooAbility::One, 'web'); + + $this->assertDatabaseHas(config('permission.table_names.permissions'), [ + 'name' => 'una', + 'guard_name' => 'web', + ]); } } diff --git a/tests/Actions/SyncDefinedRoleTest.php b/tests/Actions/SyncDefinedRoleTest.php index 100f752..316280e 100644 --- a/tests/Actions/SyncDefinedRoleTest.php +++ b/tests/Actions/SyncDefinedRoleTest.php @@ -2,31 +2,40 @@ namespace BinaryCats\LaravelRbac\Tests\Actions; +use BinaryCats\LaravelRbac\Actions\StorePermission; use BinaryCats\LaravelRbac\Actions\SyncDefinedRole; use BinaryCats\LaravelRbac\Tests\Fixtures\Abilities\FooAbility; use BinaryCats\LaravelRbac\Tests\TestCase; -use Illuminate\Support\Facades\Artisan; use PHPUnit\Framework\Attributes\Test; +use Spatie\Permission\Exceptions\PermissionDoesNotExist; class SyncDefinedRoleTest extends TestCase { #[Test] public function it_will_defer_syncing_defined_role_to_artisan(): void { - Artisan::expects('call') - ->once() - ->with( - 'permission:create-role', - [ - 'name' => 'foo role', - 'guard' => 'web', - 'permissions' => 'bar|una', - ] - ); + StorePermission::run('bar', 'web'); + StorePermission::run(FooAbility::One, 'web'); SyncDefinedRole::run('foo role', 'web', [ 'bar', FooAbility::One, ]); + + $this->assertDatabaseHas(config('permission.table_names.roles'), [ + 'name' => 'foo role', + 'guard_name' => 'web', + ]); + } + + #[Test] + public function it_will_throw_an_exception_on_missing_permission(): void + { + $this->expectException(PermissionDoesNotExist::class); + $this->expectExceptionMessage('There is no permission named `bar` for guard `web`'); + + SyncDefinedRole::run('foo role', 'web', [ + 'bar', + ]); } } diff --git a/tests/Commands/RbacResetCommandTest.php b/tests/Commands/RbacResetCommandTest.php index 18beef0..91aed0a 100644 --- a/tests/Commands/RbacResetCommandTest.php +++ b/tests/Commands/RbacResetCommandTest.php @@ -2,49 +2,45 @@ namespace BinaryCats\LaravelRbac\Tests\Commands; -use BinaryCats\LaravelRbac\Commands\RbacResetCommand; use BinaryCats\LaravelRbac\Tests\Fixtures\RbacResetJob; use BinaryCats\LaravelRbac\Tests\TestCase; -use Illuminate\Support\Facades\Artisan; +use Illuminate\Console\Command; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Schema; -use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\Test; class RbacResetCommandTest extends TestCase { - #[Before] - protected function setUp(): void + #[Test] + public function it_will_dispatch_all_configured_commands(): void { - parent::setUp(); - Bus::fake(); - } - #[Test] - public function it_will_dispatch_all_configured_commands() - { $this->app['config']->set([ 'rbac.jobs' => [RbacResetJob::class], ]); - Artisan::call(RbacResetCommand::class); + $this->artisan('rbac:reset') + ->assertSuccessful(); Bus::assertDispatched(RbacResetJob::class); } #[Test] - public function it_will_not_dispatch_if_not_migrated() + public function it_will_not_dispatch_if_not_migrated(): void { + Bus::fake(); + $this->app['config']->set([ - 'permission.table_names.permissions' => 'foo', + 'permission.table_names' => ['permissions' => 'foo'], ]); Schema::expects('hasTable') ->with('foo') ->andReturnFalse(); - Artisan::call(RbacResetCommand::class); + $this->artisan('rbac:reset') + ->assertExitCode(Command::INVALID); Bus::assertNotDispatched(RbacResetJob::class); } diff --git a/tests/TestCase.php b/tests/TestCase.php index 0d03c1f..6c53892 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,44 +3,38 @@ namespace BinaryCats\LaravelRbac\Tests; use BinaryCats\LaravelRbac\RbacServiceProvider; +use Illuminate\Contracts\Config\Repository; use Orchestra\Testbench\TestCase as Orchestra; -use Spatie\CollectionMacros\CollectionMacroServiceProvider; use Spatie\Permission\PermissionServiceProvider; class TestCase extends Orchestra { - protected function getPackageProviders($app) + /** + * Get the package providers fopr registrations. + * + * @param \Illuminate\Foundation\Application $app + */ + protected function getPackageProviders($app): array { return [ - CollectionMacroServiceProvider::class, PermissionServiceProvider::class, RbacServiceProvider::class, ]; } /** - * Resolve application Console Kernel implementation. - * - * @param \Illuminate\Foundation\Application $app - * - * @return void + * Define the environment. */ - protected function resolveApplicationConsoleKernel($app) - { - $app->singleton( - 'Illuminate\Contracts\Console\Kernel', - 'Illuminate\Foundation\Console\Kernel' - ); - } - - public function getEnvironmentSetUp($app) + protected function defineEnvironment($app): void { - config()->set('database.default', 'sqlite'); - config()->set('database.connections.sqlite', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); + tap($app['config'], function (Repository $config) { + $config->set('database.default', 'sqlite'); + $config->set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + }); $migration = include __DIR__.'/../vendor/spatie/laravel-permission/database/migrations/create_permission_tables.php.stub'; $migration->up();