From 3263e3b2948fa21d882bab9f43c2a55761a0daa6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:04:40 +0000 Subject: [PATCH 01/18] Reconcile Permission command, guard and integration tests with upstream Bring the Permission command, guard, provider and integration tests in line with spatie/laravel-permission main at 6615eefac655 (8.x): upstream case names, assertions, comments and file placement, with Gate and CustomGate tests moved under Integration/. Stronger Hypervel coverage is kept, including the zero team id, guard named "0" and non-HasRoles model cases. The test base no longer forces the teams, cache and model settings, so method-level environment attributes apply, and CACHE_STORE selects the cache store like upstream's CACHE_DRIVER. A database store migrates its cache tables first, and lock pruning is disabled because it adds a random query to counted tests. Fixes found along the way: - set{Permission,Role,Team}Class() now write the config like upstream. initializeCache() re-reads it, so reinitializing reverted the class while the container binding kept the new one. - The registrar resolves the cache manager when initializing the cache instead of keeping the one it was built with, so a rebound cache manager takes effect (upstream #2973). - Required settings that always ship in the config lose their code fallbacks; the optional cache settings keep one documented default, with a constant for the expiration. - The about command drops an always-present "Denied Permissions" entry that made the "Default" label unreachable. - permission:setup-teams uses now(), and two Closure::fromCallable() static-analysis workarounds become direct calls. Hypervel adaptations: an undefined permission cache store throws instead of silently falling back to the array store; setup-teams returns failure when the migration cannot be written; upstream's chmod-based failure case uses an unwritable destination because CI runs as root; Octane listener tests are not ported because team ids and loaded catalogs are coroutine-local. Validation: the Permission suite passes on the array store, these files pass on the database store, the Postgres Permission tests pass, and formatting and static analysis are clean. --- docs/upstream-sync/sync.yaml | 1 + src/docs/permission.md | 2 +- src/permission/config/permission.php | 10 +- .../src/Commands/AssignRoleCommand.php | 4 +- .../src/Commands/UpgradeForTeamsCommand.php | 2 +- src/permission/src/PermissionRegistrar.php | 23 +- .../src/PermissionServiceProvider.php | 12 +- src/permission/src/Support/Config.php | 10 +- tests/Permission/Commands/CommandTest.php | 290 ++++++++++++++---- tests/Permission/Commands/TeamCommandTest.php | 144 ++------- tests/Permission/CoroutineIsolationTest.php | 3 + tests/Permission/GuardTest.php | 28 +- tests/Permission/Integration/BladeTest.php | 51 ++- tests/Permission/Integration/CacheTest.php | 57 +++- .../{ => Integration}/CustomGateTest.php | 9 +- .../Permission/{ => Integration}/GateTest.php | 52 ++-- .../Integration/MultipleGuardsTest.php | 27 +- .../Integration/PermissionRegistrarTest.php | 149 ++++++++- tests/Permission/Integration/PolicyTest.php | 3 +- .../Integration/WildcardRouteTest.php | 8 +- .../PermissionServiceProviderTest.php | 5 +- tests/Permission/Support/ConfigTest.php | 16 +- tests/Permission/TestCase.php | 73 +++-- 23 files changed, 628 insertions(+), 351 deletions(-) rename tests/Permission/{ => Integration}/CustomGateTest.php (70%) rename tests/Permission/{ => Integration}/GateTest.php (59%) diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index ab1a137fab..1b0d258563 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -163,6 +163,7 @@ spatie/laravel-permission: checked_through: null last_reviewed_pr: null sync_date: null + notes: Upstream Pest files map to PHPUnit classes at the same paths under tests/Permission, with each it()/test() description as the method name. Upstream's permission.testing migration flag, which adds the roles team column while teams are off so cases can enable teams mid-test, is not ported; those cases enable teams before migrating with #[DefineEnvironment('usesTeams')]. The CACHE_DRIVER cache-driver test runs map to CACHE_STORE. Passport::actingAsClient() maps to TestCase::actingAsClient() with the PassportGuard fixture because Hypervel has no Passport package. Octane listeners and register_octane_reset_listener are not ported because team IDs and loaded catalogs are coroutine-local. aimeos/laravel-nestedset: branch: master diff --git a/src/docs/permission.md b/src/docs/permission.md index cd0549f466..e9c6120183 100644 --- a/src/docs/permission.md +++ b/src/docs/permission.md @@ -188,7 +188,7 @@ return [ ]; ``` -When `store` is omitted or set to `default`, the application's default cache store is used. The expiration defaults to 24 hours when omitted. Separate keys isolate the permission catalog, model-role assignments, direct model permissions, and the assignment namespace token so mutations can invalidate only the affected data. Omitted key members use the package names shown in the example. The `column_names_except` list removes unneeded model attributes from the cached catalog; required identity, guard, team, and partition columns cannot be excluded. +When `store` is omitted or set to `default`, the application's default cache store is used. A store that isn't defined in your cache configuration throws an exception. The expiration defaults to 24 hours when omitted. Separate keys isolate the permission catalog, model-role assignments, direct model permissions, and the assignment namespace token so mutations can invalidate only the affected data. Omitted key members use the package names shown in the example. The `column_names_except` list removes unneeded model attributes from the cached catalog; required identity, guard, team, and partition columns cannot be excluded. You may include required role or permission names in authorization exception messages: diff --git a/src/permission/config/permission.php b/src/permission/config/permission.php index 66245df567..bcd32b10f2 100644 --- a/src/permission/config/permission.php +++ b/src/permission/config/permission.php @@ -79,7 +79,7 @@ | and permission attached and detached events. Events are only constructed | when a listener is registered for the corresponding event class. | - */ + */ 'register_permission_check_method' => true, @@ -93,7 +93,7 @@ | Teams scope roles and assignments by the configured team foreign key. | A custom resolver must implement the PermissionsTeamResolver contract. | - */ + */ 'teams' => false, @@ -112,7 +112,7 @@ | These options expose required role or permission names in authorization | exception messages. Leave them disabled when those names are sensitive. | - */ + */ 'display_permission_in_exception' => false, @@ -126,7 +126,7 @@ | Wildcard matching is disabled by default. A custom parser must implement | the Hypervel\Permission\Contracts\Wildcard contract. | - */ + */ 'enable_wildcard_permission' => false, @@ -139,7 +139,7 @@ | | Permission data is cached for 24 hours by default. The named cache keys | separate catalog and assignment data so each can be invalidated precisely. - | Omitted key members use the package names shown below. + | Omitted settings and key members use the defaults shown below. | Column exclusions reduce the serialized catalog without hiding required | model, partition, or team columns. | diff --git a/src/permission/src/Commands/AssignRoleCommand.php b/src/permission/src/Commands/AssignRoleCommand.php index 6a6de1d804..b624027f03 100644 --- a/src/permission/src/Commands/AssignRoleCommand.php +++ b/src/permission/src/Commands/AssignRoleCommand.php @@ -4,7 +4,6 @@ namespace Hypervel\Permission\Commands; -use Closure; use Hypervel\Console\Command; use Hypervel\Database\Eloquent\Model; use Hypervel\Permission\PermissionRegistrar; @@ -74,8 +73,7 @@ public function handle(PermissionRegistrar $permissionRegistrar): int try { $role = $roleClass::findOrCreate($roleName, is_string($guardName) ? $guardName : null); - $assignRole = Closure::fromCallable([$user, 'assignRole']); - $assignRole($role); + $user->assignRole($role); } finally { setPermissionsTeamId($teamIdAux); } diff --git a/src/permission/src/Commands/UpgradeForTeamsCommand.php b/src/permission/src/Commands/UpgradeForTeamsCommand.php index 6a4fa4dfdc..9b60a732ee 100644 --- a/src/permission/src/Commands/UpgradeForTeamsCommand.php +++ b/src/permission/src/Commands/UpgradeForTeamsCommand.php @@ -120,7 +120,7 @@ protected function alreadyExistingMigrations(): array */ protected function getMigrationPath(?string $date = null): string { - $date = $date ?: date('Y_m_d_His'); + $date = $date ?: now()->format('Y_m_d_His'); return database_path("migrations/{$date}_{$this->migrationSuffix}"); } diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php index fa28a9663a..69f59ba85d 100644 --- a/src/permission/src/PermissionRegistrar.php +++ b/src/permission/src/PermissionRegistrar.php @@ -53,6 +53,8 @@ class PermissionRegistrar public const string DEFAULT_TEAM_FOREIGN_KEY = 'team_id'; + public const int DEFAULT_CACHE_EXPIRATION_SECONDS = 86400; + public const array DEFAULT_CACHE_COLUMN_NAMES_EXCEPT = ['created_at', 'updated_at', 'deleted_at']; public const string ROLE_CATALOG_CACHE_KEY = 'hypervel.permission.cache.roles'; @@ -110,6 +112,8 @@ class PermissionRegistrar protected string $modelCacheTokenKey; + protected CacheManager $cacheManager; + protected ?string $cacheStoreName = null; /** @@ -126,7 +130,6 @@ class PermissionRegistrar * Create a new permission registrar. */ public function __construct( - protected CacheManager $cacheManager, protected ConfigRepository $config, protected Container $app, protected ModelCacheCoordinator $modelCacheCoordinator, @@ -277,14 +280,17 @@ public function initializeCache(): void /** @var null|class-string $teamClass */ $teamClass = $this->config->get('permission.models.team'); /** @var class-string $teamResolverClass */ - $teamResolverClass = $this->config->string('permission.team_resolver', DefaultTeamResolver::class); + $teamResolverClass = $this->config->string('permission.team_resolver'); $this->permissionClass = $permissionClass; $this->roleClass = $roleClass; $this->teamClass = $teamClass; $this->teamResolver = $this->app->make($teamResolverClass); - $this->cacheExpirationTime = $this->config->integer('permission.cache.expiration_seconds', 86400); + $this->cacheExpirationTime = $this->config->integer( + 'permission.cache.expiration_seconds', + self::DEFAULT_CACHE_EXPIRATION_SECONDS, + ); $this->teams = $this->config->boolean('permission.teams'); $this->teamsKey = $this->config->string( 'permission.column_names.team_foreign_key', @@ -315,6 +321,10 @@ public function initializeCache(): void ? $pivotPermission : self::DEFAULT_PERMISSION_PIVOT_KEY; + // Resolve the manager here rather than in the constructor, so reinitializing after the + // 'cache' binding is replaced doesn't keep using stores memoized by the previous manager. + $this->cacheManager = $this->app->make('cache'); + $cacheStore = $this->config->string('permission.cache.store', 'default'); $this->cacheStoreName = $cacheStore === 'default' ? null : $cacheStore; @@ -1293,10 +1303,8 @@ public function getWildcardPermissionIndex(Model $record): array return $indexes[$key]; } - $getWildcardClass = Closure::fromCallable([$record, 'getWildcardClass']); - /** @var array> $index */ - $index = $this->app->make($getWildcardClass(), ['record' => $record])->getIndex(); + $index = $this->app->make($record->getWildcardClass(), ['record' => $record])->getIndex(); // @phpstan-ignore method.notFound (the record uses HasPermissions) $indexes[$key] = $index; CoroutineContext::set(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY, $indexes); @@ -1921,6 +1929,7 @@ public function setPermissionClass(string $permissionClass): static { $this->validatePermissionClass($permissionClass); $this->permissionClass = $permissionClass; + $this->config->set('permission.models.permission', $permissionClass); $this->app->bind(PermissionContract::class, $permissionClass); $this->forgetCachedPermissions(); @@ -1949,6 +1958,7 @@ public function setRoleClass(string $roleClass): static { $this->validateRoleClass($roleClass); $this->roleClass = $roleClass; + $this->config->set('permission.models.role', $roleClass); $this->app->bind(RoleContract::class, $roleClass); $this->forgetCachedPermissions(); @@ -1995,6 +2005,7 @@ public function getAssignmentPivotClass(Model $model, string $relation): string public function setTeamClass(?string $teamClass): static { $this->teamClass = $teamClass; + $this->config->set('permission.models.team', $teamClass); $this->forgetCachedPermissions(); return $this; diff --git a/src/permission/src/PermissionServiceProvider.php b/src/permission/src/PermissionServiceProvider.php index ca2b42e02c..37de1a9128 100644 --- a/src/permission/src/PermissionServiceProvider.php +++ b/src/permission/src/PermissionServiceProvider.php @@ -5,11 +5,11 @@ namespace Hypervel\Permission; use Composer\InstalledVersions; -use Hypervel\Cache\CacheManager; use Hypervel\Cache\ModelCacheCoordinator; use Hypervel\Container\Container; use Hypervel\Contracts\Auth\Access\Gate as GateContract; use Hypervel\Contracts\Auth\Factory as AuthFactory; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Foundation\Console\AboutCommand; use Hypervel\Permission\Commands\AssignRoleCommand; use Hypervel\Permission\Commands\CacheResetCommand; @@ -39,8 +39,7 @@ public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/permission.php', 'permission'); - $this->app->singleton(PermissionRegistrar::class, fn ($app) => new PermissionRegistrar( - $app->make(CacheManager::class), + $this->app->singleton(PermissionRegistrar::class, fn (Application $app): PermissionRegistrar => new PermissionRegistrar( $app->make('config'), $app, $app->make(ModelCacheCoordinator::class), @@ -187,7 +186,7 @@ protected function registerGateHook(): void $this->callAfterResolving(GateContract::class, function (GateContract $gate): void { $config = $this->app->make('config'); - if (! $config->boolean('permission.register_permission_check_method', true)) { + if (! $config->boolean('permission.register_permission_check_method')) { return; } @@ -207,9 +206,8 @@ protected function registerAbout(): void AboutCommand::add('Hypervel Permissions', static function () use ($config): array { $enabledFeatures = Collection::make([ 'Teams' => $config->boolean('permission.teams'), - 'Wildcard Permissions' => $config->boolean('permission.enable_wildcard_permission', false), - 'Passport Client Credentials' => $config->boolean('permission.use_passport_client_credentials', false), - 'Denied Permissions' => true, + 'Wildcard-Permissions' => $config->boolean('permission.enable_wildcard_permission'), + 'Passport' => $config->boolean('permission.use_passport_client_credentials'), ]) ->filter() ->keys(); diff --git a/src/permission/src/Support/Config.php b/src/permission/src/Support/Config.php index b584a4fadb..7db00dfacf 100644 --- a/src/permission/src/Support/Config.php +++ b/src/permission/src/Support/Config.php @@ -166,7 +166,7 @@ public static function permissionModel(): string */ public static function eventsEnabled(): bool { - return self::repository()->boolean('permission.events_enabled', false); + return self::repository()->boolean('permission.events_enabled'); } /** @@ -174,7 +174,7 @@ public static function eventsEnabled(): bool */ public static function usePassportClientCredentials(): bool { - return self::repository()->boolean('permission.use_passport_client_credentials', false); + return self::repository()->boolean('permission.use_passport_client_credentials'); } /** @@ -182,7 +182,7 @@ public static function usePassportClientCredentials(): bool */ public static function displayRoleInException(): bool { - return self::repository()->boolean('permission.display_role_in_exception', false); + return self::repository()->boolean('permission.display_role_in_exception'); } /** @@ -190,7 +190,7 @@ public static function displayRoleInException(): bool */ public static function displayPermissionInException(): bool { - return self::repository()->boolean('permission.display_permission_in_exception', false); + return self::repository()->boolean('permission.display_permission_in_exception'); } /** @@ -198,7 +198,7 @@ public static function displayPermissionInException(): bool */ public static function wildcardPermissionsEnabled(): bool { - return self::repository()->boolean('permission.enable_wildcard_permission', false); + return self::repository()->boolean('permission.enable_wildcard_permission'); } /** diff --git a/tests/Permission/Commands/CommandTest.php b/tests/Permission/Commands/CommandTest.php index 581854b715..566ddb9c58 100644 --- a/tests/Permission/Commands/CommandTest.php +++ b/tests/Permission/Commands/CommandTest.php @@ -4,25 +4,50 @@ namespace Hypervel\Tests\Permission\Commands; +use Hypervel\Contracts\Console\Kernel; use Hypervel\Database\Eloquent\Model; +use Hypervel\Permission\Commands\UpgradeForTeamsCommand; use Hypervel\Permission\Models\Permission; use Hypervel\Permission\Models\Role; use Hypervel\Permission\PermissionRegistrar; use Hypervel\Support\Facades\Artisan; +use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Tests\Permission\Fixtures\Models\User; use Hypervel\Tests\Permission\Fixtures\Models\UserWithoutHasRoles; use Hypervel\Tests\Permission\TestCase; +use PHPUnit\Framework\Attributes\TestWith; class CommandTest extends TestCase { + /** + * The teams migrations that existed before the test. + * + * @var array + */ + private array $existingTeamsMigrations; + + protected function setUp(): void + { + parent::setUp(); + + $this->existingTeamsMigrations = $this->teamsMigrations(); + } + + protected function tearDown(): void + { + foreach (array_diff($this->teamsMigrations(), $this->existingTeamsMigrations) as $migration) { + unlink($migration); + } + + parent::tearDown(); + } + public function testItCanCreateARole(): void { Artisan::call('permission:create-role', ['name' => 'new-role']); - $role = Role::query()->where('name', 'new-role')->first(); - - $this->assertNotNull($role); - $this->assertCount(0, $role->permissions); + $this->assertCount(1, Role::where('name', 'new-role')->get()); + $this->assertCount(0, Role::where('name', 'new-role')->first()->permissions); } public function testItCanCreateARoleWithASpecificGuard(): void @@ -32,14 +57,14 @@ public function testItCanCreateARoleWithASpecificGuard(): void 'guard' => 'api', ]); - $this->assertTrue(Role::query()->where('name', 'new-role')->where('guard_name', 'api')->exists()); + $this->assertCount(1, Role::where('name', 'new-role')->where('guard_name', 'api')->get()); } public function testItCanCreateAPermission(): void { Artisan::call('permission:create-permission', ['name' => 'new-permission']); - $this->assertTrue(Permission::query()->where('name', 'new-permission')->exists()); + $this->assertCount(1, Permission::where('name', 'new-permission')->get()); } public function testItCanCreateAPermissionWithASpecificGuard(): void @@ -49,17 +74,17 @@ public function testItCanCreateAPermissionWithASpecificGuard(): void 'guard' => 'api', ]); - $this->assertTrue(Permission::query()->where('name', 'new-permission')->where('guard_name', 'api')->exists()); + $this->assertCount(1, Permission::where('name', 'new-permission')->where('guard_name', 'api')->get()); } - public function testItCanCreateARoleAndPermissionsAtTheSameTime(): void + public function testItCanCreateARoleAndPermissionsAtSameTime(): void { Artisan::call('permission:create-role', [ 'name' => 'new-role', 'permissions' => 'first permission | second permission', ]); - $role = Role::query()->where('name', 'new-role')->first(); + $role = Role::where('name', 'new-role')->first(); $this->assertTrue($role->hasPermissionTo('first permission')); $this->assertTrue($role->hasPermissionTo('second permission')); @@ -70,7 +95,8 @@ public function testItCanCreateARoleWithoutDuplication(): void Artisan::call('permission:create-role', ['name' => 'new-role']); Artisan::call('permission:create-role', ['name' => 'new-role']); - $this->assertCount(1, Role::query()->where('name', 'new-role')->get()); + $this->assertCount(1, Role::where('name', 'new-role')->get()); + $this->assertCount(0, Role::where('name', 'new-role')->first()->permissions); } public function testItCanCreateAPermissionWithoutDuplication(): void @@ -78,19 +104,21 @@ public function testItCanCreateAPermissionWithoutDuplication(): void Artisan::call('permission:create-permission', ['name' => 'new-permission']); Artisan::call('permission:create-permission', ['name' => 'new-permission']); - $this->assertCount(1, Permission::query()->where('name', 'new-permission')->get()); + $this->assertCount(1, Permission::where('name', 'new-permission')->get()); } public function testItCanShowPermissionTables(): void { - Role::query()->where('name', 'testRole2')->delete(); + Role::where('name', 'testRole2')->delete(); Role::create(['name' => 'testRole_2']); Artisan::call('permission:show'); + $output = Artisan::output(); $this->assertStringContainsString('Guard: web', $output); $this->assertStringContainsString('Guard: admin', $output); + $this->assertMatchesRegularExpression('/\|\s+\|\s+testRole\s+\|\s+testRole_2\s+\|/', $output); $this->assertMatchesRegularExpression('/\|\s+edit-articles\s+\|\s+·\s+\|\s+·\s+\|/', $output); @@ -99,12 +127,15 @@ public function testItCanShowPermissionTables(): void Artisan::call('permission:show'); - $this->assertMatchesRegularExpression('/\|\s+edit-articles\s+\|\s+✔\s+\|\s+·\s+\|/', Artisan::output()); + $output = Artisan::output(); + + $this->assertMatchesRegularExpression('/\|\s+edit-articles\s+\|\s+✔\s+\|\s+·\s+\|/', $output); } public function testItCanShowPermissionsForGuard(): void { Artisan::call('permission:show', ['guard' => 'web']); + $output = Artisan::output(); $this->assertStringContainsString('Guard: web', $output); @@ -124,104 +155,188 @@ public function testItCanShowPermissionsForGuardNamedZero(): void public function testItCanSetupTeamsUpgrade(): void { - $this->app->make('config')->set('permission.teams', true); - $this->app->make(PermissionRegistrar::class)->initializeCache(); - $before = glob(database_path('migrations/*_add_teams_fields.php')) ?: []; + config()->set('permission.teams', true); - try { - Artisan::call('permission:setup-teams', [ - '--no-interaction' => true, - ]); + $this->artisan('permission:setup-teams') + ->expectsQuestion('Proceed with the migration creation?', 'yes') + ->expectsOutputToContain('Migration created successfully.') + ->assertExitCode(0); - $matchingFiles = array_values(array_diff(glob(database_path('migrations/*_add_teams_fields.php')) ?: [], $before)); + $matchingFiles = array_values(array_diff($this->teamsMigrations(), $this->existingTeamsMigrations)); + $this->assertCount(1, $matchingFiles); - $this->assertNotEmpty($matchingFiles); + $addTeamsFields = require $matchingFiles[0]; + $addTeamsFields->up(); + $addTeamsFields->up(); // test upgrade teams migration fresh - $migration = require $matchingFiles[count($matchingFiles) - 1]; - $migration->up(); - $migration->up(); - Model::flushGuardableColumns(); + // Upstream's roles table already has this column, but here it was added after the + // seeded roles cached their guardable columns. + Model::flushGuardableColumns(); - Role::create(['name' => 'new-role', 'team_test_id' => 1]); - $role = Role::query()->where('name', 'new-role')->first(); + Role::create(['name' => 'new-role', 'team_test_id' => 1]); + $role = Role::where('name', 'new-role')->first(); + $this->assertNotNull($role); + $this->assertSame(1, (int) $role->team_test_id); + } - $this->assertNotNull($role); - $this->assertSame(1, (int) $role->team_test_id); - } finally { - foreach (array_diff(glob(database_path('migrations/*_add_teams_fields.php')) ?: [], $before) as $path) { - unlink($path); - } - } + public function testItFailsToSetupTeamsWhenTheTeamsFeatureIsDisabled(): void + { + config()->set('permission.teams', false); + + $this->artisan('permission:setup-teams') + ->expectsOutputToContain('Teams feature is disabled in your permission.php file.') + ->assertExitCode(1); + } + + public function testItCanDeclineTheTeamsMigrationCreation(): void + { + config()->set('permission.teams', true); + + $this->artisan('permission:setup-teams') + ->expectsConfirmation('Proceed with the migration creation?', 'no') + ->assertExitCode(0); + + $this->assertSame($this->existingTeamsMigrations, $this->teamsMigrations()); + } + + public function testItWarnsWhenATeamsMigrationAlreadyExists(): void + { + config()->set('permission.teams', true); + + $existingMigration = database_path('migrations/0000_00_00_000000_add_teams_fields.php'); + file_put_contents($existingMigration, 'teamsMigrations(); + + $this->artisan('permission:setup-teams') + ->expectsOutputToContain('Setup teams migration already exists.') + ->expectsConfirmation('Proceed with the migration creation?', 'no') + ->assertExitCode(0); + + $this->assertSame($migrations, $this->teamsMigrations()); + } + + public function testItWarnsWhenMultipleTeamsMigrationsAlreadyExist(): void + { + config()->set('permission.teams', true); + + $existingMigration1 = database_path('migrations/0000_00_00_000000_add_teams_fields.php'); + $existingMigration2 = database_path('migrations/0000_00_00_000001_add_teams_fields.php'); + file_put_contents($existingMigration1, 'teamsMigrations(); + + $this->artisan('permission:setup-teams') + ->expectsOutputToContain('Setup teams migrations already exist.') + ->expectsConfirmation('Proceed with the migration creation?', 'no') + ->assertExitCode(0); + + $this->assertSame($migrations, $this->teamsMigrations()); } - public function testSetupTeamsFailsWhenTeamsAreDisabled(): void + public function testItShowsAnErrorWhenTheTeamsMigrationCannotBeCreated(): void { + config()->set('permission.teams', true); + + // CI runs as root, which can still write to a read-only directory, so + // replace upstream's chmod with a destination that cannot be written. + $this->app->make(Kernel::class)->registerCommand(new InvalidDestinationUpgradeForTeamsCommand); + $this->artisan('permission:setup-teams') - ->expectsOutputToContain('Teams feature is disabled') - ->assertFailed(); + ->expectsConfirmation('Proceed with the migration creation?', 'yes') + ->expectsOutputToContain("Couldn't create migration.") + ->assertExitCode(1); + + $this->assertSame($this->existingTeamsMigrations, $this->teamsMigrations()); + } + + #[DefineEnvironment('usesTeams')] + public function testItCanShowRolesByTeams(): void + { + Role::where('name', 'testRole2')->delete(); + Role::create(['name' => 'testRole_2']); + Role::create(['name' => 'testRole_Team', 'team_test_id' => 1]); + Role::create(['name' => 'testRole_Team', 'team_test_id' => 2]); // same name different team + Artisan::call('permission:show'); + + $output = Artisan::output(); + + $this->assertMatchesRegularExpression('/\|\s+\|\s+Team ID: NULL\s+\|\s+Team ID: 1\s+\|\s+Team ID: 2\s+\|/', $output); + $this->assertMatchesRegularExpression('/\|\s+\|\s+testRole\s+\|\s+testRole_2\s+\|\s+testRole_Team\s+\|\s+testRole_Team\s+\|/', $output); } - public function testItCanRespondToAboutCommandWithDefaultFeatures(): void + public function testItCanRespondToAboutCommandWithDefault(): void { - $this->app->make(PermissionRegistrar::class)->initializeCache(); + app(PermissionRegistrar::class)->initializeCache(); Artisan::call('about'); $output = str_replace("\r\n", "\n", Artisan::output()); - $this->assertMatchesRegularExpression('/Hypervel Permissions[ .\n]*Features Enabled[ .]*Denied Permissions[ .\n]*Version/', $output); + $pattern = '/Hypervel Permissions[ .\n]*Features Enabled[ .]*Default[ .\n]*Version/'; + $this->assertMatchesRegularExpression($pattern, $output); } public function testItCanRespondToAboutCommandWithTeams(): void { - $this->app->make('config')->set('permission.teams', true); - $this->app->make(PermissionRegistrar::class)->initializeCache(); + app(PermissionRegistrar::class)->initializeCache(); + + config()->set('permission.teams', true); Artisan::call('about'); $output = str_replace("\r\n", "\n", Artisan::output()); - $this->assertMatchesRegularExpression('/Hypervel Permissions[ .\n]*Features Enabled[ .]*Teams, Denied Permissions[ .\n]*Version/', $output); + $pattern = '/Hypervel Permissions[ .\n]*Features Enabled[ .]*Teams[ .\n]*Version/'; + $this->assertMatchesRegularExpression($pattern, $output); } public function testItCanAssignRoleToUser(): void { - $user = User::query()->first(); + $user = User::first(); Artisan::call('permission:assign-role', [ 'name' => 'testRole', - 'userId' => (string) $user->id, + 'userId' => $user->id, 'guard' => 'web', 'userModelNamespace' => User::class, ]); - $this->assertStringContainsString("Role `testRole` assigned to user ID {$user->id} successfully.", Artisan::output()); - $this->assertTrue($user->fresh()->hasRole('testRole')); + $output = Artisan::output(); + + $this->assertStringContainsString('Role `testRole` assigned to user ID ' . $user->id . ' successfully.', $output); + $this->assertCount(1, Role::where('name', 'testRole')->get()); + $this->assertCount(1, $user->roles); + $this->assertTrue($user->hasRole('testRole')); } - public function testItFailsToAssignRoleWhenUserDoesNotExist(): void + public function testItFailsToAssignRoleWhenUserNotFound(): void { Artisan::call('permission:assign-role', [ 'name' => 'testRole', - 'userId' => '99999', + 'userId' => 99999, 'guard' => 'web', 'userModelNamespace' => User::class, ]); - $this->assertStringContainsString('User with ID 99999 not found.', Artisan::output()); + $output = Artisan::output(); + + $this->assertStringContainsString('User with ID 99999 not found.', $output); } public function testItFailsToAssignRoleWhenNamespaceInvalid(): void { - $user = User::query()->first(); + $user = User::first(); + $userModelClass = 'App\Models\NonExistentUser'; Artisan::call('permission:assign-role', [ 'name' => 'testRole', - 'userId' => (string) $user->id, + 'userId' => $user->id, 'guard' => 'web', 'userModelNamespace' => $userModelClass, ]); - $this->assertStringContainsString("User model class [{$userModelClass}] does not exist.", Artisan::output()); + $output = Artisan::output(); + + $this->assertStringContainsString("User model class [{$userModelClass}] does not exist.", $output); } public function testItFailsToAssignRoleWhenModelDoesNotUseHasRoles(): void @@ -230,7 +345,7 @@ public function testItFailsToAssignRoleWhenModelDoesNotUseHasRoles(): void Artisan::call('permission:assign-role', [ 'name' => 'testRole', - 'userId' => (string) $user->id, + 'userId' => $user->id, 'guard' => 'web', 'userModelNamespace' => UserWithoutHasRoles::class, ]); @@ -238,28 +353,73 @@ public function testItFailsToAssignRoleWhenModelDoesNotUseHasRoles(): void $this->assertStringContainsString('must use the HasRoles trait', Artisan::output()); } - public function testItWarnsWhenAssigningRoleWithTeamIdButTeamsDisabled(): void + #[TestWith([1])] + #[TestWith(['0'])] + public function testItWarnsWhenAssigningRoleWithTeamIdButTeamsDisabled(int|string $teamId): void { - $user = User::query()->first(); + $user = User::first(); Artisan::call('permission:assign-role', [ 'name' => 'testRole', - 'userId' => (string) $user->id, + 'userId' => $user->id, 'userModelNamespace' => User::class, - '--team-id' => '0', + '--team-id' => $teamId, ]); - $this->assertStringContainsString('Teams feature disabled', Artisan::output()); + $output = Artisan::output(); + + $this->assertStringContainsString('Teams feature disabled', $output); } - public function testItWarnsWhenCreatingRoleWithTeamIdButTeamsDisabled(): void + #[TestWith([1])] + #[TestWith(['0'])] + public function testItWarnsWhenCreatingARoleWithTeamIdButTeamsDisabled(int|string $teamId): void { Artisan::call('permission:create-role', [ - 'name' => 'zero-team-role', - '--team-id' => '0', + 'name' => 'new-role', + '--team-id' => $teamId, ]); - $this->assertStringContainsString('Teams feature disabled', Artisan::output()); - $this->assertDatabaseMissing('roles', ['name' => 'zero-team-role']); + $output = Artisan::output(); + + $this->assertStringContainsString('Teams feature disabled, argument --team-id has no effect', $output); + $this->assertCount(0, Role::where('name', 'new-role')->get()); + } + + #[DefineEnvironment('usesTeams')] + public function testItWarnsWhenCreatingARoleWithTeamIdAndTheRoleAlreadyExistsOnTheGlobalTeam(): void + { + Artisan::call('permission:create-role', ['name' => 'new-role']); + + Artisan::call('permission:create-role', [ + 'name' => 'new-role', + '--team-id' => 1, + ]); + + $output = Artisan::output(); + + $this->assertStringContainsString('Role `new-role` already exists on the global team; argument --team-id has no effect', $output); + $this->assertCount(1, Role::where('name', 'new-role')->get()); + } + + /** + * Get the teams migrations in the migrations directory. + * + * @return array + */ + private function teamsMigrations(): array + { + return glob(database_path('migrations/*_add_teams_fields.php')) ?: []; + } +} + +class InvalidDestinationUpgradeForTeamsCommand extends UpgradeForTeamsCommand +{ + /** + * Return an existing directory as the migration destination. + */ + protected function getMigrationPath(?string $date = null): string + { + return $date === null ? database_path('migrations') : parent::getMigrationPath($date); } } diff --git a/tests/Permission/Commands/TeamCommandTest.php b/tests/Permission/Commands/TeamCommandTest.php index 5d3ce5afeb..1caa8dac4f 100644 --- a/tests/Permission/Commands/TeamCommandTest.php +++ b/tests/Permission/Commands/TeamCommandTest.php @@ -5,14 +5,9 @@ namespace Hypervel\Tests\Permission\Commands; use Hypervel\Contracts\Foundation\Application as ApplicationContract; -use Hypervel\Permission\Commands\UpgradeForTeamsCommand; -use Hypervel\Permission\Models\Role; -use Hypervel\Permission\PermissionRegistrar; use Hypervel\Support\Facades\Artisan; -use Hypervel\Tests\Permission\Fixtures\Models\Team; use Hypervel\Tests\Permission\Fixtures\Models\User; use Hypervel\Tests\Permission\TestCase; -use Symfony\Component\Console\Tester\CommandTester; class TeamCommandTest extends TestCase { @@ -20,37 +15,42 @@ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); - $app->make('config')->set([ - 'permission.teams' => true, - 'permission.models.team' => Team::class, - ]); + $app->make('config')->set('permission.teams', true); + } + + protected function setUpInCoroutine(): void + { + $this->setUpTeams(); } public function testItCanAssignRoleToUserWithTeamId(): void { - $user = User::query()->first(); + $user = User::first(); Artisan::call('permission:assign-role', [ 'name' => 'testRole', - 'userId' => (string) $user->id, + 'userId' => $user->id, 'guard' => 'web', 'userModelNamespace' => User::class, '--team-id' => 1, ]); - $this->assertStringContainsString("Role `testRole` assigned to user ID {$user->id} successfully.", Artisan::output()); + $output = Artisan::output(); + + $this->assertStringContainsString('Role `testRole` assigned to user ID ' . $user->id . ' successfully.', $output); setPermissionsTeamId(1); - $this->assertTrue($user->fresh()->hasRole('testRole')); + $user->unsetRelation('roles'); + $this->assertTrue($user->hasRole('testRole')); } - public function testItCanAssignRolesToUserOnDifferentTeams(): void + public function testItCanAssignRoleToUserOnDifferentTeams(): void { - $user = User::query()->first(); + $user = User::first(); Artisan::call('permission:assign-role', [ 'name' => 'testRole', - 'userId' => (string) $user->id, + 'userId' => $user->id, 'guard' => 'web', 'userModelNamespace' => User::class, '--team-id' => 1, @@ -58,32 +58,32 @@ public function testItCanAssignRolesToUserOnDifferentTeams(): void Artisan::call('permission:assign-role', [ 'name' => 'testRole2', - 'userId' => (string) $user->id, + 'userId' => $user->id, 'guard' => 'web', 'userModelNamespace' => User::class, '--team-id' => 2, ]); setPermissionsTeamId(1); - $user = $user->fresh(); + $user->unsetRelation('roles'); $this->assertTrue($user->hasRole('testRole')); $this->assertFalse($user->hasRole('testRole2')); setPermissionsTeamId(2); - $user = $user->fresh(); + $user->unsetRelation('roles'); $this->assertTrue($user->hasRole('testRole2')); $this->assertFalse($user->hasRole('testRole')); } public function testItRestoresPreviousTeamIdAfterAssigningRole(): void { - $user = User::query()->first(); + $user = User::first(); setPermissionsTeamId(5); Artisan::call('permission:assign-role', [ 'name' => 'testRole', - 'userId' => (string) $user->id, + 'userId' => $user->id, 'guard' => 'web', 'userModelNamespace' => User::class, '--team-id' => 1, @@ -94,7 +94,7 @@ public function testItRestoresPreviousTeamIdAfterAssigningRole(): void public function testItPreservesZeroAsAnExplicitTeamId(): void { - $user = User::query()->firstOrFail(); + $user = User::first(); setPermissionsTeamId(5); Artisan::call('permission:create-role', [ @@ -110,7 +110,7 @@ public function testItPreservesZeroAsAnExplicitTeamId(): void Artisan::call('permission:assign-role', [ 'name' => 'zero-team-role', - 'userId' => (string) $user->getKey(), + 'userId' => $user->id, 'guard' => 'web', 'userModelNamespace' => User::class, '--team-id' => '0', @@ -119,102 +119,8 @@ public function testItPreservesZeroAsAnExplicitTeamId(): void $this->assertSame(5, getPermissionsTeamId()); setPermissionsTeamId('0'); + $user->unsetRelation('roles'); - $this->assertTrue($user->fresh()->hasRole('zero-team-role')); - } - - public function testItWarnsWhenATeamIdResolvesAnExistingGlobalRole(): void - { - setPermissionsTeamId(null); - Role::create(['name' => 'global-role']); - - Artisan::call('permission:create-role', [ - 'name' => 'global-role', - '--team-id' => 1, - ]); - - $this->assertStringContainsString( - 'already exists on the global team; argument --team-id has no effect', - Artisan::output(), - ); - $this->assertSame(1, Role::query()->where('name', 'global-role')->count()); - } - - public function testItCanCreateTeamsMigration(): void - { - $before = glob(database_path('migrations/*_add_teams_fields.php')) ?: []; - - try { - Artisan::call('permission:setup-teams', [ - '--no-interaction' => true, - ]); - - $after = glob(database_path('migrations/*_add_teams_fields.php')) ?: []; - - $this->assertCount(count($before) + 1, $after); - $this->assertStringContainsString('Migration created successfully.', Artisan::output()); - } finally { - foreach (array_diff(glob(database_path('migrations/*_add_teams_fields.php')) ?: [], $before) as $path) { - unlink($path); - } - } - } - - public function testItWarnsAboutAnExistingMigrationBeforeADeclinedSetup(): void - { - $migration = database_path('migrations/2020_01_01_000000_add_teams_fields.php'); - touch($migration); - - try { - $before = glob(database_path('migrations/*_add_teams_fields.php')) ?: []; - - $this->artisan('permission:setup-teams') - ->expectsOutputToContain('Setup teams migration already exists') - ->expectsConfirmation('Proceed with the migration creation?', 'no') - ->assertSuccessful(); - - $this->assertSame($before, glob(database_path('migrations/*_add_teams_fields.php')) ?: []); - } finally { - unlink($migration); - } - } - - public function testSetupTeamsFailsWhenTheMigrationCannotBeCopied(): void - { - $command = new InvalidDestinationUpgradeForTeamsCommand; - $command->setHypervel($this->app); - $tester = new CommandTester($command); - $tester->setInputs(['yes']); - - $this->assertSame(UpgradeForTeamsCommand::FAILURE, $tester->execute([])); - $this->assertStringContainsString("Couldn't create migration.", $tester->getDisplay()); - } - - public function testItCanShowRolesByTeams(): void - { - $this->app->make(PermissionRegistrar::class)->initializeCache(); - - Role::query()->where('name', 'testRole2')->delete(); - Role::create(['name' => 'testRole_2']); - Role::create(['name' => 'testRole_Team', 'team_test_id' => 1]); - Role::create(['name' => 'testRole_Team', 'team_test_id' => 2]); - - Artisan::call('permission:show'); - - $output = Artisan::output(); - - $this->assertMatchesRegularExpression('/\|\s+\|\s+Team ID: NULL\s+\|\s+Team ID: 1\s+\|\s+Team ID: 2\s+\|/', $output); - $this->assertMatchesRegularExpression('/\|\s+\|\s+testRole\s+\|\s+testRole_2\s+\|\s+testRole_Team\s+\|\s+testRole_Team\s+\|/', $output); - } -} - -class InvalidDestinationUpgradeForTeamsCommand extends UpgradeForTeamsCommand -{ - /** - * Return an existing directory as an invalid copy destination. - */ - protected function getMigrationPath(?string $date = null): string - { - return database_path('migrations'); + $this->assertTrue($user->hasRole('zero-team-role')); } } diff --git a/tests/Permission/CoroutineIsolationTest.php b/tests/Permission/CoroutineIsolationTest.php index 0819b0aa1c..6651213d59 100644 --- a/tests/Permission/CoroutineIsolationTest.php +++ b/tests/Permission/CoroutineIsolationTest.php @@ -8,6 +8,9 @@ class CoroutineIsolationTest extends TestCase { + // REMOVED: upstream's Integration/OctaneListenerTest. Hypervel has no Octane reset listener + // because team IDs and loaded permission collections are coroutine-local. + public function testTeamIdIsIsolatedPerCoroutine(): void { [$first, $second] = parallel([ diff --git a/tests/Permission/GuardTest.php b/tests/Permission/GuardTest.php index ca5e997655..d81d8d515c 100644 --- a/tests/Permission/GuardTest.php +++ b/tests/Permission/GuardTest.php @@ -19,38 +19,34 @@ class GuardTest extends TestCase { - public function testItReturnsNullForAGuardWithoutAProvider(): void + public function testItReturnsNullForTheModelOfAGuardThatHasNoProviderConfigured(): void { - $this->app->make('config')->set('auth.guards.no-provider-guard', []); + config()->set('auth.guards.no-provider-guard', []); $this->assertNull(Guard::getModelForGuard('no-provider-guard')); } - public function testItResolvesTheModelForAnLdapProvider(): void + public function testItResolvesTheModelForAGuardUsingAnLdapProvider(): void { - $this->app->make('config')->set([ - 'auth.guards.ldap-guard' => ['provider' => 'ldap-provider'], - 'auth.providers.ldap-provider' => [ - 'driver' => 'ldap', - 'database' => ['model' => User::class], - ], + config()->set('auth.guards.ldap-guard', ['provider' => 'ldap-provider']); + config()->set('auth.providers.ldap-provider', [ + 'driver' => 'ldap', + 'database' => ['model' => User::class], ]); $this->assertSame(User::class, Guard::getModelForGuard('ldap-guard')); } - public function testItReturnsNullWhenNoPassportGuardIsConfigured(): void + public function testItReturnsNullFromGetPassportClientWhenNoPassportGuardsAreConfigured(): void { $this->assertNull(Guard::getPassportClient('web')); } - public function testItReturnsNullWhenThePassportGuardHasNoClientSurface(): void + public function testItReturnsNullFromGetPassportClientWhenTheResolvedGuardDoesNotSupportClients(): void { - $this->app->make('config')->set( - 'auth.guards.fake-passport', - ['driver' => 'passport', 'provider' => 'users'], - ); + config()->set('auth.guards.fake-passport', ['driver' => 'passport', 'provider' => 'users']); + // The auth manager's guard() return type rejects upstream's stdClass, so use a guard without client(). Auth::shouldReceive('guard')->once()->with('fake-passport')->andReturn(m::mock(GuardContract::class)); $this->assertNull(Guard::getPassportClient('web')); @@ -89,7 +85,7 @@ public function testGetNamesRejectsPersistedPermissionModelsMissingTheGuardColum ->findOrFail($storedModel->getKey()); $this->expectException(MissingAttributeException::class); - $this->expectExceptionMessage('The attribute [guard_name]'); + $this->expectExceptionMessageIsOrContains('The attribute [guard_name]'); Guard::getNames($partialModel); } diff --git a/tests/Permission/Integration/BladeTest.php b/tests/Permission/Integration/BladeTest.php index 157ec82ff9..d8a9932cd1 100644 --- a/tests/Permission/Integration/BladeTest.php +++ b/tests/Permission/Integration/BladeTest.php @@ -13,11 +13,10 @@ class BladeTest extends TestCase { - protected function setUp(): void + protected function setUpInCoroutine(): void { - parent::setUp(); - $roleModel = app(Role::class); + $roleModel->create(['name' => 'member']); $roleModel->create(['name' => 'writer']); $roleModel->create(['name' => 'intern']); @@ -25,7 +24,7 @@ protected function setUp(): void $roleModel->create(['name' => 'moderator', 'guard_name' => 'admin']); } - public function testItEvaluatesAllBladeDirectivesAsFalseWhenNobodyIsLoggedIn(): void + public function testItEvaluatesAllBladeDirectivesAsFalseWhenThereIsNobodyLoggedIn(): void { $permission = 'edit-articles'; $role = 'writer'; @@ -43,7 +42,7 @@ public function testItEvaluatesAllBladeDirectivesAsFalseWhenNobodyIsLoggedIn(): $this->assertSame('does not have any of the given roles', $this->renderView('hasAnyRole', ['roles' => implode('|', $roles)])); } - public function testItEvaluatesAllBladeDirectivesAsFalseWhenUserHasNoRolesOrPermissions(): void + public function testItEvaluatesAllBladeDirectivesAsFalseWhenSomebodyWithoutRolesOrPermissionsIsLoggedIn(): void { Auth::setUser($this->testUser); @@ -107,7 +106,7 @@ public function testItAcceptsAGuardNameInTheCanDirective(): void $this->assertSame('has permission', $this->renderView('can', compact('permission', 'guard'))); } - public function testCanDirectiveIsTrueWhenUserHasPermission(): void + public function testItEvaluatesTheCanDirectiveAsTrueWhenTheLoggedInUserHasThePermission(): void { $user = $this->writer(); $user->givePermissionTo('edit-articles'); @@ -116,7 +115,7 @@ public function testCanDirectiveIsTrueWhenUserHasPermission(): void $this->assertSame('has permission', $this->renderView('can', ['permission' => 'edit-articles'])); } - public function testHaspermissionDirectiveIsTrueWhenUserHasPermission(): void + public function testItEvaluatesTheHaspermissionDirectiveAsTrueWhenTheLoggedInUserHasThePermission(): void { $user = $this->writer(); $permission = 'edit-articles'; @@ -137,49 +136,49 @@ public function testHaspermissionDirectiveIsTrueWhenUserHasPermission(): void $this->assertSame('has permission', $this->renderView('haspermission', compact('permission', 'guard', 'elsepermission'))); } - public function testRoleDirectiveIsTrueWhenUserHasRole(): void + public function testItEvaluatesTheRoleDirectiveAsTrueWhenTheLoggedInUserHasTheRole(): void { Auth::setUser($this->writer()); $this->assertSame('has role', $this->renderView('role', ['role' => 'writer', 'elserole' => 'na'])); } - public function testElseroleDirectiveIsTrueWhenUserHasElseRole(): void + public function testItEvaluatesTheElseroleDirectiveAsTrueWhenTheLoggedInUserHasTheRole(): void { Auth::setUser($this->member()); $this->assertSame('has else role', $this->renderView('role', ['role' => 'writer', 'elserole' => 'member'])); } - public function testRoleDirectiveIsTrueForGivenGuard(): void + public function testItEvaluatesTheRoleDirectiveAsTrueWhenTheLoggedInUserHasTheRoleForTheGivenGuard(): void { Auth::guard('admin')->setUser($this->superAdmin()); $this->assertSame('has role for guard', $this->renderView('guardRole', ['role' => 'super-admin', 'guard' => 'admin'])); } - public function testHasroleDirectiveIsTrueWhenUserHasRole(): void + public function testItEvaluatesTheHasroleDirectiveAsTrueWhenTheLoggedInUserHasTheRole(): void { Auth::setUser($this->writer()); $this->assertSame('has role', $this->renderView('hasRole', ['role' => 'writer'])); } - public function testHasroleDirectiveIsTrueForGivenGuard(): void + public function testItEvaluatesTheHasroleDirectiveAsTrueWhenTheLoggedInUserHasTheRoleForTheGivenGuard(): void { Auth::guard('admin')->setUser($this->superAdmin()); $this->assertSame('has role', $this->renderView('guardHasRole', ['role' => 'super-admin', 'guard' => 'admin'])); } - public function testUnlessroleDirectiveIsTrueWhenUserDoesNotHaveRole(): void + public function testItEvaluatesTheUnlessroleDirectiveAsTrueWhenTheLoggedInUserDoesNotHaveTheRole(): void { Auth::setUser($this->writer()); $this->assertSame('does not have role', $this->renderView('unlessrole', ['role' => 'another'])); } - public function testUnlessroleDirectiveIsTrueForGivenGuard(): void + public function testItEvaluatesTheUnlessroleDirectiveAsTrueWhenTheLoggedInUserDoesNotHaveTheRoleForTheGivenGuard(): void { Auth::guard('admin')->setUser($this->superAdmin()); @@ -187,7 +186,7 @@ public function testUnlessroleDirectiveIsTrueForGivenGuard(): void $this->assertSame('does not have role', $this->renderView('guardunlessrole', ['role' => 'super-admin', 'guard' => 'web'])); } - public function testHasanyroleDirectiveIsFalseWhenUserDoesNotHaveAnyRequiredRole(): void + public function testItEvaluatesTheHasanyroleDirectiveAsFalseWhenTheLoggedInUserDoesNotHaveAnyOfTheRequiredRoles(): void { $roles = ['writer', 'intern']; Auth::setUser($this->member()); @@ -196,7 +195,7 @@ public function testHasanyroleDirectiveIsFalseWhenUserDoesNotHaveAnyRequiredRole $this->assertSame('does not have any of the given roles', $this->renderView('hasAnyRole', ['roles' => implode('|', $roles)])); } - public function testHasanyroleDirectiveIsTrueWhenUserHasSomeRequiredRoles(): void + public function testItEvaluatesTheHasanyroleDirectiveAsTrueWhenTheLoggedInUserDoesHaveSomeOfTheRequiredRoles(): void { $roles = ['member', 'writer', 'intern']; Auth::setUser($this->member()); @@ -205,7 +204,7 @@ public function testHasanyroleDirectiveIsTrueWhenUserHasSomeRequiredRoles(): voi $this->assertSame('does have some of the roles', $this->renderView('hasAnyRole', ['roles' => implode('|', $roles)])); } - public function testHasanyroleDirectiveIsTrueForGivenGuard(): void + public function testItEvaluatesTheHasanyroleDirectiveAsTrueWhenTheLoggedInUserDoesHaveSomeOfTheRequiredRolesForTheGivenGuard(): void { $roles = ['super-admin', 'moderator']; $guard = 'admin'; @@ -214,7 +213,7 @@ public function testHasanyroleDirectiveIsTrueForGivenGuard(): void $this->assertSame('does have some of the roles', $this->renderView('guardHasAnyRole', compact('roles', 'guard'))); } - public function testHasanyroleDirectiveIsTrueForPipeInput(): void + public function testItEvaluatesTheHasanyroleDirectiveAsTrueWhenTheLoggedInUserDoesHaveSomeOfTheRequiredRolesInPipe(): void { $guard = 'admin'; Auth::guard('admin')->setUser($this->superAdmin()); @@ -222,7 +221,7 @@ public function testHasanyroleDirectiveIsTrueForPipeInput(): void $this->assertSame('does have some of the roles', $this->renderView('guardHasAnyRolePipe', compact('guard'))); } - public function testHasanyroleDirectiveIsFalseForPipeInput(): void + public function testItEvaluatesTheHasanyroleDirectiveAsFalseWhenTheLoggedInUserDoesntHaveSomeOfTheRequiredRolesInPipe(): void { $guard = ''; Auth::guard('admin')->setUser($this->member()); @@ -230,7 +229,7 @@ public function testHasanyroleDirectiveIsFalseForPipeInput(): void $this->assertSame('does not have any of the given roles', $this->renderView('guardHasAnyRolePipe', compact('guard'))); } - public function testHasallrolesDirectiveIsFalseWhenUserDoesNotHaveAllRequiredRoles(): void + public function testItEvaluatesTheHasallrolesDirectiveAsFalseWhenTheLoggedInUserDoesNotHaveAllRequiredRoles(): void { $roles = ['member', 'writer']; Auth::setUser($this->member()); @@ -239,7 +238,7 @@ public function testHasallrolesDirectiveIsFalseWhenUserDoesNotHaveAllRequiredRol $this->assertSame('does not have all of the given roles', $this->renderView('hasAllRoles', ['roles' => implode('|', $roles)])); } - public function testHasallrolesDirectiveIsTrueWhenUserHasAllRequiredRoles(): void + public function testItEvaluatesTheHasallrolesDirectiveAsTrueWhenTheLoggedInUserDoesHaveAllRequiredRoles(): void { $roles = ['member', 'writer']; $user = $this->member(); @@ -250,7 +249,7 @@ public function testHasallrolesDirectiveIsTrueWhenUserHasAllRequiredRoles(): voi $this->assertSame('does have all of the given roles', $this->renderView('hasAllRoles', ['roles' => implode('|', $roles)])); } - public function testHasallrolesDirectiveIsTrueForGivenGuard(): void + public function testItEvaluatesTheHasallrolesDirectiveAsTrueWhenTheLoggedInUserDoesHaveAllRequiredRolesForTheGivenGuard(): void { $roles = ['super-admin', 'moderator']; $guard = 'admin'; @@ -261,7 +260,7 @@ public function testHasallrolesDirectiveIsTrueForGivenGuard(): void $this->assertSame('does have all of the given roles', $this->renderView('guardHasAllRoles', compact('roles', 'guard'))); } - public function testHasallrolesDirectiveIsTrueForPipeInput(): void + public function testItEvaluatesTheHasallrolesDirectiveAsTrueWhenTheLoggedInUserDoesHaveAllRequiredRolesInPipe(): void { $guard = 'admin'; $admin = $this->superAdmin(); @@ -271,7 +270,7 @@ public function testHasallrolesDirectiveIsTrueForPipeInput(): void $this->assertSame('does have all of the given roles', $this->renderView('guardHasAllRolesPipe', compact('guard'))); } - public function testHasallrolesDirectiveIsFalseForPipeInput(): void + public function testItEvaluatesTheHasallrolesDirectiveAsFalseWhenTheLoggedInUserDoesntHaveAllRequiredRolesInPipe(): void { $guard = ''; $user = $this->member(); @@ -281,7 +280,7 @@ public function testHasallrolesDirectiveIsFalseForPipeInput(): void $this->assertSame('does not have all of the given roles', $this->renderView('guardHasAllRolesPipe', compact('guard'))); } - public function testHasallrolesDirectiveIsTrueForArrayInput(): void + public function testItEvaluatesTheHasallrolesDirectiveAsTrueWhenTheLoggedInUserDoesHaveAllRequiredRolesInArray(): void { $guard = 'admin'; $admin = $this->superAdmin(); @@ -291,7 +290,7 @@ public function testHasallrolesDirectiveIsTrueForArrayInput(): void $this->assertSame('does have all of the given roles', $this->renderView('guardHasAllRolesArray', compact('guard'))); } - public function testHasallrolesDirectiveIsFalseForArrayInput(): void + public function testItEvaluatesTheHasallrolesDirectiveAsFalseWhenTheLoggedInUserDoesntHaveAllRequiredRolesInArray(): void { $guard = ''; $user = $this->member(); diff --git a/tests/Permission/Integration/CacheTest.php b/tests/Permission/Integration/CacheTest.php index 11486073ee..aae5fb3371 100644 --- a/tests/Permission/Integration/CacheTest.php +++ b/tests/Permission/Integration/CacheTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Permission\Integration; +use Hypervel\Cache\DatabaseStore; use Hypervel\Contracts\Cache\Repository; use Hypervel\Permission\Contracts\Permission; use Hypervel\Permission\Contracts\Role; @@ -19,16 +20,27 @@ class CacheTest extends TestCase { protected PermissionRegistrar $registrar; + protected int $cacheInitCount = 0; + + protected int $cacheLoadCount = 0; + + // Permissions, role pivots and the role catalog. Upstream doesn't cache roles, + // so its count is 2 and its role lookups query the database instead. protected int $cacheRunCount = 3; - protected function setUp(): void + protected function setUpInCoroutine(): void { - parent::setUp(); - $this->registrar = $this->app->make(PermissionRegistrar::class); + $this->registrar->forgetCachedPermissions(); DB::connection()->enableQueryLog(); + + if ($this->registrar->getCacheStore() instanceof DatabaseStore) { + // A cold entry is read once, then filled under a cache lock: acquire, refresh, write and release. + $this->cacheInitCount = 1; + $this->cacheLoadCount = 4; + } } public function testItCanCacheThePermissions(): void @@ -37,7 +49,7 @@ public function testItCanCacheThePermissions(): void $this->registrar->getPermissions(); - $this->assertQueryCount($this->cacheRunCount); + $this->assertQueryCount($this->cacheInitCount + $this->cacheLoadCount + $this->cacheRunCount); } public function testItFlushesTheCacheWhenCreatingAPermission(): void @@ -48,7 +60,7 @@ public function testItFlushesTheCacheWhenCreatingAPermission(): void $this->registrar->getPermissions(); - $this->assertQueryCount($this->cacheRunCount); + $this->assertQueryCount($this->cacheInitCount + $this->cacheLoadCount + $this->cacheRunCount); } public function testItFlushesTheCacheWhenUpdatingAPermission(): void @@ -62,7 +74,7 @@ public function testItFlushesTheCacheWhenUpdatingAPermission(): void $this->registrar->getPermissions(); - $this->assertQueryCount($this->cacheRunCount); + $this->assertQueryCount($this->cacheInitCount + $this->cacheLoadCount + $this->cacheRunCount); } public function testItFlushesTheCacheWhenCreatingARole(): void @@ -73,7 +85,7 @@ public function testItFlushesTheCacheWhenCreatingARole(): void $this->registrar->getPermissions(); - $this->assertQueryCount($this->cacheRunCount); + $this->assertQueryCount($this->cacheInitCount + $this->cacheLoadCount + $this->cacheRunCount); } public function testItFlushesTheCacheWhenUpdatingARole(): void @@ -87,7 +99,7 @@ public function testItFlushesTheCacheWhenUpdatingARole(): void $this->registrar->getPermissions(); - $this->assertQueryCount($this->cacheRunCount); + $this->assertQueryCount($this->cacheInitCount + $this->cacheLoadCount + $this->cacheRunCount); } public function testItShouldNotFlushTheCacheWhenRemovingAPermissionFromAUser(): void @@ -132,7 +144,7 @@ public function testItFlushesTheCacheWhenRemovingARoleFromAPermission(): void $this->registrar->getPermissions(); - $this->assertQueryCount($this->cacheRunCount); + $this->assertQueryCount($this->cacheInitCount + $this->cacheLoadCount + $this->cacheRunCount); } public function testItFlushesTheCacheWhenAssigningAPermissionToARole(): void @@ -143,7 +155,7 @@ public function testItFlushesTheCacheWhenAssigningAPermissionToARole(): void $this->registrar->getPermissions(); - $this->assertQueryCount($this->cacheRunCount); + $this->assertQueryCount($this->cacheInitCount + $this->cacheLoadCount + $this->cacheRunCount); } public function testItShouldNotFlushTheCacheOnUserCreation(): void @@ -167,7 +179,7 @@ public function testItFlushesTheCacheWhenGivingAPermissionToARole(): void $this->registrar->getPermissions(); - $this->assertQueryCount($this->cacheRunCount); + $this->assertQueryCount($this->cacheInitCount + $this->cacheLoadCount + $this->cacheRunCount); } public function testNoOpPermissionSyncDoesNotFlushTheCache(): void @@ -202,6 +214,7 @@ public function testItUsesTheCacheForHasPermissionTo(): void $this->testUser->assignRole('testRole'); $this->testUser->loadMissing('roles', 'permissions'); + // assignRole() loaded the catalog to find the role by name, so the first check is cached too. $this->resetQueryCount(); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); $this->assertQueryCount(0); @@ -219,7 +232,7 @@ public function testItUsesTheCacheForHasPermissionTo(): void $this->assertQueryCount(0); } - public function testColdAuthorizationUsesFiveQueries(): void + public function testColdAuthorizationLoadsTheCatalogAndTheModelAssignmentsOnce(): void { $this->testUserRole->givePermissionTo('edit-articles'); $this->testUser->assignRole('testRole'); @@ -228,16 +241,25 @@ public function testColdAuthorizationUsesFiveQueries(): void $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); - $this->assertQueryCount($this->cacheRunCount + 2); + // The catalog, the user's direct permissions and the user's roles are each filled once. + // A database store also reads the assignment token and reads the filled roles again. + $this->assertQueryCount( + $this->cacheRunCount + 2 + + 3 * ($this->cacheInitCount + $this->cacheLoadCount) + + 2 * $this->cacheInitCount + ); } public function testItDifferentiatesTheCacheByGuardName(): void { + // Upstream passes the guard name as a permission name, so its test throws on the + // next line before reaching the guard check. Create that permission so it continues. $this->app->make(Permission::class)->create(['name' => 'web']); $this->testUserRole->givePermissionTo(['edit-articles', 'web']); $this->testUser->assignRole('testRole'); $this->testUser->loadMissing('roles', 'permissions'); + // assignRole() loaded the catalog to find the role by name. $this->resetQueryCount(); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles', 'web')); $this->assertQueryCount(0); @@ -255,6 +277,7 @@ public function testItUsesTheCacheForGetAllPermissions(): void $this->testUser->assignRole('testRole'); $this->testUser->loadMissing('roles.permissions', 'permissions'); + // assignRole() loaded the catalog to find the role by name. $this->resetQueryCount(); $this->registrar->getPermissions(); $this->assertQueryCount(0); @@ -266,12 +289,14 @@ public function testItUsesTheCacheForGetAllPermissions(): void $this->assertQueryCount(0); } - public function testItStoresRoleAttributesOnceWhileHydratingPermissionPivots(): void + public function testItShouldNotOverHydrateRolesForGetAllPermissions(): void { $this->testUserRole->givePermissionTo(['edit-articles', 'edit-news']); $permissions = $this->registrar->getPermissions(); $roles = $permissions->flatMap->roles; + // Each role carries its own role-permission pivot with the deny flag, so permissions + // can't share upstream's single role instance. The cache stores role attributes once. $this->assertNotSame($roles[0], $roles[1]); $this->assertSame($roles[0]->getKey(), $roles[1]->getKey()); $this->assertNotSame( @@ -303,13 +328,13 @@ public function testItCanResetTheCacheWithArtisanCommand(): void $this->resetQueryCount(); $this->registrar->getPermissions(); - $this->assertQueryCount($this->cacheRunCount); + $this->assertQueryCount($this->cacheInitCount + $this->cacheLoadCount + $this->cacheRunCount); Artisan::call('permission:cache-reset'); $this->resetQueryCount(); $this->registrar->getPermissions(); - $this->assertQueryCount($this->cacheRunCount); + $this->assertQueryCount($this->cacheInitCount + $this->cacheLoadCount + $this->cacheRunCount); } public function testItShowsAnErrorWhenTheCacheExistsButCannotBeFlushed(): void diff --git a/tests/Permission/CustomGateTest.php b/tests/Permission/Integration/CustomGateTest.php similarity index 70% rename from tests/Permission/CustomGateTest.php rename to tests/Permission/Integration/CustomGateTest.php index 91130c5b85..45f333cb18 100644 --- a/tests/Permission/CustomGateTest.php +++ b/tests/Permission/Integration/CustomGateTest.php @@ -2,10 +2,11 @@ declare(strict_types=1); -namespace Hypervel\Tests\Permission; +namespace Hypervel\Tests\Permission\Integration; use Hypervel\Contracts\Auth\Access\Gate; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Tests\Permission\TestCase; class CustomGateTest extends TestCase { @@ -16,15 +17,15 @@ protected function defineEnvironment(ApplicationContract $app): void $app->make('config')->set('permission.register_permission_check_method', false); } - public function testItDoesNotRegisterPermissionCheckMethodWhenDisabled(): void + public function testItDoesntRegisterTheMethodForCheckingPermissionsOnTheGate(): void { $this->testUser->givePermissionTo('edit-articles'); - $this->assertSame([], $this->app->make(Gate::class)->abilities()); + $this->assertEmpty($this->app->make(Gate::class)->abilities()); $this->assertFalse($this->testUser->can('edit-articles')); } - public function testItCanAuthorizeUsingCustomGateDefinition(): void + public function testItCanAuthorizeUsingCustomMethodForCheckingPermissions(): void { $this->app->make(Gate::class)->define('edit-articles', fn (): bool => true); diff --git a/tests/Permission/GateTest.php b/tests/Permission/Integration/GateTest.php similarity index 59% rename from tests/Permission/GateTest.php rename to tests/Permission/Integration/GateTest.php index 485865d982..b09a0ab1d4 100644 --- a/tests/Permission/GateTest.php +++ b/tests/Permission/Integration/GateTest.php @@ -2,29 +2,33 @@ declare(strict_types=1); -namespace Hypervel\Tests\Permission; +namespace Hypervel\Tests\Permission\Integration; use Hypervel\Contracts\Auth\Access\Gate; use Hypervel\Permission\Contracts\Permission; use Hypervel\Tests\Permission\Fixtures\Models\TestRolePermissionsEnum; +use Hypervel\Tests\Permission\TestCase; class GateTest extends TestCase { - public function testItDeniesMissingPermissionsThroughGate(): void + public function testItCanDetermineIfAUserDoesNotHaveAPermission(): void { $this->assertFalse($this->testUser->can('edit-articles')); } - public function testOtherGateBeforeCallbacksCanGrantMissingPermissions(): void + public function testItAllowsOtherGateBeforeCallbacksToRunIfAUserDoesNotHaveAPermission(): void { $this->assertFalse($this->testUser->can('edit-articles')); - $this->app->make(Gate::class)->before(fn (): bool => true); + $this->app->make(Gate::class)->before(function (): bool { + // this Gate-before intercept overrides everything to true ... like a typical Super-Admin might use + return true; + }); $this->assertTrue($this->testUser->can('edit-articles')); } - public function testGateAfterCallbackCanGrantDeniedPrivileges(): void + public function testItAllowsGateAfterCallbackToGrantDeniedPrivileges(): void { $this->assertFalse($this->testUser->can('edit-articles')); @@ -33,7 +37,7 @@ public function testGateAfterCallbackCanGrantDeniedPrivileges(): void $this->assertTrue($this->testUser->can('edit-articles')); } - public function testItAllowsDirectPermissionsThroughGate(): void + public function testItCanDetermineIfAUserHasADirectPermission(): void { $this->testUser->givePermissionTo('edit-articles'); @@ -42,9 +46,26 @@ public function testItAllowsDirectPermissionsThroughGate(): void $this->assertFalse($this->testUser->can('admin-permission')); } - public function testItAllowsRolePermissionsThroughGate(): void + public function testItCanDetermineIfAUserHasADirectPermissionUsingEnums(): void + { + $enum = TestRolePermissionsEnum::ViewArticles; + + $permission = $this->app->make(Permission::class)->findOrCreate($enum->value, 'web'); + + $this->assertFalse($this->testUser->can($enum->value)); + $this->assertFalse($this->testUser->canAny([$enum->value, 'some other permission'])); + + $this->testUser->givePermissionTo($enum); + + $this->assertTrue($this->testUser->hasPermissionTo($enum)); + $this->assertTrue($this->testUser->can($enum->value)); + $this->assertTrue($this->testUser->canAny([$enum->value, 'some other permission'])); + } + + public function testItCanDetermineIfAUserHasAPermissionThroughRoles(): void { $this->testUserRole->givePermissionTo($this->testUserPermission); + $this->testUser->assignRole($this->testUserRole); $this->assertTrue($this->testUser->hasPermissionTo($this->testUserPermission)); @@ -53,9 +74,10 @@ public function testItAllowsRolePermissionsThroughGate(): void $this->assertFalse($this->testUser->can('admin-permission')); } - public function testItAllowsRolePermissionsForUsersWithDifferentGuardsThroughGate(): void + public function testItCanDetermineIfAUserWithADifferentGuardHasAPermissionWhenUsingRoles(): void { $this->testAdminRole->givePermissionTo($this->testAdminPermission); + $this->testAdmin->assignRole($this->testAdminRole); $this->assertTrue($this->testAdmin->hasPermissionTo($this->testAdminPermission)); @@ -64,20 +86,6 @@ public function testItAllowsRolePermissionsForUsersWithDifferentGuardsThroughGat $this->assertFalse($this->testAdmin->can('edit-articles')); } - public function testItAllowsEnumPermissionsThroughGate(): void - { - $this->app->make(Permission::class)::findOrCreate(TestRolePermissionsEnum::ViewArticles); - - $this->assertFalse($this->testUser->can(TestRolePermissionsEnum::ViewArticles->value)); - $this->assertFalse($this->testUser->canAny([TestRolePermissionsEnum::ViewArticles->value, 'missing'])); - - $this->testUser->givePermissionTo(TestRolePermissionsEnum::ViewArticles); - - $this->assertTrue($this->testUser->hasPermissionTo(TestRolePermissionsEnum::ViewArticles)); - $this->assertTrue($this->testUser->can(TestRolePermissionsEnum::ViewArticles->value)); - $this->assertTrue($this->testUser->canAny([TestRolePermissionsEnum::ViewArticles->value, 'missing'])); - } - public function testDeniedPermissionDeniesGatePermission(): void { $this->testUser->givePermissionTo('edit-articles'); diff --git a/tests/Permission/Integration/MultipleGuardsTest.php b/tests/Permission/Integration/MultipleGuardsTest.php index c9cf5083c6..8efef15be8 100644 --- a/tests/Permission/Integration/MultipleGuardsTest.php +++ b/tests/Permission/Integration/MultipleGuardsTest.php @@ -4,13 +4,27 @@ namespace Hypervel\Tests\Permission\Integration; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Permission\Contracts\Permission; use Hypervel\Tests\Permission\Fixtures\Models\Manager; use Hypervel\Tests\Permission\TestCase; class MultipleGuardsTest extends TestCase { - public function testItCanGivePermissionsToAModelUsedByMultipleGuards(): void + protected function defineEnvironment(ApplicationContract $app): void + { + parent::defineEnvironment($app); + + $app->make('config')->set('auth.guards', [ + 'web' => ['driver' => 'session', 'provider' => 'users'], + 'api' => ['driver' => 'token', 'provider' => 'users'], + 'jwt' => ['driver' => 'token', 'provider' => 'users'], + 'abc' => ['driver' => 'abc'], + 'admin' => ['driver' => 'session', 'provider' => 'admins'], + ]); + } + + public function testItCanGiveAPermissionToAModelThatIsUsedByMultipleGuards(): void { $this->testUser->givePermissionTo($this->app->make(Permission::class)::create([ 'name' => 'do_this', @@ -27,7 +41,7 @@ public function testItCanGivePermissionsToAModelUsedByMultipleGuards(): void $this->assertFalse($this->testUser->checkPermissionTo('do_that', 'web')); } - public function testGateCanGrantPermissionByGuardName(): void + public function testTheGateCanGrantPermissionToAUserByPassingAGuardName(): void { $this->testUser->givePermissionTo($this->app->make(Permission::class)::create([ 'name' => 'do_this', @@ -42,6 +56,7 @@ public function testGateCanGrantPermissionByGuardName(): void $this->assertTrue($this->testUser->can('do_this', 'web')); $this->assertTrue($this->testUser->can('do_that', 'api')); $this->assertFalse($this->testUser->can('do_that', 'web')); + $this->assertTrue($this->testUser->cannot('do_that', 'web')); $this->assertTrue($this->testUser->canAny(['do_this', 'do_that'], 'web')); @@ -52,18 +67,21 @@ public function testGateCanGrantPermissionByGuardName(): void $this->assertTrue($this->testAdmin->can('admin-permission')); $this->assertTrue($this->testAdmin->can('admin-permission', 'admin')); $this->assertTrue($this->testAdmin->cannot('admin-permission', 'web')); + $this->assertTrue($this->testAdmin->cannot('non-existing-permission')); $this->assertTrue($this->testAdmin->cannot('non-existing-permission', 'web')); $this->assertTrue($this->testAdmin->cannot('non-existing-permission', 'admin')); $this->assertTrue($this->testAdmin->cannot(['admin-permission', 'non-existing-permission'], 'web')); + $this->assertFalse($this->testAdmin->can('edit-articles', 'web')); $this->assertFalse($this->testAdmin->can('edit-articles', 'admin')); + $this->assertTrue($this->testUser->cannot('edit-articles', 'admin')); $this->assertTrue($this->testUser->cannot('admin-permission', 'admin')); $this->assertTrue($this->testUser->cannot('admin-permission', 'web')); } - public function testItHonorsGuardNameMethodWhenOverridingGuardNameProperty(): void + public function testItCanHonourGuardNameFunctionOnModelForOverridingGuardNameProperty(): void { $user = Manager::create(['email' => 'manager@test.com']); $user->givePermissionTo($this->app->make(Permission::class)::create([ @@ -71,8 +89,11 @@ public function testItHonorsGuardNameMethodWhenOverridingGuardNameProperty(): vo 'guard_name' => 'jwt', ])); + // Manager test user has the guardName override method, which returns 'jwt' $this->assertTrue($user->checkPermissionTo('do_jwt', 'jwt')); $this->assertTrue($user->hasPermissionTo('do_jwt', 'jwt')); + + // Manager test user has the $guard_name property set to 'web' $this->assertFalse($user->checkPermissionTo('do_jwt', 'web')); } } diff --git a/tests/Permission/Integration/PermissionRegistrarTest.php b/tests/Permission/Integration/PermissionRegistrarTest.php index 253ac02dfd..4669dd1f9a 100644 --- a/tests/Permission/Integration/PermissionRegistrarTest.php +++ b/tests/Permission/Integration/PermissionRegistrarTest.php @@ -14,8 +14,12 @@ use Hypervel\Permission\Models\Role as HypervelRole; use Hypervel\Permission\PermissionRegistrar; use Hypervel\Permission\Support\Config; +use Hypervel\Support\Facades\Cache; +use Hypervel\Support\Facades\DB; +use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Permission\Fixtures\Models\Permission as TestPermission; use Hypervel\Tests\Permission\Fixtures\Models\Role as TestRole; +use Hypervel\Tests\Permission\Fixtures\Models\Team; use Hypervel\Tests\Permission\TestCase; use InvalidArgumentException; @@ -34,6 +38,92 @@ public function testItCanClearLoadedPermissionsCollection(): void $this->assertFalse(CoroutineContext::has(PermissionRegistrar::PERMISSION_CATALOG_CONTEXT_KEY)); } + public function testItClearsTheLoadedPermissionsCollectionWhenReinitializingTheCache(): void + { + $registrar = $this->app->make(PermissionRegistrar::class); + + $registrar->getPermissions(); + + $this->assertTrue(CoroutineContext::has(PermissionRegistrar::PERMISSION_CATALOG_CONTEXT_KEY)); + + $registrar->initializeCache(); + + $this->assertFalse(CoroutineContext::has(PermissionRegistrar::PERMISSION_CATALOG_CONTEXT_KEY)); + } + + public function testItDoesNotLeakAPreviousTenantsPermissionsAfterSwitchingCacheContextViaInitializeCache(): void + { + // Two separate cache "stores" stand in for two tenants' cache namespaces + // (e.g. distinct cache prefixes/connections in a real multi-tenant app). + config([ + 'cache.stores.tenant_a' => ['driver' => 'array'], + 'cache.stores.tenant_b' => ['driver' => 'array'], + ]); + + // Insert both tenants' rows via the query builder, bypassing Eloquent, + // so the RefreshesPermissionCache model events don't auto-bust the cache and + // mask the very staleness this test is meant to catch. + $tenantAId = DB::table('permissions')->insertGetId([ + 'name' => 'tenant-permission', + 'guard_name' => 'web', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + config(['permission.cache.store' => 'tenant_a']); + app(PermissionRegistrar::class)->initializeCache(); + + $loaded = app(PermissionRegistrar::class)->getPermissions()->firstWhere('name', 'tenant-permission'); + $this->assertSame($tenantAId, $loaded->getKey()); + + // Simulate switching to tenant B: its own row for the "same" permission has + // a different primary key, as it would in a separate tenant database. + DB::table('permissions')->where('id', $tenantAId)->delete(); + $tenantBId = DB::table('permissions')->insertGetId([ + 'name' => 'tenant-permission', + 'guard_name' => 'web', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->assertNotSame($tenantAId, $tenantBId); + + config(['permission.cache.store' => 'tenant_b']); + app(PermissionRegistrar::class)->initializeCache(); + + $loaded = app(PermissionRegistrar::class)->getPermissions()->firstWhere('name', 'tenant-permission'); + $this->assertSame($tenantBId, $loaded->getKey()); + } + + public function testItPicksUpAReboundCacheManagerWhenInitializeCacheRunsAfterTheContainerCacheBindingIsReplaced(): void + { + // Neither the array nor the file store apply cache.prefix (only + // database/redis/storage do), so this needs a store that + // actually honours the prefix to observe the staleness. + if (! Schema::hasTable('cache')) { + $this->createCacheTable(); + } + config()->set('cache.default', 'database'); + + // This mirrors what spatie/laravel-multitenancy's PrefixCacheTask does on every + // tenant switch: change cache.prefix, then forget the container's cache + // singletons so they get rebuilt against the new prefix. + $switchPrefix = function (string $prefix): void { + config()->set('cache.prefix', $prefix); + app('cache')->forgetDriver(config('cache.default')); + app()->forgetInstance('cache'); + app()->forgetInstance('cache.store'); + Cache::clearResolvedInstances(); + }; + + $switchPrefix('tenant_a_'); + app(PermissionRegistrar::class)->initializeCache(); + $this->assertSame('tenant_a_', app(PermissionRegistrar::class)->getCacheStore()->getPrefix()); + + $switchPrefix('tenant_b_'); + app(PermissionRegistrar::class)->initializeCache(); + $this->assertSame('tenant_b_', app(PermissionRegistrar::class)->getCacheStore()->getPrefix()); + } + public function testItCanCheckUids(): void { $uids = [ @@ -90,7 +180,12 @@ public function testItCanChangePermissionClass(): void $this->app->make(PermissionRegistrar::class)->setPermissionClass(TestPermission::class); - $this->assertSame(HypervelPermission::class, $this->app->make('config')->get('permission.models.permission')); + $this->assertSame(TestPermission::class, $this->app->make('config')->get('permission.models.permission')); + $this->assertSame(TestPermission::class, $this->app->make(PermissionRegistrar::class)->getPermissionClass()); + $this->assertInstanceOf(TestPermission::class, $this->app->make(PermissionContract::class)); + + $this->app->make(PermissionRegistrar::class)->initializeCache(); + $this->assertSame(TestPermission::class, $this->app->make(PermissionRegistrar::class)->getPermissionClass()); $this->assertInstanceOf(TestPermission::class, $this->app->make(PermissionContract::class)); } @@ -109,11 +204,35 @@ public function testItCanChangeRoleClass(): void $this->app->make(PermissionRegistrar::class)->setRoleClass(TestRole::class); - $this->assertSame(HypervelRole::class, $this->app->make('config')->get('permission.models.role')); + $this->assertSame(TestRole::class, $this->app->make('config')->get('permission.models.role')); + $this->assertSame(TestRole::class, $this->app->make(PermissionRegistrar::class)->getRoleClass()); + $this->assertInstanceOf(TestRole::class, $this->app->make(RoleContract::class)); + + $this->app->make(PermissionRegistrar::class)->initializeCache(); + $this->assertSame(TestRole::class, $this->app->make(PermissionRegistrar::class)->getRoleClass()); $this->assertInstanceOf(TestRole::class, $this->app->make(RoleContract::class)); } + public function testItCanChangeTeamClass(): void + { + $registrar = $this->app->make(PermissionRegistrar::class); + + $this->assertNull($registrar->getTeamClass()); + + $registrar->setTeamClass(Team::class); + $registrar->initializeCache(); + + $this->assertSame(Team::class, $this->app->make('config')->get('permission.models.team')); + $this->assertSame(Team::class, $registrar->getTeamClass()); + + $registrar->setTeamClass(null); + $registrar->initializeCache(); + + $this->assertNull($this->app->make('config')->get('permission.models.team')); + $this->assertNull($registrar->getTeamClass()); + } + public function testItCanChangeTeamId(): void { $teamId = '00000000-0000-0000-0000-000000000000'; @@ -132,6 +251,23 @@ public function testItCanChangeTeamIdUsingAModelInstance(): void $this->assertSame($this->testUser->getKey(), $registrar->getPermissionsTeamId()); } + public function testItRejectsAnUndefinedCacheStore(): void + { + // Upstream silently falls back to the array store, which hides the configuration + // error and changes the selected backend. + config()->set('permission.cache.store', 'this-store-does-not-exist'); + + app(PermissionRegistrar::class)->initializeCache(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('Cache store [this-store-does-not-exist] is not defined.'); + + app(PermissionRegistrar::class)->getCacheStore(); + } + + // REMOVED: upstream's "retries loading permissions when another load is already in progress". + // The loaded catalog is coroutine-local, so there is no shared in-progress load to wait for. + public function testPermissionLookupUsesGuardExactCatalogIndex(): void { $permissionClass = $this->app->make(PermissionContract::class); @@ -256,7 +392,6 @@ public function testInitializeCacheUsesOptionalConfigurationDefaults(): void unset( $permissionConfig['models']['team'], $permissionConfig['models']['default_model'], - $permissionConfig['team_resolver'], $permissionConfig['cache']['expiration_seconds'], $permissionConfig['cache']['store'], $permissionConfig['cache']['column_names_except'], @@ -273,7 +408,7 @@ public function testInitializeCacheUsesOptionalConfigurationDefaults(): void $this->assertNull($registrar->getTeamClass()); $this->assertNull(Config::defaultModel()); $this->assertSame('team-a', $registrar->getPermissionsTeamId()); - $this->assertSame(86400, $registrar->cacheExpirationTime); + $this->assertSame(PermissionRegistrar::DEFAULT_CACHE_EXPIRATION_SECONDS, $registrar->cacheExpirationTime); $this->assertSame($this->app->make('cache')->store()->getStore(), $registrar->getCacheStore()); $role = $this->app->make(RoleContract::class)::findByName('testRole'); @@ -326,7 +461,7 @@ public function testInitializeCacheRejectsRequiredDefaultModelColumns(): void ); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( + $this->expectExceptionMessageIs( 'Permission cache column exclusions cannot contain required role columns [id, name, guard_name] ' . 'or permission columns [id, name, guard_name].' ); @@ -343,7 +478,7 @@ public function testInitializeCacheUsesConfiguredModelPrimaryKeys(): void ]); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( + $this->expectExceptionMessageIs( 'Permission cache column exclusions cannot contain required role columns [role_test_id] ' . 'or permission columns [permission_test_id].' ); @@ -359,7 +494,7 @@ public function testInitializeCacheRejectsTheTeamColumnOnlyForRoles(): void ]); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('role columns [team_test_id]'); + $this->expectExceptionMessageIsOrContains('role columns [team_test_id]'); $this->app->make(PermissionRegistrar::class)->initializeCache(); } diff --git a/tests/Permission/Integration/PolicyTest.php b/tests/Permission/Integration/PolicyTest.php index 2875f90df7..55f753773f 100644 --- a/tests/Permission/Integration/PolicyTest.php +++ b/tests/Permission/Integration/PolicyTest.php @@ -23,10 +23,11 @@ public function testPolicyMethodsAndBeforeInterceptsCanAllowAndDeny(): void $this->assertTrue($this->testUser->can('update', $record2)); + // test that the Admin cannot yet view 'special admin content', because doesn't have Role yet $this->assertFalse($this->testAdmin->can('update', $record1)); $this->testAdmin->assignRole($this->testAdminRole); - + // test that the Admin can view 'special admin content' $this->assertTrue($this->testAdmin->can('update', $record1)); $this->assertTrue($this->testAdmin->can('update', $record2)); } diff --git a/tests/Permission/Integration/WildcardRouteTest.php b/tests/Permission/Integration/WildcardRouteTest.php index 0fc294a61e..66741b8e8c 100644 --- a/tests/Permission/Integration/WildcardRouteTest.php +++ b/tests/Permission/Integration/WildcardRouteTest.php @@ -4,16 +4,16 @@ namespace Hypervel\Tests\Permission\Integration; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Tests\Permission\TestCase; class WildcardRouteTest extends TestCase { - protected function setUp(): void + protected function defineEnvironment(ApplicationContract $app): void { - parent::setUp(); + parent::defineEnvironment($app); - $this->app->make('config')->set('permission.enable_wildcard_permission', true); - $this->flushPermissionState(); + $app->make('config')->set('permission.enable_wildcard_permission', true); } public function testPermissionFunction(): void diff --git a/tests/Permission/PermissionServiceProviderTest.php b/tests/Permission/PermissionServiceProviderTest.php index 1c86abc95b..942a7162e5 100644 --- a/tests/Permission/PermissionServiceProviderTest.php +++ b/tests/Permission/PermissionServiceProviderTest.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Permission; use Hypervel\Contracts\Foundation\Application as ApplicationContract; -use Hypervel\Permission\DefaultTeamResolver; use Hypervel\Permission\PermissionRegistrar; use Hypervel\Permission\PermissionServiceProvider; use Hypervel\Testbench\TestCase; @@ -22,7 +21,7 @@ public function testCanonicalOptionalDefaultsAreDeclared(): void { $config = require dirname(__DIR__, 2) . '/src/permission/config/permission.php'; - $this->assertSame(DefaultTeamResolver::class, $config['team_resolver']); + $this->assertSame(PermissionRegistrar::DEFAULT_CACHE_EXPIRATION_SECONDS, $config['cache']['expiration_seconds']); $this->assertSame(PermissionRegistrar::DEFAULT_CACHE_COLUMN_NAMES_EXCEPT, $config['cache']['column_names_except']); $this->assertSame(PermissionRegistrar::DEFAULT_TEAM_FOREIGN_KEY, $config['column_names']['team_foreign_key']); $this->assertSame(PermissionRegistrar::ROLE_CATALOG_CACHE_KEY, $config['cache']['keys']['roles']); @@ -41,7 +40,7 @@ public function testMigrationReportsWhenPermissionConfigurationIsNotLoaded(): vo . '/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php'; $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Error: config/permission.php not loaded.'); + $this->expectExceptionMessageIsOrContains('Error: config/permission.php not loaded.'); $migration->up(); } diff --git a/tests/Permission/Support/ConfigTest.php b/tests/Permission/Support/ConfigTest.php index a56ef8ab99..a15a3d9bff 100644 --- a/tests/Permission/Support/ConfigTest.php +++ b/tests/Permission/Support/ConfigTest.php @@ -47,24 +47,12 @@ public function testDefaultGuardFollowsCurrentGuard(): void $this->assertSame('admin', Config::defaultGuard()); } - public function testOptionalFeatureSettingsUseOwnedDefaultsWhenOmitted(): void + public function testWildcardPermissionClassUsesItsDefaultWhenOmitted(): void { $permissionConfig = config()->array('permission'); - unset( - $permissionConfig['events_enabled'], - $permissionConfig['use_passport_client_credentials'], - $permissionConfig['display_role_in_exception'], - $permissionConfig['display_permission_in_exception'], - $permissionConfig['enable_wildcard_permission'], - $permissionConfig['wildcard_permission'], - ); + unset($permissionConfig['wildcard_permission']); config()->set('permission', $permissionConfig); - $this->assertFalse(Config::eventsEnabled()); - $this->assertFalse(Config::usePassportClientCredentials()); - $this->assertFalse(Config::displayRoleInException()); - $this->assertFalse(Config::displayPermissionInException()); - $this->assertFalse(Config::wildcardPermissionsEnabled()); $this->assertSame(WildcardPermission::class, Config::wildcardPermissionClass()); } } diff --git a/tests/Permission/TestCase.php b/tests/Permission/TestCase.php index d333d9d638..d470ad0908 100644 --- a/tests/Permission/TestCase.php +++ b/tests/Permission/TestCase.php @@ -6,6 +6,7 @@ use Hypervel\Auth\EloquentUserProvider; use Hypervel\Cache\CacheManager; +use Hypervel\Cache\DatabaseStore; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Schema\Blueprint; @@ -18,6 +19,7 @@ use Hypervel\Permission\Guard; use Hypervel\Permission\PermissionRegistrar; use Hypervel\Permission\PermissionServiceProvider; +use Hypervel\Support\Facades\Auth; use Hypervel\Support\Facades\Route; use Hypervel\Support\Facades\Schema; use Hypervel\Testbench\TestCase as TestbenchTestCase; @@ -27,6 +29,9 @@ use Hypervel\Tests\Permission\Fixtures\Models\Role; use Hypervel\Tests\Permission\Fixtures\Models\Team; use Hypervel\Tests\Permission\Fixtures\Models\User; +use Hypervel\Tests\Permission\Fixtures\PassportGuard; + +use function Hypervel\Testbench\default_migration_path; abstract class TestCase extends TestbenchTestCase { @@ -73,28 +78,10 @@ protected function defineEnvironment(ApplicationContract $app): void $app->make('config')->set([ 'database.default' => 'testing', 'permission.register_permission_check_method' => true, - 'permission.teams' => false, 'permission.column_names.model_morph_key' => 'model_test_id', 'permission.column_names.team_foreign_key' => 'team_test_id', 'permission.column_names.role_pivot_key' => 'role_test_id', 'permission.column_names.permission_pivot_key' => 'permission_test_id', - 'permission.cache' => [ - 'expiration_seconds' => 86400, - 'store' => 'array', - 'keys' => [ - 'roles' => 'hypervel.permission.cache.roles', - 'model_roles' => 'hypervel.permission.cache.model.roles', - 'model_permissions' => 'hypervel.permission.cache.model.permissions', - 'model_token' => 'hypervel.permission.cache.model.token', - ], - 'column_names_except' => ['created_at', 'updated_at', 'deleted_at'], - ], - 'permission.models' => [ - 'permission' => \Hypervel\Permission\Models\Permission::class, - 'role' => \Hypervel\Permission\Models\Role::class, - 'team' => null, - 'default_model' => User::class, - ], 'auth.guards.web' => [ 'driver' => 'session', 'provider' => 'users', @@ -139,9 +126,9 @@ protected function defineEnvironment(ApplicationContract $app): void ], ], 'view.paths' => [__DIR__ . '/Fixtures/views'], - 'cache.default' => 'array', - 'cache.stores.array' => ['driver' => 'array'], 'cache.prefix' => 'permission_tests', + // Pruning expired database cache locks would add a random query to counted queries. + 'cache.stores.database.lock_lottery' => [0, 100], ]); } @@ -150,13 +137,19 @@ protected function defineEnvironment(ApplicationContract $app): void */ protected function migrateFreshUsing(): array { + $paths = [dirname(__DIR__, 2) . '/src/permission/database/migrations']; + + // The permission migration clears its cache keys, so a database store needs its tables first. + if ($this->app->make(PermissionRegistrar::class)->getCacheStore() instanceof DatabaseStore) { + $paths[] = default_migration_path() . '/0001_01_01_000003_testbench_create_cache_table.php'; + $paths[] = default_migration_path() . '/0001_01_01_000004_testbench_create_cache_locks_table.php'; + } + return [ '--seed' => $this->shouldSeed(), '--database' => $this->getRefreshConnection(), '--realpath' => true, - '--path' => [ - dirname(__DIR__, 2) . '/src/permission/database/migrations', - ], + '--path' => $paths, ]; } @@ -241,6 +234,17 @@ protected function setUpPassport(): void $this->testClientPermission = $this->app->make(PermissionContract::class)->create(['name' => 'edit-posts', 'guard_name' => 'api']); } + /** + * Authenticate the given client through the Passport guard. + * + * Hypervel has no Passport package, so this stands in for Passport::actingAsClient(). + */ + protected function actingAsClient(Client $client): void + { + Auth::extend('passport', fn (): PassportGuard => new PassportGuard($client)); + Auth::forgetGuards(); + } + /** * Set up team-aware permissions. */ @@ -251,6 +255,17 @@ protected function setUpTeams(): void setPermissionsTeamId(1); } + /** + * Enable teams before the permission tables are migrated. + * + * Upstream's migration always adds the roles team column under its `permission.testing` + * flag. Hypervel's migration adds team columns only when teams are enabled. + */ + protected function usesTeams(ApplicationContract $app): void + { + $app->make('config')->set('permission.teams', true); + } + /** * Set up custom role and permission models. */ @@ -290,6 +305,18 @@ protected function reloadPermissions(): void $this->app->make(PermissionRegistrar::class)->forgetCachedPermissions(); } + /** + * Create the database cache table. + */ + protected function createCacheTable(): void + { + Schema::create('cache', function (Blueprint $table): void { + $table->string('key')->unique(); + $table->text('value'); + $table->integer('expiration'); + }); + } + /** * Define test routes. */ From bc09bd42734433519e8e293ca6c56759336e187c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:19:56 +0000 Subject: [PATCH 02/18] Recheck model cache fills against the backing store The model cache coordinator rechecked the cache after acquiring its fill lock with a plain get(). Through a memoized repository, that read returned the miss remembered before the lock, so a fill finished by another request in the meantime was loaded and published again. Later fills in the same coroutine also locked and read the store again, because memoized writes forget their key. The recheck now uses getAuthoritativeRaw() when the repository supports it, matching Repository::flexible(). An envelope found by the recheck, or one published through the fill repository, replaces the coroutine's memo entry for a plain MemoizedStore. Failed writes, lost leases and lazy writer repositories are not remembered. Tagged keys never reach that memo. On the database cache store, a cold Permission catalog fill is now 9 statements and a cold authorization 24; the roles re-read after filling is gone. Validation: tests/Cache, tests/Auth, tests/Integration/Auth (with Redis), tests/Sanctum, tests/Permission on the array and database stores (only known later-slice failures), cs-fixer and composer analyse. --- src/cache/src/ModelCacheCoordinator.php | 49 ++++++++-- tests/Cache/ModelCacheCoordinatorTest.php | 106 +++++++++++++++++++++ tests/Permission/Integration/CacheTest.php | 9 +- 3 files changed, 152 insertions(+), 12 deletions(-) diff --git a/src/cache/src/ModelCacheCoordinator.php b/src/cache/src/ModelCacheCoordinator.php index c38ee3f609..c9ca1b717a 100644 --- a/src/cache/src/ModelCacheCoordinator.php +++ b/src/cache/src/ModelCacheCoordinator.php @@ -6,9 +6,11 @@ use Closure; use Hypervel\Cache\Exceptions\UnsupportedModelCacheStoreException; +use Hypervel\Contracts\Cache\AuthoritativeRawReadable; use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Contracts\Cache\RefreshableLock; use Hypervel\Contracts\Cache\Repository as CacheRepository; +use Hypervel\Contracts\Cache\Store; /** * Coordinate shared model cache fills and exact invalidations. @@ -65,14 +67,22 @@ public function fill( return $cached[self::ENVELOPE_VALUE_KEY]; } - $lock = $this->lock($cache, $key); + $store = $cache->getStore(); + $lock = $this->lock($store, $key); $acquired = false; $result = $lock - ->get(function () use ($cache, $key, $ttl, $read, $cacheNull, $writeCache, $lock, &$acquired): mixed { + ->get(function () use ($cache, $store, $key, $ttl, $read, $cacheNull, $writeCache, $lock, &$acquired): mixed { $acquired = true; - $cached = $cache->get($key); + + // A memoized read would repeat the miss above and hide a fill that + // finished before this lock was acquired. + $cached = $cache instanceof AuthoritativeRawReadable + ? $cache->getAuthoritativeRaw($key) + : $cache->get($key); if ($this->isEnvelope($cached)) { + $this->rememberEnvelope($store, $key, $cached); + return $cached[self::ENVELOPE_VALUE_KEY]; } @@ -88,8 +98,13 @@ public function fill( return $value; } - ($writeCache === null ? $cache : $writeCache()) - ->put($key, $this->envelope($value), $ttl); + $envelope = $this->envelope($value); + $published = ($writeCache === null ? $cache : $writeCache()) + ->put($key, $envelope, $ttl); + + if ($published && $writeCache === null) { + $this->rememberEnvelope($store, $key, $envelope); + } return $value; }); @@ -102,7 +117,7 @@ public function fill( */ public function invalidate(CacheRepository $cache, string $key): bool { - return (bool) $this->lock($cache, $key) + return (bool) $this->lock($cache->getStore(), $key) ->betweenBlockedAttemptsSleepFor(self::INVALIDATION_RETRY_MILLISECONDS) ->block( self::INVALIDATION_WAIT_SECONDS, @@ -123,6 +138,25 @@ private function envelope(mixed $value): array ]; } + /** + * Remember a shared envelope in a plain memoized store for the current coroutine. + * + * Memoized writes forget their key, and a memoized miss survives a store read + * that bypasses it, so later fills would otherwise lock and read the store again. + * + * @param array{__hypervel_model_cache: 'present', value: mixed} $envelope + */ + private function rememberEnvelope(Store $store, string $key, array $envelope): void + { + // Memoized tagged caches expose their backing store, so tagged keys never reach this memo. + if (! $store instanceof MemoizedStore) { + return; + } + + $store->forgetMemoized($key); + $store->memoize($key, fn (): array => $envelope); + } + /** * Determine whether the value is a cache presence envelope. */ @@ -139,9 +173,8 @@ private function isEnvelope(mixed $value): bool * * @throws UnsupportedModelCacheStoreException */ - private function lock(CacheRepository $cache, string $key): RefreshableLock + private function lock(Store $store, string $key): RefreshableLock { - $store = $cache->getStore(); $validatedStore = $store instanceof MemoizedStore ? $store->getInnerStore() : $store; diff --git a/tests/Cache/ModelCacheCoordinatorTest.php b/tests/Cache/ModelCacheCoordinatorTest.php index 2f467c0d9b..91a6dea832 100644 --- a/tests/Cache/ModelCacheCoordinatorTest.php +++ b/tests/Cache/ModelCacheCoordinatorTest.php @@ -101,6 +101,101 @@ public function testColdFillDoubleChecksAfterLockAcquisition(): void )); } + public function testColdFillDoubleChecksPastAMemoizedMissAndRemembersTheFill(): void + { + $coordinator = new ModelCacheCoordinator; + $backingRepository = new Repository(new ArrayStore); + $memoizedRepository = new Repository(new MemoizedStore('array', $backingRepository)); + + $this->assertNull($memoizedRepository->get('key')); + + // Another request fills the entry after this coroutine remembered the miss. + $coordinator->fill($backingRepository, 'key', 300, fn (): string => 'published'); + + $this->assertSame('published', $coordinator->fill( + $memoizedRepository, + 'key', + 300, + fn (): never => throw new RuntimeException('The source must not be read after a warm double-check.'), + )); + + $backingRepository->forget('key'); + + $this->assertSame('published', $coordinator->fill( + $memoizedRepository, + 'key', + 300, + fn (): never => throw new RuntimeException('The double-checked value must be reused without locking.'), + )); + } + + public function testPublishedFillIsRememberedForTheCurrentCoroutine(): void + { + $coordinator = new ModelCacheCoordinator; + $backingRepository = new Repository(new ArrayStore); + $memoizedRepository = new Repository(new MemoizedStore('array', $backingRepository)); + + $this->assertSame('user', $coordinator->fill($memoizedRepository, 'key', 300, fn (): string => 'user')); + + $backingRepository->forget('key'); + + $this->assertSame('user', $coordinator->fill( + $memoizedRepository, + 'key', + 300, + fn (): never => throw new RuntimeException('The published value must be reused without locking.'), + )); + } + + public function testFailedPublicationIsNotRemembered(): void + { + $coordinator = new ModelCacheCoordinator; + $memoizedRepository = new Repository(new MemoizedStore( + 'array', + new Repository(new FailingWriteCoordinatorArrayStore), + )); + $reads = 0; + + for ($iteration = 0; $iteration < 2; ++$iteration) { + $this->assertSame('user', $coordinator->fill( + $memoizedRepository, + 'key', + 300, + function () use (&$reads): string { + ++$reads; + + return 'user'; + }, + )); + } + + $this->assertSame(2, $reads); + } + + public function testLazyWriterPublicationIsNotRemembered(): void + { + $coordinator = new ModelCacheCoordinator; + $memoizedRepository = new Repository(new MemoizedStore('array', new Repository(new ArrayStore))); + $writerRepository = new Repository(new ArrayStore); + $reads = 0; + + for ($iteration = 0; $iteration < 2; ++$iteration) { + $this->assertSame('user', $coordinator->fill( + $memoizedRepository, + 'key', + 300, + function () use (&$reads): string { + ++$reads; + + return 'user'; + }, + writeCache: fn (): CacheRepository => $writerRepository, + )); + } + + $this->assertSame(2, $reads); + } + public function testLazyWriterIsResolvedOnlyWhenPublishing(): void { $coordinator = new ModelCacheCoordinator; @@ -620,6 +715,17 @@ protected function getCurrentOwner(): ?string } } +class FailingWriteCoordinatorArrayStore extends ArrayStore +{ + /** + * Report a failed write without storing the item. + */ + public function put(string $key, mixed $value, int $seconds): bool + { + return false; + } +} + class ContendedCoordinatorWorkerArrayStore extends WorkerArrayStore { public int $failedAcquisitions = 0; diff --git a/tests/Permission/Integration/CacheTest.php b/tests/Permission/Integration/CacheTest.php index aae5fb3371..73092091ae 100644 --- a/tests/Permission/Integration/CacheTest.php +++ b/tests/Permission/Integration/CacheTest.php @@ -37,9 +37,10 @@ protected function setUpInCoroutine(): void DB::connection()->enableQueryLog(); if ($this->registrar->getCacheStore() instanceof DatabaseStore) { - // A cold entry is read once, then filled under a cache lock: acquire, refresh, write and release. + // A cold entry is read once, then filled under a cache lock: acquire, read again, + // refresh, write and release. $this->cacheInitCount = 1; - $this->cacheLoadCount = 4; + $this->cacheLoadCount = 5; } } @@ -242,11 +243,11 @@ public function testColdAuthorizationLoadsTheCatalogAndTheModelAssignmentsOnce() $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); // The catalog, the user's direct permissions and the user's roles are each filled once. - // A database store also reads the assignment token and reads the filled roles again. + // A database store also reads the assignment token. $this->assertQueryCount( $this->cacheRunCount + 2 + 3 * ($this->cacheInitCount + $this->cacheLoadCount) - + 2 * $this->cacheInitCount + + $this->cacheInitCount ); } From de6ac34279d5353b381df94ef1d747d3ec6228b0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:39:56 +0000 Subject: [PATCH 03/18] Reconcile Permission middleware, model and assigned-model tests with upstream Bring the middleware, model and reverse-assignment tests in line with spatie/laravel-permission main at 6615eefac655 (8.x): upstream case names, order and assertions, including every client case. The separate Passport client middleware test is folded into the upstream client cases, keeping its disabled-credentials case and its same-permission precheck in the via-role case. Stronger Hypervel assertions stay inside the upstream-named cases: a non-matching wildcard permission is denied, and an admin-guard user is denied web-guard roles and permissions. Hypervel-only cases for JSON responses, users without Authorizable, the empty guard and integer-backed enums are kept. Wildcard and model tests move config to defineEnvironment() and seeding into the test coroutine, the teams assigned-model case uses a method-level environment, and its duplicate in the team variant is removed. Deprecated expectExceptionMessage() calls become expectExceptionMessageIsOrContains(). Source cleanups: - RoleMiddleware, RoleOrPermissionMiddleware and WildcardPermission call hasAnyRole() and getAllPermissions() directly like upstream instead of through Closure::fromCallable() static-analysis workarounds. - The middleware and the provider's route macros convert listed enum names with array_map(enum_value(...)) instead of untyped wrappers; the route macros keep mixed input and declare their Route return type. The middleware keep Hypervel's Authorizable check and per-name can() loop because the Authorizable contract does not include canAny(). Validation: these files pass on the array and database cache stores; the Permission suite shows only known later-slice failures; formatting and static analysis are clean. --- .../src/Middleware/PermissionMiddleware.php | 5 +- .../src/Middleware/RoleMiddleware.php | 5 +- .../Middleware/RoleOrPermissionMiddleware.php | 5 +- .../src/PermissionServiceProvider.php | 12 +- src/permission/src/WildcardPermission.php | 6 +- .../PassportClientMiddlewareTest.php | 181 ----------- .../Middleware/PermissionMiddlewareTest.php | 283 ++++++++++++------ .../Middleware/RoleMiddlewareTest.php | 236 +++++++++------ .../RoleOrPermissionMiddlewareTest.php | 143 ++++++--- .../Middleware/WildcardMiddlewareTest.php | 42 +-- tests/Permission/Models/PermissionTest.php | 7 +- tests/Permission/Models/RoleTest.php | 65 ++-- tests/Permission/Models/WildcardRoleTest.php | 11 +- .../Traits/HasAssignedModelsTest.php | 69 +++-- .../Traits/TeamHasAssignedModelsTest.php | 16 +- 15 files changed, 579 insertions(+), 507 deletions(-) delete mode 100644 tests/Permission/Middleware/PassportClientMiddlewareTest.php diff --git a/src/permission/src/Middleware/PermissionMiddleware.php b/src/permission/src/Middleware/PermissionMiddleware.php index 2e715f39eb..0bc252292d 100644 --- a/src/permission/src/Middleware/PermissionMiddleware.php +++ b/src/permission/src/Middleware/PermissionMiddleware.php @@ -79,10 +79,7 @@ protected static function parsePermissionsToString(array|string|UnitEnum $permis $permission = enum_value($permission); if (is_array($permission)) { - return implode('|', array_map( - fn ($name) => $name instanceof UnitEnum ? (string) enum_value($name) : $name, - $permission - )); + return implode('|', array_map(enum_value(...), $permission)); } return (string) $permission; diff --git a/src/permission/src/Middleware/RoleMiddleware.php b/src/permission/src/Middleware/RoleMiddleware.php index 9b9a834c91..365d8712f2 100644 --- a/src/permission/src/Middleware/RoleMiddleware.php +++ b/src/permission/src/Middleware/RoleMiddleware.php @@ -48,9 +48,8 @@ public function handle(Request $request, Closure $next, array|string|UnitEnum $r } $roles = explode('|', self::parseRolesToString($role)); - $hasAnyRole = Closure::fromCallable([$user, 'hasAnyRole']); - if (! $hasAnyRole($roles)) { + if (! $user->hasAnyRole($roles)) { throw UnauthorizedException::forRoles($roles); } @@ -77,7 +76,7 @@ protected static function parseRolesToString(array|string|UnitEnum $role): strin $role = enum_value($role); if (is_array($role)) { - return implode('|', array_map(fn ($r) => enum_value($r), $role)); + return implode('|', array_map(enum_value(...), $role)); } return (string) $role; diff --git a/src/permission/src/Middleware/RoleOrPermissionMiddleware.php b/src/permission/src/Middleware/RoleOrPermissionMiddleware.php index 7263322a63..160637aa4b 100644 --- a/src/permission/src/Middleware/RoleOrPermissionMiddleware.php +++ b/src/permission/src/Middleware/RoleOrPermissionMiddleware.php @@ -49,7 +49,6 @@ public function handle(Request $request, Closure $next, array|string|UnitEnum $r } $rolesOrPermissions = explode('|', self::parseRoleOrPermissionToString($roleOrPermission)); - $hasAnyRole = Closure::fromCallable([$user, 'hasAnyRole']); foreach ($rolesOrPermissions as $roleOrPermission) { if ($user->can($roleOrPermission)) { @@ -57,7 +56,7 @@ public function handle(Request $request, Closure $next, array|string|UnitEnum $r } } - if ($hasAnyRole($rolesOrPermissions)) { + if ($user->hasAnyRole($rolesOrPermissions)) { return $next($request); } @@ -83,7 +82,7 @@ protected static function parseRoleOrPermissionToString(array|string|UnitEnum $r $roleOrPermission = enum_value($roleOrPermission); if (is_array($roleOrPermission)) { - return implode('|', array_map(fn ($r) => enum_value($r), $roleOrPermission)); + return implode('|', array_map(enum_value(...), $roleOrPermission)); } return (string) $roleOrPermission; diff --git a/src/permission/src/PermissionServiceProvider.php b/src/permission/src/PermissionServiceProvider.php index 37de1a9128..01da98ceaf 100644 --- a/src/permission/src/PermissionServiceProvider.php +++ b/src/permission/src/PermissionServiceProvider.php @@ -141,25 +141,25 @@ protected function registerModelBindings(): void */ protected function registerMacroHelpers(): void { - Route::macro('role', function ($roles = []) { + Route::macro('role', function (mixed $roles = []): Route { $roles = Arr::wrap($roles); - $roles = array_map(fn ($role) => enum_value($role), $roles); + $roles = array_map(enum_value(...), $roles); /** @var Route $this */ return $this->middleware('role:' . implode('|', $roles)); }); - Route::macro('permission', function ($permissions = []) { + Route::macro('permission', function (mixed $permissions = []): Route { $permissions = Arr::wrap($permissions); - $permissions = array_map(fn ($permission) => enum_value($permission), $permissions); + $permissions = array_map(enum_value(...), $permissions); /** @var Route $this */ return $this->middleware('permission:' . implode('|', $permissions)); }); - Route::macro('roleOrPermission', function ($rolesOrPermissions = []) { + Route::macro('roleOrPermission', function (mixed $rolesOrPermissions = []): Route { $rolesOrPermissions = Arr::wrap($rolesOrPermissions); - $rolesOrPermissions = array_map(fn ($item) => enum_value($item), $rolesOrPermissions); + $rolesOrPermissions = array_map(enum_value(...), $rolesOrPermissions); /** @var Route $this */ return $this->middleware('role_or_permission:' . implode('|', $rolesOrPermissions)); diff --git a/src/permission/src/WildcardPermission.php b/src/permission/src/WildcardPermission.php index 20ceb67228..1eb09c2a23 100644 --- a/src/permission/src/WildcardPermission.php +++ b/src/permission/src/WildcardPermission.php @@ -4,7 +4,6 @@ namespace Hypervel\Permission; -use Closure; use Hypervel\Database\Eloquent\Model; use Hypervel\Permission\Contracts\Wildcard; use Hypervel\Permission\Exceptions\WildcardPermissionNotProperlyFormatted; @@ -34,9 +33,8 @@ public function getIndex(): array { $index = []; - $getAllPermissions = Closure::fromCallable([$this->record, 'getAllPermissions']); - - foreach ($getAllPermissions() as $permission) { + // @phpstan-ignore method.notFound (the record uses HasPermissions) + foreach ($this->record->getAllPermissions() as $permission) { $index[$permission->guard_name] = $this->buildIndex( $index[$permission->guard_name] ?? [], explode(static::PART_DELIMITER, $permission->name), diff --git a/tests/Permission/Middleware/PassportClientMiddlewareTest.php b/tests/Permission/Middleware/PassportClientMiddlewareTest.php deleted file mode 100644 index 6531b31e6b..0000000000 --- a/tests/Permission/Middleware/PassportClientMiddlewareTest.php +++ /dev/null @@ -1,181 +0,0 @@ -setUpPassportClient(); - - $this->testClient->givePermissionTo('edit-posts'); - - $this->assertSame(200, $this->runMiddleware($this->app->make(PermissionMiddleware::class), 'edit-posts', null, true)); - } - - public function testPassportClientCanAccessPermissionMiddlewareIfItHasOneOfThePermissions(): void - { - $this->setUpPassportClient(); - - $this->testClient->givePermissionTo('edit-posts'); - - $this->assertSame(200, $this->runMiddleware($this->app->make(PermissionMiddleware::class), 'edit-news|edit-posts', null, true)); - $this->assertSame(200, $this->runMiddleware($this->app->make(PermissionMiddleware::class), ['edit-news', 'edit-posts'], null, true)); - } - - public function testPassportClientCanAccessPermissionMiddlewareThroughRolePermission(): void - { - $this->setUpPassportClient(); - - $this->assertSame(403, $this->runMiddleware($this->app->make(PermissionMiddleware::class), 'edit-posts', null, true)); - - $this->testClientRole->givePermissionTo('edit-posts'); - $this->testClient->assignRole('clientRole'); - - $this->assertSame(200, $this->runMiddleware($this->app->make(PermissionMiddleware::class), 'edit-posts', null, true)); - } - - public function testPassportClientCannotAccessPermissionMiddlewareWithADifferentPermission(): void - { - $this->setUpPassportClient(); - - $this->testClient->givePermissionTo('edit-posts'); - - $this->assertSame(403, $this->runMiddleware($this->app->make(PermissionMiddleware::class), 'edit-news', null, true)); - } - - public function testPassportClientCannotAccessPermissionMiddlewareWithoutPermissions(): void - { - $this->setUpPassportClient(); - - $this->assertSame(403, $this->runMiddleware($this->app->make(PermissionMiddleware::class), 'edit-articles|edit-posts', null, true)); - } - - public function testPassportClientCanAccessRoleMiddleware(): void - { - $this->setUpPassportClient(); - - $this->testClient->assignRole('clientRole'); - - $this->assertSame(200, $this->runMiddleware($this->app->make(RoleMiddleware::class), 'clientRole', null, true)); - } - - public function testPassportClientCanAccessRoleMiddlewareIfItHasOneOfTheRoles(): void - { - $this->setUpPassportClient(); - - $this->testClient->assignRole('clientRole'); - - $this->assertSame(200, $this->runMiddleware($this->app->make(RoleMiddleware::class), 'clientRole|testRole2', null, true)); - $this->assertSame(200, $this->runMiddleware($this->app->make(RoleMiddleware::class), ['testRole2', 'clientRole'], null, true)); - } - - public function testPassportClientCannotAccessRoleMiddlewareWithADifferentRole(): void - { - $this->setUpPassportClient(); - - $this->testClient->assignRole('clientRole'); - - $this->assertSame(403, $this->runMiddleware($this->app->make(RoleMiddleware::class), 'clientRole2', null, true)); - } - - public function testPassportClientCannotAccessRoleMiddlewareWithoutRoles(): void - { - $this->setUpPassportClient(); - - $this->assertSame(403, $this->runMiddleware($this->app->make(RoleMiddleware::class), 'testRole|testRole2', null, true)); - } - - public function testPassportClientCannotAccessRoleMiddlewareWhenRoleIsUndefined(): void - { - $this->setUpPassportClient(); - - $this->assertSame(403, $this->runMiddleware($this->app->make(RoleMiddleware::class), '', null, true)); - } - - public function testPassportClientCanAccessRoleOrPermissionMiddleware(): void - { - $this->setUpPassportClient(); - - $this->testClient->assignRole('clientRole'); - $this->testClient->givePermissionTo('edit-posts'); - - $this->assertSame(200, $this->runMiddleware($this->app->make(RoleOrPermissionMiddleware::class), 'clientRole|edit-news|edit-posts', null, true)); - - $this->testClient->removeRole('clientRole'); - - $this->assertSame(200, $this->runMiddleware($this->app->make(RoleOrPermissionMiddleware::class), 'clientRole|edit-posts', null, true)); - - $this->testClient->revokePermissionTo('edit-posts'); - $this->testClient->assignRole('clientRole'); - - $this->assertSame(200, $this->runMiddleware($this->app->make(RoleOrPermissionMiddleware::class), 'clientRole|edit-posts', null, true)); - $this->assertSame(200, $this->runMiddleware($this->app->make(RoleOrPermissionMiddleware::class), ['clientRole', 'edit-posts'], null, true)); - } - - public function testPassportClientCannotAccessRoleOrPermissionMiddlewareWithoutTheRoleOrPermission(): void - { - $this->setUpPassportClient(); - - $this->assertSame(403, $this->runMiddleware($this->app->make(RoleOrPermissionMiddleware::class), 'clientRole|edit-posts', null, true)); - $this->assertSame(403, $this->runMiddleware($this->app->make(RoleOrPermissionMiddleware::class), 'missingRole|missingPermission', null, true)); - } - - public function testPassportClientIsNotUsedWhenFeatureIsDisabled(): void - { - $this->setUpPassportClient(); - - $this->app->make('config')->set('permission.use_passport_client_credentials', false); - - $this->testClient->givePermissionTo('edit-posts'); - - $this->assertSame(403, $this->runMiddleware($this->app->make(PermissionMiddleware::class), 'edit-posts', 'api', true)); - } - - public function testPassportClientMustMatchRequestedGuard(): void - { - $this->setUpPassportClient(); - - $this->testClient->givePermissionTo('edit-posts'); - - $this->assertSame(403, $this->runMiddleware($this->app->make(PermissionMiddleware::class), 'edit-posts', 'web', true)); - } - - public function testPassportClientCannotAccessRoleMiddlewareWithWrongGuard(): void - { - $this->setUpPassportClient(); - - $this->testClient->assignRole('clientRole'); - - $this->assertSame(403, $this->runMiddleware($this->app->make(RoleMiddleware::class), 'clientRole', 'admin', true)); - } - - public function testPassportClientCannotAccessRoleOrPermissionMiddlewareWithWrongGuard(): void - { - $this->setUpPassportClient(); - - $this->testClient->assignRole('clientRole'); - $this->testClient->givePermissionTo('edit-posts'); - - $this->assertSame(403, $this->runMiddleware($this->app->make(RoleOrPermissionMiddleware::class), 'edit-posts|clientRole', 'admin', true)); - } - - protected function setUpPassportClient(): void - { - $this->setUpPassport(); - - $client = $this->testClient; - - Auth::extend('passport', fn (): PassportGuard => new PassportGuard($client)); - Auth::forgetGuards(); - } -} diff --git a/tests/Permission/Middleware/PermissionMiddlewareTest.php b/tests/Permission/Middleware/PermissionMiddlewareTest.php index 4604843eac..c18ece5928 100644 --- a/tests/Permission/Middleware/PermissionMiddlewareTest.php +++ b/tests/Permission/Middleware/PermissionMiddlewareTest.php @@ -14,6 +14,7 @@ use Hypervel\Support\Facades\Gate; use Hypervel\Tests\Permission\Fixtures\Models\PlainAuthenticatableUser; use Hypervel\Tests\Permission\Fixtures\Models\TestRolePermissionsEnum; +use Hypervel\Tests\Permission\Fixtures\Models\User; use Hypervel\Tests\Permission\Fixtures\Models\UserWithoutHasRoles; use Hypervel\Tests\Permission\TestCase; use InvalidArgumentException; @@ -28,19 +29,66 @@ class PermissionMiddlewareTest extends TestCase { protected PermissionMiddleware $permissionMiddleware; - protected function setUp(): void + protected function setUpInCoroutine(): void { - parent::setUp(); - + $this->setUpPassport(); $this->permissionMiddleware = $this->app->make(PermissionMiddleware::class); } - public function testGuestCannotAccessPermissionProtectedRoute(): void + public function testAGuestCannotAccessARouteProtectedByThePermissionMiddleware(): void { $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-articles')); } - public function testUserCanAccessRouteWithDirectPermission(): void + public function testAUserCannotAccessARouteProtectedByThePermissionMiddlewareOfADifferentGuard(): void + { + // These permissions are created fresh here in reverse order of guard being applied, so they are not "found first" in the db lookup when matching + $this->app->make(Permission::class)->create(['name' => 'admin-permission2', 'guard_name' => 'web']); + $p1 = $this->app->make(Permission::class)->create(['name' => 'admin-permission2', 'guard_name' => 'admin']); + $this->app->make(Permission::class)->create(['name' => 'edit-articles2', 'guard_name' => 'admin']); + $p2 = $this->app->make(Permission::class)->create(['name' => 'edit-articles2', 'guard_name' => 'web']); + + Auth::guard('admin')->login($this->testAdmin); + + $this->testAdmin->givePermissionTo($p1); + + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'admin-permission2', 'admin')); + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-articles2', 'admin')); + + Auth::login($this->testUser); + + $this->testUser->givePermissionTo($p2); + + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'edit-articles2', 'web')); + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'admin-permission2', 'web')); + } + + public function testAClientCannotAccessARouteProtectedByThePermissionMiddlewareOfADifferentGuard(): void + { + // These permissions are created fresh here in reverse order of guard being applied, so they are not "found first" in the db lookup when matching + $this->app->make(Permission::class)->create(['name' => 'admin-permission2', 'guard_name' => 'web']); + $p1 = $this->app->make(Permission::class)->create(['name' => 'admin-permission2', 'guard_name' => 'api']); + + $this->actingAsClient($this->testClient); + + $this->testClient->givePermissionTo($p1); + + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'admin-permission2', 'api', true)); + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-articles2', 'web', true)); + } + + public function testASuperAdminUserCanAccessARouteProtectedByPermissionMiddleware(): void + { + Auth::login($this->testUser); + + Gate::before(function (User $user, string $ability): ?bool { + return $user->getKey() === $this->testUser->getKey() ? true : null; + }); + + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'edit-articles')); + } + + public function testAUserCanAccessARouteProtectedByPermissionMiddlewareIfHaveThisPermission(): void { Auth::login($this->testUser); @@ -64,16 +112,26 @@ public function testAuthorizedRequestPreservesJsonResponse(): void )); } - public function testSuperAdminGateBeforeCanAccessPermissionProtectedRoute(): void + public function testAClientCanAccessARouteProtectedByPermissionMiddlewareIfHaveThisPermission(): void { - Auth::login($this->testUser); + $this->actingAsClient($this->testClient); - Gate::before(fn ($user): ?bool => $user->getKey() === $this->testUser->getKey() ? true : null); + $this->testClient->givePermissionTo('edit-posts'); - $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'edit-articles')); + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'edit-posts', null, true)); + } + + public function testAClientIsNotUsedWhenPassportClientCredentialsAreDisabled(): void + { + $this->actingAsClient($this->testClient); + config()->set('permission.use_passport_client_credentials', false); + + $this->testClient->givePermissionTo('edit-posts'); + + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-posts', 'api', true)); } - public function testUserCanAccessRouteWithOneOfSeveralPermissions(): void + public function testAUserCanAccessARouteProtectedByThisPermissionMiddlewareIfHaveOneOfThePermissions(): void { Auth::login($this->testUser); @@ -83,22 +141,33 @@ public function testUserCanAccessRouteWithOneOfSeveralPermissions(): void $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, ['edit-news', 'edit-articles'])); } - public function testUserCanAccessRouteWithWildcardPermission(): void + public function testAClientCanAccessARouteProtectedByThisPermissionMiddlewareIfHaveOneOfThePermissions(): void { - $this->app->make('config')->set('permission.enable_wildcard_permission', true); - $this->flushPermissionState(); + $this->actingAsClient($this->testClient); - Auth::login($this->testUser); + $this->testClient->givePermissionTo('edit-posts'); - $this->app->make(Permission::class)::create(['name' => 'articles.*.test']); - $this->testUser->givePermissionTo('articles.*.test'); + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'edit-news|edit-posts', null, true)); + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, ['edit-news', 'edit-posts'], null, true)); + } - $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'news.edit|articles.create.test')); - $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, ['news.edit', 'articles.create.test'])); - $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'articles.create.other')); + public function testAUserCannotAccessARouteProtectedByThePermissionMiddlewareIfHaveNotHasRolesTrait(): void + { + $userWithoutHasRoles = UserWithoutHasRoles::create(['email' => 'test_not_has_roles@user.com']); + + Auth::login($userWithoutHasRoles); + + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-news')); } - public function testUserCannotAccessRouteWithoutMatchingPermission(): void + public function testPlainAuthenticatableUserWithoutAuthorizableCannotAccessRoute(): void + { + Auth::login(PlainAuthenticatableUser::create(['email' => 'plain_authenticatable@user.com'])); + + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-news')); + } + + public function testAUserCannotAccessARouteProtectedByThePermissionMiddlewareIfHaveADifferentPermission(): void { Auth::login($this->testUser); @@ -107,51 +176,108 @@ public function testUserCannotAccessRouteWithoutMatchingPermission(): void $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-news')); } - public function testUserCannotAccessRouteWithoutPermissions(): void + public function testAClientCannotAccessARouteProtectedByThePermissionMiddlewareIfHaveADifferentPermission(): void + { + $this->actingAsClient($this->testClient); + + $this->testClient->givePermissionTo('edit-posts'); + + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-news', null, true)); + } + + public function testAUserCannotAccessARouteProtectedByPermissionMiddlewareIfHaveNotPermissions(): void { Auth::login($this->testUser); $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-articles|edit-news')); } - public function testUserCanAccessRouteWithPermissionViaRole(): void + public function testAClientCannotAccessARouteProtectedByPermissionMiddlewareIfHaveNotPermissions(): void + { + $this->actingAsClient($this->testClient); + + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-articles|edit-posts', null, true)); + } + + public function testAUserCanAccessARouteProtectedByPermissionMiddlewareIfHasPermissionViaRole(): void { Auth::login($this->testUser); + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-articles')); + $this->testUserRole->givePermissionTo('edit-articles'); $this->testUser->assignRole('testRole'); $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'edit-articles')); } - public function testUserWithoutHasRolesTraitCannotAccessRoute(): void + public function testAClientCanAccessARouteProtectedByPermissionMiddlewareIfHasPermissionViaRole(): void { - Auth::login(UserWithoutHasRoles::create(['email' => 'test_not_has_roles@user.com'])); + $this->actingAsClient($this->testClient); - $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-news')); + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-articles', null, true)); + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-posts', null, true)); + + $this->testClientRole->givePermissionTo('edit-posts'); + $this->testClient->assignRole('clientRole'); + + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'edit-posts', null, true)); } - public function testPlainAuthenticatableUserWithoutAuthorizableCannotAccessRoute(): void + public function testTheRequiredPermissionsCanBeFetchedFromTheException(): void { - Auth::login(PlainAuthenticatableUser::create(['email' => 'plain_authenticatable@user.com'])); + Auth::login($this->testUser); - $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-news')); + $message = null; + $requiredPermissions = []; + + try { + $this->permissionMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'some-permission'); + } catch (UnauthorizedException $e) { + $message = $e->getMessage(); + $requiredPermissions = $e->getRequiredPermissions(); + } + + $this->assertSame('User does not have the right permissions.', $message); + $this->assertSame(['some-permission'], $requiredPermissions); } - public function testGuardSpecificPermissionIsUsed(): void + public function testTheRequiredPermissionsCanBeDisplayedInTheException(): void { - $this->app->make(Permission::class)->create(['name' => 'admin-permission2', 'guard_name' => 'web']); - $adminPermission = $this->app->make(Permission::class)->create(['name' => 'admin-permission2', 'guard_name' => 'admin']); + Auth::login($this->testUser); + config()->set(['permission.display_permission_in_exception' => true]); - Auth::guard('admin')->login($this->testAdmin); + $message = null; - $this->testAdmin->givePermissionTo($adminPermission); + try { + $this->permissionMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'some-permission'); + } catch (UnauthorizedException $e) { + $message = $e->getMessage(); + } - $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'admin-permission2', 'admin')); - $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'admin-permission2', 'web')); + $this->assertStringEndsWith('Necessary permissions are some-permission', $message); + } + + public function testUseNotExistingCustomGuardInPermission(): void + { + $class = null; + + try { + $this->permissionMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'edit-articles', 'xxx'); + } catch (InvalidArgumentException $e) { + $class = get_class($e); + } + + $this->assertSame(InvalidArgumentException::class, $class); } - public function testUserCannotAccessPermissionWithAdminGuardWhileLoggedInUsingDefaultGuard(): void + public function testUserCanNotAccessPermissionWithGuardAdminWhileLoginUsingDefaultGuard(): void { Auth::login($this->testUser); @@ -160,7 +286,16 @@ public function testUserCannotAccessPermissionWithAdminGuardWhileLoggedInUsingDe $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-articles', 'admin')); } - public function testUserCanAccessPermissionWithAdminGuardWhileLoggedInUsingAdminGuard(): void + public function testClientCanNotAccessPermissionWithGuardAdminWhileLoginUsingDefaultGuard(): void + { + $this->actingAsClient($this->testClient); + + $this->testClient->givePermissionTo('edit-posts'); + + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-posts', 'admin', true)); + } + + public function testUserCanAccessPermissionWithGuardAdminWhileLoginUsingAdminGuard(): void { Auth::guard('admin')->login($this->testAdmin); @@ -177,21 +312,22 @@ public function testEmptyGuardUsesDefaultGuard(): void $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'edit-articles', '')); } - public function testItCanBeCreatedWithStaticUsingMethod(): void + public function testTheMiddlewareCanBeCreatedWithStaticUsingMethod(): void { - $this->assertSame(PermissionMiddleware::class . ':edit-articles', PermissionMiddleware::using('edit-articles')); - $this->assertSame(PermissionMiddleware::class . ':edit-articles,my-guard', PermissionMiddleware::using('edit-articles', 'my-guard')); - $this->assertSame(PermissionMiddleware::class . ':edit-articles|edit-news', PermissionMiddleware::using(['edit-articles', 'edit-news'])); + $this->assertSame('Hypervel\Permission\Middleware\PermissionMiddleware:edit-articles', PermissionMiddleware::using('edit-articles')); + + $this->assertSame('Hypervel\Permission\Middleware\PermissionMiddleware:edit-articles,my-guard', PermissionMiddleware::using('edit-articles', 'my-guard')); + + $this->assertSame('Hypervel\Permission\Middleware\PermissionMiddleware:edit-articles|edit-news', PermissionMiddleware::using(['edit-articles', 'edit-news'])); } - public function testItCanHandleEnumPermissionsWithStaticUsingMethod(): void + public function testTheMiddlewareCanHandleEnumBasedPermissionsWithStaticUsingMethod(): void { - $this->assertSame(PermissionMiddleware::class . ':view articles', PermissionMiddleware::using(TestRolePermissionsEnum::ViewArticles)); - $this->assertSame(PermissionMiddleware::class . ':view articles,my-guard', PermissionMiddleware::using(TestRolePermissionsEnum::ViewArticles, 'my-guard')); - $this->assertSame(PermissionMiddleware::class . ':view articles|edit articles', PermissionMiddleware::using([ - TestRolePermissionsEnum::ViewArticles, - TestRolePermissionsEnum::EditArticles, - ])); + $this->assertSame('Hypervel\Permission\Middleware\PermissionMiddleware:view articles', PermissionMiddleware::using(TestRolePermissionsEnum::ViewArticles)); + + $this->assertSame('Hypervel\Permission\Middleware\PermissionMiddleware:view articles,my-guard', PermissionMiddleware::using(TestRolePermissionsEnum::ViewArticles, 'my-guard')); + + $this->assertSame('Hypervel\Permission\Middleware\PermissionMiddleware:view articles|edit articles', PermissionMiddleware::using([TestRolePermissionsEnum::ViewArticles, TestRolePermissionsEnum::EditArticles])); } public function testItCanHandleIntegerEnumPermissionsWithStaticUsingMethod(): void @@ -204,7 +340,7 @@ public function testItCanHandleIntegerEnumPermissionsWithStaticUsingMethod(): vo ])); } - public function testItCanHandleEnumPermissionsWithHandleMethod(): void + public function testTheMiddlewareCanHandleEnumBasedPermissionsWithHandleMethod(): void { $this->app->make(Permission::class)->create(['name' => TestRolePermissionsEnum::ViewArticles->value]); $this->app->make(Permission::class)->create(['name' => TestRolePermissionsEnum::EditArticles->value]); @@ -216,53 +352,6 @@ public function testItCanHandleEnumPermissionsWithHandleMethod(): void $this->testUser->givePermissionTo(TestRolePermissionsEnum::EditArticles); - $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, [ - TestRolePermissionsEnum::ViewArticles, - TestRolePermissionsEnum::EditArticles, - ])); - } - - public function testItExposesRequiredPermissionsOnTheUnauthorizedException(): void - { - Auth::login($this->testUser); - - try { - $this->permissionMiddleware->handle(new Request, function (): Response { - return (new Response)->setContent(''); - }, 'permission.some'); - } catch (UnauthorizedException $exception) { - $this->assertSame(['permission.some'], $exception->getRequiredPermissions()); - - return; - } - - $this->fail('Expected unauthorized permission exception was not thrown.'); - } - - public function testItCanDisplayRequiredPermissionsOnTheUnauthorizedException(): void - { - Auth::login($this->testUser); - $this->app->make('config')->set('permission.display_permission_in_exception', true); - - try { - $this->permissionMiddleware->handle(new Request, function (): Response { - return (new Response)->setContent(''); - }, 'some-permission'); - } catch (UnauthorizedException $exception) { - $this->assertStringEndsWith('Necessary permissions are some-permission', $exception->getMessage()); - - return; - } - - $this->fail('Expected unauthorized permission exception was not thrown.'); - } - - public function testItThrowsForMissingCustomGuard(): void - { - $this->expectException(InvalidArgumentException::class); - - $this->permissionMiddleware->handle(new Request, function (): Response { - return (new Response)->setContent(''); - }, 'edit-articles', 'xxx'); + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, [TestRolePermissionsEnum::ViewArticles, TestRolePermissionsEnum::EditArticles])); } } diff --git a/tests/Permission/Middleware/RoleMiddlewareTest.php b/tests/Permission/Middleware/RoleMiddlewareTest.php index 560fd1d310..cf32a6ea35 100644 --- a/tests/Permission/Middleware/RoleMiddlewareTest.php +++ b/tests/Permission/Middleware/RoleMiddlewareTest.php @@ -21,19 +21,36 @@ class RoleMiddlewareTest extends TestCase { protected RoleMiddleware $roleMiddleware; - protected function setUp(): void + protected function setUpInCoroutine(): void { - parent::setUp(); - + $this->setUpPassport(); $this->roleMiddleware = $this->app->make(RoleMiddleware::class); } - public function testGuestCannotAccessRoleProtectedRoute(): void + public function testAGuestCannotAccessARouteProtectedByRolemiddleware(): void { $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole')); } - public function testUserCanAccessRouteWithRole(): void + public function testAUserCannotAccessARouteProtectedByRoleMiddlewareOfAnotherGuard(): void + { + Auth::login($this->testUser); + + $this->testUser->assignRole('testRole'); + + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testAdminRole')); + } + + public function testAClientCannotAccessARouteProtectedByRoleMiddlewareOfAnotherGuard(): void + { + $this->actingAsClient($this->testClient); + + $this->testClient->assignRole('clientRole'); + + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testAdminRole', null, true)); + } + + public function testAUserCanAccessARouteProtectedByRoleMiddlewareIfHaveThisRole(): void { Auth::login($this->testUser); @@ -57,16 +74,16 @@ public function testAuthorizedRequestPreservesJsonResponse(): void )); } - public function testUserCannotAccessRouteWithRoleFromAnotherGuard(): void + public function testAClientCanAccessARouteProtectedByRoleMiddlewareIfHaveThisRole(): void { - Auth::login($this->testUser); + $this->actingAsClient($this->testClient); - $this->testUser->assignRole('testRole'); + $this->testClient->assignRole('clientRole'); - $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testAdminRole')); + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, 'clientRole', null, true)); } - public function testUserCanAccessRouteWithOneOfSeveralRoles(): void + public function testAUserCanAccessARouteProtectedByThisRoleMiddlewareIfHaveOneOfTheRoles(): void { Auth::login($this->testUser); @@ -76,32 +93,21 @@ public function testUserCanAccessRouteWithOneOfSeveralRoles(): void $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, ['testRole2', 'testRole'])); } - public function testUserCannotAccessRouteWithDifferentRole(): void + public function testAClientCanAccessARouteProtectedByThisRoleMiddlewareIfHaveOneOfTheRoles(): void { - Auth::login($this->testUser); + $this->actingAsClient($this->testClient); - $this->testUser->assignRole('testRole'); + $this->testClient->assignRole('clientRole'); - $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole2')); + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, 'clientRole|testRole2', null, true)); + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, ['testRole2', 'clientRole'], null, true)); } - public function testUserCannotAccessRouteWithoutRoles(): void + public function testAUserCannotAccessARouteProtectedByTheRoleMiddlewareIfHaveNotHasRolesTrait(): void { - Auth::login($this->testUser); + $userWithoutHasRoles = UserWithoutHasRoles::create(['email' => 'test_not_has_roles@user.com']); - $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole|testRole2')); - } - - public function testUserCannotAccessRouteWithUndefinedRole(): void - { - Auth::login($this->testUser); - - $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, '')); - } - - public function testUserWithoutHasRolesTraitCannotAccessRoute(): void - { - Auth::login(UserWithoutHasRoles::create(['email' => 'test_not_has_roles@user.com'])); + Auth::login($userWithoutHasRoles); $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole')); } @@ -113,109 +119,171 @@ public function testPlainAuthenticatableUserWithoutAuthorizableCannotAccessRoute $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole')); } - public function testUserCanAccessRoleWithMatchingGuard(): void + public function testAUserCannotAccessARouteProtectedByTheRoleMiddlewareIfHaveADifferentRole(): void { - Auth::guard('admin')->login($this->testAdmin); + Auth::login($this->testUser); - $this->testAdmin->assignRole('testAdminRole'); + $this->testUser->assignRole(['testRole']); - $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, 'testAdminRole', 'admin')); - $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole', 'admin')); + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole2')); } - public function testEmptyGuardUsesDefaultGuard(): void + public function testAClientCannotAccessARouteProtectedByTheRoleMiddlewareIfHaveADifferentRole(): void { - Auth::login($this->testUser); - $this->testUser->assignRole('testRole'); + $this->actingAsClient($this->testClient); - $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, 'testRole', '')); + $this->testClient->assignRole(['clientRole']); + + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'clientRole2', null, true)); } - public function testUserCannotAccessRoleWithAdminGuardWhileLoggedInUsingDefaultGuard(): void + public function testAUserCannotAccessARouteProtectedByRoleMiddlewareIfHaveNotRoles(): void { Auth::login($this->testUser); - $this->testUser->assignRole('testRole'); - - $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole', 'admin')); + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole|testRole2')); } - public function testItCanBeCreatedWithStaticUsingMethod(): void + public function testAClientCannotAccessARouteProtectedByRoleMiddlewareIfHaveNotRoles(): void { - $this->assertSame(RoleMiddleware::class . ':testAdminRole', RoleMiddleware::using('testAdminRole')); - $this->assertSame(RoleMiddleware::class . ':testAdminRole,my-guard', RoleMiddleware::using('testAdminRole', 'my-guard')); - $this->assertSame(RoleMiddleware::class . ':testAdminRole|anotherRole', RoleMiddleware::using(['testAdminRole', 'anotherRole'])); - } + $this->actingAsClient($this->testClient); - public function testItCanHandleEnumRolesWithStaticUsingMethod(): void - { - $this->assertSame(RoleMiddleware::class . ':writer', RoleMiddleware::using(TestRolePermissionsEnum::Writer)); - $this->assertSame(RoleMiddleware::class . ':writer,my-guard', RoleMiddleware::using(TestRolePermissionsEnum::Writer, 'my-guard')); - $this->assertSame(RoleMiddleware::class . ':writer|editor', RoleMiddleware::using([ - TestRolePermissionsEnum::Writer, - TestRolePermissionsEnum::Editor, - ])); + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole|testRole2', null, true)); } - public function testItCanHandleEnumRolesWithHandleMethod(): void + public function testAUserCannotAccessARouteProtectedByRoleMiddlewareIfRoleIsUndefined(): void { - $this->app->make(Role::class)->create(['name' => TestRolePermissionsEnum::Writer->value]); - $this->app->make(Role::class)->create(['name' => TestRolePermissionsEnum::Editor->value]); - Auth::login($this->testUser); - $this->testUser->assignRole(TestRolePermissionsEnum::Writer); - $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, TestRolePermissionsEnum::Writer)); + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, '')); + } - $this->testUser->assignRole(TestRolePermissionsEnum::Editor); + public function testAClientCannotAccessARouteProtectedByRoleMiddlewareIfRoleIsUndefined(): void + { + $this->actingAsClient($this->testClient); - $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, [ - TestRolePermissionsEnum::Writer, - TestRolePermissionsEnum::Editor, - ])); + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, '', null, true)); } - public function testItExposesRequiredRolesOnTheUnauthorizedException(): void + public function testTheRequiredRolesCanBeFetchedFromTheException(): void { Auth::login($this->testUser); + $message = null; + $requiredRoles = []; + try { $this->roleMiddleware->handle(new Request, function (): Response { return (new Response)->setContent(''); - }, 'role.some'); - } catch (UnauthorizedException $exception) { - $this->assertSame(['role.some'], $exception->getRequiredRoles()); - - return; + }, 'some-role'); + } catch (UnauthorizedException $e) { + $message = $e->getMessage(); + $requiredRoles = $e->getRequiredRoles(); } - $this->fail('Expected unauthorized role exception was not thrown.'); + $this->assertSame('User does not have the right roles.', $message); + $this->assertSame(['some-role'], $requiredRoles); } - public function testItCanDisplayRequiredRolesOnTheUnauthorizedException(): void + public function testTheRequiredRolesCanBeDisplayedInTheException(): void { Auth::login($this->testUser); - $this->app->make('config')->set('permission.display_role_in_exception', true); + config()->set(['permission.display_role_in_exception' => true]); + + $message = null; try { $this->roleMiddleware->handle(new Request, function (): Response { return (new Response)->setContent(''); }, 'some-role'); - } catch (UnauthorizedException $exception) { - $this->assertStringEndsWith('Necessary roles are some-role', $exception->getMessage()); + } catch (UnauthorizedException $e) { + $message = $e->getMessage(); + } + + $this->assertStringEndsWith('Necessary roles are some-role', $message); + } + + public function testUseNotExistingCustomGuardInRole(): void + { + $class = null; - return; + try { + $this->roleMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'testRole', 'xxx'); + } catch (InvalidArgumentException $e) { + $class = get_class($e); } - $this->fail('Expected unauthorized role exception was not thrown.'); + $this->assertSame(InvalidArgumentException::class, $class); + } + + public function testUserCanNotAccessRoleWithGuardAdminWhileLoginUsingDefaultGuard(): void + { + Auth::login($this->testUser); + + $this->testUser->assignRole('testRole'); + + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole', 'admin')); + } + + public function testClientCanNotAccessRoleWithGuardAdminWhileLoginUsingDefaultGuard(): void + { + $this->actingAsClient($this->testClient); + + $this->testClient->assignRole('clientRole'); + + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'clientRole', 'admin', true)); } - public function testItThrowsForMissingCustomGuard(): void + public function testUserCanAccessRoleWithGuardAdminWhileLoginUsingAdminGuard(): void { - $this->expectException(InvalidArgumentException::class); + Auth::guard('admin')->login($this->testAdmin); + + $this->testAdmin->assignRole('testAdminRole'); + + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, 'testAdminRole', 'admin')); + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole', 'admin')); + } + + public function testEmptyGuardUsesDefaultGuard(): void + { + Auth::login($this->testUser); + $this->testUser->assignRole('testRole'); + + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, 'testRole', '')); + } + + public function testTheMiddlewareCanBeCreatedWithStaticUsingMethod(): void + { + $this->assertSame('Hypervel\Permission\Middleware\RoleMiddleware:testAdminRole', RoleMiddleware::using('testAdminRole')); + + $this->assertSame('Hypervel\Permission\Middleware\RoleMiddleware:testAdminRole,my-guard', RoleMiddleware::using('testAdminRole', 'my-guard')); + + $this->assertSame('Hypervel\Permission\Middleware\RoleMiddleware:testAdminRole|anotherRole', RoleMiddleware::using(['testAdminRole', 'anotherRole'])); + } + + public function testTheMiddlewareCanHandleEnumBasedRolesWithStaticUsingMethod(): void + { + $this->assertSame('Hypervel\Permission\Middleware\RoleMiddleware:writer', RoleMiddleware::using(TestRolePermissionsEnum::Writer)); + + $this->assertSame('Hypervel\Permission\Middleware\RoleMiddleware:writer,my-guard', RoleMiddleware::using(TestRolePermissionsEnum::Writer, 'my-guard')); + + $this->assertSame('Hypervel\Permission\Middleware\RoleMiddleware:writer|editor', RoleMiddleware::using([TestRolePermissionsEnum::Writer, TestRolePermissionsEnum::Editor])); + } + + public function testTheMiddlewareCanHandleEnumBasedRolesWithHandleMethod(): void + { + $this->app->make(Role::class)->create(['name' => TestRolePermissionsEnum::Writer->value]); + $this->app->make(Role::class)->create(['name' => TestRolePermissionsEnum::Editor->value]); + + Auth::login($this->testUser); + $this->testUser->assignRole(TestRolePermissionsEnum::Writer); + + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, TestRolePermissionsEnum::Writer)); + + $this->testUser->assignRole(TestRolePermissionsEnum::Editor); - $this->roleMiddleware->handle(new Request, function (): Response { - return (new Response)->setContent(''); - }, 'testRole', 'xxx'); + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, [TestRolePermissionsEnum::Writer, TestRolePermissionsEnum::Editor])); } } diff --git a/tests/Permission/Middleware/RoleOrPermissionMiddlewareTest.php b/tests/Permission/Middleware/RoleOrPermissionMiddlewareTest.php index 85835ecbd5..599a0a23e8 100644 --- a/tests/Permission/Middleware/RoleOrPermissionMiddlewareTest.php +++ b/tests/Permission/Middleware/RoleOrPermissionMiddlewareTest.php @@ -12,6 +12,7 @@ use Hypervel\Support\Facades\Auth; use Hypervel\Support\Facades\Gate; use Hypervel\Tests\Permission\Fixtures\Models\PlainAuthenticatableUser; +use Hypervel\Tests\Permission\Fixtures\Models\User; use Hypervel\Tests\Permission\Fixtures\Models\UserWithoutHasRoles; use Hypervel\Tests\Permission\TestCase; use InvalidArgumentException; @@ -20,19 +21,18 @@ class RoleOrPermissionMiddlewareTest extends TestCase { protected RoleOrPermissionMiddleware $roleOrPermissionMiddleware; - protected function setUp(): void + protected function setUpInCoroutine(): void { - parent::setUp(); - + $this->setUpPassport(); $this->roleOrPermissionMiddleware = $this->app->make(RoleOrPermissionMiddleware::class); } - public function testGuestCannotAccessProtectedRoute(): void + public function testAGuestCannotAccessARouteProtectedByTheRoleOrPermissionMiddleware(): void { $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole')); } - public function testUserCanAccessRouteWithEitherPermissionOrRole(): void + public function testAUserCanAccessARouteProtectedByPermissionOrRoleMiddlewareIfHasThisPermissionOrRole(): void { Auth::login($this->testUser); @@ -42,6 +42,7 @@ public function testUserCanAccessRouteWithEitherPermissionOrRole(): void $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|edit-news|edit-articles')); $this->testUser->removeRole('testRole'); + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|edit-articles')); $this->testUser->revokePermissionTo('edit-articles'); @@ -66,18 +67,42 @@ public function testAuthorizedRequestPreservesJsonResponse(): void )); } - public function testSuperAdminGateBeforeCanAccessProtectedRoute(): void + public function testAClientCanAccessARouteProtectedByPermissionOrRoleMiddlewareIfHasThisPermissionOrRole(): void + { + $this->actingAsClient($this->testClient); + + $this->testClient->assignRole('clientRole'); + $this->testClient->givePermissionTo('edit-posts'); + + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'clientRole|edit-news|edit-posts', null, true)); + + $this->testClient->removeRole('clientRole'); + + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'clientRole|edit-posts', null, true)); + + $this->testClient->revokePermissionTo('edit-posts'); + $this->testClient->assignRole('clientRole'); + + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'clientRole|edit-posts', null, true)); + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, ['clientRole', 'edit-posts'], null, true)); + } + + public function testASuperAdminUserCanAccessARouteProtectedByPermissionOrRoleMiddleware(): void { Auth::login($this->testUser); - Gate::before(fn ($user): ?bool => $user->getKey() === $this->testUser->getKey() ? true : null); + Gate::before(function (User $user, string $ability): ?bool { + return $user->getKey() === $this->testUser->getKey() ? true : null; + }); $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|edit-articles')); } - public function testUserWithoutHasRolesTraitCannotAccessRoute(): void + public function testAUserCanNotAccessARouteProtectedByPermissionOrRoleMiddlewareIfHaveNotHasRolesTrait(): void { - Auth::login(UserWithoutHasRoles::create(['email' => 'test_not_has_roles@user.com'])); + $userWithoutHasRoles = UserWithoutHasRoles::create(['email' => 'test_not_has_roles@user.com']); + + Auth::login($userWithoutHasRoles); $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|edit-articles')); } @@ -89,7 +114,7 @@ public function testPlainAuthenticatableUserWithoutAuthorizableCannotAccessRoute $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|edit-articles')); } - public function testUserCannotAccessRouteWithoutMatchingPermissionOrRole(): void + public function testAUserCanNotAccessARouteProtectedByPermissionOrRoleMiddlewareIfHaveNotThisPermissionAndRole(): void { Auth::login($this->testUser); @@ -97,18 +122,30 @@ public function testUserCannotAccessRouteWithoutMatchingPermissionOrRole(): void $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'missingRole|missingPermission')); } - public function testUserCanAccessPermissionOrRoleWithMatchingGuard(): void + public function testAClientCanNotAccessARouteProtectedByPermissionOrRoleMiddlewareIfHaveNotThisPermissionAndRole(): void { - Auth::guard('admin')->login($this->testAdmin); + $this->actingAsClient($this->testClient); - $this->testAdmin->assignRole('testAdminRole'); - $this->testAdmin->givePermissionTo('admin-permission'); + $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'clientRole|edit-posts', null, true)); + $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'missingRole|missingPermission', null, true)); + } - $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'admin-permission|testAdminRole', 'admin')); - $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'edit-articles|testRole', 'admin')); + public function testUseNotExistingCustomGuardInRoleOrPermission(): void + { + $class = null; + + try { + $this->roleOrPermissionMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'testRole', 'xxx'); + } catch (InvalidArgumentException $e) { + $class = get_class($e); + } + + $this->assertSame(InvalidArgumentException::class, $class); } - public function testUserCannotAccessPermissionOrRoleWithAdminGuardWhileLoggedInUsingDefaultGuard(): void + public function testUserCanNotAccessPermissionOrRoleWithGuardAdminWhileLoginUsingDefaultGuard(): void { Auth::login($this->testUser); @@ -118,6 +155,27 @@ public function testUserCannotAccessPermissionOrRoleWithAdminGuardWhileLoggedInU $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'edit-articles|testRole', 'admin')); } + public function testClientCanNotAccessPermissionOrRoleWithGuardAdminWhileLoginUsingDefaultGuard(): void + { + $this->actingAsClient($this->testClient); + + $this->testClient->assignRole('clientRole'); + $this->testClient->givePermissionTo('edit-posts'); + + $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'edit-posts|clientRole', 'admin', true)); + } + + public function testUserCanAccessPermissionOrRoleWithGuardAdminWhileLoginUsingAdminGuard(): void + { + Auth::guard('admin')->login($this->testAdmin); + + $this->testAdmin->assignRole('testAdminRole'); + $this->testAdmin->givePermissionTo('admin-permission'); + + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'admin-permission|testAdminRole', 'admin')); + $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'edit-articles|testRole', 'admin')); + } + public function testEmptyGuardUsesDefaultGuard(): void { Auth::login($this->testUser); @@ -126,58 +184,51 @@ public function testEmptyGuardUsesDefaultGuard(): void $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'edit-articles|testRole', '')); } - public function testItCanBeCreatedWithStaticUsingMethod(): void - { - $this->assertSame(RoleOrPermissionMiddleware::class . ':edit-articles', RoleOrPermissionMiddleware::using('edit-articles')); - $this->assertSame(RoleOrPermissionMiddleware::class . ':edit-articles,my-guard', RoleOrPermissionMiddleware::using('edit-articles', 'my-guard')); - $this->assertSame(RoleOrPermissionMiddleware::class . ':edit-articles|testAdminRole', RoleOrPermissionMiddleware::using(['edit-articles', 'testAdminRole'])); - } - - public function testItExposesRequiredRolesOrPermissionsOnTheUnauthorizedException(): void + public function testTheRequiredPermissionsOrRolesCanBeFetchedFromTheException(): void { Auth::login($this->testUser); + $message = null; + $requiredRolesOrPermissions = []; + try { $this->roleOrPermissionMiddleware->handle(new Request, function (): Response { return (new Response)->setContent(''); }, 'some-permission|some-role'); - } catch (UnauthorizedException $exception) { - $this->assertSame('User does not have any of the necessary access rights.', $exception->getMessage()); - $this->assertSame(['some-permission', 'some-role'], $exception->getRequiredPermissions()); - - return; + } catch (UnauthorizedException $e) { + $message = $e->getMessage(); + $requiredRolesOrPermissions = $e->getRequiredPermissions(); } - $this->fail('Expected unauthorized role or permission exception was not thrown.'); + $this->assertSame('User does not have any of the necessary access rights.', $message); + $this->assertSame(['some-permission', 'some-role'], $requiredRolesOrPermissions); } - public function testItCanDisplayRequiredRolesOrPermissionsOnTheUnauthorizedException(): void + public function testTheRequiredPermissionsOrRolesCanBeDisplayedInTheException(): void { Auth::login($this->testUser); - $this->app->make('config')->set([ - 'permission.display_permission_in_exception' => true, - 'permission.display_role_in_exception' => true, - ]); + config()->set(['permission.display_permission_in_exception' => true]); + config()->set(['permission.display_role_in_exception' => true]); + + $message = null; try { $this->roleOrPermissionMiddleware->handle(new Request, function (): Response { return (new Response)->setContent(''); }, 'some-permission|some-role'); - } catch (UnauthorizedException $exception) { - $this->assertStringEndsWith('Necessary roles or permissions are some-permission, some-role', $exception->getMessage()); - - return; + } catch (UnauthorizedException $e) { + $message = $e->getMessage(); } - $this->fail('Expected unauthorized role or permission exception was not thrown.'); + $this->assertStringEndsWith('Necessary roles or permissions are some-permission, some-role', $message); } - public function testItThrowsForMissingCustomGuard(): void + public function testTheMiddlewareCanBeCreatedWithStaticUsingMethod(): void { - $this->expectException(InvalidArgumentException::class); + $this->assertSame('Hypervel\Permission\Middleware\RoleOrPermissionMiddleware:edit-articles', RoleOrPermissionMiddleware::using('edit-articles')); + + $this->assertSame('Hypervel\Permission\Middleware\RoleOrPermissionMiddleware:edit-articles,my-guard', RoleOrPermissionMiddleware::using('edit-articles', 'my-guard')); - $this->roleOrPermissionMiddleware->handle(new Request, function (): Response { - return (new Response)->setContent(''); - }, 'testRole', 'xxx'); + $this->assertSame('Hypervel\Permission\Middleware\RoleOrPermissionMiddleware:edit-articles|testAdminRole', RoleOrPermissionMiddleware::using(['edit-articles', 'testAdminRole'])); } } diff --git a/tests/Permission/Middleware/WildcardMiddlewareTest.php b/tests/Permission/Middleware/WildcardMiddlewareTest.php index 738ab7a0f9..ddcf2b0199 100644 --- a/tests/Permission/Middleware/WildcardMiddlewareTest.php +++ b/tests/Permission/Middleware/WildcardMiddlewareTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Permission\Middleware; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Http\Request; use Hypervel\Http\Response; use Hypervel\Permission\Exceptions\UnauthorizedException; @@ -22,66 +23,73 @@ class WildcardMiddlewareTest extends TestCase protected RoleOrPermissionMiddleware $roleOrPermissionMiddleware; - protected function setUp(): void + protected function defineEnvironment(ApplicationContract $app): void { - parent::setUp(); + parent::defineEnvironment($app); + $app->make('config')->set('permission.enable_wildcard_permission', true); + } + + protected function setUpInCoroutine(): void + { $this->roleMiddleware = $this->app->make(RoleMiddleware::class); $this->permissionMiddleware = $this->app->make(PermissionMiddleware::class); $this->roleOrPermissionMiddleware = $this->app->make(RoleOrPermissionMiddleware::class); - - $this->app->make('config')->set('permission.enable_wildcard_permission', true); - $this->flushPermissionState(); } - public function testGuestCannotAccessPermissionProtectedRoute(): void + public function testItDoesNotAllowAGuestToAccessARouteProtectedByThePermissionMiddleware(): void { $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'articles.edit')); } - public function testUserCanAccessRouteWithWildcardPermission(): void + public function testItAllowsAUserToAccessARouteProtectedByPermissionMiddlewareIfTheyHaveThisPermission(): void { Auth::login($this->testUser); Permission::create(['name' => 'articles']); + $this->testUser->givePermissionTo('articles'); $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'articles.edit')); } - public function testUserCanAccessRouteWithOneOfTheWildcardPermissions(): void + public function testItAllowsAUserToAccessARouteProtectedByThisPermissionMiddlewareIfTheyHaveOneOfThePermissions(): void { Auth::login($this->testUser); Permission::create(['name' => 'articles.*.test']); + $this->testUser->givePermissionTo('articles.*.test'); $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'news.edit|articles.create.test')); $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, ['news.edit', 'articles.create.test'])); + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'articles.create.other')); } - public function testUserCannotAccessRouteWithDifferentWildcardPermission(): void + public function testItDoesNotAllowAUserToAccessARouteProtectedByThePermissionMiddlewareIfTheyHaveADifferentPermission(): void { Auth::login($this->testUser); Permission::create(['name' => 'articles.*']); + $this->testUser->givePermissionTo('articles.*'); $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'news.edit')); } - public function testUserCannotAccessRouteWithNoMatchingPermission(): void + public function testItDoesNotAllowAUserToAccessARouteProtectedByPermissionMiddlewareIfTheyHaveNoPermissions(): void { Auth::login($this->testUser); $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'articles.edit|news.edit')); } - public function testUserCanAccessPermissionOrRoleProtectedRouteWithWildcardPermissionOrRole(): void + public function testItAllowsAUserToAccessARouteProtectedByPermissionOrRoleMiddlewareIfTheyHaveThisPermissionOrRole(): void { Auth::login($this->testUser); Permission::create(['name' => 'articles.*']); + $this->testUser->assignRole('testRole'); $this->testUser->givePermissionTo('articles.*'); @@ -98,20 +106,20 @@ public function testUserCanAccessPermissionOrRoleProtectedRouteWithWildcardPermi $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, ['testRole', 'articles.edit'])); } - public function testItCanFetchRequiredPermissionsFromException(): void + public function testItCanFetchTheRequiredPermissionsFromTheException(): void { Auth::login($this->testUser); + $requiredPermissions = []; + try { $this->permissionMiddleware->handle(new Request, function (): Response { return (new Response)->setContent(''); }, 'permission.some'); - } catch (UnauthorizedException $exception) { - $this->assertSame(['permission.some'], $exception->getRequiredPermissions()); - - return; + } catch (UnauthorizedException $e) { + $requiredPermissions = $e->getRequiredPermissions(); } - $this->fail('Expected unauthorized permission exception was not thrown.'); + $this->assertSame(['permission.some'], $requiredPermissions); } } diff --git a/tests/Permission/Models/PermissionTest.php b/tests/Permission/Models/PermissionTest.php index bc0aadcbaf..026aa34bc0 100644 --- a/tests/Permission/Models/PermissionTest.php +++ b/tests/Permission/Models/PermissionTest.php @@ -127,7 +127,7 @@ public function testGuardNameRejectsPersistedPermissionsMissingTheGuardColumn(): ->findOrFail($this->testUserPermission->getKey()); $this->expectException(MissingAttributeException::class); - $this->expectExceptionMessage('The attribute [guard_name]'); + $this->expectExceptionMessageIsOrContains('The attribute [guard_name]'); $permission->guardName(); } @@ -141,7 +141,7 @@ public function testUsersRelationRejectsPermissionsMissingTheGuardColumn(): void ->findOrFail($this->testUserPermission->getKey()); $this->expectException(MissingAttributeException::class); - $this->expectExceptionMessage('The attribute [guard_name]'); + $this->expectExceptionMessageIsOrContains('The attribute [guard_name]'); $permission->users(); } @@ -187,6 +187,9 @@ public function testItDoesNotTreatStringZeroAsEmptyWhenGivingPermission(): void class PermissionWithGuardNameAccessor extends PermissionModel { + /** + * Get the guard name. + */ public function getGuardNameAttribute(string $value): string { return $value === 'stored' ? 'accessed' : $value; diff --git a/tests/Permission/Models/RoleTest.php b/tests/Permission/Models/RoleTest.php index 700bcb53fc..d51b736bac 100644 --- a/tests/Permission/Models/RoleTest.php +++ b/tests/Permission/Models/RoleTest.php @@ -20,6 +20,7 @@ use Hypervel\Tests\Permission\Fixtures\Models\User; use Hypervel\Tests\Permission\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionMethod; enum TestRoleEnum: string { @@ -28,10 +29,8 @@ enum TestRoleEnum: string class RoleTest extends TestCase { - protected function setUp(): void + protected function setUpInCoroutine(): void { - parent::setUp(); - Permission::create(['name' => 'other-permission']); Permission::create(['name' => 'wrong-guard-permission', 'guard_name' => 'admin']); } @@ -108,13 +107,12 @@ public function testItThrowsAnExceptionWhenGivenAPermissionThatDoesNotExist(): v public function testItThrowsAnExceptionWhenGivenAPermissionThatBelongsToAnotherGuard(): void { - $this->expectException(PermissionDoesNotExist::class); - - $this->testUserRole->givePermissionTo('admin-permission'); - } + try { + $this->testUserRole->givePermissionTo('admin-permission'); + $this->fail('Expected missing permission exception.'); + } catch (PermissionDoesNotExist) { + } - public function testItThrowsGuardMismatchWhenGivenAPermissionObjectFromAnotherGuard(): void - { $this->expectException(GuardDoesNotMatch::class); $this->testUserRole->givePermissionTo($this->testAdminPermission); @@ -123,7 +121,7 @@ public function testItThrowsGuardMismatchWhenGivenAPermissionObjectFromAnotherGu public function testGuardMismatchReportsAnExplicitZeroNamedGuard(): void { $this->expectException(GuardDoesNotMatch::class); - $this->expectExceptionMessage('should use guard `0` instead of `admin`'); + $this->expectExceptionMessageIsOrContains('should use guard `0` instead of `admin`'); $this->testUserRole->hasPermissionTo($this->testAdminPermission, '0'); } @@ -131,7 +129,7 @@ public function testGuardMismatchReportsAnExplicitZeroNamedGuard(): void public function testGuardMismatchTreatsAnEmptyGuardAsUnspecified(): void { $this->expectException(GuardDoesNotMatch::class); - $this->expectExceptionMessage('should use guard `web` instead of `admin`'); + $this->expectExceptionMessageIsOrContains('should use guard `web` instead of `admin`'); $this->testUserRole->hasPermissionTo($this->testAdminPermission, ''); } @@ -171,22 +169,30 @@ public function testItThrowsAnExceptionWhenSyncingPermissionsThatDoNotExist(): v $this->testUserRole->syncPermissions('permission-does-not-exist'); } - public function testItThrowsAnExceptionWhenSyncingPermissionsThatBelongToADifferentGuardByName(): void + public function testItThrowsAnExceptionWhenSyncingPermissionsThatBelongToADifferentGuard(): void { $this->testUserRole->givePermissionTo('edit-articles'); - $this->expectException(PermissionDoesNotExist::class); + try { + $this->testUserRole->syncPermissions('admin-permission'); + $this->fail('Expected missing permission exception.'); + } catch (PermissionDoesNotExist) { + } - $this->testUserRole->syncPermissions('admin-permission'); + $this->expectException(GuardDoesNotMatch::class); + + $this->testUserRole->syncPermissions($this->testAdminPermission); } - public function testItThrowsGuardMismatchWhenSyncingPermissionObjectsFromAnotherGuard(): void + public function testItWillRemoveAllPermissionsWhenPassingAnEmptyArrayToSyncPermissions(): void { $this->testUserRole->givePermissionTo('edit-articles'); + $this->testUserRole->givePermissionTo('edit-news'); - $this->expectException(GuardDoesNotMatch::class); + $this->testUserRole->syncPermissions([]); - $this->testUserRole->syncPermissions($this->testAdminPermission); + $this->assertFalse($this->testUserRole->hasPermissionTo('edit-articles')); + $this->assertFalse($this->testUserRole->hasPermissionTo('edit-news')); } public function testSyncPermissionErrorDoesNotDetachPermissions(): void @@ -245,6 +251,13 @@ public function testItCanBeFoundByName(string|BackedEnum $name, string $expected $this->assertSame($expected, $role->name); } + public function testItReturnsFalseWhenCheckingPermissionViaRoleOnARoleInstanceDirectly(): void + { + $method = new ReflectionMethod($this->testUserRole, 'hasPermissionViaRole'); + + $this->assertFalse($method->invoke($this->testUserRole, $this->testUserPermission)); + } + public function testItReturnsFalseIfItDoesNotHaveAPermissionObject(): void { $permission = Permission::findByName('other-permission'); @@ -298,6 +311,15 @@ public function testItThrowsAnExceptionWhenARoleWithTheGivenIdDoesNotExist(): vo $this->app->make(Role::class)::findById(456789, 'web'); } + public function testItThrowsAnExceptionWhenAPermissionOfTheWrongGuardIsPassedIn(): void + { + $permission = Permission::findByName('wrong-guard-permission', 'admin'); + + $this->expectException(GuardDoesNotMatch::class); + + $this->testUserRole->hasPermissionTo($permission); + } + public function testItBelongsToAGuard(): void { $role = $this->app->make(Role::class)->create(['name' => 'admin', 'guard_name' => 'admin']); @@ -333,7 +355,7 @@ public function testGuardNameRejectsPersistedRolesMissingTheGuardColumn(): void ->findOrFail($this->testUserRole->getKey()); $this->expectException(MissingAttributeException::class); - $this->expectExceptionMessage('The attribute [guard_name]'); + $this->expectExceptionMessageIsOrContains('The attribute [guard_name]'); $role->guardName(); } @@ -347,12 +369,12 @@ public function testUsersRelationRejectsRolesMissingTheGuardColumn(): void ->findOrFail($this->testUserRole->getKey()); $this->expectException(MissingAttributeException::class); - $this->expectExceptionMessage('The attribute [guard_name]'); + $this->expectExceptionMessageIsOrContains('The attribute [guard_name]'); $role->users(); } - public function testItCanChangeRoleClassAtRuntime(): void + public function testItCanChangeRoleClassOnRuntime(): void { $role = $this->app->make(Role::class)->create(['name' => 'test-role-old']); @@ -389,6 +411,9 @@ public function testItDoesNotTreatStringZeroAsEmptyWhenAssigningRole(): void class RoleWithGuardNameAccessor extends RoleModel { + /** + * Get the guard name. + */ public function getGuardNameAttribute(string $value): string { return $value === 'stored' ? 'accessed' : $value; diff --git a/tests/Permission/Models/WildcardRoleTest.php b/tests/Permission/Models/WildcardRoleTest.php index 8e99c04ff9..6ff96c2c1a 100644 --- a/tests/Permission/Models/WildcardRoleTest.php +++ b/tests/Permission/Models/WildcardRoleTest.php @@ -4,18 +4,21 @@ namespace Hypervel\Tests\Permission\Models; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Permission\Models\Permission; use Hypervel\Tests\Permission\TestCase; class WildcardRoleTest extends TestCase { - protected function setUp(): void + protected function defineEnvironment(ApplicationContract $app): void { - parent::setUp(); + parent::defineEnvironment($app); - $this->app->make('config')->set('permission.enable_wildcard_permission', true); - $this->flushPermissionState(); + $app->make('config')->set('permission.enable_wildcard_permission', true); + } + protected function setUpInCoroutine(): void + { Permission::create(['name' => 'other-permission']); Permission::create(['name' => 'wrong-guard-permission', 'guard_name' => 'admin']); } diff --git a/tests/Permission/Traits/HasAssignedModelsTest.php b/tests/Permission/Traits/HasAssignedModelsTest.php index 07c29c29ce..46dfc11d1e 100644 --- a/tests/Permission/Traits/HasAssignedModelsTest.php +++ b/tests/Permission/Traits/HasAssignedModelsTest.php @@ -9,6 +9,7 @@ use Hypervel\Permission\PermissionRegistrar; use Hypervel\Permission\Support\Config; use Hypervel\Support\Facades\DB; +use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Tests\Permission\Fixtures\Models\SoftDeletingUser; use Hypervel\Tests\Permission\Fixtures\Models\User; use Hypervel\Tests\Permission\TestCase; @@ -234,6 +235,9 @@ public function testReverseAssignmentsRejectKeylessModelInputs(string $method, b $this->assertFalse($otherUser->fresh()->hasRole($this->testUserRole)); } + /** + * Provide reverse assignment methods with keyless model inputs. + */ public static function reverseAssignmentProvider(): array { return [ @@ -289,29 +293,7 @@ public function testItCanRemoveARoleFromModelsUsingIdsWithExplicitModelClass(): $this->assertFalse($user1->fresh()->hasRole($this->testUserRole)); } - public function testItUsesConfigDefaultModelWhenResolvingIds(): void - { - config()->set('permission.models.default_model', User::class); - - $user1 = User::create(['email' => 'user1@test.com']); - - $this->testUserRole->syncModels([$user1->getKey()]); - - $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); - } - - public function testNullDefaultModelUsesTheAuthenticatedGuardModelWhenResolvingIds(): void - { - config()->set('permission.models.default_model', null); - - $user = User::create(['email' => 'user@test.com']); - - $this->testUserRole->syncModels([$user->getKey()]); - - $this->assertTrue($user->fresh()->hasRole($this->testUserRole)); - } - - public function testUnsavedRoleReverseAssignmentsAreQueryFreeFluentNoOps(): void + public function testItDoesNothingWhenAssigningAnUnsavedRoleToModels(): void { $user = User::create(['email' => 'user@test.com']); $role = $this->testUserRole->newInstance([ @@ -336,6 +318,44 @@ public function testUnsavedRoleReverseAssignmentsAreQueryFreeFluentNoOps(): void ->count()); } + #[DefineEnvironment('usesTeams')] + public function testItAppliesTheCurrentTeamIdWhenAssigningModelsWithTeamsEnabled(): void + { + $this->setUpTeams(); + + $user1 = User::create(['email' => 'team-user1@test.com']); + + $this->testUserRole->assignToModels($user1); + + $pivot = DB::table(Config::modelHasRolesTable()) + ->where(Config::morphKey(), $user1->getKey()) + ->first(); + + $this->assertSame(1, (int) $pivot->team_test_id); + } + + public function testItUsesConfigDefaultModelWhenResolvingIds(): void + { + config()->set('permission.models.default_model', User::class); + + $user1 = User::create(['email' => 'user1@test.com']); + + $this->testUserRole->syncModels([$user1->getKey()]); + + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + } + + public function testNullDefaultModelUsesTheAuthenticatedGuardModelWhenResolvingIds(): void + { + config()->set('permission.models.default_model', null); + + $user = User::create(['email' => 'user@test.com']); + + $this->testUserRole->syncModels([$user->getKey()]); + + $this->assertTrue($user->fresh()->hasRole($this->testUserRole)); + } + #[DataProvider('reverseAssignmentOwnerProvider')] public function testReverseAssignmentsRejectAKeylessPersistedRoleBeforeMutation(string $method): void { @@ -362,6 +382,9 @@ public function testReverseAssignmentsRejectAKeylessPersistedRoleBeforeMutation( $this->assertFalse($replacementUser->fresh()->hasRole($this->testUserRole)); } + /** + * Provide reverse assignment methods. + */ public static function reverseAssignmentOwnerProvider(): array { return [ diff --git a/tests/Permission/Traits/TeamHasAssignedModelsTest.php b/tests/Permission/Traits/TeamHasAssignedModelsTest.php index e6d05749b3..719d3d60ba 100644 --- a/tests/Permission/Traits/TeamHasAssignedModelsTest.php +++ b/tests/Permission/Traits/TeamHasAssignedModelsTest.php @@ -25,19 +25,6 @@ protected function setUpInCoroutine(): void $this->setUpTeams(); } - public function testItAppliesTheCurrentTeamIdWhenAssigningModels(): void - { - $user = User::create(['email' => 'team-user@test.com']); - - $this->testUserRole->assignToModels($user); - - $pivot = DB::table(Config::modelHasRolesTable()) - ->where(Config::morphKey(), $user->getKey()) - ->first(); - - $this->assertSame(1, (int) $pivot->team_test_id); - } - public function testItAssignsModelsInCurrentTeamWhenModelAlreadyHasRoleInAnotherTeam(): void { $user = User::create(['email' => 'user1@test.com']); @@ -151,6 +138,9 @@ public function testReverseModelMutationsRequireASelectedTeam(): void } } + /** + * Count the user's assignments of the test role across all teams. + */ private function roleAssignmentsFor(User $user): int { return DB::table(Config::modelHasRolesTable()) From 64cbc563b6b7e66438b1614e2dc757d48e94cdb5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:56:21 +0000 Subject: [PATCH 04/18] Reconcile HasPermissionsTest with upstream and narrow queued-flush invalidation Bring HasPermissionsTest in line with spatie/laravel-permission main at 6615eefac655 (8.x): every upstream case under its upstream name and order, the restored third user and comment, and the ported detach-event case for syncPermissions(). The custom-pivot case changes the auth provider model at runtime, so it calls Guard::flushState() after the change because Guard caches provider models for the worker lifetime. Exception-only catch blocks drop their no-op assertTrue(true), and deprecated expectExceptionMessage() becomes expectExceptionMessageIsOrContains(). The custom-models variant renames its skipped integer-scope override to match. Count cases add the database cache store's statements through a new TestCase::usesDatabaseCacheStore() helper. PermissionRegistrarTest's final missing-name cases expect their exceptions directly. Source fix: saving a model with queued assignments invalidated both its role and direct-permission caches for every queued context, even when only permissions were queued. On the database cache store that cost three extra statements per save (8 instead of 5). The flush now invalidates the role cache only for queued role contexts and the permission cache through the existing per-context permission path. Reverse role assignment had the same waste for each affected model and now invalidates only role caches. The combined registrar invalidation methods lost their callers and are removed. A root CacheTest case covers the reverse-assignment path keeping the warm direct-permission memo. Validation: HasPermissionsTest and PermissionRegistrarTest pass on the array and database cache stores; the Permission suite passes on the array store, with only known later-slice failures on the database store; formatting and static analysis are clean. --- src/permission/src/PermissionRegistrar.php | 45 ------- .../src/Traits/HasAssignedModels.php | 4 +- src/permission/src/Traits/HasRoles.php | 36 +++-- tests/Permission/CacheTest.php | 12 ++ .../Integration/PermissionRegistrarTest.php | 18 +-- tests/Permission/TestCase.php | 8 ++ .../Permission/Traits/HasPermissionsTest.php | 127 +++++++++++++++--- .../HasPermissionsWithCustomModelsTest.php | 2 +- 8 files changed, 164 insertions(+), 88 deletions(-) diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php index 69f59ba85d..84d9ccae0f 100644 --- a/src/permission/src/PermissionRegistrar.php +++ b/src/permission/src/PermissionRegistrar.php @@ -854,51 +854,6 @@ public function forgetModelAssignmentCacheForIdentity( $this->forgetRuntimeCacheItem(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY, $runtimeKey); } - /** - * Invalidate assignment caches after a model mutation settles. - */ - public function invalidateModelAssignmentCacheAfterMutation( - Model $model, - ?PermissionPartition $partition, - int|string|null $team, - ): void { - $this->invalidateModelAssignmentCacheForIdentityAfterMutation( - $model->getMorphClass(), - (string) $model->getKey(), - $partition, - $team, - ); - } - - /** - * Invalidate assignment caches for an exact identity after a mutation settles. - */ - public function invalidateModelAssignmentCacheForIdentityAfterMutation( - string $morphType, - int|string $modelKey, - ?PermissionPartition $partition, - int|string|null $team, - ): void { - $runtimeKey = $this->modelRuntimeCacheKeyForIdentity( - $morphType, - $modelKey, - $partition, - $team, - ); - $clearRuntime = function () use ($runtimeKey): void { - $this->forgetRuntimeCacheItem(self::MODEL_VIA_ROLE_PERMISSIONS_CONTEXT_KEY, $runtimeKey); - $this->forgetRuntimeCacheItem(self::MODEL_DIRECT_PERMISSIONS_CONTEXT_KEY, $runtimeKey); - $this->forgetRuntimeCacheItem(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY, $runtimeKey); - }; - - foreach ([$this->modelRolesCacheKeyPrefix, $this->modelPermissionsCacheKeyPrefix] as $prefix) { - $this->settleCacheMutation( - $this->modelCacheKeyForIdentity($prefix, $morphType, $modelKey, $partition, $team), - $clearRuntime, - ); - } - } - /** * Forget a model's cached role assignments. */ diff --git a/src/permission/src/Traits/HasAssignedModels.php b/src/permission/src/Traits/HasAssignedModels.php index 4fd98a1c43..6b5a2536b5 100644 --- a/src/permission/src/Traits/HasAssignedModels.php +++ b/src/permission/src/Traits/HasAssignedModels.php @@ -246,7 +246,7 @@ private function assignedModelRelationContext(PermissionRegistrar $registrar): P } /** - * Forget exact assignment caches affected by a reverse assignment operation. + * Forget the exact role caches affected by a reverse assignment operation. * * @param class-string $modelClass * @param list $ids @@ -260,7 +260,7 @@ private function forgetAssignedModelCaches( $morphType = (new $modelClass)->getMorphClass(); foreach ($ids as $id) { - $registrar->invalidateModelAssignmentCacheForIdentityAfterMutation( + $registrar->invalidateModelRoleCacheForIdentityAfterMutation( $morphType, $id, $context->partition, diff --git a/src/permission/src/Traits/HasRoles.php b/src/permission/src/Traits/HasRoles.php index a3fa2b158e..b25a83b2cd 100644 --- a/src/permission/src/Traits/HasRoles.php +++ b/src/permission/src/Traits/HasRoles.php @@ -585,23 +585,35 @@ protected function flushQueuedPermissionAssignments(): void $this->unsetRelation('permissions'); } - $contexts = []; + if ($this instanceof Permission) { + $contexts = []; - foreach ([...$roleAssignments, ...$permissionAssignments] as $assignment) { - $contexts[$assignment['context']->identity()] = $assignment['context']; - } + foreach ([...$roleAssignments, ...$permissionAssignments] as $assignment) { + $contexts[$assignment['context']->identity()] = $assignment['context']; + } - foreach ($contexts as $context) { - if ($this instanceof Permission) { + foreach ($contexts as $context) { $registrar->invalidatePermissionCatalogAfterMutation($context->partition); - } else { - $registrar->invalidateModelAssignmentCacheAfterMutation( - $this, - $context->partition, - $context->team, - ); } + + return; } + + $roleContexts = []; + + foreach ($roleAssignments as $assignment) { + $roleContexts[$assignment['context']->identity()] = $assignment['context']; + } + + foreach ($roleContexts as $context) { + $registrar->invalidateModelRoleCacheAfterMutation( + $this, + $context->partition, + $context->team, + ); + } + + $this->invalidateQueuedPermissionAssignmentContexts($permissionAssignments); } /** diff --git a/tests/Permission/CacheTest.php b/tests/Permission/CacheTest.php index ba6b6d8eb9..8733b6deed 100644 --- a/tests/Permission/CacheTest.php +++ b/tests/Permission/CacheTest.php @@ -133,6 +133,18 @@ public function testRoleAssignmentMutationsInvalidateWarmViaRolePermissionMemo() $this->assertSame(['edit-news'], $this->testUser->getPermissionsViaRoles()->pluck('name')->all()); } + public function testReverseRoleAssignmentsKeepTheWarmDirectPermissionMemo(): void + { + $this->testUser->givePermissionTo('edit-articles'); + $user = User::findOrFail($this->testUser->getKey()); + $directPermission = $user->getDirectPermissions()->sole(); + + $this->testUserRole->assignToModels($user); + + $this->assertTrue($user->hasRole('testRole')); + $this->assertSame($directPermission, $user->getDirectPermissions()->sole()); + } + public function testUnsavedModelsDoNotUseViaRolePermissionMemo(): void { $user = new User(['email' => 'unsaved@user.com']); diff --git a/tests/Permission/Integration/PermissionRegistrarTest.php b/tests/Permission/Integration/PermissionRegistrarTest.php index 4669dd1f9a..9ec8143227 100644 --- a/tests/Permission/Integration/PermissionRegistrarTest.php +++ b/tests/Permission/Integration/PermissionRegistrarTest.php @@ -308,12 +308,9 @@ public function testMissingCatalogLookupReturnsEmptyCollectionAndModelsStillThro $this->assertTrue($registrar->getPermissions(['name' => 'missing-permission', 'guard_name' => 'web'])->isEmpty()); - try { - $permissionClass::findByName('missing-permission'); - $this->fail('Expected missing permission exception was not thrown.'); - } catch (PermissionDoesNotExist) { - $this->assertTrue(true); - } + $this->expectException(PermissionDoesNotExist::class); + + $permissionClass::findByName('missing-permission'); } public function testRoleLookupUsesCatalogIndexAndStillThrowsWhenMissing(): void @@ -323,12 +320,9 @@ public function testRoleLookupUsesCatalogIndexAndStillThrowsWhenMissing(): void $this->assertTrue($role->is($roleClass::findById($role->getKey()))); - try { - $roleClass::findByName('missing-role'); - $this->fail('Expected missing role exception was not thrown.'); - } catch (RoleDoesNotExist) { - $this->assertTrue(true); - } + $this->expectException(RoleDoesNotExist::class); + + $roleClass::findByName('missing-role'); } public function testPermissionCreateUsesDatabaseForDuplicateCheckWhenCatalogIsStale(): void diff --git a/tests/Permission/TestCase.php b/tests/Permission/TestCase.php index d470ad0908..ed158ae09f 100644 --- a/tests/Permission/TestCase.php +++ b/tests/Permission/TestCase.php @@ -305,6 +305,14 @@ protected function reloadPermissions(): void $this->app->make(PermissionRegistrar::class)->forgetCachedPermissions(); } + /** + * Determine whether the permission cache uses the database store. + */ + protected function usesDatabaseCacheStore(): bool + { + return $this->app->make(PermissionRegistrar::class)->getCacheStore() instanceof DatabaseStore; + } + /** * Create the database cache table. */ diff --git a/tests/Permission/Traits/HasPermissionsTest.php b/tests/Permission/Traits/HasPermissionsTest.php index 39a00f3834..2dc68162fa 100644 --- a/tests/Permission/Traits/HasPermissionsTest.php +++ b/tests/Permission/Traits/HasPermissionsTest.php @@ -6,6 +6,8 @@ use Hypervel\Database\Eloquent\MissingAttributeException; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; +use Hypervel\Database\Eloquent\Relations\MorphPivot; use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Permission\Contracts\Permission; use Hypervel\Permission\Contracts\Role; @@ -13,17 +15,39 @@ use Hypervel\Permission\Events\PermissionDetachedEvent; use Hypervel\Permission\Exceptions\GuardDoesNotMatch; use Hypervel\Permission\Exceptions\PermissionDoesNotExist; +use Hypervel\Permission\Guard; use Hypervel\Permission\PermissionRegistrar; +use Hypervel\Permission\Traits\HasRoles; use Hypervel\Support\ClassInvoker; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Event; use Hypervel\Tests\Permission\Fixtures\Models\SoftDeletingUser; use Hypervel\Tests\Permission\Fixtures\Models\TestRolePermissionsEnum; use Hypervel\Tests\Permission\Fixtures\Models\User; +use Hypervel\Tests\Permission\Fixtures\Models\UserWithoutHasRoles; use Hypervel\Tests\Permission\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use stdClass; +class HasPermissionsCustomPivot extends MorphPivot +{ +} + +class HasPermissionsCustomPivotUser extends UserWithoutHasRoles +{ + use HasRoles { + permissions as traitPermissions; + } + + /** + * Get the permissions relation through the custom pivot. + */ + public function permissions(): BelongsToMany + { + return $this->traitPermissions()->using(HasPermissionsCustomPivot::class); + } +} + class HasPermissionsTest extends TestCase { public function testItCanAssignAPermissionToAUser(): void @@ -65,7 +89,6 @@ public function testItThrowsAnExceptionWhenAssigningAPermissionToAUserFromADiffe $this->testUser->givePermissionTo($this->testAdminPermission); $this->fail('Expected guard mismatch exception was not thrown.'); } catch (GuardDoesNotMatch) { - $this->assertTrue(true); } $this->expectException(PermissionDoesNotExist::class); @@ -92,6 +115,27 @@ public function testItSilentlyIgnoresValuesThatCannotBeResolvedToAPermission(): $this->assertCount(1, $this->testUser->permissions); } + public function testItCanRevokeAPermissionWhenUsingACustomPivotClassWithoutTeams(): void + { + config()->set('auth.providers.users.model', HasPermissionsCustomPivotUser::class); + // Guard caches provider models for the worker lifetime. + Guard::flushState(); + + $user = HasPermissionsCustomPivotUser::create(['email' => 'custom-pivot-permissions-without-teams@test.com']); + + $user->givePermissionTo('edit-articles', 'edit-news'); + + $this->assertSame(['edit-articles', 'edit-news'], $user->getPermissionNames()->sort()->values()->all()); + + $user->revokePermissionTo('edit-articles'); + + $this->assertSame(['edit-news'], $user->getPermissionNames()->sort()->values()->all()); + + $user->syncPermissions([]); + + $this->assertEmpty($user->getPermissionNames()); + } + public function testItCanAssignAndRemoveAPermissionUsingEnums(): void { $enum = TestRolePermissionsEnum::ViewArticles; @@ -147,7 +191,7 @@ public function testItCanScopeUsersUsingAString(): void $this->assertCount(2, User::withoutPermission('edit-news')->get()); } - public function testItCanScopeUsersUsingAnInt(): void + public function testItCanScopeUsersUsingAInt(): void { User::all()->each(fn ($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); @@ -167,10 +211,11 @@ public function testItCanScopeUsersUsingAnArray(): void User::all()->each(fn ($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); $user2 = User::create(['email' => 'user2@test.com']); - User::create(['email' => 'user3@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); $user1->givePermissionTo(['edit-articles', 'edit-news']); $this->testUserRole->givePermissionTo('edit-articles'); $user2->assignRole('testRole'); + $user3->assignRole('testRole2'); $this->assertCount(2, User::permission(['edit-articles', 'edit-news'])->get()); $this->assertCount(1, User::permission(['edit-news'])->get()); @@ -194,10 +239,11 @@ public function testItCanScopeUsersUsingACollection(): void User::all()->each(fn ($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); $user2 = User::create(['email' => 'user2@test.com']); - User::create(['email' => 'user3@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); $user1->givePermissionTo(['edit-articles', 'edit-news']); $this->testUserRole->givePermissionTo('edit-articles'); $user2->assignRole('testRole'); + $user3->assignRole('testRole2'); $this->assertCount(2, User::permission(collect(['edit-articles', 'edit-news']))->get()); $this->assertCount(1, User::permission(collect(['edit-news']))->get()); @@ -225,11 +271,14 @@ public function testPermissionScopesRejectKeylessModelInputs(string $scope, bool $permissions = $mixed ? [$this->testUserPermission, $keylessPermission] : $keylessPermission; $this->expectException(MissingAttributeException::class); - $this->expectExceptionMessage($keylessPermission->getKeyName()); + $this->expectExceptionMessageIsOrContains($keylessPermission->getKeyName()); User::query()->{$scope}($permissions)->get(); } + /** + * Provide permission scopes with keyless model inputs. + */ public static function permissionScopeProvider(): array { return [ @@ -310,7 +359,6 @@ public function testItThrowsAnExceptionWhenTryingToScopeANonExistingPermission() User::permission('not defined permission')->get(); $this->fail('Expected permission does not exist exception was not thrown.'); } catch (PermissionDoesNotExist) { - $this->assertTrue(true); } $this->expectException(PermissionDoesNotExist::class); @@ -324,7 +372,6 @@ public function testItThrowsAnExceptionWhenTryingToScopeAPermissionFromAnotherGu User::permission('testAdminPermission')->get(); $this->fail('Expected permission does not exist exception was not thrown.'); } catch (PermissionDoesNotExist) { - $this->assertTrue(true); } $this->expectException(PermissionDoesNotExist::class); @@ -332,7 +379,7 @@ public function testItThrowsAnExceptionWhenTryingToScopeAPermissionFromAnotherGu User::withoutPermission('testAdminPermission')->get(); } - public function testItDoesNotDetachPermissionsWhenUserSoftDeleting(): void + public function testItDoesntDetachPermissionsWhenUserSoftDeleting(): void { $user = SoftDeletingUser::create(['email' => 'test@example.com']); $user->givePermissionTo(['edit-news']); @@ -633,7 +680,7 @@ public function testItSyncPermissionIgnoresNullInputs(): void $this->assertFalse($this->testUser->hasDirectPermission('edit-news')); } - public function testItDoesNotDetachPermissionsWhenSyncPermissionErrors(): void + public function testItSyncPermissionErrorDoesNotDetachPermissions(): void { $this->testUser->givePermissionTo('edit-news'); @@ -665,6 +712,9 @@ public function testPermissionMutationsRejectKeylessModelInputs(string $method, $this->assertTrue($this->testUser->fresh()->hasDirectPermission('edit-news')); } + /** + * Provide permission mutations with keyless model inputs. + */ public static function permissionMutationProvider(): array { return [ @@ -705,6 +755,9 @@ public function testPermissionMutationsRejectAKeylessPersistedSubjectBeforeMutat $this->assertFalse($user->hasDeniedPermission('edit-blog')); } + /** + * Provide permission mutations for a keyless persisted subject. + */ public static function permissionOwnerMutationProvider(): array { return [ @@ -749,7 +802,7 @@ public function testItCanSyncPermissionsToAModelThatIsNotPersisted(): void $user = new User(['email' => 'test@user.com']); $user->syncPermissions('edit-articles'); $user->save(); - $user->save(); + $user->save(); // test save same model twice $this->assertTrue($user->hasPermissionTo('edit-articles')); @@ -788,7 +841,7 @@ public function testQueuedSyncPermissionsReplacesEarlierQueuedDeniedPermissionAs $this->assertFalse($user->hasDeniedPermission('edit-articles')); } - public function testItDoesNotRunUnnecessarySqlWhenAssigningNewPermissions(): void + public function testItDoesNotRunUnnecessarySqlsWhenAssigningNewPermissions(): void { $permission2 = app(Permission::class)->where('name', 'edit-news')->first(); @@ -796,10 +849,18 @@ public function testItDoesNotRunUnnecessarySqlWhenAssigningNewPermissions(): voi $this->testUser->syncPermissions($this->testUserPermission, $permission2); DB::disableQueryLog(); - $this->assertCount(2, DB::getQueryLog()); + $necessaryQueriesCount = 2; + + // A database cache store also reads the assignment token and invalidates the user's + // permission cache entry (lock, delete and release). + if ($this->usesDatabaseCacheStore()) { + $necessaryQueriesCount += 4; + } + + $this->assertCount($necessaryQueriesCount, DB::getQueryLog()); } - public function testItDoesNotLetQueuedGivePermissionToInterfereWithOtherObjects(): void + public function testItCallingGivePermissionToBeforeSavingObjectDoesntInterfereWithOtherObjects(): void { $user = new User(['email' => 'test@user.com']); $user->givePermissionTo('edit-news'); @@ -817,10 +878,12 @@ public function testItDoesNotLetQueuedGivePermissionToInterfereWithOtherObjects( $this->assertTrue($user2->fresh()->hasPermissionTo('edit-articles')); $this->assertFalse($user2->fresh()->hasPermissionTo('edit-news')); - $this->assertCount(2, DB::getQueryLog()); + // A database cache store also invalidates the new user's permission cache entry + // (lock, delete and release). + $this->assertCount($this->usesDatabaseCacheStore() ? 5 : 2, DB::getQueryLog()); // avoid unnecessary sync } - public function testItDoesNotLetQueuedSyncPermissionsInterfereWithOtherObjects(): void + public function testItCallingSyncPermissionsBeforeSavingObjectDoesntInterfereWithOtherObjects(): void { $user = new User(['email' => 'test@user.com']); $user->syncPermissions('edit-news'); @@ -838,7 +901,9 @@ public function testItDoesNotLetQueuedSyncPermissionsInterfereWithOtherObjects() $this->assertTrue($user2->fresh()->hasPermissionTo('edit-articles')); $this->assertFalse($user2->fresh()->hasPermissionTo('edit-news')); - $this->assertCount(2, DB::getQueryLog()); + // A database cache store also invalidates the new user's permission cache entry + // (lock, delete and release). + $this->assertCount($this->usesDatabaseCacheStore() ? 5 : 2, DB::getQueryLog()); // avoid unnecessary sync } public function testItCanRetrievePermissionNames(): void @@ -948,6 +1013,36 @@ public function testItFiresAnEventWhenAPermissionIsRemoved(): void }); } + public function testItFiresDetachEventWhenSyncingPermissions(): void + { + Event::fake([PermissionDetachedEvent::class, PermissionAttachedEvent::class]); + app('config')->set('permission.events_enabled', true); + + $this->testUser->givePermissionTo('edit-articles', 'edit-news'); + + $this->testUser->syncPermissions('edit-articles'); + + $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); + $this->assertFalse($this->testUser->hasPermissionTo('edit-news')); + + Event::assertDispatched(PermissionDetachedEvent::class, function (PermissionDetachedEvent $event): bool { + $names = collect($event->permissionsOrIds)->map( + fn (mixed $permission): string => is_object($permission) + ? $permission->name + : app(Permission::class)::findById($permission)->name + ); + + return $event->model instanceof User + && ! $event->model->hasPermissionTo('edit-news') + && $names->contains('edit-news'); + }); + + Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event): bool { + return $event->model instanceof User + && $event->model->hasPermissionTo('edit-articles'); + }); + } + public function testItCanBeGivenAPermissionOnRoleWhenLazyLoadingIsRestricted(): void { $this->assertTrue(Model::preventsLazyLoading()); diff --git a/tests/Permission/Traits/HasPermissionsWithCustomModelsTest.php b/tests/Permission/Traits/HasPermissionsWithCustomModelsTest.php index 58652e079b..a06354dd0e 100644 --- a/tests/Permission/Traits/HasPermissionsWithCustomModelsTest.php +++ b/tests/Permission/Traits/HasPermissionsWithCustomModelsTest.php @@ -21,7 +21,7 @@ protected function setUpInCoroutine(): void $this->setUpCustomModels(); } - public function testItCanScopeUsersUsingAnInt(): void + public function testItCanScopeUsersUsingAInt(): void { // Skipped because custom model uses uuid, replacement "testItCanScopeUsersUsingAUuid". $this->assertTrue(true); From e5306018bb7a96d6dfe64c9755c059089c7abc41 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:08:31 +0000 Subject: [PATCH 05/18] Reconcile custom-model and team HasPermissions tests with upstream Bring HasPermissionsWithCustomModelsTest and TeamHasPermissionsTest in line with spatie/laravel-permission main at 6615eefac655 (8.x). The custom-models variant follows upstream's case order and restores its skip comment for the integer-scope override. TeamHasPermissionsTest gains upstream's five custom-pivot team cases and their fixtures, and three cases take their upstream names. Several upstream cases change the auth provider model at runtime. The new TestCase::useAuthUserModel() sets it and flushes Guard's provider model cache, which lasts for the worker lifetime; the HasPermissionsTest custom-pivot case now uses it too. The custom-models variant's database-store failures came from an always-zero query-count offset. The counts now name the database cache store's real statements: soft deletes invalidate the permission catalog when deleting and when deleted, force deletes invalidate it once in the delete transaction and write a new assignment token, and the team warm reuse case pays for two assignment cache fills. Validation: HasPermissionsTest, HasPermissionsWithCustomModelsTest and TeamHasPermissionsTest pass on the array and database cache stores; the Permission suite passes on the array store, with only known later-slice failures on the database store; formatting is clean. --- tests/Permission/TestCase.php | 13 ++ .../Permission/Traits/HasPermissionsTest.php | 5 +- .../HasPermissionsWithCustomModelsTest.php | 103 ++++++------ .../Traits/TeamHasPermissionsTest.php | 151 +++++++++++++++++- 4 files changed, 215 insertions(+), 57 deletions(-) diff --git a/tests/Permission/TestCase.php b/tests/Permission/TestCase.php index ed158ae09f..8e7d00728a 100644 --- a/tests/Permission/TestCase.php +++ b/tests/Permission/TestCase.php @@ -297,6 +297,19 @@ protected function setUpRoleNesting(): void }); } + /** + * Use a user model for the default auth provider. + * + * @param class-string $model + */ + protected function useAuthUserModel(string $model): void + { + $this->app->make('config')->set('auth.providers.users.model', $model); + + // Guard caches provider models for the worker lifetime. + Guard::flushState(); + } + /** * Reload permission cache state. */ diff --git a/tests/Permission/Traits/HasPermissionsTest.php b/tests/Permission/Traits/HasPermissionsTest.php index 2dc68162fa..dd3c8f0131 100644 --- a/tests/Permission/Traits/HasPermissionsTest.php +++ b/tests/Permission/Traits/HasPermissionsTest.php @@ -15,7 +15,6 @@ use Hypervel\Permission\Events\PermissionDetachedEvent; use Hypervel\Permission\Exceptions\GuardDoesNotMatch; use Hypervel\Permission\Exceptions\PermissionDoesNotExist; -use Hypervel\Permission\Guard; use Hypervel\Permission\PermissionRegistrar; use Hypervel\Permission\Traits\HasRoles; use Hypervel\Support\ClassInvoker; @@ -117,9 +116,7 @@ public function testItSilentlyIgnoresValuesThatCannotBeResolvedToAPermission(): public function testItCanRevokeAPermissionWhenUsingACustomPivotClassWithoutTeams(): void { - config()->set('auth.providers.users.model', HasPermissionsCustomPivotUser::class); - // Guard caches provider models for the worker lifetime. - Guard::flushState(); + $this->useAuthUserModel(HasPermissionsCustomPivotUser::class); $user = HasPermissionsCustomPivotUser::create(['email' => 'custom-pivot-permissions-without-teams@test.com']); diff --git a/tests/Permission/Traits/HasPermissionsWithCustomModelsTest.php b/tests/Permission/Traits/HasPermissionsWithCustomModelsTest.php index a06354dd0e..68e70e8c0f 100644 --- a/tests/Permission/Traits/HasPermissionsWithCustomModelsTest.php +++ b/tests/Permission/Traits/HasPermissionsWithCustomModelsTest.php @@ -14,22 +14,34 @@ class HasPermissionsWithCustomModelsTest extends HasPermissionsTest { - protected int $resetDatabaseQuery = 0; - protected function setUpInCoroutine(): void { $this->setUpCustomModels(); } - public function testItCanScopeUsersUsingAInt(): void + public function testItCanUseCustomModelPermission(): void { - // Skipped because custom model uses uuid, replacement "testItCanScopeUsersUsingAUuid". - $this->assertTrue(true); + $this->assertSame(Permission::class, $this->testUserPermission::class); } - public function testItCanUseCustomModelPermission(): void + public function testItCanUseCustomFieldsFromCache(): void { - $this->assertSame(Permission::class, $this->testUserPermission::class); + DB::connection()->getSchemaBuilder()->table(config('permission.table_names.roles'), function ($table): void { + $table->string('type')->default('R'); + }); + DB::connection()->getSchemaBuilder()->table(config('permission.table_names.permissions'), function ($table): void { + $table->string('type')->default('P'); + }); + + $this->testUserRole->givePermissionTo($this->testUserPermission); + app(PermissionRegistrar::class)->getPermissions(); + + DB::enableQueryLog(); + $this->assertSame('P', Permission::findByName('edit-articles')->type); + $this->assertSame('R', Permission::findByName('edit-articles')->roles[0]->type); + DB::disableQueryLog(); + + $this->assertCount(0, DB::getQueryLog()); } public function testBasePermissionRequestsReuseTheConfiguredCustomCatalogAndPrimaryKey(): void @@ -57,43 +69,11 @@ public function testBasePermissionRequestsReuseTheConfiguredCustomCatalogAndPrim $this->assertSame([], DB::getQueryLog()); } - public function testFindOrCreateRestoresSoftDeletedPermission(): void - { - $permission = Permission::create(['name' => 'restorable-permission']); - $permissionId = $permission->getKey(); - $this->testUserRole->givePermissionTo($permission); - $this->testUser->givePermissionTo($permission); - - $permission->delete(); - - $restoredPermission = Permission::findOrCreate('restorable-permission'); - - $this->assertSame($permissionId, $restoredPermission->getKey()); - $this->assertFalse($restoredPermission->trashed()); - $this->assertNull($restoredPermission->deleted_at); - $this->assertSame(1, Permission::withTrashed()->where('name', 'restorable-permission')->count()); - $this->assertTrue($this->testUserRole->hasPermissionTo($restoredPermission)); - $this->assertTrue($this->testUser->fresh()->hasPermissionTo($restoredPermission)); - } - - public function testItCanUseCustomFieldsFromCache(): void + public function testItCanScopeUsersUsingAInt(): void { - DB::connection()->getSchemaBuilder()->table(config('permission.table_names.roles'), function ($table): void { - $table->string('type')->default('R'); - }); - DB::connection()->getSchemaBuilder()->table(config('permission.table_names.permissions'), function ($table): void { - $table->string('type')->default('P'); - }); - - $this->testUserRole->givePermissionTo($this->testUserPermission); - app(PermissionRegistrar::class)->getPermissions(); - - DB::enableQueryLog(); - $this->assertSame('P', Permission::findByName('edit-articles')->type); - $this->assertSame('R', Permission::findByName('edit-articles')->roles[0]->type); - DB::disableQueryLog(); - - $this->assertCount(0, DB::getQueryLog()); + // Skipped because custom model uses uuid, + // replacement "testItCanScopeUsersUsingAUuid" + $this->assertTrue(true); } public function testItCanScopeUsersUsingAUuid(): void @@ -111,7 +91,7 @@ public function testItCanScopeUsersUsingAUuid(): void $this->assertCount(1, User::permission([$uuid2])->get()); } - public function testItDoesNotDetachRolesWhenSoftDeleting(): void + public function testItDoesntDetachRolesWhenSoftDeleting(): void { $this->testUserRole->givePermissionTo($this->testUserPermission); @@ -119,7 +99,9 @@ public function testItDoesNotDetachRolesWhenSoftDeleting(): void $this->testUserPermission->delete(); DB::disableQueryLog(); - $this->assertCount(1 + $this->resetDatabaseQuery, DB::getQueryLog()); + // A database cache store invalidates the permission catalog when deleting and again when deleted + // (lock, delete and release each). + $this->assertCount($this->usesDatabaseCacheStore() ? 7 : 1, DB::getQueryLog()); $permission = Permission::onlyTrashed()->find($this->testUserPermission->getKey()); @@ -131,7 +113,7 @@ public function testItDoesNotDetachRolesWhenSoftDeleting(): void ); } - public function testItDoesNotDetachUsersWhenSoftDeleting(): void + public function testItDoesntDetachUsersWhenSoftDeleting(): void { $this->testUser->givePermissionTo($this->testUserPermission); $registrar = app(PermissionRegistrar::class); @@ -141,7 +123,9 @@ public function testItDoesNotDetachUsersWhenSoftDeleting(): void $this->testUserPermission->delete(); DB::disableQueryLog(); - $this->assertCount(1 + $this->resetDatabaseQuery, DB::getQueryLog()); + // A database cache store invalidates the permission catalog when deleting and again when deleted + // (lock, delete and release each). + $this->assertCount($this->usesDatabaseCacheStore() ? 7 : 1, DB::getQueryLog()); $permission = Permission::onlyTrashed()->find($this->testUserPermission->getKey()); @@ -166,7 +150,9 @@ public function testItDoesDetachRolesAndUsersWhenForceDeleting(): void $this->testUserPermission->forceDelete(); DB::disableQueryLog(); - $this->assertCount(3 + $this->resetDatabaseQuery, DB::getQueryLog()); + // A database cache store invalidates the permission catalog once for the delete transaction + // (lock, delete and release) and writes a new model assignment token. + $this->assertCount($this->usesDatabaseCacheStore() ? 7 : 3, DB::getQueryLog()); // avoid detach permissions on permissions $this->assertNull(Permission::withTrashed()->find($permissionId)); $this->assertSame( @@ -184,7 +170,26 @@ public function testItDoesDetachRolesAndUsersWhenForceDeleting(): void $this->assertNotSame($token, $registrar->modelAssignmentCacheToken()); } - public function testItTouchesWhenAssigningNewPermissions(): void + public function testFindOrCreateRestoresSoftDeletedPermission(): void + { + $permission = Permission::create(['name' => 'restorable-permission']); + $permissionId = $permission->getKey(); + $this->testUserRole->givePermissionTo($permission); + $this->testUser->givePermissionTo($permission); + + $permission->delete(); + + $restoredPermission = Permission::findOrCreate('restorable-permission'); + + $this->assertSame($permissionId, $restoredPermission->getKey()); + $this->assertFalse($restoredPermission->trashed()); + $this->assertNull($restoredPermission->deleted_at); + $this->assertSame(1, Permission::withTrashed()->where('name', 'restorable-permission')->count()); + $this->assertTrue($this->testUserRole->hasPermissionTo($restoredPermission)); + $this->assertTrue($this->testUser->fresh()->hasPermissionTo($restoredPermission)); + } + + public function testItShouldTouchWhenAssigningNewPermissions(): void { CarbonImmutable::setTestNow('2021-07-19 10:13:14'); diff --git a/tests/Permission/Traits/TeamHasPermissionsTest.php b/tests/Permission/Traits/TeamHasPermissionsTest.php index 7251c4c75b..3d9d51c3c2 100644 --- a/tests/Permission/Traits/TeamHasPermissionsTest.php +++ b/tests/Permission/Traits/TeamHasPermissionsTest.php @@ -7,16 +7,40 @@ use Closure; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\MorphPivot; use Hypervel\Permission\Events\PermissionAttachedEvent; use Hypervel\Permission\Events\PermissionDetachedEvent; use Hypervel\Permission\Exceptions\TeamNotSelected; use Hypervel\Permission\PermissionRegistrar; use Hypervel\Permission\Support\Config; +use Hypervel\Permission\Traits\HasRoles; use Hypervel\Support\ClassInvoker; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Event; use Hypervel\Tests\Permission\Fixtures\Models\User; +use Hypervel\Tests\Permission\Fixtures\Models\UserWithoutHasRoles; + +class TeamHasPermissionsCustomPivot extends MorphPivot +{ +} + +class TeamHasPermissionsCustomPivotUser extends UserWithoutHasRoles +{ + use HasRoles { + permissions as traitPermissions; + } + + /** + * Get the permissions relation through the custom pivot. + */ + public function permissions(): BelongsToMany + { + return $this->traitPermissions() + ->withPivot('team_test_id') + ->using(TeamHasPermissionsCustomPivot::class); + } +} class TeamHasPermissionsTest extends HasPermissionsTest { @@ -32,7 +56,7 @@ protected function setUpInCoroutine(): void $this->setUpTeams(); } - public function testItCanAssignSameAndDifferentPermissionsOnSameUserOnDifferentTeams(): void + public function testItCanAssignSameAndDifferentPermissionOnSameUserOnDifferentTeams(): void { setPermissionsTeamId(1); $this->testUser->givePermissionTo('edit-articles', 'edit-news'); @@ -53,7 +77,7 @@ public function testItCanAssignSameAndDifferentPermissionsOnSameUserOnDifferentT $this->assertFalse($this->testUser->hasAllDirectPermissions(['edit-articles', 'edit-news'])); } - public function testItCanListAllCoupledPermissionsDirectlyAndViaRolesOnSameUserOnDifferentTeams(): void + public function testItCanListAllTheCoupledPermissionsBothDirectlyAndViaRolesOnSameUserOnDifferentTeams(): void { $this->testUserRole->givePermissionTo('edit-articles'); @@ -103,7 +127,9 @@ public function testWarmAuthorizationReusesHydratedCatalogRelationsAcrossTeams() $this->assertTrue($this->testUser->hasPermissionTo($this->testUserPermission)); $this->testUser->getAllPermissions(); - $this->assertCount(2, DB::getQueryLog()); + // A database cache store adds six statements to each of the two assignment fills + // (read, lock, recheck, lease refresh, write and release). + $this->assertCount($this->usesDatabaseCacheStore() ? 14 : 2, DB::getQueryLog()); DB::flushQueryLog(); @@ -128,7 +154,7 @@ public function testDirectPermissionHydrationMemoIsSeparatedByTeam(): void $this->assertSame($teamOne, $this->testUser->getDirectPermissions()->sole()); } - public function testItCanSyncOrRemovePermissionsWithoutDetachingDifferentTeams(): void + public function testItCanSyncOrRemovePermissionWithoutDetachOnDifferentTeams(): void { setPermissionsTeamId(1); $this->testUser->syncPermissions('edit-articles', 'edit-news'); @@ -148,6 +174,123 @@ public function testItCanSyncOrRemovePermissionsWithoutDetachingDifferentTeams() $this->assertSame(['edit-articles', 'edit-blog'], $this->testUser->getPermissionNames()->sort()->values()->all()); } + public function testItCanRevokeAPermissionFromOneTeamWhenUsingACustomPivotClass(): void + { + $this->useAuthUserModel(TeamHasPermissionsCustomPivotUser::class); + + $user = TeamHasPermissionsCustomPivotUser::create(['email' => 'custom-pivot-revoke-permission@test.com']); + + setPermissionsTeamId(1); + $user->givePermissionTo('edit-articles', 'edit-news'); + + setPermissionsTeamId(2); + $user->givePermissionTo('edit-articles', 'edit-blog'); + $user->load('permissions'); + + $this->assertSame(['edit-articles', 'edit-blog'], $user->getPermissionNames()->sort()->values()->all()); + + setPermissionsTeamId(1); + $user->load('permissions'); + + $this->assertSame(['edit-articles', 'edit-news'], $user->getPermissionNames()->sort()->values()->all()); + + $user->revokePermissionTo('edit-articles'); + + $this->assertSame(['edit-news'], $user->getPermissionNames()->sort()->values()->all()); + + setPermissionsTeamId(2); + $user->load('permissions'); + + $this->assertSame(['edit-articles', 'edit-blog'], $user->getPermissionNames()->sort()->values()->all()); + } + + public function testItDoesNothingWhenRevokingAnEmptySetOfPermissionsForOneTeamWhenUsingACustomPivotClass(): void + { + $this->useAuthUserModel(TeamHasPermissionsCustomPivotUser::class); + + $user = TeamHasPermissionsCustomPivotUser::create(['email' => 'custom-pivot-revoke-empty-permission@test.com']); + + setPermissionsTeamId(1); + $user->givePermissionTo('edit-articles', 'edit-news'); + + $user->revokePermissionTo([]); + + $this->assertSame(['edit-articles', 'edit-news'], $user->getPermissionNames()->sort()->values()->all()); + } + + public function testItCanSyncPermissionsForOneTeamWhenUsingACustomPivotClass(): void + { + $this->useAuthUserModel(TeamHasPermissionsCustomPivotUser::class); + + $user = TeamHasPermissionsCustomPivotUser::create(['email' => 'custom-pivot-sync-permissions@test.com']); + + setPermissionsTeamId(1); + $user->givePermissionTo('edit-articles', 'edit-news'); + + setPermissionsTeamId(2); + $user->givePermissionTo('edit-articles', 'edit-blog'); + + setPermissionsTeamId(1); + $user->syncPermissions('edit-news'); + + $this->assertSame(['edit-news'], $user->getPermissionNames()->sort()->values()->all()); + + setPermissionsTeamId(2); + $user->load('permissions'); + + $this->assertSame(['edit-articles', 'edit-blog'], $user->getPermissionNames()->sort()->values()->all()); + } + + public function testItCanSyncPermissionsWithEventsForOneTeamWhenUsingACustomPivotClass(): void + { + Event::fake([PermissionDetachedEvent::class, PermissionAttachedEvent::class]); + app('config')->set('permission.events_enabled', true); + $this->useAuthUserModel(TeamHasPermissionsCustomPivotUser::class); + + $user = TeamHasPermissionsCustomPivotUser::create(['email' => 'custom-pivot-sync-permissions-events@test.com']); + + setPermissionsTeamId(1); + $user->givePermissionTo('edit-articles', 'edit-news'); + + setPermissionsTeamId(2); + $user->givePermissionTo('edit-articles', 'edit-blog'); + + setPermissionsTeamId(1); + $user->syncPermissions('edit-news'); + + $this->assertSame(['edit-news'], $user->getPermissionNames()->sort()->values()->all()); + + Event::assertDispatched(PermissionDetachedEvent::class); + + setPermissionsTeamId(2); + $user->load('permissions'); + + $this->assertSame(['edit-articles', 'edit-blog'], $user->getPermissionNames()->sort()->values()->all()); + } + + public function testItCanSyncToNoPermissionsForOneTeamWhenUsingACustomPivotClass(): void + { + $this->useAuthUserModel(TeamHasPermissionsCustomPivotUser::class); + + $user = TeamHasPermissionsCustomPivotUser::create(['email' => 'custom-pivot-sync-empty-permissions@test.com']); + + setPermissionsTeamId(1); + $user->givePermissionTo('edit-articles', 'edit-news'); + + setPermissionsTeamId(2); + $user->givePermissionTo('edit-articles'); + + setPermissionsTeamId(1); + $user->syncPermissions([]); + + $this->assertEmpty($user->getPermissionNames()); + + setPermissionsTeamId(2); + $user->load('permissions'); + + $this->assertSame(['edit-articles'], $user->getPermissionNames()->sort()->values()->all()); + } + public function testItCanScopeUsersOnDifferentTeams(): void { $user1 = User::create(['email' => 'user1@test.com']); From 6a2be2821f432f9ed4fd0d4c4125dc6ba49cf957 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:21:34 +0000 Subject: [PATCH 06/18] Reconcile HasRolesTest with upstream and create test users once Bring HasRolesTest in line with spatie/laravel-permission main at 6615eefac655 (8.x): every upstream case under its upstream name and order, upstream's pipe-conversion and custom-pivot cases, restored comments, the string scope's second user role, the object scope's statement order and Admin::all() in the guard withoutscope case. The pipe-conversion case replaces Hypervel's malformed-quote case, which covered one of its inputs. The cross-guard sync catch drops its no-op assertTrue(true), and deprecated expectExceptionMessage() becomes expectExceptionMessageIsOrContains(). Upstream's teams branch in the unnecessary-SQL case is dropped: the sync reads the current team's pivot rows directly instead of reloading the relation. TeamHasRolesTest therefore inherits the base case, and its duplicate override is removed. Count cases add the database cache store's statements. Test setup fix: setUpBaseTestPermissions() created the test user and admin, so setUpCustomModels() added a second pair. The custom-models variant's Admin::all() then returned two models, which enables lazy-load prevention for the admin's touched relations on delete; the base case had worked around it with an eager load. The user and admin are now created once after the fixture tables, like upstream's setUpDatabase(). Validation: HasRolesTest and TeamHasRolesTest pass on the array and database cache stores; the custom-model variants, DeniedPermissionTest and RoleWithNestingTest pass on the array store; the Permission suite passes on the array store, with only known later-slice failures on the database store; formatting is clean. --- tests/Permission/TestCase.php | 7 +- tests/Permission/Traits/HasRolesTest.php | 211 +++++++++++++------ tests/Permission/Traits/TeamHasRolesTest.php | 13 -- 3 files changed, 153 insertions(+), 78 deletions(-) diff --git a/tests/Permission/TestCase.php b/tests/Permission/TestCase.php index 8e7d00728a..17aaeaa67f 100644 --- a/tests/Permission/TestCase.php +++ b/tests/Permission/TestCase.php @@ -160,6 +160,10 @@ protected function afterRefreshingDatabase(): void { $this->createFixtureTables(); $this->flushPermissionState(); + + $this->testUser = User::create(['email' => 'test@user.com']); + $this->testAdmin = Admin::create(['email' => 'admin@user.com']); + $this->setUpBaseTestPermissions(); $this->setUpRoutes(); } @@ -203,9 +207,6 @@ protected function createFixtureTables(): void */ protected function setUpBaseTestPermissions(): void { - $this->testUser = User::create(['email' => 'test@user.com']); - $this->testAdmin = Admin::create(['email' => 'admin@user.com']); - $this->testUserRole = $this->app->make(RoleContract::class)->create(['name' => 'testRole']); $this->app->make(RoleContract::class)->create(['name' => 'testRole2']); $this->testAdminRole = $this->app->make(RoleContract::class)->create(['name' => 'testAdminRole', 'guard_name' => 'admin']); diff --git a/tests/Permission/Traits/HasRolesTest.php b/tests/Permission/Traits/HasRolesTest.php index 6dd9ce04ec..dfa21dfad0 100644 --- a/tests/Permission/Traits/HasRolesTest.php +++ b/tests/Permission/Traits/HasRolesTest.php @@ -6,22 +6,46 @@ use Hypervel\Database\Eloquent\MissingAttributeException; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; +use Hypervel\Database\Eloquent\Relations\MorphPivot; use Hypervel\Permission\Contracts\Permission; use Hypervel\Permission\Contracts\Role; use Hypervel\Permission\Events\RoleAttachedEvent; use Hypervel\Permission\Events\RoleDetachedEvent; use Hypervel\Permission\Exceptions\GuardDoesNotMatch; use Hypervel\Permission\Exceptions\RoleDoesNotExist; +use Hypervel\Permission\Traits\HasRoles; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Event; use Hypervel\Tests\Permission\Fixtures\Models\Admin; use Hypervel\Tests\Permission\Fixtures\Models\SoftDeletingUser; use Hypervel\Tests\Permission\Fixtures\Models\TestRolePermissionsEnum; use Hypervel\Tests\Permission\Fixtures\Models\User; +use Hypervel\Tests\Permission\Fixtures\Models\UserWithoutHasRoles; use Hypervel\Tests\Permission\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionMethod; use TypeError; +class HasRolesCustomPivot extends MorphPivot +{ +} + +class HasRolesCustomPivotUser extends UserWithoutHasRoles +{ + use HasRoles { + roles as traitRoles; + } + + /** + * Get the roles relation through the custom pivot. + */ + public function roles(): BelongsToMany + { + return $this->traitRoles()->using(HasRolesCustomPivot::class); + } +} + class HasRolesTest extends TestCase { public function testItCanDetermineThatTheUserDoesNotHaveARole(): void @@ -108,6 +132,7 @@ public function testItCanScopeARoleUsingEnums(): void $user2 = User::create(['email' => 'user2@test.com']); User::create(['email' => 'user3@test.com']); + // assign only one user to a role $user2->assignRole($enum1); $this->assertTrue($user2->hasRole($enum1)); $this->assertFalse($user2->hasRole($enum2)); @@ -117,6 +142,42 @@ public function testItCanScopeARoleUsingEnums(): void $this->assertCount(3, User::withoutRole($enum2)->get()); } + public function testItCanConvertPipeStringsToArraysStrippingOnlyMatchedSurroundingQuotes(): void + { + $convert = function (string $pipeString): array { + $method = new ReflectionMethod($this->testUser, 'convertPipeToArray'); + + return $method->invoke($this->testUser, $pipeString); + }; + + // Unquoted input splits on the pipe. + $this->assertSame(['writer', 'admin'], $convert('writer|admin')); + + // Matched surrounding quotes are stripped before splitting. + $this->assertSame(['writer', 'admin'], $convert("'writer|admin'")); + $this->assertSame(['writer', 'admin'], $convert('"writer|admin"')); + + // Mismatched quotes (leading quote only) must NOT be stripped: the leading + // quote is part of the first value. The previous self-comparison bug made + // this guard dead and incorrectly stripped the quote. + $this->assertSame(["'writer", 'admin'], $convert("'writer|admin")); + + // Matching surrounding characters that aren't quotes must NOT be stripped. + $this->assertSame(['xwriter', 'adminx'], $convert('xwriter|adminx')); + + // Very short strings are just stripped of pipes and returned as a single value. + $this->assertSame(['a'], $convert('a|')); + $this->assertSame([''], $convert('|')); + } + + public function testItCanCheckExactRolesUsingAPipeDelimitedString(): void + { + $this->testUser->assignRole('testRole', 'testRole2'); + + $this->assertTrue($this->testUser->hasExactRoles('testRole|testRole2')); + $this->assertFalse($this->testUser->hasExactRoles('testRole|testRole2|testRole3')); + } + public function testItCanAssignAndRemoveARole(): void { $this->assertFalse($this->testUser->hasRole('testRole')); @@ -130,6 +191,25 @@ public function testItCanAssignAndRemoveARole(): void $this->assertFalse($this->testUser->hasRole('testRole')); } + public function testItCanRemoveARoleWhenUsingACustomPivotClassWithoutTeams(): void + { + $this->useAuthUserModel(HasRolesCustomPivotUser::class); + + $user = HasRolesCustomPivotUser::create(['email' => 'custom-pivot-without-teams@test.com']); + + $user->assignRole('testRole', 'testRole2'); + + $this->assertSame(['testRole', 'testRole2'], $user->getRoleNames()->sort()->values()->all()); + + $user->removeRole('testRole'); + + $this->assertSame(['testRole2'], $user->getRoleNames()->sort()->values()->all()); + + $user->syncRoles([]); + + $this->assertEmpty($user->getRoleNames()); + } + public function testItRemovesARoleAndReturnsRoles(): void { $this->testUser->assignRole('testRole'); @@ -176,23 +256,6 @@ public function testItCanAssignAndRemoveARoleUsingAnId(): void $this->assertFalse($this->testUser->hasRole($this->testUserRole)); } - public function testMalformedQuotedPipeRoleStringDoesNotTrimLeadingQuote(): void - { - app(Role::class)->create(['name' => 'admin']); - - $this->testUser->assignRole('admin'); - - $this->assertFalse($this->testUser->hasRole('"admin|editor')); - } - - public function testItCanCheckExactRolesUsingAPipeDelimitedString(): void - { - $this->testUser->assignRole('testRole', 'testRole2'); - - $this->assertTrue($this->testUser->hasExactRoles('testRole|testRole2')); - $this->assertFalse($this->testUser->hasExactRoles('testRole|testRole2|testRole3')); - } - public function testItCanAssignAndRemoveMultipleRolesAtOnce(): void { $this->testUser->assignRole($this->testUserRole->getKey(), 'testRole2'); @@ -334,7 +397,7 @@ public function testItWillRemoveAllRolesWhenAnEmptyArrayIsPassedToSyncRoles(): v $this->assertFalse($this->testUser->hasRole('testRole2')); } - public function testItDoesNotDetachRolesWhenSyncRolesErrors(): void + public function testItSyncRolesErrorDoesNotDetachRoles(): void { $this->testUser->assignRole('testRole'); @@ -365,6 +428,9 @@ public function testRoleMutationsRejectKeylessModelInputs(string $method, bool $ $this->assertTrue($this->testUser->fresh()->hasRole('testRole2')); } + /** + * Provide role mutations with keyless model inputs. + */ public static function roleMutationProvider(): array { return [ @@ -399,6 +465,9 @@ public function testRoleMutationsRejectAKeylessPersistedSubjectBeforeMutation(st $this->assertFalse($this->testUser->fresh()->hasRole('testRole')); } + /** + * Provide role mutations for a keyless persisted subject. + */ public static function roleOwnerMutationProvider(): array { return [ @@ -427,7 +496,7 @@ public function testItWillSyncRolesToAModelThatIsNotPersisted(): void $user = new User(['email' => 'test@user.com']); $user->syncRoles([$this->testUserRole]); $user->save(); - $user->save(); + $user->save(); // test save same model twice $this->assertTrue($user->hasRole($this->testUserRole)); @@ -436,7 +505,7 @@ public function testItWillSyncRolesToAModelThatIsNotPersisted(): void $this->assertTrue($user->fresh()->hasRole($this->testUserRole)); } - public function testItDoesNotRunUnnecessarySqlWhenAssigningNewRoles(): void + public function testItDoesNotRunUnnecessarySqlsWhenAssigningNewRoles(): void { $role2 = app(Role::class)->where('name', 'testRole2')->first(); @@ -444,10 +513,20 @@ public function testItDoesNotRunUnnecessarySqlWhenAssigningNewRoles(): void $this->testUser->syncRoles($this->testUserRole, $role2); DB::disableQueryLog(); - $this->assertCount(2, DB::getQueryLog()); + // Teams add no query: the sync reads the current team's pivot rows directly + // instead of reloading the relation. + $necessaryQueriesCount = 2; + + // A database cache store also reads the assignment token and invalidates the user's + // role cache entry (lock, delete and release). + if ($this->usesDatabaseCacheStore()) { + $necessaryQueriesCount += 4; + } + + $this->assertCount($necessaryQueriesCount, DB::getQueryLog()); } - public function testItDoesNotLetQueuedSyncRolesInterfereWithOtherObjects(): void + public function testItCallingSyncRolesBeforeSavingObjectDoesntInterfereWithOtherObjects(): void { $user = new User(['email' => 'test@user.com']); $user->syncRoles('testRole'); @@ -465,10 +544,12 @@ public function testItDoesNotLetQueuedSyncRolesInterfereWithOtherObjects(): void $this->assertTrue($user2->fresh()->hasRole('testRole2')); $this->assertFalse($user2->fresh()->hasRole('testRole')); - $this->assertCount(2, DB::getQueryLog()); + // A database cache store also invalidates the new user's role cache entry + // (lock, delete and release). + $this->assertCount($this->usesDatabaseCacheStore() ? 5 : 2, DB::getQueryLog()); // avoid unnecessary sync } - public function testItDoesNotLetQueuedAssignRoleInterfereWithOtherObjects(): void + public function testItCallingAssignRoleBeforeSavingObjectDoesntInterfereWithOtherObjects(): void { $user = new User(['email' => 'test@user.com']); $user->assignRole('testRole'); @@ -486,7 +567,9 @@ public function testItDoesNotLetQueuedAssignRoleInterfereWithOtherObjects(): voi $this->assertTrue($adminUser->fresh()->hasRole('testRole2')); $this->assertFalse($adminUser->fresh()->hasRole('testRole')); - $this->assertCount(2, DB::getQueryLog()); + // A database cache store also invalidates the new user's role cache entry + // (lock, delete and release). + $this->assertCount($this->usesDatabaseCacheStore() ? 5 : 2, DB::getQueryLog()); // avoid unnecessary sync } public function testItThrowsAnExceptionWhenSyncingARoleFromAnotherGuard(): void @@ -495,7 +578,6 @@ public function testItThrowsAnExceptionWhenSyncingARoleFromAnotherGuard(): void $this->testUser->syncRoles('testRole', 'testAdminRole'); $this->fail('Expected role does not exist exception was not thrown.'); } catch (RoleDoesNotExist) { - $this->assertTrue(true); } $this->expectException(GuardDoesNotMatch::class); @@ -522,13 +604,14 @@ public function testItDeletesPivotTableEntriesWhenDeletingModels(): void public function testItCanScopeUsersUsingAString(): void { $user1 = User::create(['email' => 'user1@test.com']); - User::create(['email' => 'user2@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); $user1->assignRole('testRole'); + $user2->assignRole('testRole2'); $this->assertCount(1, User::role('testRole')->get()); } - public function testItCanWithoutScopeUsersUsingAString(): void + public function testItCanWithoutscopeUsersUsingAString(): void { User::all()->each(fn ($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); @@ -552,7 +635,7 @@ public function testItCanScopeUsersUsingAnArray(): void $this->assertCount(2, User::role(['testRole', 'testRole2'])->get()); } - public function testItCanWithoutScopeUsersUsingAnArray(): void + public function testItCanWithoutscopeUsersUsingAnArray(): void { User::all()->each(fn ($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); @@ -579,7 +662,7 @@ public function testItCanScopeUsersUsingAnArrayOfIdsAndNames(): void $this->assertCount(2, User::role([$firstAssignedRoleName, $secondAssignedRoleId])->get()); } - public function testItCanWithoutScopeUsersUsingAnArrayOfIdsAndNames(): void + public function testItCanWithoutscopeUsersUsingAnArrayOfIdsAndNames(): void { app(Role::class)->create(['name' => 'testRole3']); @@ -608,7 +691,7 @@ public function testItCanScopeUsersUsingACollection(): void $this->assertCount(2, User::role(collect(['testRole', 'testRole2']))->get()); } - public function testItCanWithoutScopeUsersUsingACollection(): void + public function testItCanWithoutscopeUsersUsingACollection(): void { app(Role::class)->create(['name' => 'testRole3']); @@ -627,15 +710,16 @@ public function testItCanWithoutScopeUsersUsingACollection(): void public function testItCanScopeUsersUsingAnObject(): void { $user1 = User::create(['email' => 'user1@test.com']); - User::create(['email' => 'user2@test.com'])->assignRole('testRole2'); + $user2 = User::create(['email' => 'user2@test.com']); $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole2'); $this->assertCount(1, User::role($this->testUserRole)->get()); $this->assertCount(1, User::role([$this->testUserRole])->get()); $this->assertCount(1, User::role(collect([$this->testUserRole]))->get()); } - public function testItCanWithoutScopeUsersUsingAnObject(): void + public function testItCanWithoutscopeUsersUsingAnObject(): void { User::all()->each(fn ($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); @@ -659,11 +743,14 @@ public function testRoleScopesRejectKeylessModelInputs(string $scope, bool $mixe $roles = $mixed ? [$this->testUserRole, $keylessRole] : $keylessRole; $this->expectException(MissingAttributeException::class); - $this->expectExceptionMessage($keylessRole->getKeyName()); + $this->expectExceptionMessageIsOrContains($keylessRole->getKeyName()); User::query()->{$scope}($roles)->get(); } + /** + * Provide role scopes with keyless model inputs. + */ public static function roleScopeProvider(): array { return [ @@ -695,6 +782,31 @@ public function testItCanScopeAgainstASpecificGuard(): void $this->assertCount(1, Admin::role('testAdminRole2', 'admin')->get()); } + public function testItCanWithoutscopeAgainstASpecificGuard(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole('testRole'); + $user2->assignRole('testRole2'); + $user3->assignRole('testRole2'); + + $this->assertCount(2, User::withoutRole('testRole', 'web')->get()); + + Admin::all()->each(fn ($item) => $item->delete()); + $user4 = Admin::create(['email' => 'user4@test.com']); + $user5 = Admin::create(['email' => 'user5@test.com']); + $user6 = Admin::create(['email' => 'user6@test.com']); + $testAdminRole2 = app(Role::class)->create(['name' => 'testAdminRole2', 'guard_name' => 'admin']); + $user4->assignRole($this->testAdminRole); + $user5->assignRole($this->testAdminRole); + $user6->assignRole($testAdminRole2); + + $this->assertCount(1, Admin::withoutRole('testAdminRole', 'admin')->get()); + $this->assertCount(2, Admin::withoutRole('testAdminRole2', 'admin')->get()); + } + public function testItCanScopeAgainstAZeroNamedGuard(): void { config()->set('auth.guards.0', [ @@ -737,31 +849,6 @@ public function testRoleChecksHonorAZeroNamedGuard(): void $this->assertTrue($user->hasAllRoles([$webRole->name], '')); } - public function testItCanWithoutScopeAgainstASpecificGuard(): void - { - User::all()->each(fn ($item) => $item->delete()); - $user1 = User::create(['email' => 'user1@test.com']); - $user2 = User::create(['email' => 'user2@test.com']); - $user3 = User::create(['email' => 'user3@test.com']); - $user1->assignRole('testRole'); - $user2->assignRole('testRole2'); - $user3->assignRole('testRole2'); - - $this->assertCount(2, User::withoutRole('testRole', 'web')->get()); - - Admin::with(['roles', 'permissions'])->get()->each(fn ($item) => $item->delete()); - $user4 = Admin::create(['email' => 'user4@test.com']); - $user5 = Admin::create(['email' => 'user5@test.com']); - $user6 = Admin::create(['email' => 'user6@test.com']); - $testAdminRole2 = app(Role::class)->create(['name' => 'testAdminRole2', 'guard_name' => 'admin']); - $user4->assignRole($this->testAdminRole); - $user5->assignRole($this->testAdminRole); - $user6->assignRole($testAdminRole2); - - $this->assertCount(1, Admin::withoutRole('testAdminRole', 'admin')->get()); - $this->assertCount(2, Admin::withoutRole('testAdminRole2', 'admin')->get()); - } - public function testItThrowsAnExceptionWhenTryingToScopeARoleFromAnotherGuard(): void { $this->expectException(RoleDoesNotExist::class); @@ -769,7 +856,7 @@ public function testItThrowsAnExceptionWhenTryingToScopeARoleFromAnotherGuard(): User::role('testAdminRole')->get(); } - public function testItThrowsAnExceptionWhenTryingToCallWithoutScopeOnARoleFromAnotherGuard(): void + public function testItThrowsAnExceptionWhenTryingToCallWithoutscopeOnARoleFromAnotherGuard(): void { $this->expectException(RoleDoesNotExist::class); @@ -783,7 +870,7 @@ public function testItThrowsAnExceptionWhenTryingToScopeANonExistingRole(): void User::role('role not defined')->get(); } - public function testItThrowsAnExceptionWhenTryingToUseWithoutScopeOnANonExistingRole(): void + public function testItThrowsAnExceptionWhenTryingToUseWithoutscopeOnANonExistingRole(): void { $this->expectException(RoleDoesNotExist::class); @@ -836,7 +923,7 @@ public function testItCanDetermineThatAUserHasAllOfTheGivenRoles(): void $this->assertFalse($this->testUser->hasAllRoles(['testRole', 'second role'], 'fakeGuard')); } - public function testItCanDetermineThatAUserHasExactlyAllOfTheGivenRoles(): void + public function testItCanDetermineThatAUserHasExactAllOfTheGivenRoles(): void { $roleModel = app(Role::class); diff --git a/tests/Permission/Traits/TeamHasRolesTest.php b/tests/Permission/Traits/TeamHasRolesTest.php index f8f138fce5..df1b6499f3 100644 --- a/tests/Permission/Traits/TeamHasRolesTest.php +++ b/tests/Permission/Traits/TeamHasRolesTest.php @@ -32,19 +32,6 @@ protected function setUpInCoroutine(): void $this->setUpTeams(); } - public function testItDoesNotRunUnnecessarySqlWhenAssigningNewRoles(): void - { - $role2 = app(Role::class)->where('name', 'testRole2')->first(); - - DB::enableQueryLog(); - $this->testUser->syncRoles($this->testUserRole, $role2); - DB::disableQueryLog(); - - // Hypervel's team-aware sync path writes the current team pivot directly, - // so it avoids the extra relation reload that Spatie needs under Laravel. - $this->assertCount(2, DB::getQueryLog()); - } - public function testItDeletesPivotTableEntriesWhenDeletingModelsAcrossTeams(): void { $user1 = User::create(['email' => 'user1@test.com']); From 2aae0bea188381af7c1925ed5a08cf745d17ae9a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:30:59 +0000 Subject: [PATCH 07/18] Reconcile custom-model and team HasRoles tests and TeamScopeTest with upstream Bring HasRolesWithCustomModelsTest, TeamHasRolesTest and TeamScopeTest in line with spatie/laravel-permission main at 6615eefac655 (8.x). HasRolesWithCustomModelsTest takes upstream's case names and order. Its always-zero query-count offset is replaced by the database cache store's real statements: soft deletes invalidate the role catalog when deleting and when deleted, and force deletes invalidate it once in the delete transaction and write a new assignment token. TeamHasRolesTest runs the team pivot-deletion body under the base name and the base body as ...FromHasRolesTest, as upstream names them. It gains upstream's five custom-pivot team cases and their fixtures, restores the multi-team case's comments, takes the upstream sync-or- remove name and drops a no-op assertTrue(true). TeamScopeTest restores upstream's two multi-expectation exception cases from five split cases and the introspection case's full name. The team class config in defineEnvironment() is removed because setTeamClass() in setup already sets it; permission.teams stays there because the migration adds team columns only when teams are enabled. Deprecated expectExceptionMessage() becomes expectExceptionMessageIsOrContains(). TestCase types getPackageProviders()'s $app like its parent and imports the package role and permission models for its property types. Validation: the three files pass on the array and database cache stores; the Permission suite passes on the array store, with only known later-slice failures on the database store; formatting is clean. --- tests/Permission/TestCase.php | 17 +- .../Traits/HasRolesWithCustomModelsTest.php | 58 +++--- tests/Permission/Traits/TeamHasRolesTest.php | 191 ++++++++++++++++-- tests/Permission/Traits/TeamScopeTest.php | 53 +++-- 4 files changed, 237 insertions(+), 82 deletions(-) diff --git a/tests/Permission/TestCase.php b/tests/Permission/TestCase.php index 17aaeaa67f..1402a05305 100644 --- a/tests/Permission/TestCase.php +++ b/tests/Permission/TestCase.php @@ -17,6 +17,8 @@ use Hypervel\Permission\Contracts\Role as RoleContract; use Hypervel\Permission\Exceptions\UnauthorizedException; use Hypervel\Permission\Guard; +use Hypervel\Permission\Models\Permission as BasePermission; +use Hypervel\Permission\Models\Role as BaseRole; use Hypervel\Permission\PermissionRegistrar; use Hypervel\Permission\PermissionServiceProvider; use Hypervel\Support\Facades\Auth; @@ -43,25 +45,24 @@ abstract class TestCase extends TestbenchTestCase protected Admin $testAdmin; - protected \Hypervel\Permission\Models\Role $testUserRole; + protected BaseRole $testUserRole; - protected \Hypervel\Permission\Models\Role $testAdminRole; + protected BaseRole $testAdminRole; - protected \Hypervel\Permission\Models\Permission $testUserPermission; + protected BasePermission $testUserPermission; - protected \Hypervel\Permission\Models\Permission $testAdminPermission; + protected BasePermission $testAdminPermission; protected Client $testClient; - protected \Hypervel\Permission\Models\Permission $testClientPermission; + protected BasePermission $testClientPermission; - protected \Hypervel\Permission\Models\Role $testClientRole; + protected BaseRole $testClientRole; /** * Get package providers. - * @param mixed $app */ - protected function getPackageProviders($app): array + protected function getPackageProviders(ApplicationContract $app): array { return [ PermissionServiceProvider::class, diff --git a/tests/Permission/Traits/HasRolesWithCustomModelsTest.php b/tests/Permission/Traits/HasRolesWithCustomModelsTest.php index cdb382c415..ad6dff58e5 100644 --- a/tests/Permission/Traits/HasRolesWithCustomModelsTest.php +++ b/tests/Permission/Traits/HasRolesWithCustomModelsTest.php @@ -14,8 +14,6 @@ class HasRolesWithCustomModelsTest extends HasRolesTest { - protected int $resetDatabaseQuery = 0; - protected function setUpInCoroutine(): void { $this->setUpCustomModels(); @@ -78,26 +76,7 @@ public function testCleanAssignmentTokenReadsDoNotConstructPermissionModelsForCa $this->assertSame(0, ConstructionCountingPermission::$constructionCount); } - public function testFindOrCreateRestoresSoftDeletedRole(): void - { - $role = Role::create(['name' => 'restorable-role']); - $roleId = $role->getKey(); - $role->givePermissionTo($this->testUserPermission); - $this->testUser->assignRole($role); - - $role->delete(); - - $restoredRole = Role::findOrCreate('restorable-role'); - - $this->assertSame($roleId, $restoredRole->getKey()); - $this->assertFalse($restoredRole->trashed()); - $this->assertNull($restoredRole->deleted_at); - $this->assertSame(1, Role::withTrashed()->where('name', 'restorable-role')->count()); - $this->assertTrue($restoredRole->hasPermissionTo($this->testUserPermission)); - $this->assertTrue($this->testUser->fresh()->hasRole($restoredRole)); - } - - public function testItDoesNotDetachPermissionsWhenSoftDeleting(): void + public function testItDoesntDetachPermissionsWhenSoftDeleting(): void { $this->testUserRole->givePermissionTo($this->testUserPermission); @@ -105,7 +84,9 @@ public function testItDoesNotDetachPermissionsWhenSoftDeleting(): void $this->testUserRole->delete(); DB::disableQueryLog(); - $this->assertCount(1 + $this->resetDatabaseQuery, DB::getQueryLog()); + // A database cache store invalidates the role catalog when deleting and again when deleted + // (lock, delete and release each). + $this->assertCount($this->usesDatabaseCacheStore() ? 7 : 1, DB::getQueryLog()); $role = Role::onlyTrashed()->find($this->testUserRole->getKey()); @@ -117,7 +98,7 @@ public function testItDoesNotDetachPermissionsWhenSoftDeleting(): void ); } - public function testItDoesNotDetachUsersWhenSoftDeleting(): void + public function testItDoesntDetachUsersWhenSoftDeleting(): void { $this->testUser->assignRole($this->testUserRole); $registrar = app(PermissionRegistrar::class); @@ -127,7 +108,9 @@ public function testItDoesNotDetachUsersWhenSoftDeleting(): void $this->testUserRole->delete(); DB::disableQueryLog(); - $this->assertCount(1 + $this->resetDatabaseQuery, DB::getQueryLog()); + // A database cache store invalidates the role catalog when deleting and again when deleted + // (lock, delete and release each). + $this->assertCount($this->usesDatabaseCacheStore() ? 7 : 1, DB::getQueryLog()); $role = Role::onlyTrashed()->find($this->testUserRole->getKey()); @@ -152,7 +135,9 @@ public function testItDoesDetachPermissionsAndUsersWhenForceDeleting(): void $this->testUserRole->forceDelete(); DB::disableQueryLog(); - $this->assertCount(3 + $this->resetDatabaseQuery, DB::getQueryLog()); + // A database cache store invalidates the role catalog once for the delete transaction + // (lock, delete and release) and writes a new model assignment token. + $this->assertCount($this->usesDatabaseCacheStore() ? 7 : 3, DB::getQueryLog()); $this->assertNull(Role::withTrashed()->find($roleId)); $this->assertSame( @@ -170,7 +155,26 @@ public function testItDoesDetachPermissionsAndUsersWhenForceDeleting(): void $this->assertNotSame($token, $registrar->modelAssignmentCacheToken()); } - public function testItTouchesWhenAssigningNewRoles(): void + public function testFindOrCreateRestoresSoftDeletedRole(): void + { + $role = Role::create(['name' => 'restorable-role']); + $roleId = $role->getKey(); + $role->givePermissionTo($this->testUserPermission); + $this->testUser->assignRole($role); + + $role->delete(); + + $restoredRole = Role::findOrCreate('restorable-role'); + + $this->assertSame($roleId, $restoredRole->getKey()); + $this->assertFalse($restoredRole->trashed()); + $this->assertNull($restoredRole->deleted_at); + $this->assertSame(1, Role::withTrashed()->where('name', 'restorable-role')->count()); + $this->assertTrue($restoredRole->hasPermissionTo($this->testUserPermission)); + $this->assertTrue($this->testUser->fresh()->hasRole($restoredRole)); + } + + public function testItShouldTouchWhenAssigningNewRoles(): void { CarbonImmutable::setTestNow('2021-07-19 10:13:14'); diff --git a/tests/Permission/Traits/TeamHasRolesTest.php b/tests/Permission/Traits/TeamHasRolesTest.php index df1b6499f3..a4a1521831 100644 --- a/tests/Permission/Traits/TeamHasRolesTest.php +++ b/tests/Permission/Traits/TeamHasRolesTest.php @@ -8,16 +8,44 @@ use Closure; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; +use Hypervel\Database\Eloquent\Relations\MorphPivot; use Hypervel\Permission\Contracts\Role; +use Hypervel\Permission\Events\RoleAttachedEvent; +use Hypervel\Permission\Events\RoleDetachedEvent; use Hypervel\Permission\Exceptions\RoleDoesNotExist; use Hypervel\Permission\Exceptions\TeamNotSelected; use Hypervel\Permission\PermissionRegistrar; use Hypervel\Permission\Support\Config; use Hypervel\Permission\Traits\HasPermissions; +use Hypervel\Permission\Traits\HasRoles; use Hypervel\Support\Facades\DB; +use Hypervel\Support\Facades\Event; use Hypervel\Tests\Permission\Fixtures\Models\User; +use Hypervel\Tests\Permission\Fixtures\Models\UserWithoutHasRoles; use UnitEnum; +class TeamHasRolesCustomPivot extends MorphPivot +{ +} + +class TeamHasRolesCustomPivotUser extends UserWithoutHasRoles +{ + use HasRoles { + roles as traitRoles; + } + + /** + * Get the roles relation through the custom pivot. + */ + public function roles(): BelongsToMany + { + return $this->traitRoles() + ->withPivot('team_test_id') + ->using(TeamHasRolesCustomPivot::class); + } +} + class TeamHasRolesTest extends HasRolesTest { protected function defineEnvironment(ApplicationContract $app): void @@ -32,7 +60,12 @@ protected function setUpInCoroutine(): void $this->setUpTeams(); } - public function testItDeletesPivotTableEntriesWhenDeletingModelsAcrossTeams(): void + public function testItDeletesPivotTableEntriesWhenDeletingModelsFromHasRolesTest(): void + { + parent::testItDeletesPivotTableEntriesWhenDeletingModels(); + } + + public function testItDeletesPivotTableEntriesWhenDeletingModels(): void { $user1 = User::create(['email' => 'user1@test.com']); $user2 = User::create(['email' => 'user2@test.com']); @@ -42,27 +75,26 @@ public function testItDeletesPivotTableEntriesWhenDeletingModelsAcrossTeams(): v $user1->givePermissionTo('edit-articles'); $user2->assignRole('testRole'); $user2->givePermissionTo('edit-articles'); - setPermissionsTeamId(2); $user1->givePermissionTo('edit-news'); - $this->assertDatabaseHas('model_has_permissions', ['model_test_id' => $user1->getKey()]); - $this->assertDatabaseHas('model_has_roles', ['model_test_id' => $user1->getKey()]); + $this->assertDatabaseHas('model_has_permissions', [config('permission.column_names.model_morph_key') => $user1->id]); + $this->assertDatabaseHas('model_has_roles', [config('permission.column_names.model_morph_key') => $user1->id]); $user1->delete(); setPermissionsTeamId(1); - $this->assertDatabaseMissing('model_has_permissions', ['model_test_id' => $user1->getKey()]); - $this->assertDatabaseMissing('model_has_roles', ['model_test_id' => $user1->getKey()]); - $this->assertDatabaseHas('model_has_permissions', ['model_test_id' => $user2->getKey()]); - $this->assertDatabaseHas('model_has_roles', ['model_test_id' => $user2->getKey()]); + $this->assertDatabaseMissing('model_has_permissions', [config('permission.column_names.model_morph_key') => $user1->id]); + $this->assertDatabaseMissing('model_has_roles', [config('permission.column_names.model_morph_key') => $user1->id]); + $this->assertDatabaseHas('model_has_permissions', [config('permission.column_names.model_morph_key') => $user2->id]); + $this->assertDatabaseHas('model_has_roles', [config('permission.column_names.model_morph_key') => $user2->id]); } public function testItCanAssignSameAndDifferentRolesOnSameUserDifferentTeams(): void { - app(Role::class)->create(['name' => 'testRole3']); + app(Role::class)->create(['name' => 'testRole3']); // team_test_id = 1 by main class app(Role::class)->create(['name' => 'testRole3', 'team_test_id' => 2]); - app(Role::class)->create(['name' => 'testRole4', 'team_test_id' => null]); + app(Role::class)->create(['name' => 'testRole4', 'team_test_id' => null]); // global role $testRole3Team1 = app(Role::class)->where(['name' => 'testRole3', 'team_test_id' => 1])->first(); $testRole3Team2 = app(Role::class)->where(['name' => 'testRole3', 'team_test_id' => 2])->first(); @@ -74,6 +106,10 @@ public function testItCanAssignSameAndDifferentRolesOnSameUserDifferentTeams(): setPermissionsTeamId(1); $this->testUser->assignRole('testRole', 'testRole2'); + + // explicit load of roles to assert no mismatch + // when same role assigned in diff teams + // while old team's roles are loaded $this->testUser->load('roles'); setPermissionsTeamId(2); @@ -87,19 +123,18 @@ public function testItCanAssignSameAndDifferentRolesOnSameUserDifferentTeams(): $this->testUser->assignRole('testRole3', 'testRole4'); $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'testRole2', 'testRole3', 'testRole4'])); - $this->assertTrue($this->testUser->hasRole($testRole3Team1)); - $this->assertTrue($this->testUser->hasRole($testRole4NoTeam)); + $this->assertTrue($this->testUser->hasRole($testRole3Team1)); // testRole3 team=1 + $this->assertTrue($this->testUser->hasRole($testRole4NoTeam)); // global role team=null setPermissionsTeamId(2); $this->testUser->load('roles'); $this->assertSame(['testRole', 'testRole3'], $this->testUser->getRoleNames()->sort()->values()->all()); $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'testRole3'])); - $this->assertTrue($this->testUser->hasRole($testRole3Team2)); - + $this->assertTrue($this->testUser->hasRole($testRole3Team2)); // testRole3 team=2 $this->testUser->assignRole('testRole4'); $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'testRole3', 'testRole4'])); - $this->assertTrue($this->testUser->hasRole($testRole4NoTeam)); + $this->assertTrue($this->testUser->hasRole($testRole4NoTeam)); // global role team=null } public function testRoleLookupFindsGlobalAndCurrentTeamRolesOnly(): void @@ -117,7 +152,6 @@ public function testRoleLookupFindsGlobalAndCurrentTeamRolesOnly(): void app(Role::class)::findByName('team-two-role'); $this->fail('Expected missing team role exception was not thrown.'); } catch (RoleDoesNotExist) { - $this->assertTrue(true); } setPermissionsTeamId(2); @@ -246,7 +280,7 @@ public function testRoleFindOrCreateReturnsGlobalRoleInCurrentTeamScope(): void $this->assertSame(1, app(Role::class)->where('name', 'global-find-or-create')->count()); } - public function testItCanSyncOrRemoveRolesWithoutDetachingDifferentTeams(): void + public function testItCanSyncOrRemoveRolesWithoutDetachOnDifferentTeams(): void { app(Role::class)->create(['name' => 'testRole3', 'team_test_id' => 2]); @@ -270,6 +304,129 @@ public function testItCanSyncOrRemoveRolesWithoutDetachingDifferentTeams(): void $this->assertSame(['testRole', 'testRole3'], $this->testUser->getRoleNames()->sort()->values()->all()); } + public function testItCanRemoveARoleFromOneTeamWhenUsingACustomPivotClass(): void + { + $this->useAuthUserModel(TeamHasRolesCustomPivotUser::class); + + app(Role::class)->create(['name' => 'testRole3', 'team_test_id' => 2]); + + $user = TeamHasRolesCustomPivotUser::create(['email' => 'custom-pivot-remove-role@test.com']); + + setPermissionsTeamId(1); + $user->syncRoles('testRole', 'testRole2'); + + setPermissionsTeamId(2); + $user->syncRoles('testRole', 'testRole3'); + $user->load('roles'); + + $this->assertSame(['testRole', 'testRole3'], $user->getRoleNames()->sort()->values()->all()); + + setPermissionsTeamId(1); + $user->load('roles'); + + $this->assertSame(['testRole', 'testRole2'], $user->getRoleNames()->sort()->values()->all()); + + $user->removeRole('testRole'); + + $this->assertSame(['testRole2'], $user->getRoleNames()->sort()->values()->all()); + + setPermissionsTeamId(2); + $user->load('roles'); + + $this->assertSame(['testRole', 'testRole3'], $user->getRoleNames()->sort()->values()->all()); + } + + public function testItDoesNothingWhenRemovingAnEmptySetOfRolesForOneTeamWhenUsingACustomPivotClass(): void + { + $this->useAuthUserModel(TeamHasRolesCustomPivotUser::class); + + $user = TeamHasRolesCustomPivotUser::create(['email' => 'custom-pivot-remove-empty-roles@test.com']); + + setPermissionsTeamId(1); + $user->syncRoles('testRole', 'testRole2'); + + $user->removeRole([]); + + $this->assertSame(['testRole', 'testRole2'], $user->getRoleNames()->sort()->values()->all()); + } + + public function testItCanSyncRolesForOneTeamWhenUsingACustomPivotClass(): void + { + $this->useAuthUserModel(TeamHasRolesCustomPivotUser::class); + + app(Role::class)->create(['name' => 'testRole3', 'team_test_id' => 2]); + + $user = TeamHasRolesCustomPivotUser::create(['email' => 'custom-pivot-sync-roles@test.com']); + + setPermissionsTeamId(1); + $user->assignRole('testRole', 'testRole2'); + + setPermissionsTeamId(2); + $user->assignRole('testRole', 'testRole3'); + + setPermissionsTeamId(1); + $user->syncRoles('testRole2'); + + $this->assertSame(['testRole2'], $user->getRoleNames()->sort()->values()->all()); + + setPermissionsTeamId(2); + $user->load('roles'); + + $this->assertSame(['testRole', 'testRole3'], $user->getRoleNames()->sort()->values()->all()); + } + + public function testItCanSyncRolesWithEventsForOneTeamWhenUsingACustomPivotClass(): void + { + Event::fake([RoleDetachedEvent::class, RoleAttachedEvent::class]); + app('config')->set('permission.events_enabled', true); + $this->useAuthUserModel(TeamHasRolesCustomPivotUser::class); + + app(Role::class)->create(['name' => 'testRole3', 'team_test_id' => 2]); + + $user = TeamHasRolesCustomPivotUser::create(['email' => 'custom-pivot-sync-roles-events@test.com']); + + setPermissionsTeamId(1); + $user->assignRole('testRole', 'testRole2'); + + setPermissionsTeamId(2); + $user->assignRole('testRole', 'testRole3'); + + setPermissionsTeamId(1); + $user->syncRoles('testRole2'); + + $this->assertSame(['testRole2'], $user->getRoleNames()->sort()->values()->all()); + + Event::assertDispatched(RoleDetachedEvent::class); + + setPermissionsTeamId(2); + $user->load('roles'); + + $this->assertSame(['testRole', 'testRole3'], $user->getRoleNames()->sort()->values()->all()); + } + + public function testItCanSyncToNoRolesForOneTeamWhenUsingACustomPivotClass(): void + { + $this->useAuthUserModel(TeamHasRolesCustomPivotUser::class); + + $user = TeamHasRolesCustomPivotUser::create(['email' => 'custom-pivot-sync-empty-roles@test.com']); + + setPermissionsTeamId(1); + $user->assignRole('testRole', 'testRole2'); + + setPermissionsTeamId(2); + $user->assignRole('testRole'); + + setPermissionsTeamId(1); + $user->syncRoles([]); + + $this->assertEmpty($user->getRoleNames()); + + setPermissionsTeamId(2); + $user->load('roles'); + + $this->assertSame(['testRole'], $user->getRoleNames()->sort()->values()->all()); + } + public function testItCanScopeUsersOnDifferentTeams(): void { User::all()->each(fn ($item) => $item->delete()); diff --git a/tests/Permission/Traits/TeamScopeTest.php b/tests/Permission/Traits/TeamScopeTest.php index 0921137565..33c084b584 100644 --- a/tests/Permission/Traits/TeamScopeTest.php +++ b/tests/Permission/Traits/TeamScopeTest.php @@ -22,10 +22,7 @@ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); - $app->make('config')->set([ - 'permission.teams' => true, - 'permission.models.team' => Team::class, - ]); + $app->make('config')->set('permission.teams', true); } protected function setUpInCoroutine(): void @@ -40,20 +37,18 @@ public function testItThrowsAnExceptionWhenTeamScopesAreQueriedWhileTeamsAreNotE config()->set('permission.teams', false); app(PermissionRegistrar::class)->teams = false; - $this->expectException(TeamsNotEnabled::class); - User::team(1)->get(); - } - - public function testItThrowsAnExceptionWhenWithoutTeamScopeIsQueriedWhileTeamsAreNotEnabled(): void - { - config()->set('permission.teams', false); - app(PermissionRegistrar::class)->teams = false; + try { + User::team(1)->get(); + $this->fail('Expected teams not enabled exception was not thrown.'); + } catch (TeamsNotEnabled) { + } $this->expectException(TeamsNotEnabled::class); + User::withoutTeam(1)->get(); } - public function testItReturnsAnEmptyTeamsRelationWhenTeamsAreNotEnabled(): void + public function testItReturnsAnEmptyTeamsRelationWhenTeamsAreNotEnabledSoModelIntrospectionDoesNotBreak(): void { config()->set('permission.teams', false); app(PermissionRegistrar::class)->teams = false; @@ -64,30 +59,25 @@ public function testItReturnsAnEmptyTeamsRelationWhenTeamsAreNotEnabled(): void $this->assertCount(0, $relation->get()); } - public function testItThrowsAnExceptionWhenTeamModelIsNotConfiguredForTeamScope(): void + public function testItThrowsAnExceptionWhenTeamModelIsNotConfigured(): void { app(PermissionRegistrar::class)->setTeamClass(null); config()->set('permission.models.team', null); - $this->expectException(TeamModelNotConfigured::class); - User::team(1)->get(); - } + try { + User::team(1)->get(); + $this->fail('Expected team model not configured exception was not thrown.'); + } catch (TeamModelNotConfigured) { + } - public function testItThrowsAnExceptionWhenTeamModelIsNotConfiguredForWithoutTeamScope(): void - { - app(PermissionRegistrar::class)->setTeamClass(null); - config()->set('permission.models.team', null); + try { + User::withoutTeam(1)->get(); + $this->fail('Expected team model not configured exception was not thrown.'); + } catch (TeamModelNotConfigured) { + } $this->expectException(TeamModelNotConfigured::class); - User::withoutTeam(1)->get(); - } - public function testItThrowsAnExceptionWhenTeamModelIsNotConfiguredForTeamsRelation(): void - { - app(PermissionRegistrar::class)->setTeamClass(null); - config()->set('permission.models.team', null); - - $this->expectException(TeamModelNotConfigured::class); $this->testUser->teams()->get(); } @@ -187,11 +177,14 @@ public function testTeamScopesRejectKeylessModelInputs(string $scope, bool $mixe $teams = $mixed ? [$teamOne, $keylessTeam] : $keylessTeam; $this->expectException(MissingAttributeException::class); - $this->expectExceptionMessage($keylessTeam->getKeyName()); + $this->expectExceptionMessageIsOrContains($keylessTeam->getKeyName()); User::query()->{$scope}($teams)->get(); } + /** + * Provide team scopes with keyless model inputs. + */ public static function teamScopeProvider(): array { return [ From 4be57ab0a0aa1d445f055fc16f484c2b9f0ecaae Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:12:02 +0000 Subject: [PATCH 08/18] Reconcile wildcard and unit-enum tests and name catalog model connections Bring WildcardHasPermissionsTest in line with spatie/laravel-permission main at 6615eefac655 (8.x): all 19 upstream cases under their upstream names, order, variables and comments, plus upstream's two wildcard index cases for assigning and removing a role. Wildcards are enabled in defineEnvironment() instead of setUp(), which mutated config and flushed the cache outside the test coroutine and failed on Redis. A Hypervel case covers syncModels() rotating the assignment token that every wildcard index key includes. The Hypervel-only WildcardPermissionTest is removed: five of its cases duplicated upstream cases, and its token case built its state with a raw pivot delete and a direct token rotation. UnitEnumTest now covers the signatures Hypervel widens from upstream's BackedEnum: findOrCreate() and findByName() on roles and permissions, and the three middleware using() methods. Catalog models were cloned from a new model prototype, as upstream does, so their connection name stayed null while database-loaded models carry the resolved name. Model::is() compares connection names, so $user->roles->contains(Role::findByName(...)) and findById()->is(find()) returned false for the same rows. The registrar now names the prototype like Eloquent hydration's Connection::getWritableName(): the model's connection, or the model resolver's default when null or empty, with a read alias reduced to its base name and a write alias kept. It parses the name instead of resolving a connection, which would take a pooled connection on warm checks. The wildcard docs said a permission must exist before it can be checked; only the assigned wildcard permission needs a record. GuardTest gains its data provider's title docblock. Validation: the changed test files pass on the array, database and Redis cache stores; the Permission suite passes on the array store, with only known later-slice failures on the database store; the Postgres Permission tests pass; formatting and static analysis are clean. --- src/docs/permission.md | 2 +- src/permission/src/PermissionRegistrar.php | 29 +- tests/Permission/GuardTest.php | 3 + .../Integration/PermissionRegistrarTest.php | 47 +++ .../Traits/WildcardHasPermissionsTest.php | 339 +++++++++++------- tests/Permission/UnitEnumTest.php | 30 +- tests/Permission/WildcardPermissionTest.php | 122 ------- 7 files changed, 308 insertions(+), 264 deletions(-) delete mode 100644 tests/Permission/WildcardPermissionTest.php diff --git a/src/docs/permission.md b/src/docs/permission.md index e9c6120183..745c4c9847 100644 --- a/src/docs/permission.md +++ b/src/docs/permission.md @@ -1206,7 +1206,7 @@ Permission::create(['name' => 'posts,users.create,update,view']); $user->givePermissionTo('posts,users.create,update,view'); ``` -The wildcard permission or wildcard pattern must exist as a permission record before it can be assigned or checked. +Like any permission, a wildcard permission must exist as a permission record before it can be assigned. The names you check do not need records of their own, so `hasPermissionTo('posts.create')` matches `posts.*` even when no `posts.create` permission exists. To customize wildcard parsing, configure `wildcard_permission` with a class that implements `Hypervel\Permission\Contracts\Wildcard`. diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php index 84d9ccae0f..65a41645a7 100644 --- a/src/permission/src/PermissionRegistrar.php +++ b/src/permission/src/PermissionRegistrar.php @@ -16,6 +16,7 @@ use Hypervel\Contracts\Config\Repository as ConfigRepository; use Hypervel\Contracts\Container\Container; use Hypervel\Database\Connection; +use Hypervel\Database\ConnectionName; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; @@ -2094,7 +2095,7 @@ protected function relationCollection(Model $model, string $relation): Collectio */ private function getHydratedPermissionCollection(array $permissions, Collection $roles): Collection { - $permissionInstance = (new ($this->getPermissionClass())())->newInstance([], true); + $permissionInstance = $this->newCatalogModelPrototype($this->getPermissionClass()); $rolesByKey = $roles->keyBy(fn (Model $role): string => (string) $role->getKey()); $context = new PermissionRelationContext($this->resolvePartition(), false, null); @@ -2124,7 +2125,7 @@ function (array $item) use ($permissionInstance, $rolesByKey, $context): Model { */ private function getHydratedRoleCollection(array $roles): Collection { - $roleInstance = (new ($this->getRoleClass())())->newInstance([], true); + $roleInstance = $this->newCatalogModelPrototype($this->getRoleClass()); return Collection::make(array_map( fn (array $item): Model => (clone $roleInstance)->setRawAttributes((array) $item['attributes'], true), @@ -2132,6 +2133,30 @@ private function getHydratedRoleCollection(array $roles): Collection )); } + /** + * Create the prototype that hydrated catalog models are cloned from. + * + * @param class-string $class + */ + private function newCatalogModelPrototype(string $class): Model + { + $model = (new $class)->newInstance([], true); + $connection = $model->getConnectionName(); + + if ($connection === null || $connection === '') { + $connection = $model::getConnectionResolver()->getDefaultConnection(); + } + + $connectionName = ConnectionName::parse($connection); + + // Name the connection like Eloquent hydration (Connection::getWritableName()) so catalog models + // match database-loaded models in is() and contains(). Resolving the connection to read its name + // would take a pooled connection on warm checks that otherwise use none. + return $model->setConnection( + $connectionName->isRead() ? $connectionName->base : $connectionName->requested, + ); + } + /** * Index models by primary key. * diff --git a/tests/Permission/GuardTest.php b/tests/Permission/GuardTest.php index d81d8d515c..0ede681485 100644 --- a/tests/Permission/GuardTest.php +++ b/tests/Permission/GuardTest.php @@ -90,6 +90,9 @@ public function testGetNamesRejectsPersistedPermissionModelsMissingTheGuardColum Guard::getNames($partialModel); } + /** + * Provide the role and permission model classes. + */ public static function permissionModelClasses(): array { return [ diff --git a/tests/Permission/Integration/PermissionRegistrarTest.php b/tests/Permission/Integration/PermissionRegistrarTest.php index 9ec8143227..f25a0b3fe6 100644 --- a/tests/Permission/Integration/PermissionRegistrarTest.php +++ b/tests/Permission/Integration/PermissionRegistrarTest.php @@ -22,6 +22,8 @@ use Hypervel\Tests\Permission\Fixtures\Models\Team; use Hypervel\Tests\Permission\TestCase; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use UnitEnum; class PermissionRegistrarTest extends TestCase { @@ -380,6 +382,36 @@ public function testCatalogResolvedRolesExposeExpectedAttributes(): void $this->assertFalse(array_key_exists('created_at', $role->getAttributes())); } + #[DataProvider('catalogRoleConnections')] + public function testCatalogModelsMatchDatabaseLoadedModels(string $roleClass, string $connection): void + { + $this->testUser->assignRole('testRole'); + $this->testUser->givePermissionTo('edit-articles'); + $this->app->make(PermissionRegistrar::class)->setRoleClass($roleClass); + $user = $this->testUser->fresh(); + + $role = $roleClass::findByName('testRole'); + $permission = $this->app->make(PermissionContract::class)::findByName('edit-articles'); + + $this->assertSame($connection, $role->getConnectionName()); + $this->assertTrue($role->is($roleClass::query()->find($role->getKey()))); + $this->assertTrue($user->roles->contains($role)); + $this->assertTrue($user->permissions->contains($permission)); + } + + /** + * Provide catalog role connections and their hydrated names. + */ + public static function catalogRoleConnections(): array + { + return [ + 'default connection' => [HypervelRole::class, 'testing'], + 'empty connection' => [EmptyConnectionRole::class, 'testing'], + 'read alias' => [ReadConnectionRole::class, 'testing'], + 'write alias' => [WriteConnectionRole::class, 'testing::write'], + ]; + } + public function testInitializeCacheUsesOptionalConfigurationDefaults(): void { $permissionConfig = config()->array('permission'); @@ -518,3 +550,18 @@ public function testInitializeCacheValidatesThePartitionColumnWithoutResolvingIt $this->assertFalse($resolverCalled); } } + +class EmptyConnectionRole extends HypervelRole +{ + protected UnitEnum|string|null $connection = ''; +} + +class ReadConnectionRole extends HypervelRole +{ + protected UnitEnum|string|null $connection = 'testing::read'; +} + +class WriteConnectionRole extends HypervelRole +{ + protected UnitEnum|string|null $connection = 'testing::write'; +} diff --git a/tests/Permission/Traits/WildcardHasPermissionsTest.php b/tests/Permission/Traits/WildcardHasPermissionsTest.php index 3508e8df8e..a7f2fbbc9c 100644 --- a/tests/Permission/Traits/WildcardHasPermissionsTest.php +++ b/tests/Permission/Traits/WildcardHasPermissionsTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Permission\Traits; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Permission\Exceptions\PermissionDoesNotExist; use Hypervel\Permission\Exceptions\WildcardPermissionInvalidArgument; use Hypervel\Permission\Exceptions\WildcardPermissionNotImplementsContract; @@ -16,217 +17,280 @@ class WildcardHasPermissionsTest extends TestCase { - protected function setUp(): void + protected function defineEnvironment(ApplicationContract $app): void { - parent::setUp(); + parent::defineEnvironment($app); - $this->app->make('config')->set('permission.enable_wildcard_permission', true); - $this->flushPermissionState(); + $app->make('config')->set('permission.enable_wildcard_permission', true); } public function testItCanCheckWildcardPermission(): void { - $user = User::create(['email' => 'user1@test.com']); + $user1 = User::create(['email' => 'user1@test.com']); - $user->givePermissionTo([ - Permission::create(['name' => 'articles.edit,view,create']), - Permission::create(['name' => 'news.*']), - Permission::create(['name' => 'posts.*']), - ]); + $permission1 = Permission::create(['name' => 'articles.edit,view,create']); + $permission2 = Permission::create(['name' => 'news.*']); + $permission3 = Permission::create(['name' => 'posts.*']); - $this->assertTrue($user->hasPermissionTo('posts.create')); - $this->assertTrue($user->hasPermissionTo('posts.create.123')); - $this->assertTrue($user->hasPermissionTo('posts.*')); - $this->assertTrue($user->hasPermissionTo('articles.view')); - $this->assertFalse($user->hasPermissionTo('projects.view')); + $user1->givePermissionTo([$permission1, $permission2, $permission3]); + + $this->assertTrue($user1->hasPermissionTo('posts.create')); + $this->assertTrue($user1->hasPermissionTo('posts.create.123')); + $this->assertTrue($user1->hasPermissionTo('posts.*')); + $this->assertTrue($user1->hasPermissionTo('articles.view')); + $this->assertFalse($user1->hasPermissionTo('projects.view')); } public function testItCanCheckWildcardPermissionForANonDefaultGuard(): void { - $user = User::create(['email' => 'user1@test.com']); + $user1 = User::create(['email' => 'user1@test.com']); - $user->givePermissionTo([ - Permission::create(['name' => 'articles.edit,view,create', 'guard_name' => 'api']), - Permission::create(['name' => 'news.*', 'guard_name' => 'api']), - Permission::create(['name' => 'posts.*', 'guard_name' => 'api']), - ]); - - $this->assertTrue($user->hasPermissionTo('posts.create', 'api')); - $this->assertTrue($user->hasPermissionTo('posts.create.123', 'api')); - $this->assertTrue($user->hasPermissionTo('posts.*', 'api')); - $this->assertTrue($user->hasPermissionTo('articles.view', 'api')); - $this->assertFalse($user->hasPermissionTo('projects.view', 'api')); + $permission1 = Permission::create(['name' => 'articles.edit,view,create', 'guard_name' => 'api']); + $permission2 = Permission::create(['name' => 'news.*', 'guard_name' => 'api']); + $permission3 = Permission::create(['name' => 'posts.*', 'guard_name' => 'api']); + + $user1->givePermissionTo([$permission1, $permission2, $permission3]); + + $this->assertTrue($user1->hasPermissionTo('posts.create', 'api')); + $this->assertTrue($user1->hasPermissionTo('posts.create.123', 'api')); + $this->assertTrue($user1->hasPermissionTo('posts.*', 'api')); + $this->assertTrue($user1->hasPermissionTo('articles.view', 'api')); + $this->assertFalse($user1->hasPermissionTo('projects.view', 'api')); } public function testItCanCheckWildcardPermissionFromInstanceWithoutExplicitGuardArgument(): void { - $user = User::create(['email' => 'user1@test.com']); + $user1 = User::create(['email' => 'user1@test.com']); - $permission1 = Permission::create(['name' => 'articles.edit', 'guard_name' => 'api']); $permission2 = Permission::create(['name' => 'articles.view']); + $permission1 = Permission::create(['name' => 'articles.edit', 'guard_name' => 'api']); $permission3 = Permission::create(['name' => 'news.*', 'guard_name' => 'api']); $permission4 = Permission::create(['name' => 'posts.*', 'guard_name' => 'api']); - $user->givePermissionTo([$permission1, $permission2, $permission3]); + $user1->givePermissionTo([$permission1, $permission2, $permission3]); - $this->assertTrue($user->hasPermissionTo($permission1)); - $this->assertTrue($user->hasPermissionTo($permission2)); - $this->assertTrue($user->hasPermissionTo($permission3)); - $this->assertFalse($user->hasPermissionTo($permission4)); - $this->assertFalse($user->hasPermissionTo('articles.edit')); + $this->assertTrue($user1->hasPermissionTo($permission1)); + $this->assertTrue($user1->hasPermissionTo($permission2)); + $this->assertTrue($user1->hasPermissionTo($permission3)); + $this->assertFalse($user1->hasPermissionTo($permission4)); + $this->assertFalse($user1->hasPermissionTo('articles.edit')); } public function testItCanAssignWildcardPermissionsUsingEnums(): void { + $user1 = User::create(['email' => 'user1@test.com']); + $articlesCreator = TestRolePermissionsEnum::WildcardArticlesCreator; $newsEverything = TestRolePermissionsEnum::WildcardNewsEverything; $postsEverything = TestRolePermissionsEnum::WildcardPostsEverything; $postsCreate = TestRolePermissionsEnum::WildcardPostsCreate; - $user = User::create(['email' => 'user1@test.com']); - $user->givePermissionTo([ - Permission::findOrCreate($articlesCreator), - Permission::findOrCreate($newsEverything), - Permission::findOrCreate($postsEverything), - ]); - - $this->assertTrue($user->hasPermissionTo($postsCreate)); - $this->assertTrue($user->hasPermissionTo($postsCreate->value . '.123')); - $this->assertTrue($user->hasPermissionTo($postsEverything)); - $this->assertTrue($user->hasPermissionTo(TestRolePermissionsEnum::WildcardArticlesView)); - $this->assertTrue($user->hasAnyPermission(TestRolePermissionsEnum::WildcardArticlesView)); - $this->assertFalse($user->hasPermissionTo(TestRolePermissionsEnum::WildcardProjectsView)); - - $user->revokePermissionTo([$articlesCreator, $newsEverything, $postsEverything]); - - $this->assertFalse($user->hasPermissionTo($postsCreate)); - $this->assertFalse($user->hasPermissionTo($postsCreate->value . '.123')); - $this->assertFalse($user->hasPermissionTo($postsEverything)); - $this->assertFalse($user->hasPermissionTo(TestRolePermissionsEnum::WildcardArticlesView)); - $this->assertFalse($user->hasAnyPermission(TestRolePermissionsEnum::WildcardArticlesView)); + $permission1 = app(Permission::class)->findOrCreate($articlesCreator->value, 'web'); + $permission2 = app(Permission::class)->findOrCreate($newsEverything->value, 'web'); + $permission3 = app(Permission::class)->findOrCreate($postsEverything->value, 'web'); + + $user1->givePermissionTo([$permission1, $permission2, $permission3]); + + $this->assertTrue($user1->hasPermissionTo($postsCreate)); + $this->assertTrue($user1->hasPermissionTo($postsCreate->value . '.123')); + $this->assertTrue($user1->hasPermissionTo($postsEverything)); + + $this->assertTrue($user1->hasPermissionTo(TestRolePermissionsEnum::WildcardArticlesView)); + $this->assertTrue($user1->hasAnyPermission(TestRolePermissionsEnum::WildcardArticlesView)); + + $this->assertFalse($user1->hasPermissionTo(TestRolePermissionsEnum::WildcardProjectsView)); + + $user1->revokePermissionTo([$permission1, $permission2, $permission3]); + + $this->assertFalse($user1->hasPermissionTo(TestRolePermissionsEnum::WildcardPostsCreate)); + $this->assertFalse($user1->hasPermissionTo($postsCreate->value . '.123')); + $this->assertFalse($user1->hasPermissionTo(TestRolePermissionsEnum::WildcardPostsEverything)); + + $this->assertFalse($user1->hasPermissionTo(TestRolePermissionsEnum::WildcardArticlesView)); + $this->assertFalse($user1->hasAnyPermission(TestRolePermissionsEnum::WildcardArticlesView)); } public function testItCanCheckWildcardPermissionsViaRoles(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + + $user1->assignRole('testRole'); + + $permission1 = Permission::create(['name' => 'articles,projects.edit,view,create']); + $permission2 = Permission::create(['name' => 'news.*.456']); + $permission3 = Permission::create(['name' => 'posts']); + + $this->testUserRole->givePermissionTo([$permission1, $permission2, $permission3]); + + $this->assertTrue($user1->hasPermissionTo('posts.create')); + $this->assertTrue($user1->hasPermissionTo('news.create.456')); + $this->assertTrue($user1->hasPermissionTo('projects.create')); + $this->assertTrue($user1->hasPermissionTo('articles.view')); + $this->assertFalse($user1->hasPermissionTo('articles.list')); + $this->assertFalse($user1->hasPermissionTo('projects.list')); + } + + public function testItClearsWildcardIndexWhenAssigningARole(): void { $user = User::create(['email' => 'user1@test.com']); + + $permission = Permission::create(['name' => 'posts.*']); + $this->testUserRole->givePermissionTo($permission); + + // Check permission before assigning role — this populates the wildcard index + $this->assertFalse($user->hasPermissionTo('posts.create')); + $user->assignRole('testRole'); - $this->testUserRole->givePermissionTo([ - Permission::create(['name' => 'articles,projects.edit,view,create']), - Permission::create(['name' => 'news.*.456']), - Permission::create(['name' => 'posts']), - ]); + // After assigning the role, the wildcard index should be cleared + $this->assertTrue($user->hasPermissionTo('posts.create')); + } + + public function testItClearsWildcardIndexWhenRemovingARole(): void + { + $user = User::create(['email' => 'user1@test.com']); + + $permission = Permission::create(['name' => 'posts.*']); + $this->testUserRole->givePermissionTo($permission); + $user->assignRole('testRole'); $this->assertTrue($user->hasPermissionTo('posts.create')); - $this->assertTrue($user->hasPermissionTo('news.create.456')); - $this->assertTrue($user->hasPermissionTo('projects.create')); - $this->assertTrue($user->hasPermissionTo('articles.view')); - $this->assertFalse($user->hasPermissionTo('articles.list')); - $this->assertFalse($user->hasPermissionTo('projects.list')); + + $user->removeRole('testRole'); + + // After removing the role, the wildcard index should be cleared + $this->assertFalse($user->hasPermissionTo('posts.create')); + } + + public function testItRebuildsWildcardIndexWhenARoleSyncsItsModels(): void + { + $user = User::create(['email' => 'user1@test.com']); + + $permission = Permission::create(['name' => 'posts.*']); + $this->testUserRole->givePermissionTo($permission); + + $user->assignRole('testRole'); + $this->assertTrue($user->hasPermissionTo('posts.create')); + + // syncModels() cannot list the models it removes, so it rotates the assignment token + // that every wildcard index key includes. + $this->testUserRole->syncModels([]); + + $this->assertFalse($user->hasPermissionTo('posts.create')); } public function testItCanCheckCustomWildcardPermission(): void { - $this->app->make('config')->set('permission.wildcard_permission', WildcardPermission::class); - $this->flushPermissionState(); + config()->set('permission.wildcard_permission', WildcardPermission::class); - $user = User::create(['email' => 'user1@test.com']); - $user->givePermissionTo([ - Permission::create(['name' => 'articles:edit;view;create']), - Permission::create(['name' => 'news:@']), - Permission::create(['name' => 'posts:@']), - ]); - - $this->assertTrue($user->hasPermissionTo('posts:create')); - $this->assertTrue($user->hasPermissionTo('posts:create:123')); - $this->assertTrue($user->hasPermissionTo('posts:@')); - $this->assertTrue($user->hasPermissionTo('articles:view')); - $this->assertFalse($user->hasPermissionTo('posts.*')); - $this->assertFalse($user->hasPermissionTo('articles.view')); - $this->assertFalse($user->hasPermissionTo('projects:view')); + $user1 = User::create(['email' => 'user1@test.com']); + + $permission1 = Permission::create(['name' => 'articles:edit;view;create']); + $permission2 = Permission::create(['name' => 'news:@']); + $permission3 = Permission::create(['name' => 'posts:@']); + + $user1->givePermissionTo([$permission1, $permission2, $permission3]); + + $this->assertTrue($user1->hasPermissionTo('posts:create')); + $this->assertTrue($user1->hasPermissionTo('posts:create:123')); + $this->assertTrue($user1->hasPermissionTo('posts:@')); + $this->assertTrue($user1->hasPermissionTo('articles:view')); + $this->assertFalse($user1->hasPermissionTo('posts.*')); + $this->assertFalse($user1->hasPermissionTo('articles.view')); + $this->assertFalse($user1->hasPermissionTo('projects:view')); } public function testItCanCheckCustomWildcardPermissionsViaRoles(): void { - $this->app->make('config')->set('permission.wildcard_permission', WildcardPermission::class); - $this->flushPermissionState(); + config()->set('permission.wildcard_permission', WildcardPermission::class); - $user = User::create(['email' => 'user1@test.com']); - $user->assignRole('testRole'); + $user1 = User::create(['email' => 'user1@test.com']); + + $user1->assignRole('testRole'); - $this->testUserRole->givePermissionTo([ - Permission::create(['name' => 'articles;projects:edit;view;create']), - Permission::create(['name' => 'news:@:456']), - Permission::create(['name' => 'posts']), - ]); - - $this->assertTrue($user->hasPermissionTo('posts:create')); - $this->assertTrue($user->hasPermissionTo('news:create:456')); - $this->assertTrue($user->hasPermissionTo('projects:create')); - $this->assertTrue($user->hasPermissionTo('articles:view')); - $this->assertFalse($user->hasPermissionTo('news.create.456')); - $this->assertFalse($user->hasPermissionTo('projects.create')); - $this->assertFalse($user->hasPermissionTo('articles:list')); - $this->assertFalse($user->hasPermissionTo('projects:list')); + $permission1 = Permission::create(['name' => 'articles;projects:edit;view;create']); + $permission2 = Permission::create(['name' => 'news:@:456']); + $permission3 = Permission::create(['name' => 'posts']); + + $this->testUserRole->givePermissionTo([$permission1, $permission2, $permission3]); + + $this->assertTrue($user1->hasPermissionTo('posts:create')); + $this->assertTrue($user1->hasPermissionTo('news:create:456')); + $this->assertTrue($user1->hasPermissionTo('projects:create')); + $this->assertTrue($user1->hasPermissionTo('articles:view')); + $this->assertFalse($user1->hasPermissionTo('news.create.456')); + $this->assertFalse($user1->hasPermissionTo('projects.create')); + $this->assertFalse($user1->hasPermissionTo('articles:list')); + $this->assertFalse($user1->hasPermissionTo('projects:list')); } public function testItCanCheckNonWildcardPermissions(): void { - $user = User::create(['email' => 'user1@test.com']); - $user->givePermissionTo([ - Permission::create(['name' => 'edit articles']), - Permission::create(['name' => 'create news']), - Permission::create(['name' => 'update comments']), - ]); - - $this->assertTrue($user->hasPermissionTo('edit articles')); - $this->assertTrue($user->hasPermissionTo('create news')); - $this->assertTrue($user->hasPermissionTo('update comments')); + $user1 = User::create(['email' => 'user1@test.com']); + + $permission1 = Permission::create(['name' => 'edit articles']); + $permission2 = Permission::create(['name' => 'create news']); + $permission3 = Permission::create(['name' => 'update comments']); + + $user1->givePermissionTo([$permission1, $permission2, $permission3]); + + $this->assertTrue($user1->hasPermissionTo('edit articles')); + $this->assertTrue($user1->hasPermissionTo('create news')); + $this->assertTrue($user1->hasPermissionTo('update comments')); } public function testItCanVerifyComplexWildcardPermissions(): void { - $user = User::create(['email' => 'user1@test.com']); - $user->givePermissionTo([ - Permission::create(['name' => '*.create,update,delete.*.test,course,finance']), - Permission::create(['name' => 'papers,posts,projects,orders.*.test,test1,test2.*']), - Permission::create(['name' => 'User::class.create,edit,view']), - ]); - - $this->assertTrue($user->hasPermissionTo('invoices.delete.367463.finance')); - $this->assertTrue($user->hasPermissionTo('projects.update.test2.test3')); - $this->assertTrue($user->hasPermissionTo('User::class.edit')); - $this->assertFalse($user->hasPermissionTo('User::class.delete')); - $this->assertFalse($user->hasPermissionTo('User::class.*')); + $user1 = User::create(['email' => 'user1@test.com']); + + $permission1 = Permission::create(['name' => '*.create,update,delete.*.test,course,finance']); + $permission2 = Permission::create(['name' => 'papers,posts,projects,orders.*.test,test1,test2.*']); + $permission3 = Permission::create(['name' => 'User::class.create,edit,view']); + + $user1->givePermissionTo([$permission1, $permission2, $permission3]); + + $this->assertTrue($user1->hasPermissionTo('invoices.delete.367463.finance')); + $this->assertTrue($user1->hasPermissionTo('projects.update.test2.test3')); + $this->assertTrue($user1->hasPermissionTo('User::class.edit')); + $this->assertFalse($user1->hasPermissionTo('User::class.delete')); + $this->assertFalse($user1->hasPermissionTo('User::class.*')); } public function testItThrowsExceptionWhenWildcardPermissionIsNotProperlyFormatted(): void { - $user = User::create(['email' => 'user1@test.com']); - $user->givePermissionTo(Permission::create(['name' => '*..'])); + $user1 = User::create(['email' => 'user1@test.com']); + + $permission = Permission::create(['name' => '*..']); + + $user1->givePermissionTo([$permission]); $this->expectException(WildcardPermissionNotProperlyFormatted::class); - $user->hasPermissionTo('invoices.*'); + + $user1->hasPermissionTo('invoices.*'); } public function testItThrowsExceptionWhenWildcardPermissionClassDoesNotImplementContract(): void { - $this->app->make('config')->set('permission.wildcard_permission', User::class); - $this->flushPermissionState(); + config()->set('permission.wildcard_permission', User::class); - $user = User::create(['email' => 'user1@test.com']); + $user1 = User::create(['email' => 'user1@test.com']); $this->expectException(WildcardPermissionNotImplementsContract::class); - $user->hasPermissionTo('posts.create'); + + $user1->hasPermissionTo('posts.create'); } public function testItThrowsExceptionWhenACommaSeparatedWildcardSubpartIsBlank(): void { - $user = User::create(['email' => 'user1@test.com']); - $user->givePermissionTo(Permission::create(['name' => 'articles,,edit'])); + $user1 = User::create(['email' => 'user1@test.com']); + + $permission = Permission::create(['name' => 'articles,,edit']); + + $user1->givePermissionTo([$permission]); $this->expectException(WildcardPermissionNotProperlyFormatted::class); - $user->hasPermissionTo('articles.edit'); + + $user1->hasPermissionTo('articles.edit'); } public function testItCanVerifyPermissionInstancesNotAssignedToUser(): void @@ -236,7 +300,7 @@ public function testItCanVerifyPermissionInstancesNotAssignedToUser(): void $userPermission = Permission::create(['name' => 'posts.*']); $permissionToVerify = Permission::create(['name' => 'posts.create']); - $user->givePermissionTo($userPermission); + $user->givePermissionTo([$userPermission]); $this->assertTrue($user->hasPermissionTo('posts.create')); $this->assertTrue($user->hasPermissionTo('posts.create.123')); @@ -262,7 +326,10 @@ public function testItCanVerifyPermissionInstancesAssignedToUser(): void public function testItCanVerifyIntegersAsStrings(): void { $user = User::create(['email' => 'user@test.com']); - $user->givePermissionTo(Permission::create(['name' => '8'])); + + $userPermission = Permission::create(['name' => '8']); + + $user->givePermissionTo([$userPermission]); $this->assertTrue($user->hasPermissionTo('8')); } @@ -272,14 +339,16 @@ public function testItThrowsExceptionWhenPermissionHasInvalidArguments(): void $user = User::create(['email' => 'user@test.com']); $this->expectException(WildcardPermissionInvalidArgument::class); + $user->hasPermissionTo(['posts.create']); } - public function testItThrowsExceptionWhenPermissionIdDoesNotExist(): void + public function testItThrowsExceptionWhenPermissionIdNotExists(): void { $user = User::create(['email' => 'user@test.com']); $this->expectException(PermissionDoesNotExist::class); + $user->hasPermissionTo(6); } } diff --git a/tests/Permission/UnitEnumTest.php b/tests/Permission/UnitEnumTest.php index 356441893b..10cf671ce3 100644 --- a/tests/Permission/UnitEnumTest.php +++ b/tests/Permission/UnitEnumTest.php @@ -4,8 +4,11 @@ namespace Hypervel\Tests\Permission; -use Hypervel\Permission\Contracts\Permission as PermissionContract; -use Hypervel\Permission\Contracts\Role as RoleContract; +use Hypervel\Permission\Middleware\PermissionMiddleware; +use Hypervel\Permission\Middleware\RoleMiddleware; +use Hypervel\Permission\Middleware\RoleOrPermissionMiddleware; +use Hypervel\Permission\Models\Permission; +use Hypervel\Permission\Models\Role; enum PurePermission { @@ -21,15 +24,34 @@ class UnitEnumTest extends TestCase { public function testPureUnitEnumsCanBeUsedForRoleAndPermissionAssignments(): void { - $role = $this->app->make(RoleContract::class)::create(['name' => PureRole::StaffWriter->name]); - $permission = $this->app->make(PermissionContract::class)::create(['name' => PurePermission::PublishArticles->name]); + $role = Role::findOrCreate(PureRole::StaffWriter); + $permission = Permission::findOrCreate(PurePermission::PublishArticles); $role->givePermissionTo(PurePermission::PublishArticles); $this->testUser->assignRole(PureRole::StaffWriter); + $this->assertSame('StaffWriter', $role->name); + $this->assertTrue($role->is(Role::findByName(PureRole::StaffWriter))); + $this->assertTrue($permission->is(Permission::findByName(PurePermission::PublishArticles))); $this->assertTrue($this->testUser->hasRole(PureRole::StaffWriter)); $this->assertTrue($this->testUser->hasPermissionTo(PurePermission::PublishArticles)); $this->assertTrue($role->hasPermissionTo(PurePermission::PublishArticles)); $this->assertTrue($permission->roles->contains($role)); } + + public function testPureUnitEnumsCanBeUsedWithMiddlewareUsingMethods(): void + { + $this->assertSame( + RoleMiddleware::class . ':StaffWriter', + RoleMiddleware::using(PureRole::StaffWriter), + ); + $this->assertSame( + PermissionMiddleware::class . ':PublishArticles', + PermissionMiddleware::using(PurePermission::PublishArticles), + ); + $this->assertSame( + RoleOrPermissionMiddleware::class . ':StaffWriter|PublishArticles', + RoleOrPermissionMiddleware::using([PureRole::StaffWriter, PurePermission::PublishArticles]), + ); + } } diff --git a/tests/Permission/WildcardPermissionTest.php b/tests/Permission/WildcardPermissionTest.php deleted file mode 100644 index 45aeac71f2..0000000000 --- a/tests/Permission/WildcardPermissionTest.php +++ /dev/null @@ -1,122 +0,0 @@ -app->make('config')->set('permission.enable_wildcard_permission', true); - $this->flushPermissionState(); - } - - public function testItCanCheckWildcardPermissions(): void - { - $this->testUser->givePermissionTo([ - Permission::create(['name' => 'articles.edit,view,create']), - Permission::create(['name' => 'news.*']), - Permission::create(['name' => 'posts.*']), - ]); - - $this->assertTrue($this->testUser->hasPermissionTo('posts.create')); - $this->assertTrue($this->testUser->hasPermissionTo('posts.create.123')); - $this->assertTrue($this->testUser->hasPermissionTo('posts.*')); - $this->assertTrue($this->testUser->hasPermissionTo('articles.view')); - $this->assertFalse($this->testUser->hasPermissionTo('projects.view')); - } - - public function testItCanCheckWildcardPermissionsViaRoles(): void - { - $this->testUser->assignRole('testRole'); - - $this->testUserRole->givePermissionTo([ - Permission::create(['name' => 'articles,projects.edit,view,create']), - Permission::create(['name' => 'news.*.456']), - Permission::create(['name' => 'posts']), - ]); - - $this->assertTrue($this->testUser->hasPermissionTo('posts.create')); - $this->assertTrue($this->testUser->hasPermissionTo('news.create.456')); - $this->assertTrue($this->testUser->hasPermissionTo('projects.create')); - $this->assertTrue($this->testUser->hasPermissionTo('articles.view')); - $this->assertFalse($this->testUser->hasPermissionTo('articles.list')); - $this->assertFalse($this->testUser->hasPermissionTo('projects.list')); - } - - public function testItCanAssignWildcardPermissionsUsingEnums(): void - { - $this->testUser->givePermissionTo([ - Permission::findOrCreate(TestRolePermissionsEnum::WildcardArticlesCreator), - Permission::findOrCreate(TestRolePermissionsEnum::WildcardNewsEverything), - Permission::findOrCreate(TestRolePermissionsEnum::WildcardPostsEverything), - ]); - - $this->assertTrue($this->testUser->hasPermissionTo(TestRolePermissionsEnum::WildcardPostsCreate)); - $this->assertTrue($this->testUser->hasPermissionTo(TestRolePermissionsEnum::WildcardPostsCreate->value . '.123')); - $this->assertTrue($this->testUser->hasPermissionTo(TestRolePermissionsEnum::WildcardPostsEverything)); - $this->assertTrue($this->testUser->hasPermissionTo(TestRolePermissionsEnum::WildcardArticlesView)); - $this->assertFalse($this->testUser->hasPermissionTo(TestRolePermissionsEnum::WildcardProjectsView)); - } - - public function testItClearsWildcardIndexWhenAssignmentsChange(): void - { - $this->testUserRole->givePermissionTo(Permission::create(['name' => 'posts.*'])); - - $this->assertFalse($this->testUser->hasPermissionTo('posts.create')); - - $this->testUser->assignRole('testRole'); - - $this->assertTrue($this->testUser->hasPermissionTo('posts.create')); - - $this->testUser->removeRole('testRole'); - - $this->assertFalse($this->testUser->hasPermissionTo('posts.create')); - } - - public function testWildcardIndexUsesCurrentAssignmentCacheToken(): void - { - $this->testUser->givePermissionTo(Permission::create(['name' => 'posts.*'])); - $registrar = $this->app->make(PermissionRegistrar::class); - - $this->assertTrue($this->testUser->hasPermissionTo('posts.create')); - - $this->testUser->getConnection() - ->table(Config::modelHasPermissionsTable()) - ->where(Config::morphKey(), $this->testUser->getKey()) - ->where('model_type', $this->testUser->getMorphClass()) - ->delete(); - $registrar->rotateModelAssignmentCacheTokenAfterMutation(null); - - $this->assertFalse($this->testUser->hasPermissionTo('posts.create')); - } - - public function testItCanUseACustomWildcardPermissionClass(): void - { - $this->app->make('config')->set('permission.wildcard_permission', WildcardPermission::class); - $this->flushPermissionState(); - - $this->testUser->givePermissionTo([ - Permission::create(['name' => 'articles:edit;view;create']), - Permission::create(['name' => 'news:@']), - Permission::create(['name' => 'posts:@']), - ]); - - $this->assertTrue($this->testUser->hasPermissionTo('posts:create')); - $this->assertTrue($this->testUser->hasPermissionTo('posts:create:123')); - $this->assertTrue($this->testUser->hasPermissionTo('posts:@')); - $this->assertTrue($this->testUser->hasPermissionTo('articles:view')); - $this->assertFalse($this->testUser->hasPermissionTo('posts.*')); - $this->assertFalse($this->testUser->hasPermissionTo('articles.view')); - $this->assertFalse($this->testUser->hasPermissionTo('projects:view')); - } -} From 410994511b46d9f0cbb40e3313e8afb35e276a0f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:35:20 +0000 Subject: [PATCH 09/18] Apply denies through wildcard matching With wildcard permissions enabled, denies matched only the exact checked name: a denied posts.* did not block an allowed posts.create, and a denied articles.edit did not block articles.edit.123 granted by articles.*. Denies are a Hypervel addition to spatie/laravel-permission (main at 6615eefac655, 8.x). The Wildcard contract adds getDeniedIndex() beside getIndex(). WildcardPermission indexes getAllPermissions() and the new public getDeniedPermissions() through one shared loop. getDeniedPermissions() reads the same cached direct and via-role collections and joins them with concat(): a loaded permissions relation or custom pivot supplies an Eloquent collection, whose merge() would replace a direct deny with a role allow of the same key. HasPermissions::hasPermissionTo() and Role::hasPermissionTo() now share hasWildcardPermission(), as upstream's wildcard branches do. It normalizes the checked value once, keeps the partition check for permission objects, returns false when the configured wildcard class matches the denied index, then checks the allowed index. hasDeniedPermission() and hasDeniedPermissionViaRoles() stay exact. The registrar stores both indexes in the existing context entry, so every wildcard index invalidation clears them together. WildcardPermission::buildIndex() also built each segment without subparts twice, in a separate branch and again in the subpart loop, so work doubled with each segment (31 calls instead of 5 for a four-segment name). Upstream has the same code. Every segment now goes through the loop once; its blank-subpart check covers the removed blank-segment check. The README records the contract addition and getDeniedPermissions(); the user guide documents getDeniedPermissions(), wildcard deny matching and custom wildcard classes. Validation: the changed test files pass on the array, database and Redis cache stores; the Permission suite passes on the array store, with only known later-slice failures on the database store; the Postgres Permission tests pass; formatting and static analysis are clean. --- src/docs/permission.md | 25 +++- src/permission/README.md | 3 +- src/permission/src/Contracts/Wildcard.php | 9 ++ src/permission/src/Models/Role.php | 4 - src/permission/src/PermissionRegistrar.php | 34 ++++- src/permission/src/Traits/HasPermissions.php | 37 +++-- src/permission/src/WildcardPermission.php | 41 ++++-- tests/Permission/DeniedPermissionTest.php | 23 ++++ tests/Permission/Models/WildcardRoleTest.php | 15 ++ .../Permission/PartitionAuthorizationTest.php | 13 ++ .../Traits/TeamHasPermissionsTest.php | 25 ++++ .../Traits/WildcardHasPermissionsTest.php | 130 ++++++++++++++++++ 12 files changed, 320 insertions(+), 39 deletions(-) diff --git a/src/docs/permission.md b/src/docs/permission.md index 745c4c9847..cae7fa3665 100644 --- a/src/docs/permission.md +++ b/src/docs/permission.md @@ -565,6 +565,12 @@ if ($user->hasDeniedPermissionViaRoles('delete articles')) { } ``` +To retrieve every permission the model is denied, directly or through its roles, use `getDeniedPermissions`: + +```php +$deniedPermissions = $user->getDeniedPermissions(); +``` + Use `syncPermissionEffects` to replace allowed and denied direct permissions together. If a permission is present in both arrays, the denied permission wins: ```php @@ -606,7 +612,7 @@ To retrieve only permissions inherited through roles, use `getPermissionsViaRole $rolePermissions = $user->getPermissionsViaRoles(); ``` -`getDirectPermissions`, `getPermissionsViaRoles`, `getAllPermissions`, and `getPermissionNames` return allowed permissions. Explicitly denied permissions are checked through `hasDeniedPermission` and `hasDeniedPermissionViaRoles`. +`getDirectPermissions`, `getPermissionsViaRoles`, `getAllPermissions`, and `getPermissionNames` return allowed permissions. Use `getDeniedPermissions` to retrieve [denied permissions](#denied-permissions). ## Using Enums @@ -1208,7 +1214,22 @@ $user->givePermissionTo('posts,users.create,update,view'); Like any permission, a wildcard permission must exist as a permission record before it can be assigned. The names you check do not need records of their own, so `hasPermissionTo('posts.create')` matches `posts.*` even when no `posts.create` permission exists. -To customize wildcard parsing, configure `wildcard_permission` with a class that implements `Hypervel\Permission\Contracts\Wildcard`. +[Denied permissions](#denied-permissions) use the same matching. A denied wildcard permission blocks every name it matches, and a denied permission blocks the names an allowed wildcard permission would otherwise grant: + +```php +$user->givePermissionTo('posts.*'); +$user->denyPermissionTo('posts.delete'); + +$user->hasPermissionTo('posts.edit'); +// true + +$user->hasPermissionTo('posts.delete.123'); +// false +``` + +`hasDeniedPermission` and `hasDeniedPermissionViaRoles` still match only the exact permission. + +To customize wildcard parsing, configure `wildcard_permission` with a class that implements `Hypervel\Permission\Contracts\Wildcard`. The class receives the model as its `record` constructor argument. Its `getIndex` and `getDeniedIndex` methods index the model's allowed and denied permissions, and `implies` checks a permission against an index. Extending `Hypervel\Permission\WildcardPermission` lets you change its `WILDCARD_TOKEN`, `PART_DELIMITER`, and `SUBPART_DELIMITER` constants while keeping its indexing. ## Polymorphic Models diff --git a/src/permission/README.md b/src/permission/README.md index 8ffd87e2ec..810f8080db 100644 --- a/src/permission/README.md +++ b/src/permission/README.md @@ -5,7 +5,8 @@ Documentation: https://hypervel.org/docs/permission ## Differences From Spatie Laravel Permission -- Hypervel adds [denied permissions](https://hypervel.org/docs/permission#denied-permissions). A denied assignment wins over direct or role-granted allows, and the migration stores each assignment's effect in an `is_denied` column, so assigning allow or deny for the same model or role and permission updates the existing assignment. `getDirectPermissions()`, `getPermissionsViaRoles()`, `getAllPermissions()`, and `getPermissionNames()` return effective allowed permissions only. +- Hypervel adds [denied permissions](https://hypervel.org/docs/permission#denied-permissions). A denied assignment wins over direct or role-granted allows, and the migration stores each assignment's effect in an `is_denied` column, so assigning allow or deny for the same model or role and permission updates the existing assignment. `getDirectPermissions()`, `getPermissionsViaRoles()`, `getAllPermissions()`, and `getPermissionNames()` return effective allowed permissions only; `getDeniedPermissions()` returns the denied ones. +- The `Wildcard` contract adds `getDeniedIndex()`, so [wildcard checks](https://hypervel.org/docs/permission#wildcard-permissions) apply denies through the same matching as allows: a denied pattern blocks the names it matches. Custom wildcard classes implement it alongside `getIndex()`. - Role and permission inputs accept [unit enums](https://hypervel.org/docs/permission#using-enums) as well as backed enums. Unit enums use their case names. - Hypervel adds opt-in [row partitioning](https://hypervel.org/docs/permission#row-partitioning) through `PermissionRegistrar::resolvePartitionUsing(...)`. The stock migration is unpartitioned; applications that enable partitioning own a [partitioned schema](https://hypervel.org/docs/permission#partitioned-schema). - The [cache configuration](https://hypervel.org/docs/permission#cache) uses `expiration_seconds` instead of `expiration_time`, with separate named cache keys so role, model-role, model-permission, and assignment-token caches can be invalidated independently. diff --git a/src/permission/src/Contracts/Wildcard.php b/src/permission/src/Contracts/Wildcard.php index ec5a042275..6ae24df8fb 100644 --- a/src/permission/src/Contracts/Wildcard.php +++ b/src/permission/src/Contracts/Wildcard.php @@ -13,6 +13,15 @@ interface Wildcard */ public function getIndex(): array; + /** + * Get the wildcard index of the record's denied permissions. + * + * Checks consult it before the permission index, so a matching deny wins over every allow. + * + * @return array> + */ + public function getDeniedIndex(): array; + /** * Determine if the wildcard permission implies another permission. * diff --git a/src/permission/src/Models/Role.php b/src/permission/src/Models/Role.php index 028e855ccd..d44d31de47 100644 --- a/src/permission/src/Models/Role.php +++ b/src/permission/src/Models/Role.php @@ -289,10 +289,6 @@ protected static function getRole(array $params = []): ?RoleContract public function hasPermissionTo(UnitEnum|int|string|PermissionContract $permission, ?string $guardName = null): bool { if ($this->getWildcardClass()) { - if ($this->hasDeniedPermission($permission, $guardName)) { - return false; - } - return $this->hasWildcardPermission($permission, $guardName); } diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php index 65a41645a7..ad2cc2f926 100644 --- a/src/permission/src/PermissionRegistrar.php +++ b/src/permission/src/PermissionRegistrar.php @@ -24,6 +24,7 @@ use Hypervel\Permission\Contracts\Permission as PermissionContract; use Hypervel\Permission\Contracts\PermissionsTeamResolver; use Hypervel\Permission\Contracts\Role as RoleContract; +use Hypervel\Permission\Contracts\Wildcard; use Hypervel\Permission\Exceptions\PermissionConnectionMismatch; use Hypervel\Permission\Exceptions\PermissionPartitionAlreadyConfigured; use Hypervel\Permission\Exceptions\PermissionPartitionModelNotSupported; @@ -1251,6 +1252,28 @@ public function forgetWildcardPermissionIndex(?Model $record = null): void * @return array> */ public function getWildcardPermissionIndex(Model $record): array + { + return $this->wildcardPermissionIndexes($record)['allowed']; + } + + /** + * Get the wildcard index of a model's denied permissions. + * + * @return array> + */ + public function getDeniedWildcardPermissionIndex(Model $record): array + { + return $this->wildcardPermissionIndexes($record)['denied']; + } + + /** + * Get a model's allowed and denied wildcard indexes. + * + * Both live in one entry, so every wildcard index invalidation clears them together. + * + * @return array{allowed: array>, denied: array>} + */ + private function wildcardPermissionIndexes(Model $record): array { $key = $this->wildcardPermissionIndexKey($record); $indexes = CoroutineContext::get(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY, []); @@ -1259,13 +1282,16 @@ public function getWildcardPermissionIndex(Model $record): array return $indexes[$key]; } - /** @var array> $index */ - $index = $this->app->make($record->getWildcardClass(), ['record' => $record])->getIndex(); // @phpstan-ignore method.notFound (the record uses HasPermissions) + /** @var Wildcard $wildcard */ + $wildcard = $this->app->make($record->getWildcardClass(), ['record' => $record]); // @phpstan-ignore method.notFound (the record uses HasPermissions) - $indexes[$key] = $index; + $indexes[$key] = [ + 'allowed' => $wildcard->getIndex(), + 'denied' => $wildcard->getDeniedIndex(), + ]; CoroutineContext::set(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY, $indexes); - return $index; + return $indexes[$key]; } /** diff --git a/src/permission/src/Traits/HasPermissions.php b/src/permission/src/Traits/HasPermissions.php index d10354b61c..f671c9f058 100644 --- a/src/permission/src/Traits/HasPermissions.php +++ b/src/permission/src/Traits/HasPermissions.php @@ -745,14 +745,6 @@ public function filterPermission($permission, ?string $guardName = null): Permis public function hasPermissionTo($permission, ?string $guardName = null): bool { if ($this->getWildcardClass()) { - if ($this->hasDeniedPermission($permission, $guardName)) { - return false; - } - - if ($this->hasDeniedPermissionViaRoles($permission, $guardName)) { - return false; - } - return $this->hasWildcardPermission($permission, $guardName); } @@ -784,7 +776,10 @@ protected function hasWildcardPermission($permission, ?string $guardName = null) $permission = $this->getPermissionClass()::findById($permission, $guardName); } + $registrar = $this->permissionRegistrar(); + if ($permission instanceof Permission) { + $this->ensurePermissionMatchesPartition($permission, $registrar->resolvePartition()); $guardName = $permission->guard_name ?? $guardName; $permission = $permission->name; } @@ -793,11 +788,14 @@ protected function hasWildcardPermission($permission, ?string $guardName = null) throw WildcardPermissionInvalidArgument::create(); } - return Container::getInstance()->make($this->getWildcardClass(), ['record' => $this])->implies( - $permission, - $guardName, - $this->permissionRegistrar()->getWildcardPermissionIndex($this), - ); + $wildcard = Container::getInstance()->make($this->getWildcardClass(), ['record' => $this]); + + // A deny wins over every allow, so a denied pattern blocks the names it matches. + if ($wildcard->implies($permission, $guardName, $registrar->getDeniedWildcardPermissionIndex($this))) { + return false; + } + + return $wildcard->implies($permission, $guardName, $registrar->getWildcardPermissionIndex($this)); } /** @@ -921,6 +919,19 @@ public function getAllPermissions(): Collection ->values(); } + /** + * Return all the permissions the model is denied, both directly and via roles. + */ + public function getDeniedPermissions(): Collection + { + // concat() keeps every assignment; Eloquent's merge() would replace a direct deny with a role allow of the same key. + return $this->directPermissionsForModelResult() + ->concat($this->getPermissionsViaRolesWithPivots()) + ->filter(fn (Model $permission): bool => $this->pivotIsDenied($permission)) + ->unique(fn (Model $permission): string => $this->permissionComparisonKey($permission)) + ->values(); + } + /** * Returns array of permissions ids. * diff --git a/src/permission/src/WildcardPermission.php b/src/permission/src/WildcardPermission.php index 1eb09c2a23..07a89e9f30 100644 --- a/src/permission/src/WildcardPermission.php +++ b/src/permission/src/WildcardPermission.php @@ -7,7 +7,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Permission\Contracts\Wildcard; use Hypervel\Permission\Exceptions\WildcardPermissionNotProperlyFormatted; -use Hypervel\Support\Str; +use Hypervel\Support\Collection; class WildcardPermission implements Wildcard { @@ -31,10 +31,31 @@ public function __construct(protected Model $record) */ public function getIndex(): array { - $index = []; + // @phpstan-ignore method.notFound (the record uses HasPermissions) + return $this->indexPermissions($this->record->getAllPermissions()); + } + /** + * Get the wildcard index of the record's denied permissions. + * + * @return array> + */ + public function getDeniedIndex(): array + { // @phpstan-ignore method.notFound (the record uses HasPermissions) - foreach ($this->record->getAllPermissions() as $permission) { + return $this->indexPermissions($this->record->getDeniedPermissions()); + } + + /** + * Build a wildcard index of the given permissions, keyed by guard. + * + * @return array> + */ + protected function indexPermissions(Collection $permissions): array + { + $index = []; + + foreach ($permissions as $permission) { $index[$permission->guard_name] = $this->buildIndex( $index[$permission->guard_name] ?? [], explode(static::PART_DELIMITER, $permission->name), @@ -62,18 +83,8 @@ protected function buildIndex(array $index, array $parts, string $permission): a $part = array_shift($parts); - if (blank($part)) { - throw WildcardPermissionNotProperlyFormatted::create($permission); - } - - if (! Str::contains($part, static::SUBPART_DELIMITER)) { - $index[$part] = $this->buildIndex( - $index[$part] ?? [], - $parts, - $permission, - ); - } - + // A segment without subparts explodes to itself, so this loop also builds plain segments. Upstream's separate + // branch for them builds the rest of the name a second time, doubling the work with each segment. $subParts = explode(static::SUBPART_DELIMITER, $part); foreach ($subParts as $subPart) { diff --git a/tests/Permission/DeniedPermissionTest.php b/tests/Permission/DeniedPermissionTest.php index d2ae984513..78a8ba5ad0 100644 --- a/tests/Permission/DeniedPermissionTest.php +++ b/tests/Permission/DeniedPermissionTest.php @@ -441,6 +441,29 @@ public function testRoleGetAllPermissionsExcludesDeniedPermissions(): void $this->assertSame(['edit-articles'], $permissionNames); $this->assertTrue($role->hasDeniedPermission('edit-news')); + $this->assertSame(['edit-news'], $role->getDeniedPermissions()->pluck('name')->all()); + } + + public function testGetDeniedPermissionsReturnsDirectAndRoleDeniesOnce(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + $this->testUserRole->denyPermissionTo('edit-news'); + $this->testUser->assignRole('testRole'); + $this->testUser->denyPermissionTo('edit-articles', 'edit-news'); + $this->testUser->givePermissionTo('edit-blog'); + + $this->assertSame( + ['edit-articles', 'edit-news'], + $this->testUser->getDeniedPermissions()->pluck('name')->sort()->values()->all(), + ); + + // A loaded relation supplies the direct permissions as an Eloquent collection. + $this->testUser->load('permissions'); + + $this->assertSame( + ['edit-articles', 'edit-news'], + $this->testUser->getDeniedPermissions()->pluck('name')->sort()->values()->all(), + ); } public function testDirectPermissionChecksDenyWhenRelationContainsDuplicateEffects(): void diff --git a/tests/Permission/Models/WildcardRoleTest.php b/tests/Permission/Models/WildcardRoleTest.php index 6ff96c2c1a..efeac7e36b 100644 --- a/tests/Permission/Models/WildcardRoleTest.php +++ b/tests/Permission/Models/WildcardRoleTest.php @@ -95,4 +95,19 @@ public function testItReturnsFalseWhenAPermissionOfTheWrongGuardIsPassedIn(): vo $this->assertFalse($this->testUserRole->hasPermissionTo($permission)); } + + public function testDeniedPermissionsWinOverWildcardMatches(): void + { + Permission::create(['name' => 'posts.*']); + Permission::create(['name' => 'posts.delete']); + Permission::create(['name' => 'news.*']); + Permission::create(['name' => 'news.create']); + + $this->testUserRole->givePermissionTo('posts.*', 'news.create'); + $this->testUserRole->denyPermissionTo('posts.delete', 'news.*'); + + $this->assertTrue($this->testUserRole->hasPermissionTo('posts.create')); + $this->assertFalse($this->testUserRole->hasPermissionTo('posts.delete.123')); + $this->assertFalse($this->testUserRole->hasPermissionTo('news.create')); + } } diff --git a/tests/Permission/PartitionAuthorizationTest.php b/tests/Permission/PartitionAuthorizationTest.php index 5fe29d2b7b..22c388530d 100644 --- a/tests/Permission/PartitionAuthorizationTest.php +++ b/tests/Permission/PartitionAuthorizationTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Permission; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Permission\Exceptions\PermissionPartitionViolation; use Hypervel\Tests\Permission\Fixtures\Models\GlobalPartitionUser; use Hypervel\Tests\Permission\Fixtures\Models\PartitionedPermission; use Hypervel\Tests\Permission\Fixtures\Models\PartitionedRole; @@ -105,6 +106,18 @@ public function testWildcardAllowsAndDeniesArePartitionIsolated(): void $this->assertTrue($user->hasPermissionTo('posts.delete.123')); } + public function testWildcardChecksRejectAPermissionFromAnotherPartition(): void + { + $user = GlobalPartitionUser::create(['email' => 'global@example.com']); + $permissionA = PartitionedPermission::create(['name' => 'posts.create']); + + $this->setPartition(self::PARTITION_B); + + $this->expectException(PermissionPartitionViolation::class); + + $user->hasPermissionTo($permissionA); + } + public function testPermissionScopesUseOnlyCurrentPartitionEffects(): void { $allowed = GlobalPartitionUser::create(['email' => 'allowed@example.com']); diff --git a/tests/Permission/Traits/TeamHasPermissionsTest.php b/tests/Permission/Traits/TeamHasPermissionsTest.php index 3d9d51c3c2..fc64b25ca3 100644 --- a/tests/Permission/Traits/TeamHasPermissionsTest.php +++ b/tests/Permission/Traits/TeamHasPermissionsTest.php @@ -12,6 +12,7 @@ use Hypervel\Permission\Events\PermissionAttachedEvent; use Hypervel\Permission\Events\PermissionDetachedEvent; use Hypervel\Permission\Exceptions\TeamNotSelected; +use Hypervel\Permission\Models\Permission; use Hypervel\Permission\PermissionRegistrar; use Hypervel\Permission\Support\Config; use Hypervel\Permission\Traits\HasRoles; @@ -154,6 +155,30 @@ public function testDirectPermissionHydrationMemoIsSeparatedByTeam(): void $this->assertSame($teamOne, $this->testUser->getDirectPermissions()->sole()); } + public function testWildcardDeniesAreSeparatedByTeam(): void + { + config()->set('permission.enable_wildcard_permission', true); + + Permission::create(['name' => 'posts.*']); + Permission::create(['name' => 'posts.create']); + + setPermissionsTeamId(1); + $this->testUser->givePermissionTo('posts.*'); + + setPermissionsTeamId(2); + $this->testUser->givePermissionTo('posts.*'); + $this->testUser->denyPermissionTo('posts.create'); + + $this->assertFalse($this->testUser->hasPermissionTo('posts.create')); + $this->assertTrue($this->testUser->hasPermissionTo('posts.edit')); + + setPermissionsTeamId(1); + $this->assertTrue($this->testUser->hasPermissionTo('posts.create')); + + setPermissionsTeamId(2); + $this->assertFalse($this->testUser->hasPermissionTo('posts.create')); + } + public function testItCanSyncOrRemovePermissionWithoutDetachOnDifferentTeams(): void { setPermissionsTeamId(1); diff --git a/tests/Permission/Traits/WildcardHasPermissionsTest.php b/tests/Permission/Traits/WildcardHasPermissionsTest.php index a7f2fbbc9c..728b6b1961 100644 --- a/tests/Permission/Traits/WildcardHasPermissionsTest.php +++ b/tests/Permission/Traits/WildcardHasPermissionsTest.php @@ -10,6 +10,7 @@ use Hypervel\Permission\Exceptions\WildcardPermissionNotImplementsContract; use Hypervel\Permission\Exceptions\WildcardPermissionNotProperlyFormatted; use Hypervel\Permission\Models\Permission; +use Hypervel\Permission\WildcardPermission as BaseWildcardPermission; use Hypervel\Tests\Permission\Fixtures\Models\TestRolePermissionsEnum; use Hypervel\Tests\Permission\Fixtures\Models\User; use Hypervel\Tests\Permission\Fixtures\Models\WildcardPermission; @@ -351,4 +352,133 @@ public function testItThrowsExceptionWhenPermissionIdNotExists(): void $user->hasPermissionTo(6); } + + public function testDeniedWildcardPermissionBlocksMatchingPermissions(): void + { + $user = User::create(['email' => 'user@test.com']); + $user->assignRole('testRole'); + + Permission::create(['name' => 'posts.*']); + Permission::create(['name' => 'posts.create']); + Permission::create(['name' => 'posts.edit']); + Permission::create(['name' => 'articles.view']); + + $user->givePermissionTo('posts.create', 'articles.view'); + $this->testUserRole->givePermissionTo('posts.edit'); + $user->denyPermissionTo('posts.*'); + + $this->assertFalse($user->hasPermissionTo('posts.create')); + $this->assertFalse($user->hasPermissionTo('posts.edit')); + $this->assertTrue($user->hasPermissionTo('articles.view')); + } + + public function testRoleDeniedWildcardPermissionBlocksMatchingDirectPermissions(): void + { + $user = User::create(['email' => 'user@test.com']); + $user->assignRole('testRole'); + + Permission::create(['name' => 'posts.*']); + Permission::create(['name' => 'posts.create']); + + $user->givePermissionTo('posts.create'); + $this->testUserRole->denyPermissionTo('posts.*'); + + $this->assertFalse($user->hasPermissionTo('posts.create')); + } + + public function testDeniedPermissionBlocksNamesMatchedByAllowedWildcardPermission(): void + { + $user = User::create(['email' => 'user@test.com']); + + Permission::create(['name' => 'articles.*']); + Permission::create(['name' => 'articles.edit']); + + $user->givePermissionTo('articles.*'); + $user->denyPermissionTo('articles.edit'); + + $this->assertFalse($user->hasPermissionTo('articles.edit')); + $this->assertFalse($user->hasPermissionTo('articles.edit.123')); + $this->assertTrue($user->hasPermissionTo('articles.view')); + } + + public function testWildcardDeniesApplyToWarmChecks(): void + { + $user = User::create(['email' => 'user@test.com']); + $user->assignRole('testRole'); + + Permission::create(['name' => 'posts.*']); + Permission::create(['name' => 'posts.create']); + + $user->givePermissionTo('posts.*'); + $this->assertTrue($user->hasPermissionTo('posts.create')); + + $user->denyPermissionTo('posts.create'); + $this->assertFalse($user->hasPermissionTo('posts.create')); + + $user->revokePermissionTo('posts.create'); + $this->assertTrue($user->hasPermissionTo('posts.create')); + + $this->testUserRole->denyPermissionTo('posts.create'); + $this->assertFalse($user->hasPermissionTo('posts.create')); + } + + public function testWildcardDeniesOnlyApplyToTheirGuard(): void + { + $user = User::create(['email' => 'user@test.com']); + + $webWildcard = Permission::create(['name' => 'posts.*']); + $apiWildcard = Permission::create(['name' => 'posts.*', 'guard_name' => 'api']); + $apiPermission = Permission::create(['name' => 'posts.create', 'guard_name' => 'api']); + + $user->givePermissionTo($webWildcard); + $user->givePermissionTo($apiPermission); + $user->denyPermissionTo($apiWildcard); + + $this->assertTrue($user->hasPermissionTo('posts.create')); + $this->assertFalse($user->hasPermissionTo('posts.create', 'api')); + } + + public function testCustomWildcardDeniesBlockMatchingPermissions(): void + { + config()->set('permission.wildcard_permission', WildcardPermission::class); + + $user = User::create(['email' => 'user@test.com']); + + Permission::create(['name' => 'posts:@']); + Permission::create(['name' => 'posts:delete']); + + $user->givePermissionTo('posts:@'); + $user->denyPermissionTo('posts:delete'); + + $this->assertTrue($user->hasPermissionTo('posts:create')); + $this->assertFalse($user->hasPermissionTo('posts:delete:123')); + } + + public function testWildcardIndexBuildsEachPermissionSegmentOnce(): void + { + $user = User::create(['email' => 'user@test.com']); + + $user->givePermissionTo(Permission::create(['name' => 'posts.edit.own.drafts'])); + + $wildcard = new CountingWildcardPermission($user); + $wildcard->getIndex(); + + // One call per segment, plus one that marks the end of the name. + $this->assertSame(5, $wildcard->buildIndexCalls); + } +} + +class CountingWildcardPermission extends BaseWildcardPermission +{ + public int $buildIndexCalls = 0; + + /** + * Build the wildcard permission index, counting each call. + */ + protected function buildIndex(array $index, array $parts, string $permission): array + { + ++$this->buildIndexCalls; + + return parent::buildIndex($index, $parts, $permission); + } } From ce35d0c6b26a63f346b1f7605e1562c78494e57f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:03:11 +0000 Subject: [PATCH 10/18] Reconcile the Permission public surface Assess the configuration, contracts, exceptions, helpers, Guard, migration and stubs, events and commands against spatie/laravel-permission main at 6615eefac655. - permission:show rendered a role's denied permissions as allowed, because it plucked permission ids only (upstream has no denies). It now reads each eager-loaded pivot's is_denied flag and shows allowed, denied and unassigned cells, with no extra queries. The user guide explains the symbols. - The show-by-teams test uses non-sequential team ids, so upstream's positional team headers would fail it. - UnauthorizedException::missingTraitHasRoles() takes the user types the middleware pass instead of object. - DefaultTeamResolver is no longer final, and its flushState() and subscriber call are removed: the subscriber's CoroutineContext flush already clears the team. - SchemaConfigTest's omitted-pivot-key case uses the array store for the permission cache. It switches the default connection to a fresh database, which a database cache store followed when the migration cleared the role cache. - Remove PublicApiTest and SchemaConfigTest cases covered elsewhere or that only asserted removed names; BladeTest covers the hasexactroles directive. PackageMetadataTest compares every external requirement with the root package. - Method title docblocks, restored upstream comments (the AssignRole one now names what it checks), the teams config comment, and README and user-guide corrections. Validation: Permission suite on the array store; database-store run with only the known cache and partition query-count failures; affected files on Redis; composer lint and composer analyse. --- src/docs/permission.md | 4 +- src/permission/README.md | 1 + src/permission/config/permission.php | 4 +- .../src/Commands/AssignRoleCommand.php | 1 + src/permission/src/Commands/ShowCommand.php | 11 ++- src/permission/src/DefaultTeamResolver.php | 12 +-- .../src/Events/PermissionAttachedEvent.php | 2 + .../src/Events/PermissionDetachedEvent.php | 2 + .../src/Events/RoleAttachedEvent.php | 2 + .../src/Events/RoleDetachedEvent.php | 2 + .../src/Exceptions/UnauthorizedException.php | 4 +- src/permission/src/Guard.php | 5 ++ src/permission/src/Models/Permission.php | 2 + src/permission/src/Models/Role.php | 2 + src/permission/src/Support/Config.php | 8 ++ src/permission/src/Traits/HasPermissions.php | 6 ++ .../src/PHPUnit/AfterEachTestSubscriber.php | 1 - tests/Permission/Commands/CommandTest.php | 15 +++- tests/Permission/Integration/BladeTest.php | 9 ++ tests/Permission/PackageMetadataTest.php | 11 ++- tests/Permission/PublicApiTest.php | 86 ------------------- tests/Permission/SchemaConfigTest.php | 38 +------- 22 files changed, 84 insertions(+), 144 deletions(-) diff --git a/src/docs/permission.md b/src/docs/permission.md index cae7fa3665..717b142e89 100644 --- a/src/docs/permission.md +++ b/src/docs/permission.md @@ -875,6 +875,8 @@ You may view the permission matrix using the `permission:show` command: php artisan permission:show ``` +Each cell shows `✔` when the role is allowed the permission, `✘` when the role is denied it, and `·` when the role has no assignment for it. + You may limit the output to a specific guard: ```shell @@ -1565,7 +1567,7 @@ Configuration and context failures use focused exceptions: ## Differences From Spatie Laravel Permission - Hypervel adds denied permissions. A denied assignment explicitly rejects an ability and wins over direct or role-granted allows. The `is_denied` flag is stored as the effect on the assignment row, so assigning allow or deny for the same model or role and permission updates the existing edge. -- `getDirectPermissions()`, `getPermissionsViaRoles()`, `getAllPermissions()`, and `getPermissionNames()` return effective allowed permissions. Explicit denied edges are exposed through `hasDeniedPermission()` and `hasDeniedPermissionViaRoles()`. +- `getDirectPermissions()`, `getPermissionsViaRoles()`, `getAllPermissions()`, and `getPermissionNames()` return effective allowed permissions. `getDeniedPermissions()` returns the denied ones, and `hasDeniedPermission()` and `hasDeniedPermissionViaRoles()` check them. - Hypervel accepts pure unit enums anywhere enum names are valid role or permission inputs. Backed enums use their values; unit enums use their case names. - Hypervel adds opt-in generic row partitioning through `PermissionRegistrar::resolvePartitionUsing(...)`. It scopes model lifecycle operations, every package relation and pivot, queries, commands, cache identities, and invalidation without depending on any partition domain. - Hypervel's cache config uses `expiration_seconds` and separate named cache keys so role, model-role, model-permission, and assignment-token caches can be invalidated independently. diff --git a/src/permission/README.md b/src/permission/README.md index 810f8080db..50547d5778 100644 --- a/src/permission/README.md +++ b/src/permission/README.md @@ -10,5 +10,6 @@ Documentation: https://hypervel.org/docs/permission - Role and permission inputs accept [unit enums](https://hypervel.org/docs/permission#using-enums) as well as backed enums. Unit enums use their case names. - Hypervel adds opt-in [row partitioning](https://hypervel.org/docs/permission#row-partitioning) through `PermissionRegistrar::resolvePartitionUsing(...)`. The stock migration is unpartitioned; applications that enable partitioning own a [partitioned schema](https://hypervel.org/docs/permission#partitioned-schema). - The [cache configuration](https://hypervel.org/docs/permission#cache) uses `expiration_seconds` instead of `expiration_time`, with separate named cache keys so role, model-role, model-permission, and assignment-token caches can be invalidated independently. +- There is no Octane reset listener or `register_octane_reset_listener` option. The current team and the loaded permission catalog are coroutine-local, so nothing carries over between requests. Ported from: https://github.com/spatie/laravel-permission diff --git a/src/permission/config/permission.php b/src/permission/config/permission.php index bcd32b10f2..cba21cc690 100644 --- a/src/permission/config/permission.php +++ b/src/permission/config/permission.php @@ -91,7 +91,9 @@ |-------------------------------------------------------------------------- | | Teams scope roles and assignments by the configured team foreign key. - | A custom resolver must implement the PermissionsTeamResolver contract. + | Enable teams before running the migration, or run "permission:setup-teams" + | to add the team columns later. A custom resolver must implement the + | PermissionsTeamResolver contract. | */ diff --git a/src/permission/src/Commands/AssignRoleCommand.php b/src/permission/src/Commands/AssignRoleCommand.php index b624027f03..05ecb4fca8 100644 --- a/src/permission/src/Commands/AssignRoleCommand.php +++ b/src/permission/src/Commands/AssignRoleCommand.php @@ -39,6 +39,7 @@ public function handle(PermissionRegistrar $permissionRegistrar): int return self::SUCCESS; } + // Validate that the model class exists and is an Eloquent model if (! is_string($userModelClass) || ! class_exists($userModelClass)) { $this->error("User model class [{$userModelClass}] does not exist."); diff --git a/src/permission/src/Commands/ShowCommand.php b/src/permission/src/Commands/ShowCommand.php index 1020e7159f..9f3e680274 100644 --- a/src/permission/src/Commands/ShowCommand.php +++ b/src/permission/src/Commands/ShowCommand.php @@ -5,6 +5,7 @@ namespace Hypervel\Permission\Commands; use Hypervel\Console\Command; +use Hypervel\Database\Eloquent\Model; use Hypervel\Permission\PermissionRegistrar; use Hypervel\Permission\Support\Config; use Hypervel\Support\Collection; @@ -52,7 +53,9 @@ public function handle(PermissionRegistrar $permissionRegistrar): int ->when($teamsEnabled, fn ($q) => $q->orderBy($teamKey)) ->orderBy('name')->get()->mapWithKeys(fn ($role) => [ $role->name . '_' . ($teamsEnabled ? (string) ($role->{$teamKey} ?? '') : '') => [ - 'permissions' => $role->permissions->pluck($permissionKey), + 'permissions' => $role->permissions->mapWithKeys(fn (Model $permission): array => [ + $permission->{$permissionKey} => (bool) $permission->getRelation('pivot')->is_denied, + ]), $teamKey => $teamsEnabled ? $role->{$teamKey} : null, ], ]); @@ -64,7 +67,11 @@ public function handle(PermissionRegistrar $permissionRegistrar): int $body = $permissions->map( fn ($permission, $id) => $roles->map( - fn (array $role_data) => $role_data['permissions']->contains($id) ? ' ✔' : ' ·' + fn (array $role_data): string => match ($role_data['permissions']->get($id)) { + false => ' ✔', + true => ' ✘', + default => ' ·', + } )->prepend($permission) ); diff --git a/src/permission/src/DefaultTeamResolver.php b/src/permission/src/DefaultTeamResolver.php index cb08cfa9f2..150d1df0bd 100644 --- a/src/permission/src/DefaultTeamResolver.php +++ b/src/permission/src/DefaultTeamResolver.php @@ -9,9 +9,9 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Permission\Contracts\PermissionsTeamResolver; -final class DefaultTeamResolver implements PermissionsTeamResolver +class DefaultTeamResolver implements PermissionsTeamResolver { - public const string TEAM_ID_CONTEXT_KEY = '__permission.team_id'; + protected const string TEAM_ID_CONTEXT_KEY = '__permission.team_id'; /** * Set the current permissions team id. @@ -37,12 +37,4 @@ public function getPermissionsTeamId(): int|string|null { return CoroutineContext::get(self::TEAM_ID_CONTEXT_KEY); } - - /** - * Flush all static state. - */ - public static function flushState(): void - { - CoroutineContext::forget(self::TEAM_ID_CONTEXT_KEY); - } } diff --git a/src/permission/src/Events/PermissionAttachedEvent.php b/src/permission/src/Events/PermissionAttachedEvent.php index 3c408ee8f2..98946b0e7e 100644 --- a/src/permission/src/Events/PermissionAttachedEvent.php +++ b/src/permission/src/Events/PermissionAttachedEvent.php @@ -18,6 +18,8 @@ class PermissionAttachedEvent use SerializesModels; /** + * Create a new event instance. + * * Internally the HasPermissions trait passes an array of permission ids (e.g. ints or UUIDs). * Theoretically one could register the event to other places and pass an Eloquent record. * So a Listener should inspect the type of $permissionsOrIds received before using. diff --git a/src/permission/src/Events/PermissionDetachedEvent.php b/src/permission/src/Events/PermissionDetachedEvent.php index e63f03ca27..38b2b0398c 100644 --- a/src/permission/src/Events/PermissionDetachedEvent.php +++ b/src/permission/src/Events/PermissionDetachedEvent.php @@ -18,6 +18,8 @@ class PermissionDetachedEvent use SerializesModels; /** + * Create a new event instance. + * * Internally the HasPermissions trait passes $permissionsOrIds as an Eloquent record. * Theoretically one could register the event to other places and pass an array etc. * So a Listener should inspect the type of $permissionsOrIds received before using. diff --git a/src/permission/src/Events/RoleAttachedEvent.php b/src/permission/src/Events/RoleAttachedEvent.php index 0c02fd3aa1..65841e2351 100644 --- a/src/permission/src/Events/RoleAttachedEvent.php +++ b/src/permission/src/Events/RoleAttachedEvent.php @@ -18,6 +18,8 @@ class RoleAttachedEvent use SerializesModels; /** + * Create a new event instance. + * * Internally the HasRoles trait passes an array of role ids (e.g. ints or UUIDs). * Theoretically one could register the event to other places passing other types. * So a Listener should inspect the type of $rolesOrIds received before using. diff --git a/src/permission/src/Events/RoleDetachedEvent.php b/src/permission/src/Events/RoleDetachedEvent.php index 6d078fd98b..7fd35d132b 100644 --- a/src/permission/src/Events/RoleDetachedEvent.php +++ b/src/permission/src/Events/RoleDetachedEvent.php @@ -18,6 +18,8 @@ class RoleDetachedEvent use SerializesModels; /** + * Create a new event instance. + * * Internally the HasRoles trait passes an array of role ids (e.g. ints or UUIDs). * Theoretically one could register the event to other places passing other types. * So a Listener should inspect the type of $rolesOrIds received before using. diff --git a/src/permission/src/Exceptions/UnauthorizedException.php b/src/permission/src/Exceptions/UnauthorizedException.php index 9ae672eb85..5db97a2fcd 100644 --- a/src/permission/src/Exceptions/UnauthorizedException.php +++ b/src/permission/src/Exceptions/UnauthorizedException.php @@ -4,6 +4,8 @@ namespace Hypervel\Permission\Exceptions; +use Hypervel\Contracts\Auth\Access\Authorizable; +use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Permission\Support\Config; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -83,7 +85,7 @@ public static function forRolesOrPermissions(array $rolesOrPermissions): static /** * Create an exception for a user missing the HasRoles trait. */ - public static function missingTraitHasRoles(object $user): static + public static function missingTraitHasRoles(Authenticatable|Authorizable $user): static { return new static(403, __('Authenticated class `:class` must use Hypervel\Permission\Traits\HasRoles trait.', [ 'class' => $user::class, diff --git a/src/permission/src/Guard.php b/src/permission/src/Guard.php index bdabb75523..028324d079 100644 --- a/src/permission/src/Guard.php +++ b/src/permission/src/Guard.php @@ -77,6 +77,7 @@ protected static function getProviderModel(string $provider): ?string return self::$providerModels[$provider]; } + // Get the provider configuration $providerConfig = self::config()->array("auth.providers.{$provider}", []); // Handle LDAP provider or standard Eloquent provider @@ -174,6 +175,8 @@ protected static function getConfigAuthGuards(string $class): Collection /** * Get the model associated with a given guard name. + * + * @return null|class-string */ public static function getModelForGuard(string $guard): ?string { @@ -181,6 +184,7 @@ public static function getModelForGuard(string $guard): ?string return self::$modelsForGuards[$guard]; } + // Get the provider configuration for the given guard $provider = self::config()->get("auth.guards.{$guard}.provider"); if ($provider === null || $provider === '') { @@ -210,6 +214,7 @@ public static function getDefaultName(string|Model $class): string $possibleGuards = static::getNames($class); + // Return the current default guard if it matches one of those that have been checked if ($possibleGuards->contains($default)) { return $default; } diff --git a/src/permission/src/Models/Permission.php b/src/permission/src/Models/Permission.php index 82a89e1e99..95727276ca 100644 --- a/src/permission/src/Models/Permission.php +++ b/src/permission/src/Models/Permission.php @@ -74,6 +74,8 @@ public function guardName(): string } /** + * Create a new permission. + * * @return Permission|PermissionContract * * @throws PermissionAlreadyExists diff --git a/src/permission/src/Models/Role.php b/src/permission/src/Models/Role.php index d44d31de47..0c60c42503 100644 --- a/src/permission/src/Models/Role.php +++ b/src/permission/src/Models/Role.php @@ -79,6 +79,8 @@ public function guardName(): string } /** + * Create a new role. + * * @return Role|RoleContract * * @throws RoleAlreadyExists diff --git a/src/permission/src/Support/Config.php b/src/permission/src/Support/Config.php index 7db00dfacf..a418df6375 100644 --- a/src/permission/src/Support/Config.php +++ b/src/permission/src/Support/Config.php @@ -56,6 +56,8 @@ public static function ensureTeamsEnabled(): void } /** + * Get the team model class. + * * @return class-string */ public static function teamModel(): string @@ -146,6 +148,8 @@ public static function defaultGuard(): string } /** + * Get the role model class. + * * @return class-string */ public static function roleModel(): string @@ -154,6 +158,8 @@ public static function roleModel(): string } /** + * Get the permission model class. + * * @return class-string */ public static function permissionModel(): string @@ -202,6 +208,8 @@ public static function wildcardPermissionsEnabled(): bool } /** + * Get the wildcard permission class. + * * @return class-string */ public static function wildcardPermissionClass(): string diff --git a/src/permission/src/Traits/HasPermissions.php b/src/permission/src/Traits/HasPermissions.php index f671c9f058..13df552d6f 100644 --- a/src/permission/src/Traits/HasPermissions.php +++ b/src/permission/src/Traits/HasPermissions.php @@ -668,6 +668,8 @@ public function scopeWithoutPermission(Builder $query, $permissions): Builder } /** + * Convert the given permissions to permission models. + * * @param array|Collection|int|Permission|string|UnitEnum $permissions * * @throws PermissionDoesNotExist @@ -2104,6 +2106,8 @@ public function getPermissionNames(): Collection } /** + * Get the stored permission models for the given permissions. + * * @param array|Collection|int|Permission|string|UnitEnum $permissions * @return Collection|(Model&Permission) */ @@ -2191,6 +2195,8 @@ private function ensurePermissionMatchesPartition( } /** + * Ensure the given role or permission uses one of the model's guards. + * * @param Permission|Role $roleOrPermission * * @throws GuardDoesNotMatch diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 41abf1fc8e..62a71b10bc 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -422,7 +422,6 @@ protected function flushPasskeysState(): void */ protected function flushPermissionState(): void { - $this->callIfExists(\Hypervel\Permission\DefaultTeamResolver::class, 'flushState'); $this->callIfExists(\Hypervel\Permission\Guard::class, 'flushState'); $this->callIfExists(\Hypervel\Permission\PermissionRegistrar::class, 'flushState'); } diff --git a/tests/Permission/Commands/CommandTest.php b/tests/Permission/Commands/CommandTest.php index 566ddb9c58..824055fbfb 100644 --- a/tests/Permission/Commands/CommandTest.php +++ b/tests/Permission/Commands/CommandTest.php @@ -130,6 +130,14 @@ public function testItCanShowPermissionTables(): void $output = Artisan::output(); $this->assertMatchesRegularExpression('/\|\s+edit-articles\s+\|\s+✔\s+\|\s+·\s+\|/', $output); + + Role::findByName('testRole')->denyPermissionTo('edit-articles'); + + Artisan::call('permission:show'); + + $output = Artisan::output(); + + $this->assertMatchesRegularExpression('/\|\s+edit-articles\s+\|\s+✘\s+\|\s+·\s+\|/', $output); } public function testItCanShowPermissionsForGuard(): void @@ -254,13 +262,14 @@ public function testItCanShowRolesByTeams(): void { Role::where('name', 'testRole2')->delete(); Role::create(['name' => 'testRole_2']); - Role::create(['name' => 'testRole_Team', 'team_test_id' => 1]); - Role::create(['name' => 'testRole_Team', 'team_test_id' => 2]); // same name different team + // Non-sequential team ids, so the headers must show the ids rather than group positions. + Role::create(['name' => 'testRole_Team', 'team_test_id' => 7]); + Role::create(['name' => 'testRole_Team', 'team_test_id' => 42]); // same name different team Artisan::call('permission:show'); $output = Artisan::output(); - $this->assertMatchesRegularExpression('/\|\s+\|\s+Team ID: NULL\s+\|\s+Team ID: 1\s+\|\s+Team ID: 2\s+\|/', $output); + $this->assertMatchesRegularExpression('/\|\s+\|\s+Team ID: NULL\s+\|\s+Team ID: 7\s+\|\s+Team ID: 42\s+\|/', $output); $this->assertMatchesRegularExpression('/\|\s+\|\s+testRole\s+\|\s+testRole_2\s+\|\s+testRole_Team\s+\|\s+testRole_Team\s+\|/', $output); } diff --git a/tests/Permission/Integration/BladeTest.php b/tests/Permission/Integration/BladeTest.php index d8a9932cd1..9d3726e06b 100644 --- a/tests/Permission/Integration/BladeTest.php +++ b/tests/Permission/Integration/BladeTest.php @@ -7,6 +7,7 @@ use Hypervel\Permission\Contracts\Role; use Hypervel\Support\Facades\Artisan; use Hypervel\Support\Facades\Auth; +use Hypervel\Support\Facades\Blade; use Hypervel\Tests\Permission\Fixtures\Models\Admin; use Hypervel\Tests\Permission\Fixtures\Models\User; use Hypervel\Tests\Permission\TestCase; @@ -300,6 +301,14 @@ public function testItEvaluatesTheHasallrolesDirectiveAsFalseWhenTheLoggedInUser $this->assertSame('does not have all of the given roles', $this->renderView('guardHasAllRolesArray', compact('guard'))); } + public function testItEvaluatesTheHasexactrolesDirectiveAgainstTheLoggedInUsersRoles(): void + { + Auth::setUser($this->writer()); + + $this->assertTrue(Blade::check('hasexactroles', 'writer')); + $this->assertFalse(Blade::check('hasexactroles', ['writer', 'member'])); + } + protected function renderView(string $view, array $parameters): string { Artisan::call('view:clear'); diff --git a/tests/Permission/PackageMetadataTest.php b/tests/Permission/PackageMetadataTest.php index d6881618ee..5105c7a297 100644 --- a/tests/Permission/PackageMetadataTest.php +++ b/tests/Permission/PackageMetadataTest.php @@ -30,10 +30,13 @@ public function testDependenciesAndProviderAreDeclared(): void JSON_THROW_ON_ERROR, ); - foreach (['composer-runtime-api', 'nesbot/carbon', 'symfony/http-kernel'] as $dependency) { - $this->assertArrayHasKey($dependency, $rootComposer['require']); - $this->assertArrayHasKey($dependency, $composer['require']); - $this->assertSame($rootComposer['require'][$dependency], $composer['require'][$dependency]); + foreach ($composer['require'] as $dependency => $constraint) { + // The root package replaces the Hypervel packages instead of requiring them. + if (str_starts_with($dependency, 'hypervel/')) { + continue; + } + + $this->assertSame($rootComposer['require'][$dependency] ?? null, $constraint, $dependency); } $this->assertSame( diff --git a/tests/Permission/PublicApiTest.php b/tests/Permission/PublicApiTest.php index 317f8c4d0b..4e08a482ac 100644 --- a/tests/Permission/PublicApiTest.php +++ b/tests/Permission/PublicApiTest.php @@ -9,16 +9,8 @@ use Hypervel\Permission\Middleware\PermissionMiddleware; use Hypervel\Permission\Middleware\RoleMiddleware; use Hypervel\Permission\Middleware\RoleOrPermissionMiddleware; -use Hypervel\Permission\PermissionRegistrar; use Hypervel\Routing\Router; -use Hypervel\Support\Facades\Auth; -use Hypervel\Support\Facades\Blade; -use Hypervel\Support\Facades\Route; -use Hypervel\Tests\Permission\Fixtures\Models\TestRolePermissionsEnum; -use Hypervel\View\Compilers\BladeCompiler; -use ReflectionClass; use ReflectionMethod; -use ReflectionParameter; class PublicApiTest extends TestCase { @@ -31,43 +23,6 @@ public function testMiddlewareAliasesAreRegistered(): void $this->assertSame(RoleOrPermissionMiddleware::class, $router->getMiddleware()['role_or_permission']); } - public function testRouteMacrosAttachPermissionMiddleware(): void - { - $roleRoute = Route::get('/roles', $this->getRouteResponse())->role(['testRole', TestRolePermissionsEnum::Editor]); - $permissionRoute = Route::get('/permissions', $this->getRouteResponse())->permission(['edit-articles', TestRolePermissionsEnum::ViewArticles]); - $roleOrPermissionRoute = Route::get('/either', $this->getRouteResponse())->roleOrPermission(['testRole', 'edit-articles']); - - $this->assertContains('role:testRole|editor', $roleRoute->middleware()); - $this->assertContains('permission:edit-articles|view articles', $permissionRoute->middleware()); - $this->assertContains('role_or_permission:testRole|edit-articles', $roleOrPermissionRoute->middleware()); - } - - public function testBladeConditionsCheckRolesAndPermissions(): void - { - Auth::login($this->testUser); - $this->testUser->assignRole('testRole'); - $this->testUser->givePermissionTo('edit-articles'); - - $this->assertTrue(Blade::check('role', 'testRole')); - $this->assertTrue(Blade::check('hasrole', 'testRole')); - $this->assertTrue(Blade::check('hasanyrole', ['missing', 'testRole'])); - $this->assertTrue(Blade::check('hasallroles', ['testRole'])); - $this->assertTrue(Blade::check('hasexactroles', ['testRole'])); - $this->assertTrue(Blade::check('haspermission', 'edit-articles')); - } - - public function testBladeDirectivesCompile(): void - { - $compiler = $this->app->make(BladeCompiler::class); - - $compiled = $compiler->compileString("@role('testRole') allowed @else missing @endrole @unlessrole('missing') unless @endunlessrole"); - - $this->assertStringContainsString("Blade::check('role', 'testRole')", $compiled); - $this->assertStringContainsString('', $compiled); - $this->assertStringContainsString("Blade::check('role', 'missing')", $compiled); - $this->assertStringContainsString('', $compiled); - } - public function testPermissionRelationsKeepLaravelBaseReturnTypes(): void { $this->assertInstanceOf(MorphToMany::class, $this->testUser->roles()); @@ -85,45 +40,4 @@ public function testPermissionRelationsKeepLaravelBaseReturnTypes(): void $this->assertSame([], $method->getParameters()); } } - - public function testAssignmentMethodsDoNotExposePartitionArguments(): void - { - $methods = [ - 'assignRole' => ['roles'], - 'removeRole' => ['role'], - 'syncRoles' => ['roles'], - 'givePermissionTo' => ['permissions'], - 'denyPermissionTo' => ['permissions'], - 'revokePermissionTo' => ['permission'], - 'syncPermissions' => ['permissions'], - 'syncPermissionEffects' => ['allowed', 'denied'], - ]; - - foreach ($methods as $method => $parameters) { - $reflection = new ReflectionMethod($this->testUser, $method); - - $this->assertSame( - $parameters, - array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $reflection->getParameters()), - ); - } - } - - public function testRemovedPermissionEffectMethodsDoNotExist(): void - { - $model = new ReflectionClass($this->testUser); - - foreach ([ - 'giveForbiddenTo', - 'hasForbiddenPermission', - 'hasForbiddenPermissionViaRoles', - 'syncPermissionsWithForbidden', - ] as $method) { - $this->assertFalse($model->hasMethod($method)); - } - - $registrar = new ReflectionClass(PermissionRegistrar::class); - - $this->assertFalse($registrar->hasMethod('hasForbiddenRolePermissions')); - } } diff --git a/tests/Permission/SchemaConfigTest.php b/tests/Permission/SchemaConfigTest.php index 00d10bc8cf..02bd8378d0 100644 --- a/tests/Permission/SchemaConfigTest.php +++ b/tests/Permission/SchemaConfigTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Permission; -use Hypervel\Permission\Contracts\Permission; use Hypervel\Support\Facades\Schema; class SchemaConfigTest extends TestCase @@ -16,40 +15,6 @@ public function testTeamsDisabledSchemaDoesNotCreateTeamColumns(): void $this->assertFalse(Schema::hasColumn('model_has_permissions', 'team_test_id')); } - public function testCustomMorphAndPivotKeysAreUsedByRelations(): void - { - $this->testUser->assignRole('testRole'); - $this->testUser->givePermissionTo('edit-articles'); - $this->testUserRole->givePermissionTo('edit-news'); - - $this->assertDatabaseHas('model_has_roles', [ - 'role_test_id' => $this->testUserRole->getKey(), - 'model_test_id' => $this->testUser->getKey(), - ]); - - $this->assertDatabaseHas('model_has_permissions', [ - 'permission_test_id' => $this->testUserPermission->getKey(), - 'model_test_id' => $this->testUser->getKey(), - ]); - - $this->assertDatabaseHas('role_has_permissions', [ - 'role_test_id' => $this->testUserRole->getKey(), - 'permission_test_id' => $this->app->make(Permission::class)::findByName('edit-news')->getKey(), - ]); - } - - public function testDeniedPermissionUpdatesExistingCustomKeyAssignmentEdge(): void - { - $this->testUser->givePermissionTo('edit-articles'); - $this->testUser->denyPermissionTo('edit-articles'); - - $this->testUser->refresh(); - - $this->assertSame(1, $this->testUser->permissions()->count()); - $this->assertTrue($this->testUser->hasDeniedPermission('edit-articles')); - $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); - } - public function testMigrationUsesConventionalPivotKeysWhenTheyAreOmitted(): void { $original = config()->array('permission'); @@ -68,6 +33,9 @@ public function testMigrationUsesConventionalPivotKeysWhenTheyAreOmitted(): void 'model_morph_key' => 'model_id', ], 'permission.teams' => false, + // The migration clears the role cache, and a database cache store would follow the default + // connection to this database, which has no cache tables. + 'permission.cache.store' => 'array', 'permission.cache.keys' => [], ]); $migration = require dirname(__DIR__, 2) From 8dbd9fb461261e13ab79e540637d4aad8c4d1259 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:51:53 +0000 Subject: [PATCH 11/18] Cut Permission catalog hydration costs and isolate Redis test runs Assess the registrar's cache and catalog machinery against spatie/laravel-permission main at 6615eefac655 (upstream has no equivalent; its CacheTest was reconciled earlier). - Package relations built a Permission model and resolved a connection for every hydrated pivot, because newPivot() asks for the pivot connection per pivot. The connection is memoized per relation, so package relation loads cost the same as plain Eloquent. - Catalog hydration clones one prepared pivot instead of building one per role-permission link, and builds its indexes in one pass. - Catalog and via-role pivots no longer point back at the models that hold them. Those references made every request's catalog cyclic, so only the garbage collector could free it, about half the per-request cost. - The cached payload stores each permission's role keys and denied role keys instead of pivot rows (129 to 41 KB at 1,000 links). - Per-request catalog lookup after a cache hit drops from about 18 to 3 ms at 1,000 links and from 186 to 26 ms at 10,000. - Remove the unused forgetModel*Cache* invalidators. They invalidated immediately, so a concurrent request could refill from pre-commit rows inside a transaction; forgetCachedPermissions() is the documented reset. - The Permission TestCase isolates a Redis permission cache per ParaTest worker before the migrations clear it, and redis.yml runs tests/Permission with the Redis store. - CacheTest covers denied role keys in the payload, a shared-store hit from a new coroutine, freeing catalog models without the cycle collector, and the database store's forget() result. The Postgres create-race test covers Role::create() too. Validation: Permission suite under ParaTest on the array, file and Redis stores; database store with only the known partition query-count failures; Postgres integration tests; composer lint and composer analyse. --- .github/workflows/redis.yml | 1 + src/permission/src/PermissionRegistrar.php | 367 +++++------------- .../Traits/EnforcesPermissionPartition.php | 5 +- src/permission/src/Traits/HasPermissions.php | 27 +- .../PermissionCreateTransactionTest.php | 51 ++- tests/Permission/CacheTest.php | 95 +++-- tests/Permission/TestCase.php | 45 +++ 7 files changed, 254 insertions(+), 337 deletions(-) diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index b79fcc3df2..4734e58a8f 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -68,6 +68,7 @@ jobs: vendor/bin/paratest --max-processes=15 tests/Integration/Horizon vendor/bin/paratest --max-processes=15 tests/Integration/Http/Redis vendor/bin/paratest --max-processes=15 tests/Integration/OpenTelemetry/Redis + CACHE_STORE=redis vendor/bin/paratest --max-processes=15 tests/Permission QUEUE_CONNECTION=redis vendor/bin/paratest --max-processes=15 tests/Integration/Queue vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Redis diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php index ad2cc2f926..a0e2f970e8 100644 --- a/src/permission/src/PermissionRegistrar.php +++ b/src/permission/src/PermissionRegistrar.php @@ -784,120 +784,6 @@ function (PermissionCacheSettlement $settlement) use ($cacheKey): void { } } - /** - * Forget a model's direct role and permission assignment caches. - */ - public function forgetModelAssignmentCache(Model $model): void - { - $this->forgetModelAssignmentCacheFor( - $model, - $this->resolvePartition(), - $this->teams ? $this->getPermissionsTeamId() : null, - ); - } - - /** - * Forget a model's assignment caches for an explicit partition and team. - */ - public function forgetModelAssignmentCacheFor( - Model $model, - ?PermissionPartition $partition, - int|string|null $team, - ): void { - $this->forgetModelAssignmentCacheForIdentity( - $model->getMorphClass(), - (string) $model->getKey(), - $partition, - $team, - ); - } - - /** - * Forget assignment caches for an explicit morph identity, partition, and team. - */ - public function forgetModelAssignmentCacheForIdentity( - string $morphType, - int|string $modelKey, - ?PermissionPartition $partition, - int|string|null $team, - ): void { - $cache = $this->cacheRepository(); - - $this->modelCacheCoordinator->invalidate( - $cache, - $this->modelCacheKeyForIdentity( - $this->modelRolesCacheKeyPrefix, - $morphType, - $modelKey, - $partition, - $team, - ), - ); - $this->modelCacheCoordinator->invalidate( - $cache, - $this->modelCacheKeyForIdentity( - $this->modelPermissionsCacheKeyPrefix, - $morphType, - $modelKey, - $partition, - $team, - ), - ); - - $runtimeKey = $this->modelRuntimeCacheKeyForIdentity( - $morphType, - $modelKey, - $partition, - $team, - ); - - $this->forgetRuntimeCacheItem(self::MODEL_VIA_ROLE_PERMISSIONS_CONTEXT_KEY, $runtimeKey); - $this->forgetRuntimeCacheItem(self::MODEL_DIRECT_PERMISSIONS_CONTEXT_KEY, $runtimeKey); - $this->forgetRuntimeCacheItem(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY, $runtimeKey); - } - - /** - * Forget a model's cached role assignments. - */ - public function forgetModelRoleCache(Model $model): void - { - $this->forgetModelRoleCacheFor( - $model, - $this->resolvePartition(), - $this->teams ? $this->getPermissionsTeamId() : null, - ); - } - - /** - * Forget a model's role assignment cache for an explicit partition and team. - */ - public function forgetModelRoleCacheFor( - Model $model, - ?PermissionPartition $partition, - int|string|null $team, - ): void { - $this->modelCacheCoordinator->invalidate( - $this->cacheRepository(), - $this->modelCacheKeyForIdentity( - $this->modelRolesCacheKeyPrefix, - $model->getMorphClass(), - (string) $model->getKey(), - $partition, - $team, - ), - ); - - $runtimeKey = $this->modelRuntimeCacheKeyForIdentity( - $model->getMorphClass(), - (string) $model->getKey(), - $partition, - $team, - ); - - $this->forgetRuntimeCacheItem(self::MODEL_VIA_ROLE_PERMISSIONS_CONTEXT_KEY, $runtimeKey); - $this->forgetRuntimeCacheItem(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY, $runtimeKey); - } - /** * Invalidate a model's role cache after a mutation settles. */ @@ -945,48 +831,6 @@ function () use ($runtimeKey): void { ); } - /** - * Forget a model's cached permission assignments. - */ - public function forgetModelPermissionCache(Model $model): void - { - $this->forgetModelPermissionCacheFor( - $model, - $this->resolvePartition(), - $this->teams ? $this->getPermissionsTeamId() : null, - ); - } - - /** - * Forget a model's permission assignment cache for an explicit partition and team. - */ - public function forgetModelPermissionCacheFor( - Model $model, - ?PermissionPartition $partition, - int|string|null $team, - ): void { - $this->modelCacheCoordinator->invalidate( - $this->cacheRepository(), - $this->modelCacheKeyForIdentity( - $this->modelPermissionsCacheKeyPrefix, - $model->getMorphClass(), - (string) $model->getKey(), - $partition, - $team, - ), - ); - - $runtimeKey = $this->modelRuntimeCacheKeyForIdentity( - $model->getMorphClass(), - (string) $model->getKey(), - $partition, - $team, - ); - - $this->forgetRuntimeCacheItem(self::MODEL_DIRECT_PERMISSIONS_CONTEXT_KEY, $runtimeKey); - $this->forgetRuntimeCacheItem(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY, $runtimeKey); - } - /** * Invalidate a model's permission cache after a mutation settles. */ @@ -1054,17 +898,6 @@ public function rememberModelViaRolePermissions(Model $model, Closure $callback) return $items[$key]; } - /** - * Forget a model's permissions granted through roles. - */ - public function forgetModelViaRolePermissions(Model $model): void - { - $this->forgetRuntimeCacheItem( - self::MODEL_VIA_ROLE_PERMISSIONS_CONTEXT_KEY, - $this->modelRuntimeCacheKey($model), - ); - } - /** * Remember a model's hydrated direct permissions. * @@ -1505,17 +1338,19 @@ private function permissionCatalog(): array ); $roles = $this->getHydratedRoleCollection($payload['roles']); - $permissions = $this->getHydratedPermissionCollection($payload['permissions'], $roles); + $roleIndexes = $this->indexModels($roles); + $permissions = $this->getHydratedPermissionCollection($payload['permissions'], $roleIndexes['byKey']); + $permissionIndexes = $this->indexModels($permissions); $catalog = [ 'roles' => $roles, 'permissions' => $permissions, - 'permissionByKey' => $this->indexModelsByKey($permissions), - 'permissionByNameAndGuard' => $this->indexModelsByNameAndGuard($permissions), - 'permissionOrderByKey' => $this->indexModelOrderByKey($permissions), - 'roleByKey' => $this->indexModelsByKey($roles), - 'roleByNameAndGuard' => $this->indexModelsByNameAndGuard($roles), - 'roleOrderByKey' => $this->indexModelOrderByKey($roles), + 'permissionByKey' => $permissionIndexes['byKey'], + 'permissionByNameAndGuard' => $permissionIndexes['byNameAndGuard'], + 'permissionOrderByKey' => $permissionIndexes['orderByKey'], + 'roleByKey' => $roleIndexes['byKey'], + 'roleByNameAndGuard' => $roleIndexes['byNameAndGuard'], + 'roleOrderByKey' => $roleIndexes['orderByKey'], 'hasDeniedRolePermissions' => (bool) $payload['hasDeniedRolePermissions'], ]; @@ -2039,35 +1874,28 @@ private function getSerializedPermissionsForCache(): array self::DEFAULT_CACHE_COLUMN_NAMES_EXCEPT, ); $hasDeniedRolePermissions = false; - $partition = $this->resolvePartition(); return [ 'permissions' => $this->getPermissionsWithRoles() - ->map(function (Model $permission) use ($except, &$hasDeniedRolePermissions, $partition): array { - $roles = $this->relationCollection($permission, 'roles') - ->map(function (Model $role) use ($permission, &$hasDeniedRolePermissions, $partition): array { - $isDenied = $this->pivotIsDenied($role); - $hasDeniedRolePermissions = $hasDeniedRolePermissions || $isDenied; - $pivot = [ - $this->pivotPermission => $permission->getKey(), - $this->pivotRole => $role->getKey(), - 'is_denied' => $isDenied, - ]; - - if ($partition) { - $pivot[$partition->column] = $partition->value; - } - - return [ - 'pivot' => $pivot, - ]; - }) - ->values() - ->all(); + ->map(function (Model $permission) use ($except, &$hasDeniedRolePermissions): array { + $roleKeys = []; + $deniedRoleKeys = []; + foreach ($this->relationCollection($permission, 'roles') as $role) { + $roleKeys[] = $role->getKey(); + + if ($this->pivotIsDenied($role)) { + $deniedRoleKeys[] = $role->getKey(); + } + } + + $hasDeniedRolePermissions = $hasDeniedRolePermissions || $deniedRoleKeys !== []; + + // Role keys instead of pivot rows keep the payload small; hydration rebuilds the pivots. return [ 'attributes' => Arr::except($permission->getAttributes(), $except), - 'roles' => $roles, + 'roles' => $roleKeys, + 'denied_roles' => $deniedRoleKeys, ]; }) ->values() @@ -2118,21 +1946,41 @@ protected function relationCollection(Model $model, string $relation): Collectio * Get the hydrated permission collection. * * @param array> $permissions + * @param array $rolesByKey */ - private function getHydratedPermissionCollection(array $permissions, Collection $roles): Collection + private function getHydratedPermissionCollection(array $permissions, array $rolesByKey): Collection { $permissionInstance = $this->newCatalogModelPrototype($this->getPermissionClass()); - $rolesByKey = $roles->keyBy(fn (Model $role): string => (string) $role->getKey()); $context = new PermissionRelationContext($this->resolvePartition(), false, null); + // Each role-permission pivot is cloned from this one, which costs far less than building a pivot model per pivot. + $pivotInstance = Pivot::fromRawAttributes( + $permissionInstance, + [], + $this->config->string('permission.table_names.role_has_permissions'), + true, + )->setPivotKeys($this->pivotPermission, $this->pivotRole); + + if ($context->partition) { + $pivotInstance->setPivotConstraints([ + ['where', [ + $context->partition->column, + '=', + $context->partition->value, + ]], + ]); + } + return Collection::make(array_map( - function (array $item) use ($permissionInstance, $rolesByKey, $context): Model { + function (array $item) use ($permissionInstance, $rolesByKey, $context, $pivotInstance): Model { $permission = (clone $permissionInstance)->setRawAttributes((array) $item['attributes'], true); $roles = $this->getHydratedPermissionRoleCollection( - (array) $item['roles'], $permission, + (array) $item['roles'], + (array) $item['denied_roles'], $rolesByKey, - $context, + $pivotInstance, + $context->partition, ); $permission->setRelation('roles', $roles); @@ -2184,31 +2032,29 @@ private function newCatalogModelPrototype(string $class): Model } /** - * Index models by primary key. + * Index models by primary key, by name and guard, and by catalog position. * - * @return array + * @return array{byKey: array, byNameAndGuard: array>, orderByKey: array} */ - private function indexModelsByKey(Collection $models): array + private function indexModels(Collection $models): array { - return $models - ->mapWithKeys(fn (Model $model): array => [(string) $model->getKey() => $model]) - ->all(); - } - - /** - * Index models by name and guard. - * - * @return array> - */ - private function indexModelsByNameAndGuard(Collection $models): array - { - $indexed = []; + $byKey = []; + $byNameAndGuard = []; + $orderByKey = []; + $position = 0; foreach ($models as $model) { - $indexed[$this->nameGuardIndexKey($model->getAttribute('name'), $model->getAttribute('guard_name'))][] = $model; + $key = (string) $model->getKey(); + $byKey[$key] = $model; + $byNameAndGuard[$this->nameGuardIndexKey($model->getAttribute('name'), $model->getAttribute('guard_name'))][] = $model; + $orderByKey[$key] = $position++; } - return $indexed; + return [ + 'byKey' => $byKey, + 'byNameAndGuard' => $byNameAndGuard, + 'orderByKey' => $orderByKey, + ]; } /** @@ -2220,65 +2066,54 @@ private function nameGuardIndexKey(mixed $name, mixed $guardName): string return (string) enum_value($guardName) . "\0" . (string) enum_value($name); } - /** - * Index model catalog order by primary key. - * - * @return array - */ - private function indexModelOrderByKey(Collection $models): array - { - $order = []; - - foreach ($models->values() as $index => $model) { - $order[(string) $model->getKey()] = $index; - } - - return $order; - } - /** * Get the hydrated role collection for a cached permission. * - * @param array> $roles + * @param array $roleKeys + * @param array $deniedRoleKeys + * @param array $roleCatalog */ private function getHydratedPermissionRoleCollection( - array $roles, Model $permission, - Collection $roleCatalog, - PermissionRelationContext $context, + array $roleKeys, + array $deniedRoleKeys, + array $roleCatalog, + Pivot $pivotInstance, + ?PermissionPartition $partition, ): Collection { - return Collection::make(array_values(array_filter(array_map(function (array $item) use ($permission, $roleCatalog, $context): ?Model { - $roleKey = $item['pivot'][$this->pivotRole] ?? null; - $role = $roleKey === null ? null : $roleCatalog->get((string) $roleKey); + $denied = $deniedRoleKeys === [] ? [] : array_flip(array_map(strval(...), $deniedRoleKeys)); + $basePivotAttributes = [ + $this->pivotPermission => $permission->getKey(), + $this->pivotRole => null, + 'is_denied' => false, + ]; + + if ($partition) { + $basePivotAttributes[$partition->column] = $partition->value; + } + + $roles = []; + + foreach ($roleKeys as $roleKey) { + $role = $roleCatalog[(string) $roleKey] ?? null; if (! $role) { - return null; + continue; } - $role = clone $role; - $pivot = Pivot::fromRawAttributes( - $permission, - (array) $item['pivot'], - $this->config->string('permission.table_names.role_has_permissions'), - true, - ); - $pivot->setPivotKeys($this->pivotPermission, $this->pivotRole) - ->setRelatedModel($role); - - if ($context->partition) { - $pivot->setPivotConstraints([ - ['where', [ - $context->partition->column, - '=', - $context->partition->value, - ]], - ]); - } + $pivotAttributes = $basePivotAttributes; + $pivotAttributes[$this->pivotRole] = $roleKey; + $pivotAttributes['is_denied'] = isset($denied[(string) $roleKey]); - $role->setRelation('pivot', $pivot); + $role = clone $role; + // The pivot keeps the prototype's parent, which only supplies timestamp column names, and no related + // model, which nothing reads. Pointing them at this permission and role would make each request's + // catalog cyclic, so only the garbage collector could free it. + $role->setRelation('pivot', (clone $pivotInstance)->setRawAttributes($pivotAttributes, true)); + $roles[] = $role; + } - return $role; - }, $roles)))); + return new Collection($roles); } /** diff --git a/src/permission/src/Traits/EnforcesPermissionPartition.php b/src/permission/src/Traits/EnforcesPermissionPartition.php index 0fd6c7652d..15f54c853b 100644 --- a/src/permission/src/Traits/EnforcesPermissionPartition.php +++ b/src/permission/src/Traits/EnforcesPermissionPartition.php @@ -18,6 +18,8 @@ trait EnforcesPermissionPartition protected PermissionRelationContext $permissionRelationContext; + protected ?ConnectionInterface $permissionPivotConnection = null; + /** * Initialize the permission partition relation state. */ @@ -42,7 +44,8 @@ public function getPermissionRelationContext(): PermissionRelationContext */ protected function getPivotConnection(): ConnectionInterface { - return $this->permissionPartitionRegistrar->getPermissionConnection(); + // newPivot() asks for this once per hydrated pivot, and resolving it constructs a permission model. + return $this->permissionPivotConnection ??= $this->permissionPartitionRegistrar->getPermissionConnection(); } /** diff --git a/src/permission/src/Traits/HasPermissions.php b/src/permission/src/Traits/HasPermissions.php index 13df552d6f..8b19ababb5 100644 --- a/src/permission/src/Traits/HasPermissions.php +++ b/src/permission/src/Traits/HasPermissions.php @@ -1896,7 +1896,6 @@ protected function loadPermissionsViaRolesWithPivots(): Collection $roleIds = array_flip($roles->map(fn (Model $role): string => (string) $role->getKey())->all()); $registrar = $this->permissionRegistrar(); - $partition = $registrar->resolvePartition(); return $registrar ->getPermissions([], false, $this->getPermissionClass()) @@ -1907,7 +1906,6 @@ protected function loadPermissionsViaRolesWithPivots(): Collection $permission, $role, $registrar, - $partition, )) ); } @@ -1977,25 +1975,16 @@ protected function permissionWithRolePivot( Model $permission, Model $role, PermissionRegistrar $registrar, - ?PermissionPartition $partition, ): Model { $permission = clone $permission; - /** @var Pivot $cachedPivot */ - $cachedPivot = $role->getRelation('pivot'); - $pivot = Pivot::fromRawAttributes( - $role, - $cachedPivot->getAttributes(), - Config::roleHasPermissionsTable(), - true, - ); - $pivot->setPivotKeys($registrar->pivotRole, $registrar->pivotPermission) - ->setRelatedModel($permission); - - if ($partition) { - $pivot->setPivotConstraints([ - ['where', [$partition->column, '=', $partition->value]], - ]); - } + /** @var Pivot $pivot */ + $pivot = clone $role->getRelation('pivot'); + + // The cached pivot already has this assignment's attributes, table and partition constraint. Its related + // model is not pointed at this permission, so the two don't reference each other and leave no cycle for the + // garbage collector. + $pivot->pivotParent = $role; + $pivot->setPivotKeys($registrar->pivotRole, $registrar->pivotPermission); $permission->setRelation('pivot', $pivot); diff --git a/tests/Integration/Permission/Database/Postgres/PermissionCreateTransactionTest.php b/tests/Integration/Permission/Database/Postgres/PermissionCreateTransactionTest.php index bc605aa93d..5d2cb7ab48 100644 --- a/tests/Integration/Permission/Database/Postgres/PermissionCreateTransactionTest.php +++ b/tests/Integration/Permission/Database/Postgres/PermissionCreateTransactionTest.php @@ -5,12 +5,15 @@ namespace Hypervel\Tests\Integration\Permission\Database\Postgres; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Database\Eloquent\Model; use Hypervel\Permission\Contracts\Permission as PermissionContract; +use Hypervel\Permission\Contracts\Role as RoleContract; use Hypervel\Permission\Exceptions\PermissionAlreadyExists; -use Hypervel\Permission\Support\Config; +use Hypervel\Permission\Exceptions\RoleAlreadyExists; use Hypervel\Support\Facades\DB; use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Tests\Permission\TestCase as PermissionTestCase; +use PHPUnit\Framework\Attributes\DataProvider; #[RequiresDatabase('pgsql')] class PermissionCreateTransactionTest extends PermissionTestCase @@ -24,37 +27,53 @@ protected function defineEnvironment(ApplicationContract $app): void $app->make('config')->set('database.default', getenv('DB_CONNECTION') ?: 'testing'); } - public function testCreateRaceExceptionDoesNotPoisonPostgresTransaction(): void + /** + * @param class-string $contract + * @param class-string $exception + */ + #[DataProvider('createdModels')] + public function testCreateRaceExceptionDoesNotPoisonPostgresTransaction(string $contract, string $exception): void { - $permissionClass = $this->app->make(PermissionContract::class); + $model = $this->app->make($contract); - $permissionClass::creating(static function ($permission) use ($permissionClass): void { - if ($permission->getAttribute('name') !== 'postgres-raced-permission') { + $model::creating(static function (Model $created) use ($model): void { + if ($created->getAttribute('name') !== 'postgres-raced') { return; } - $permissionClass::query()->insert([ - 'name' => 'postgres-raced-permission', + $model::query()->insert([ + 'name' => 'postgres-raced', 'guard_name' => 'web', ]); }); - DB::transaction(function () use ($permissionClass): void { + $caught = null; + + DB::transaction(function () use ($model, &$caught): void { try { - $permissionClass::create(['name' => 'postgres-raced-permission']); - $this->fail('Expected duplicate permission exception was not thrown.'); - } catch (PermissionAlreadyExists) { - $this->assertTrue(true); + $model::create(['name' => 'postgres-raced']); + } catch (PermissionAlreadyExists|RoleAlreadyExists $alreadyExists) { + $caught = $alreadyExists; } - $permission = $permissionClass::create(['name' => 'postgres-transaction-still-usable']); - - $this->assertSame('postgres-transaction-still-usable', $permission->name); + $model::create(['name' => 'postgres-transaction-still-usable']); }); - $this->assertDatabaseHas(Config::permissionsTable(), [ + $this->assertInstanceOf($exception, $caught); + $this->assertDatabaseHas($model->getTable(), [ 'name' => 'postgres-transaction-still-usable', 'guard_name' => 'web', ]); } + + /** + * Get the models whose create race is checked. + */ + public static function createdModels(): array + { + return [ + 'permission' => [PermissionContract::class, PermissionAlreadyExists::class], + 'role' => [RoleContract::class, RoleAlreadyExists::class], + ]; + } } diff --git a/tests/Permission/CacheTest.php b/tests/Permission/CacheTest.php index 8733b6deed..8b9bf74a0b 100644 --- a/tests/Permission/CacheTest.php +++ b/tests/Permission/CacheTest.php @@ -10,16 +10,18 @@ use Hypervel\Permission\Contracts\Role as RoleContract; use Hypervel\Permission\PermissionRegistrar; use Hypervel\Permission\Support\Config; -use Hypervel\Support\ClassInvoker; use Hypervel\Tests\Permission\Fixtures\Models\User; +use WeakReference; use function Hypervel\Coroutine\parallel; class CacheTest extends TestCase { - public function testGlobalPermissionCacheStoresRolePivotsWithoutDuplicatingRoleAttributes(): void + public function testGlobalPermissionCacheStoresRoleKeysWithoutDuplicatingRoleAttributes(): void { + $deniedRole = $this->app->make(RoleContract::class)::findByName('testRole2'); $this->testUserRole->givePermissionTo('edit-articles'); + $deniedRole->denyPermissionTo('edit-articles'); $registrar = $this->app->make(PermissionRegistrar::class); $this->testUser->hasPermissionTo('edit-articles'); @@ -37,19 +39,69 @@ public function testGlobalPermissionCacheStoresRolePivotsWithoutDuplicatingRoleA ); $this->assertIsArray($permission); - $this->assertArrayNotHasKey('attributes', $permission['roles'][0]); - $this->assertSame($this->testUserRole->getKey(), $permission['roles'][0]['pivot'][$registrar->pivotRole]); - $this->assertFalse($permission['roles'][0]['pivot']['is_denied']); + $this->assertEqualsCanonicalizing( + [$this->testUserRole->getKey(), $deniedRole->getKey()], + $permission['roles'], + ); + $this->assertSame([$deniedRole->getKey()], $permission['denied_roles']); + } + + public function testCatalogAndViaRoleModelsAreFreedWithoutTheCycleCollector(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + $this->testUser->assignRole('testRole'); + $registrar = $this->app->make(PermissionRegistrar::class); + $gcWasEnabled = gc_enabled(); + + // With the cycle collector off, only objects without reference cycles are freed. + gc_disable(); + + try { + $catalogRole = $this->app->make(PermissionContract::class)::findByName('edit-articles')->roles->sole(); + $user = User::findOrFail($this->testUser->getKey()); + $viaRolePermission = $user->getPermissionsViaRoles()->sole(); + + $references = [ + WeakReference::create($catalogRole), + WeakReference::create($catalogRole->getRelation('pivot')), + WeakReference::create($viaRolePermission), + WeakReference::create($viaRolePermission->getRelation('pivot')), + ]; + + unset($catalogRole, $user, $viaRolePermission); + $registrar->clearPermissionsCollection(); + + foreach ($references as $reference) { + $this->assertNull($reference->get()); + } + } finally { + if ($gcWasEnabled) { + gc_enable(); + } + } } public function testRoleDeniedPivotHydratesFromGlobalCache(): void { $this->testUser->assignRole('testRole'); $this->testUserRole->denyPermissionTo('edit-articles'); + $registrar = $this->app->make(PermissionRegistrar::class); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); - $this->app->make(PermissionRegistrar::class)->clearPermissionsCollection(); + // A new coroutine reads the shared store instead of this coroutine's catalog and memo. + [$pivot] = parallel([ + fn (): Model => $this->app->make(PermissionContract::class)::findByName('edit-articles') + ->roles + ->sole() + ->getRelation('pivot'), + ]); + + $this->assertSame($this->testUserPermission->getKey(), $pivot->getAttribute($registrar->pivotPermission)); + $this->assertSame($this->testUserRole->getKey(), $pivot->getAttribute($registrar->pivotRole)); + $this->assertTrue($pivot->getAttribute('is_denied')); + + $registrar->clearPermissionsCollection(); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); $this->assertTrue($this->testUser->hasDeniedPermissionViaRoles('edit-articles')); @@ -79,7 +131,8 @@ public function testPermissionCacheResetChangesModelAssignmentCacheToken(): void $firstToken = $registrar->modelAssignmentCacheToken(); $this->assertTrue($registrar->forgetCachedPermissions()); - $this->assertFalse($registrar->forgetCachedPermissions()); + // The database store's forget() reports success even when the key is already gone. + $this->assertSame($this->usesDatabaseCacheStore(), $registrar->forgetCachedPermissions()); $this->assertMatchesRegularExpression('/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/', $firstToken); $this->assertNotSame($firstToken, $registrar->modelAssignmentCacheToken()); @@ -188,34 +241,6 @@ public function testDirectPermissionHydrationIsMemoizedPerCoroutineAndClearedByM $this->assertNotSame($coroutineOne, $coroutineTwo); } - public function testExplicitInvalidationClearsKeylessAssignmentCacheEntries(): void - { - Model::preventAccessingMissingAttributes(false); - - $keylessUser = User::query() - ->select('email') - ->where('email', $this->testUser->email) - ->firstOrFail(); - $registrar = $this->app->make(PermissionRegistrar::class); - $store = new ClassInvoker($registrar->getCacheRepository()->getStore()); - - $this->assertFalse($keylessUser->hasRole('testRole')); - $this->assertFalse($keylessUser->hasDirectPermission('edit-articles')); - - $beforeRoleInvalidation = $store->getCacheItems(); - $registrar->forgetModelRoleCacheFor($keylessUser, null, null); - $afterRoleInvalidation = $store->getCacheItems(); - - $this->assertCount(1, array_diff_key($beforeRoleInvalidation, $afterRoleInvalidation)); - $this->assertSame([], array_diff_key($afterRoleInvalidation, $beforeRoleInvalidation)); - - $registrar->forgetModelPermissionCacheFor($keylessUser, null, null); - $afterPermissionInvalidation = $store->getCacheItems(); - - $this->assertCount(1, array_diff_key($afterRoleInvalidation, $afterPermissionInvalidation)); - $this->assertSame([], array_diff_key($afterPermissionInvalidation, $afterRoleInvalidation)); - } - public function testSyncPermissionEffectsInvalidatesWarmModelPermissionCache(): void { $this->testUser->givePermissionTo('edit-articles'); diff --git a/tests/Permission/TestCase.php b/tests/Permission/TestCase.php index 1402a05305..cefd7624c6 100644 --- a/tests/Permission/TestCase.php +++ b/tests/Permission/TestCase.php @@ -7,9 +7,11 @@ use Hypervel\Auth\EloquentUserProvider; use Hypervel\Cache\CacheManager; use Hypervel\Cache\DatabaseStore; +use Hypervel\Cache\RedisStore; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Http\Request; use Hypervel\Http\Response; @@ -37,6 +39,12 @@ abstract class TestCase extends TestbenchTestCase { + use InteractsWithRedis { + // Hypervel runs these hooks for every test after the database traits, so the aliases let + // setUpDatabaseTraits() isolate a Redis permission cache before the migrations clear it. + setUpInteractsWithRedis as setUpRedis; + tearDownInteractsWithRedis as tearDownRedis; + } use RefreshDatabase; protected bool $migrateRefresh = true; @@ -133,6 +141,35 @@ protected function defineEnvironment(ApplicationContract $app): void ]); } + /** + * Isolate a Redis permission cache before the migrations clear the cache through it. + */ + protected function setUpDatabaseTraits(array $uses): void + { + if ($this->usesRedisCacheStore()) { + $this->setUpRedis(); + $this->beforeApplicationDestroyed(function (): void { + $this->tearDownRedis(); + }); + } + + parent::setUpDatabaseTraits($uses); + } + + /** + * Leave Redis setup to setUpDatabaseTraits(). + */ + protected function setUpInteractsWithRedis(): void + { + } + + /** + * Leave Redis teardown to the callback setUpDatabaseTraits() registers. + */ + protected function tearDownInteractsWithRedis(): void + { + } + /** * Get the migrations to run for the test. */ @@ -328,6 +365,14 @@ protected function usesDatabaseCacheStore(): bool return $this->app->make(PermissionRegistrar::class)->getCacheStore() instanceof DatabaseStore; } + /** + * Determine whether the permission cache uses a Redis store. + */ + protected function usesRedisCacheStore(): bool + { + return $this->app->make(PermissionRegistrar::class)->getCacheStore() instanceof RedisStore; + } + /** * Create the database cache table. */ From 7bb786a912595ab4d9eb7036b8fb71e6fed5cd58 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:40:11 +0000 Subject: [PATCH 12/18] Simplify Permission assignment internals and fix role checks Assess the HasPermissions and HasRoles internals against spatie/laravel-permission main at 6615eefac655 (upstream has no equivalent for the queued assignments, provenance or deny machinery; its event cases were reconciled earlier). - Cached direct-permission pivots took the subject's connection, so saving or deleting one wrote through the subject database while live relation pivots use permission storage. They now take the permission's connection name, and they no longer point back at their permission, which made the memoized collection cyclic garbage. - hasRole() looked up integer- and UUID-valued enums as role keys, so a user holding the role with key 7 passed a check for an enum valued 7. Enums compare with role names, as upstream's enum branch does, using a strict string comparison. hasAllRoles() no longer converts a single enum before passing it to hasRole(), which sent a UUID value to the key lookup. - The permission queue is keyed by context and permission like the role queue, so a later queued effect replaces an earlier one; the collapse pass, five helpers and the flush-time team recheck go. - syncPermissions() calls syncPermissionEffects(); assignment cache invalidation and the assignment-context builders each have one helper instead of several copies. - hasDirectPermission() and Role::hasPermissionTo() look up the single assignment for a permission (the pivot primary keys allow one row per permission and subject), and Role follows upstream's check order. - Remove requireDeletionModelKey() (Eloquent rejects a keyless delete before any query), value checks the native types already enforce, and instanceof checks that only narrowed types; two of them silently skipped partition checks. - Type assignment, scope and role inputs with their documented unions; the checks upstream tests call with null, objects or arrays keep mixed. - Tests: regressions for the pivot connection, enum role names and direct-permission lifetime; remove cases for impossible duplicate effects, Eloquent's key check and upstream-covered events; assert single dispatches and complete sync results. Validation: Permission suite under ParaTest on the array, file and Redis stores; database store with only the known partition query-count failures; Postgres integration tests; php-cs-fixer and composer analyse. --- src/permission/src/Models/Role.php | 11 +- src/permission/src/PermissionRegistrar.php | 30 +- src/permission/src/Traits/HasPermissions.php | 637 +++++------------- src/permission/src/Traits/HasRoles.php | 267 ++------ tests/Permission/CacheTest.php | 8 +- tests/Permission/CustomSchemaConfigTest.php | 2 +- tests/Permission/DeletionTest.php | 97 --- tests/Permission/DeniedPermissionTest.php | 99 +-- tests/Permission/Events/EventTest.php | 102 +-- tests/Permission/PartitionModelTest.php | 23 - .../PermissionCacheTransactionTest.php | 17 + tests/Permission/Traits/HasRolesTest.php | 32 + .../Traits/TeamHasPermissionsTest.php | 2 +- 13 files changed, 330 insertions(+), 997 deletions(-) diff --git a/src/permission/src/Models/Role.php b/src/permission/src/Models/Role.php index 0c60c42503..6602321781 100644 --- a/src/permission/src/Models/Role.php +++ b/src/permission/src/Models/Role.php @@ -296,10 +296,6 @@ public function hasPermissionTo(UnitEnum|int|string|PermissionContract $permissi $permission = $this->filterPermission($permission, $guardName); - if ($this->hasDeniedPermission($permission, $guardName)) { - return false; - } - if (! $this->getGuardNames()->contains($permission->guard_name)) { throw GuardDoesNotMatch::create( $permission->guard_name, @@ -307,10 +303,9 @@ public function hasPermissionTo(UnitEnum|int|string|PermissionContract $permissi ); } - $matches = $this->relationCollection($this, 'permissions') - ->filter(fn (Model $rolePermission): bool => $rolePermission->getKey() === $permission->getKey()); + $rolePermission = $this->relationCollection($this, 'permissions') + ->first(fn (Model $rolePermission): bool => $rolePermission->getKey() === $permission->getKey()); - return $matches->isNotEmpty() - && ! $matches->contains(fn (Model $rolePermission): bool => $this->pivotIsDenied($rolePermission)); + return $rolePermission !== null && ! $this->pivotIsDenied($rolePermission); } } diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php index a0e2f970e8..dd875925c0 100644 --- a/src/permission/src/PermissionRegistrar.php +++ b/src/permission/src/PermissionRegistrar.php @@ -1881,10 +1881,10 @@ private function getSerializedPermissionsForCache(): array $roleKeys = []; $deniedRoleKeys = []; - foreach ($this->relationCollection($permission, 'roles') as $role) { + foreach ($permission->getRelation('roles') as $role) { $roleKeys[] = $role->getKey(); - if ($this->pivotIsDenied($role)) { + if ($role->getRelation('pivot')->is_denied) { $deniedRoleKeys[] = $role->getKey(); } } @@ -1911,37 +1911,13 @@ private function getSerializedPermissionsForCache(): array } /** - * Determine if any cached role-permission edge is denied. + * Determine if any role in the catalog is denied a permission. */ public function hasDeniedRolePermissions(): bool { return (bool) $this->permissionCatalog()['hasDeniedRolePermissions']; } - /** - * Determine if a hydrated pivot marks the permission as denied. - */ - protected function pivotIsDenied(Model $model): bool - { - if (! $model->relationLoaded('pivot')) { - return false; - } - - $pivot = $model->getRelation('pivot'); - - return $pivot instanceof Pivot && (bool) $pivot->getAttribute('is_denied'); - } - - /** - * Get a hydrated relation collection. - */ - protected function relationCollection(Model $model, string $relation): Collection - { - $value = $model->getRelation($relation); - - return $value instanceof Collection ? $value : new Collection; - } - /** * Get the hydrated permission collection. * diff --git a/src/permission/src/Traits/HasPermissions.php b/src/permission/src/Traits/HasPermissions.php index 8b19ababb5..6fc55e44d7 100644 --- a/src/permission/src/Traits/HasPermissions.php +++ b/src/permission/src/Traits/HasPermissions.php @@ -31,7 +31,6 @@ use Hypervel\Permission\Support\PermissionRelationContext; use Hypervel\Support\Arr; use Hypervel\Support\Collection; -use UnexpectedValueException; use UnitEnum; use function Hypervel\Support\enum_value; @@ -45,7 +44,7 @@ trait HasPermissions private ?string $wildcardClass = null; /** - * @var array, pivot: array, context: PermissionRelationContext, pivotClass: class-string}> + * @var array, permissions: array}> */ private array $queuedPermissionAssignments = []; @@ -75,9 +74,6 @@ public static function bootHasPermissions(): void return; } - // Validate only so a later deleting listener can still veto before assignments are touched. - static::requireDeletionModelKey($model); - if ($model instanceof Permission || $model instanceof Role) { static::permissionRecordDeletionPartition( $model, @@ -96,7 +92,7 @@ public static function bootHasPermissions(): void } $registrar = Container::getInstance()->make(PermissionRegistrar::class); - $modelKey = static::requireDeletionModelKey($model); + $modelKey = $model->getKey(); if ($model instanceof Role) { $partition = static::permissionRecordDeletionPartition($model, $registrar); @@ -189,20 +185,6 @@ protected static function shouldDeletePermissionAssignments(Model $model): bool return ! method_exists($model, 'isForceDeleting') || $model->isForceDeleting(); } - /** - * Get the model key required for permission assignment cleanup. - */ - protected static function requireDeletionModelKey(Model $model): mixed - { - $modelKey = $model->getKey(); - - if ($modelKey === null) { - throw new MissingAttributeException($model, $model->getKeyName()); - } - - return $modelKey; - } - /** * Delete one kind of assignment for a hard-deleted subject. * @@ -246,36 +228,10 @@ protected static function deleteSubjectAssignments( $contexts = []; foreach ($scopes as $scope) { - $partition = null; - - if ($partitionColumn !== null) { - $partitionValue = $scope->{$partitionColumn}; - - if (! is_int($partitionValue) && ! is_string($partitionValue)) { - throw new UnexpectedValueException(sprintf( - 'Permission assignment partition column "%s" contained %s; expected int or string.', - $partitionColumn, - get_debug_type($partitionValue), - )); - } - - $partition = new PermissionPartition($partitionColumn, $partitionValue); - } - - $team = $registrar->teams ? $scope->{$registrar->teamsKey} : null; - - if ($team !== null && ! is_int($team) && ! is_string($team)) { - throw new UnexpectedValueException(sprintf( - 'Permission assignment team column "%s" contained %s; expected int, string, or null.', - $registrar->teamsKey, - get_debug_type($team), - )); - } - $contexts[] = new PermissionRelationContext( - $partition, + $partitionColumn === null ? null : new PermissionPartition($partitionColumn, $scope->{$partitionColumn}), $registrar->teams, - $team, + $registrar->teams ? $scope->{$registrar->teamsKey} : null, ); } @@ -433,7 +389,7 @@ protected function getCachedDirectPermissions(): Collection { $model = $this; $registrar = $this->permissionRegistrar(); - $context = $this->permissionAssignmentContext($registrar); + $context = $this->assignmentContext($registrar); $this->forgetStalePermissionRelation($registrar, 'permissions'); @@ -461,65 +417,57 @@ protected function getCachedDirectPermissions(): Collection $this->getPermissionClass(), )->keyBy(fn (Model $permission): string => (string) $permission->getKey()); - return Collection::make($assignments) - ->map(function (array $assignment) use ($permissions, $model, $permissionKey, $registrar, $context): ?Model { - $permission = $permissions->get((string) $assignment[$permissionKey]); + // Each pivot is cloned from this one. None points back at its permission, so the memoized + // collection holds no reference cycles and is freed without the garbage collector. + $pivotInstance = MorphPivot::fromRawAttributes($model, [], Config::modelHasPermissionsTable(), true) + ->setPivotKeys(Config::morphKey(), $registrar->pivotPermission) + ->setMorphType(Config::MORPH_TYPE) + ->setMorphClass($model->getMorphClass()); + $pivotAttributes = [ + $registrar->pivotPermission => null, + Config::morphKey() => $model->getKey(), + Config::MORPH_TYPE => $model->getMorphClass(), + 'is_denied' => false, + ]; + $pivotConstraints = []; - if (! $permission instanceof Model) { - return null; - } + if ($registrar->teams) { + $pivotAttributes[$registrar->teamsKey] = $context->team; + } - $pivot = [ - $registrar->pivotPermission => $permission->getKey(), - Config::morphKey() => $model->getKey(), - Config::MORPH_TYPE => $model->getMorphClass(), - 'is_denied' => (bool) $assignment['is_denied'], - ]; + if ($context->partition) { + $pivotAttributes[$context->partition->column] = $context->partition->value; + $pivotConstraints[] = ['where', [$context->partition->column, '=', $context->partition->value]]; + } - if ($registrar->teams) { - $pivot[$registrar->teamsKey] = $context->team; - } + if ($context->teamScoped) { + $pivotConstraints[] = $context->team === null + ? ['whereNull', [$registrar->teamsKey]] + : ['where', [$registrar->teamsKey, '=', $context->team]]; + } - if ($context->partition) { - $pivot[$context->partition->column] = $context->partition->value; - } + if ($pivotConstraints !== []) { + $pivotInstance->setPivotConstraints($pivotConstraints); + } - $permission = clone $permission; - $morphPivot = MorphPivot::fromRawAttributes( - $model, - $pivot, - Config::modelHasPermissionsTable(), - true, - ); - $morphPivot - ->setPivotKeys(Config::morphKey(), $registrar->pivotPermission) - ->setRelatedModel($permission) - ->setMorphType(Config::MORPH_TYPE) - ->setMorphClass($model->getMorphClass()); - - $pivotConstraints = []; - - if ($context->partition) { - $pivotConstraints[] = ['where', [ - $context->partition->column, - '=', - $context->partition->value, - ]]; - } + return Collection::make($assignments) + ->map(function (array $assignment) use ($permissions, $permissionKey, $pivotAttributes, $pivotInstance, $registrar): ?Model { + $permission = $permissions->get((string) $assignment[$permissionKey]); - if ($context->teamScoped) { - if ($context->team === null) { - $pivotConstraints[] = ['whereNull', [$registrar->teamsKey]]; - } else { - $pivotConstraints[] = ['where', [$registrar->teamsKey, '=', $context->team]]; - } + // A soft-deleted permission keeps its assignment rows but leaves the catalog. + if ($permission === null) { + return null; } - if ($pivotConstraints !== []) { - $morphPivot->setPivotConstraints($pivotConstraints); - } + $pivotAttributes[$registrar->pivotPermission] = $permission->getKey(); + $pivotAttributes['is_denied'] = (bool) $assignment['is_denied']; - $permission->setRelation('pivot', $morphPivot); + $permission = clone $permission; + // Like live relation pivots, write through the permission storage connection, not the subject's. + $pivot = (clone $pivotInstance) + ->setConnection($permission->getConnectionName()) + ->setRawAttributes($pivotAttributes, true); + $permission->setRelation('pivot', $pivot); return $permission; }) @@ -560,11 +508,13 @@ protected function allowedDirectPermissions(): Collection * Scope the model query to certain permissions only. * * @param Builder $query - * @param array|Collection|int|Permission|string|UnitEnum $permissions * @return Builder */ - public function scopePermission(Builder $query, $permissions, bool $without = false): Builder - { + public function scopePermission( + Builder $query, + array|Collection|int|Permission|string|UnitEnum $permissions, + bool $without = false, + ): Builder { $permissions = $this->convertToPermissionModels($permissions); $permissionIds = array_map( fn ($permission) => $this->requireModelKey($permission), @@ -659,10 +609,9 @@ protected function whereRolePermissionEffect(Builder $query, int|string $permiss * whether indirectly by role or by direct permission. * * @param Builder $query - * @param array|Collection|int|Permission|string|UnitEnum $permissions * @return Builder */ - public function scopeWithoutPermission(Builder $query, $permissions): Builder + public function scopeWithoutPermission(Builder $query, array|Collection|int|Permission|string|UnitEnum $permissions): Builder { return $this->scopePermission($query, $permissions, true); } @@ -670,11 +619,9 @@ public function scopeWithoutPermission(Builder $query, $permissions): Builder /** * Convert the given permissions to permission models. * - * @param array|Collection|int|Permission|string|UnitEnum $permissions - * * @throws PermissionDoesNotExist */ - protected function convertToPermissionModels($permissions): array + protected function convertToPermissionModels(array|Collection|int|Permission|string|UnitEnum $permissions): array { if ($permissions instanceof Collection) { $permissions = $permissions->all(); @@ -707,7 +654,7 @@ protected function convertToPermissionModels($permissions): array * * @throws PermissionDoesNotExist */ - public function filterPermission($permission, ?string $guardName = null): Permission + public function filterPermission(mixed $permission, ?string $guardName = null): Permission { $permission = enum_value($permission); @@ -744,7 +691,7 @@ public function filterPermission($permission, ?string $guardName = null): Permis * * @throws PermissionDoesNotExist */ - public function hasPermissionTo($permission, ?string $guardName = null): bool + public function hasPermissionTo(mixed $permission, ?string $guardName = null): bool { if ($this->getWildcardClass()) { return $this->hasWildcardPermission($permission, $guardName); @@ -768,7 +715,7 @@ public function hasPermissionTo($permission, ?string $guardName = null): bool * * @param int|Permission|string|UnitEnum $permission */ - protected function hasWildcardPermission($permission, ?string $guardName = null): bool + protected function hasWildcardPermission(mixed $permission, ?string $guardName = null): bool { $guardName = $guardName ?? $this->getDefaultGuardName(); @@ -805,7 +752,7 @@ protected function hasWildcardPermission($permission, ?string $guardName = null) * * @param int|Permission|string|UnitEnum $permission */ - public function checkPermissionTo($permission, ?string $guardName = null): bool + public function checkPermissionTo(mixed $permission, ?string $guardName = null): bool { try { return $this->hasPermissionTo($permission, $guardName); @@ -819,7 +766,7 @@ public function checkPermissionTo($permission, ?string $guardName = null): bool * * @param array|Collection|int|Permission|string|UnitEnum ...$permissions */ - public function hasAnyPermission(...$permissions): bool + public function hasAnyPermission(mixed ...$permissions): bool { $permissions = collect($permissions)->flatten(); @@ -837,7 +784,7 @@ public function hasAnyPermission(...$permissions): bool * * @param array|Collection|int|Permission|string|UnitEnum ...$permissions */ - public function hasAllPermissions(...$permissions): bool + public function hasAllPermissions(mixed ...$permissions): bool { $permissions = collect($permissions)->flatten(); @@ -859,10 +806,7 @@ protected function hasPermissionViaRole(Permission $permission): bool return false; } - if (! $permission instanceof Model) { - return false; - } - + /** @var Model&Permission $permission */ return $this->hasRole( $this->relationCollection($permission, 'roles') ->reject(fn (Model $role): bool => $this->pivotIsDenied($role)) @@ -876,15 +820,14 @@ protected function hasPermissionViaRole(Permission $permission): bool * * @throws PermissionDoesNotExist */ - public function hasDirectPermission($permission): bool + public function hasDirectPermission(mixed $permission): bool { $permission = $this->filterPermission($permission); - $matches = $this->getCachedDirectPermissions() - ->filter(fn (Model $directPermission): bool => $directPermission->getKey() === $permission->getKey()); + $directPermission = $this->getCachedDirectPermissions() + ->first(fn (Model $directPermission): bool => $directPermission->getKey() === $permission->getKey()); - return $matches->isNotEmpty() - && ! $matches->contains(fn (Model $directPermission): bool => $this->pivotIsDenied($directPermission)); + return $directPermission !== null && ! $this->pivotIsDenied($directPermission); } /** @@ -982,20 +925,16 @@ private function requireModelKey(Model $model): mixed /** * Grant the given permission(s) to the model. - * - * @param array|Collection|int|Permission|string|UnitEnum $permissions */ - public function givePermissionTo(...$permissions): static + public function givePermissionTo(array|Collection|int|Permission|string|UnitEnum|null ...$permissions): static { return $this->attachPermissions($permissions, false); } /** * Deny the given permission(s) for the model. - * - * @param array|Collection|int|Permission|string|UnitEnum $permissions */ - public function denyPermissionTo(...$permissions): static + public function denyPermissionTo(array|Collection|int|Permission|string|UnitEnum|null ...$permissions): static { return $this->attachPermissions($permissions, true); } @@ -1009,7 +948,7 @@ private function attachPermissions(array $permissions, bool $isDenied): static { $model = $this; $registrar = $this->permissionRegistrar(); - $context = $this->permissionAssignmentContext($registrar); + $context = $this->assignmentContext($registrar); $registrar->ensureTeamIsSelectedForMutation($context); $permissions = $this->collectPermissions($permissions, $context->partition); @@ -1022,7 +961,7 @@ private function attachPermissions(array $permissions, bool $isDenied): static if (! $model->exists) { $this->queuePermissionAssignments( $permissions, - $this->permissionAssignmentPivot($isDenied, $context), + $isDenied, $context, $registrar->getAssignmentPivotClass($this, 'permissions'), ); @@ -1050,17 +989,7 @@ private function attachPermissions(array $permissions, bool $isDenied): static } $model->unsetRelation('permissions'); - - if ($this instanceof Role) { - $registrar->invalidatePermissionCatalogAfterMutation($context->partition); - } else { - $registrar->invalidateModelPermissionCacheAfterMutation( - $model, - $context->partition, - $context->team, - ); - } - + $this->invalidatePermissionAssignmentCaches($registrar, $context); $this->dispatchPermissionAttachedEvent($permissions); return $this; @@ -1292,81 +1221,55 @@ private function synchronizePermissionAssignments( } /** - * Insert permission assignments into an empty assignment set. + * Queue permission assignments until the model is saved. + * + * Queuing a permission again replaces its queued effect. * * @param array $permissions - * @param array $pivot * @param class-string $pivotClass */ - protected function attachPermissionAssignments( + protected function queuePermissionAssignments( array $permissions, - array $pivot, - ?PermissionRelationContext $context, + bool $isDenied, + PermissionRelationContext $context, string $pivotClass, ): void { if ($permissions === []) { return; } - $relation = $this->permissionAssignmentRelation($context); - - if ($pivotClass !== Pivot::class) { - $relation->using($pivotClass); - } - - $relation->attach($permissions, $pivot, false); - $relation->touchIfTouching(); - } - - /** - * Queue permission assignments until the model is saved. - * - * @param array $permissions - * @param array $pivot - * @param class-string $pivotClass - */ - protected function queuePermissionAssignments( - array $permissions, - array $pivot, - PermissionRelationContext $context, - string $pivotClass, - ): void { - $this->queuedPermissionAssignments[] = [ - 'permissions' => $permissions, - 'pivot' => $pivot, + $identity = $context->identity(); + $this->queuedPermissionAssignments[$identity] ??= [ 'context' => $context, 'pivotClass' => $pivotClass, + 'permissions' => [], ]; + + foreach ($permissions as $permission) { + $this->queuedPermissionAssignments[$identity]['permissions'][$this->assignmentIdIdentity($permission)] = [ + 'id' => $permission, + 'is_denied' => $isDenied, + ]; + } } /** - * Replace queued permission assignments for the given scope. + * Replace the permission assignments queued for a captured context. * - * @param array, pivot: array, context: PermissionRelationContext, pivotClass: class-string}> $assignments + * @param array $allowed + * @param array $denied + * @param class-string $pivotClass */ private function replaceQueuedPermissionAssignments( - array $assignments, + array $allowed, + array $denied, PermissionRelationContext $context, + string $pivotClass, ): void { - $scopeKey = $context->identity(); - - $this->queuedPermissionAssignments = array_values(array_filter( - $this->queuedPermissionAssignments, - fn (array $assignment): bool => $assignment['context']->identity() !== $scopeKey, - )); + unset($this->queuedPermissionAssignments[$context->identity()]); - foreach ($assignments as $assignment) { - if ($assignment['permissions'] === []) { - continue; - } - - $this->queuePermissionAssignments( - $assignment['permissions'], - $assignment['pivot'], - $assignment['context'], - $assignment['pivotClass'], - ); - } + $this->queuePermissionAssignments($allowed, false, $context, $pivotClass); + $this->queuePermissionAssignments($denied, true, $context, $pivotClass); } /** @@ -1379,31 +1282,18 @@ protected function removeQueuedPermissionAssignments( PermissionRelationContext $context, ): void { $identity = $context->identity(); - $assignments = []; - - foreach ($this->queuedPermissionAssignments as $assignment) { - if ($assignment['context']->identity() !== $identity) { - $assignments[] = $assignment; - continue; - } - - $remainingPermissions = array_values(array_filter( - $assignment['permissions'], - fn (int|string $permission): bool => ! in_array($permission, $permissions, true), - )); - if ($remainingPermissions === $assignment['permissions']) { - $assignments[] = $assignment; - continue; - } + if (! isset($this->queuedPermissionAssignments[$identity])) { + return; + } - if ($remainingPermissions !== []) { - $assignment['permissions'] = $remainingPermissions; - $assignments[] = $assignment; - } + foreach ($permissions as $permission) { + unset($this->queuedPermissionAssignments[$identity]['permissions'][$this->assignmentIdIdentity($permission)]); } - $this->queuedPermissionAssignments = $assignments; + if ($this->queuedPermissionAssignments[$identity]['permissions'] === []) { + unset($this->queuedPermissionAssignments[$identity]); + } } /** @@ -1411,133 +1301,72 @@ protected function removeQueuedPermissionAssignments( */ protected function flushQueuedPermissionAssignments(): void { - $assignments = $this->collapseQueuedPermissionAssignments(); + $assignments = $this->queuedPermissionAssignments; if ($assignments === []) { return; } $registrar = $this->permissionRegistrar(); - $this->ensureQueuedPermissionAssignmentTeamsSelected($assignments, $registrar); - $registrar->getPermissionConnection()->transaction( - fn () => $this->attachQueuedPermissionAssignmentBatches($assignments), - ); + $registrar->getPermissionConnection()->transaction(function () use ($assignments): void { + $this->attachQueuedPermissionAssignments($assignments); + }); - $this->clearQueuedPermissionAssignments(); + $this->queuedPermissionAssignments = []; $this->unsetRelation('permissions'); - $this->invalidateQueuedPermissionAssignmentContexts($assignments); - } - /** - * Attach collapsed queued permission assignment batches. - * - * @param array, pivot: array, context: PermissionRelationContext, pivotClass: class-string}> $assignments - */ - protected function attachQueuedPermissionAssignmentBatches(array $assignments): void - { foreach ($assignments as $assignment) { - $this->attachPermissionAssignments( - $assignment['permissions'], - $assignment['pivot'], - $assignment['context'], - $assignment['pivotClass'], - ); + $this->invalidatePermissionAssignmentCaches($registrar, $assignment['context']); } } /** - * Ensure queued permission assignments have their required team context. + * Insert queued permission assignments, with one insert per context and effect. * - * @param array, pivot: array, context: PermissionRelationContext, pivotClass: class-string}> $assignments + * @param array, permissions: array}> $assignments */ - protected function ensureQueuedPermissionAssignmentTeamsSelected( - array $assignments, - PermissionRegistrar $registrar, - ): void { + protected function attachQueuedPermissionAssignments(array $assignments): void + { foreach ($assignments as $assignment) { - $registrar->ensureTeamIsSelectedForMutation($assignment['context']); - } - } + $relation = $this->permissionAssignmentRelation($assignment['context']); - /** - * Clear queued permission assignments after their transaction commits. - */ - protected function clearQueuedPermissionAssignments(): void - { - $this->queuedPermissionAssignments = []; - } + if ($assignment['pivotClass'] !== Pivot::class) { + $relation->using($assignment['pivotClass']); + } - /** - * Invalidate the captured contexts of committed permission assignments. - * - * @param array, pivot: array, context: PermissionRelationContext, pivotClass: class-string}> $assignments - */ - protected function invalidateQueuedPermissionAssignmentContexts(array $assignments): void - { - $registrar = $this->permissionRegistrar(); - $contexts = []; + foreach ([false, true] as $isDenied) { + $permissions = []; - foreach ($assignments as $assignment) { - $contexts[$assignment['context']->identity()] = $assignment['context']; - } + foreach ($assignment['permissions'] as $permission) { + if ($permission['is_denied'] === $isDenied) { + $permissions[] = $permission['id']; + } + } - foreach ($contexts as $context) { - if ($this instanceof Role) { - $registrar->invalidatePermissionCatalogAfterMutation($context->partition); - } else { - $registrar->invalidateModelPermissionCacheAfterMutation( - $this, - $context->partition, - $context->team, - ); + if ($permissions !== []) { + $relation->attach($permissions, $this->permissionAssignmentPivot($isDenied, $assignment['context']), false); + } } + + $relation->touchIfTouching(); } } /** - * Collapse queued permission assignments to their final edge state. - * - * @return array, pivot: array, context: PermissionRelationContext, pivotClass: class-string}> + * Invalidate the caches a direct permission assignment change affects. */ - protected function collapseQueuedPermissionAssignments(): array - { - $collapsed = []; - - // Collapse by edge first so the last queued effect wins for each permission and team. - foreach ($this->queuedPermissionAssignments as $assignment) { - foreach ($assignment['permissions'] as $permission) { - $key = PermissionPartition::encodeCacheSegment($permission) - . ':' . $assignment['context']->identity(); - - $collapsed[$key] = [ - 'permission' => $permission, - 'pivot' => $assignment['pivot'], - 'context' => $assignment['context'], - 'pivotClass' => $assignment['pivotClass'], - ]; - } - } - - $batches = []; - - // Then batch by pivot so each distinct team and effect can be inserted together. - foreach ($collapsed as $assignment) { - $pivot = $assignment['pivot']; - $batchKey = $assignment['context']->identity() . ':' - . ((bool) $pivot['is_denied'] ? 'denied' : 'allowed') . ':' - . PermissionPartition::encodeCacheSegment($assignment['pivotClass']); + private function invalidatePermissionAssignmentCaches( + PermissionRegistrar $registrar, + PermissionRelationContext $context, + ): void { + if ($this instanceof Role) { + $registrar->invalidatePermissionCatalogAfterMutation($context->partition); - $batches[$batchKey] ??= [ - 'permissions' => [], - 'pivot' => $pivot, - 'context' => $assignment['context'], - 'pivotClass' => $assignment['pivotClass'], - ]; - $batches[$batchKey]['permissions'][] = $assignment['permission']; + return; } - return array_values($batches); + $registrar->invalidateModelPermissionCacheAfterMutation($this, $context->partition, $context->team); } /** @@ -1575,70 +1404,10 @@ public function forgetWildcardPermissionIndex(): void /** * Remove all current permissions and set the given ones. - * - * @param array|Collection|int|Permission|string|UnitEnum $permissions */ - public function syncPermissions(...$permissions): static + public function syncPermissions(array|Collection|int|Permission|string|UnitEnum|null ...$permissions): static { - $registrar = $this->permissionRegistrar(); - $context = $this->permissionAssignmentContext($registrar); - $registrar->ensureTeamIsSelectedForMutation($context); - $permissions = $this->collectPermissions($permissions, $context->partition); - - if (! $this->exists) { - $this->replaceQueuedPermissionAssignments( - [ - [ - 'permissions' => $permissions, - 'pivot' => $this->permissionAssignmentPivot(false, $context), - 'context' => $context, - 'pivotClass' => $registrar->getAssignmentPivotClass($this, 'permissions'), - ], - ], - $context, - ); - $this->dispatchPermissionAttachedEvent($permissions); - - return $this; - } - - $this->requireModelKey($this); - - $relation = $this->permissions(); - $context = $this->permissionRelationContext($relation); - $detachedPermissions = $this->permissionDetachedEventIsListenedFor() - ? $relation->get() - : new Collection; - - $changes = $this->synchronizePermissionAssignments( - $permissions, - [], - $relation, - $context, - true, - ); - - if ($changes['attached'] !== [] - || $changes['detached'] !== [] - || $changes['updated'] !== []) { - $this->unsetRelation('permissions'); - - if ($this instanceof Role) { - $registrar->invalidatePermissionCatalogAfterMutation($context->partition); - } else { - $registrar->invalidateModelPermissionCacheAfterMutation( - $this, - $context->partition, - $context->team, - ); - } - } - - if ($detachedPermissions->isNotEmpty()) { - $this->dispatchPermissionDetachedEvent($detachedPermissions); - } - - $this->dispatchPermissionAttachedEvent($permissions); + $this->syncPermissionEffects($permissions); return $this; } @@ -1656,7 +1425,7 @@ public function syncPermissions(...$permissions): static public function syncPermissionEffects(array|Collection $allowed = [], array|Collection $denied = []): array { $registrar = $this->permissionRegistrar(); - $context = $this->permissionAssignmentContext($registrar); + $context = $this->assignmentContext($registrar); $registrar->ensureTeamIsSelectedForMutation($context); $allowedIds = $this->collectPermissions($allowed, $context->partition); @@ -1668,24 +1437,11 @@ public function syncPermissionEffects(array|Collection $allowed = [], array|Coll $permissions = array_merge($allowedIds, $deniedIds); if (! $this->exists) { - $pivotClass = $registrar->getAssignmentPivotClass($this, 'permissions'); - $this->replaceQueuedPermissionAssignments( - [ - [ - 'permissions' => $allowedIds, - 'pivot' => $this->permissionAssignmentPivot(false, $context), - 'context' => $context, - 'pivotClass' => $pivotClass, - ], - [ - 'permissions' => $deniedIds, - 'pivot' => $this->permissionAssignmentPivot(true, $context), - 'context' => $context, - 'pivotClass' => $pivotClass, - ], - ], + $allowedIds, + $deniedIds, $context, + $registrar->getAssignmentPivotClass($this, 'permissions'), ); $this->dispatchPermissionAttachedEvent($permissions); @@ -1712,16 +1468,7 @@ public function syncPermissionEffects(array|Collection $allowed = [], array|Coll || $changes['detached'] !== [] || $changes['updated'] !== []) { $this->unsetRelation('permissions'); - - if ($this instanceof Role) { - $registrar->invalidatePermissionCatalogAfterMutation($context->partition); - } else { - $registrar->invalidateModelPermissionCacheAfterMutation( - $this, - $context->partition, - $context->team, - ); - } + $this->invalidatePermissionAssignmentCaches($registrar, $context); } if ($detachedPermissions->isNotEmpty()) { @@ -1735,13 +1482,11 @@ public function syncPermissionEffects(array|Collection $allowed = [], array|Coll /** * Revoke the given permission(s). - * - * @param Permission|Permission[]|string|string[]|UnitEnum $permission */ - public function revokePermissionTo($permission): static + public function revokePermissionTo(array|Collection|int|Permission|string|UnitEnum $permission): static { $registrar = $this->permissionRegistrar(); - $context = $this->permissionAssignmentContext($registrar); + $context = $this->assignmentContext($registrar); $registrar->ensureTeamIsSelectedForMutation($context); $storedPermission = $this->getStoredPermission($permission, $context->partition); $permissions = $this->collectPermissions($storedPermission, $context->partition); @@ -1762,20 +1507,9 @@ public function revokePermissionTo($permission): static $this->requireModelKey($this); $relation = $this->permissions(); - $context = $this->permissionRelationContext($relation); - $detached = $relation->detach($storedPermission); - - if ($detached > 0) { - if ($this instanceof Role) { - $registrar->invalidatePermissionCatalogAfterMutation($context->partition); - } else { - $registrar->invalidateModelPermissionCacheAfterMutation( - $this, - $context->partition, - $context->team, - ); - } + if ($relation->detach($permissions) > 0) { + $this->invalidatePermissionAssignmentCaches($registrar, $this->permissionRelationContext($relation)); $this->unsetRelation('permissions'); } @@ -1807,10 +1541,8 @@ protected function permissionDetachedEventIsListenedFor(): bool /** * Determine if the model has an explicit denied direct permission. - * - * @param int|Permission|string|UnitEnum $permission */ - public function hasDeniedPermission($permission, ?string $guardName = null): bool + public function hasDeniedPermission(int|Permission|string|UnitEnum $permission, ?string $guardName = null): bool { $guardName = $this->guardNameForPermissionMatch($permission, $guardName); @@ -1823,10 +1555,8 @@ public function hasDeniedPermission($permission, ?string $guardName = null): boo /** * Determine if the model has an explicit denied permission via roles. - * - * @param int|Permission|string|UnitEnum $permission */ - public function hasDeniedPermissionViaRoles($permission, ?string $guardName = null): bool + public function hasDeniedPermissionViaRoles(int|Permission|string|UnitEnum $permission, ?string $guardName = null): bool { if ($this instanceof Role || $this instanceof Permission) { return false; @@ -1913,12 +1643,12 @@ protected function loadPermissionsViaRolesWithPivots(): Collection /** * Resolve a permission for matching without throwing. */ - protected function permissionForMatch(mixed $permission, string $guardName): ?Model + protected function permissionForMatch(int|Permission|string|UnitEnum $permission, string $guardName): ?Model { $permissionKey = Guard::getModelKeyName($this->getPermissionClass()); if ($permission instanceof Permission) { - if (! $permission instanceof Model || $permission->guard_name !== $guardName) { + if ($permission->guard_name !== $guardName) { return null; } @@ -1928,11 +1658,6 @@ protected function permissionForMatch(mixed $permission, string $guardName): ?Mo } $permission = enum_value($permission); - - if (! is_string($permission) && ! is_int($permission)) { - return null; - } - $params = is_int($permission) || PermissionRegistrar::isUid($permission) ? [$permissionKey => $permission, 'guard_name' => $guardName] : ['name' => $permission, 'guard_name' => $guardName]; @@ -1951,7 +1676,7 @@ protected function deniedPermissionKeys(Collection ...$permissionCollections): a foreach ($permissionCollections as $permissions) { foreach ($permissions as $permission) { - if ($permission instanceof Model && $this->pivotIsDenied($permission)) { + if ($this->pivotIsDenied($permission)) { $keys[$this->permissionComparisonKey($permission)] = true; } } @@ -1996,14 +1721,8 @@ protected function permissionWithRolePivot( */ protected function pivotIsDenied(Model $model): bool { - if (! $model->relationLoaded('pivot')) { - return false; - } - - $pivot = $model->getRelation('pivot'); - - return $pivot instanceof Pivot - && $this->permissionEffectIsDenied($pivot->getAttribute('is_denied')); + return $model->relationLoaded('pivot') + && $this->permissionEffectIsDenied($model->getRelation('pivot')->getAttribute('is_denied')); } /** @@ -2031,16 +1750,17 @@ protected function relationCollection(Model $model, string $relation): Collectio $model->loadMissing($relation); } - $value = $model->getRelation($relation); - - return $value instanceof Collection ? $value : new Collection; + return $model->getRelation($relation); } /** * Determine if a stored permission matches an input permission. */ - protected function storedPermissionMatches(Model $storedPermission, mixed $permission, ?string $guardName = null): bool - { + protected function storedPermissionMatches( + Model $storedPermission, + int|Permission|string|UnitEnum $permission, + ?string $guardName = null, + ): bool { if ($guardName !== null && $storedPermission->getAttribute('guard_name') !== $guardName) { return false; } @@ -2055,14 +1775,13 @@ protected function storedPermissionMatches(Model $storedPermission, mixed $permi return (string) $storedPermission->getKey() === (string) $permission; } - return is_string($permission) - && $storedPermission->getAttribute('name') === $permission; + return $storedPermission->getAttribute('name') === $permission; } /** * Resolve the guard to use when matching stored permissions. */ - protected function guardNameForPermissionMatch(mixed $permission, ?string $guardName = null): string + protected function guardNameForPermissionMatch(int|Permission|string|UnitEnum $permission, ?string $guardName = null): string { if ($permission instanceof Permission) { $this->ensurePermissionMatchesPartition( @@ -2101,9 +1820,9 @@ public function getPermissionNames(): Collection * @return Collection|(Model&Permission) */ protected function getStoredPermission( - $permissions, + mixed $permissions, ?PermissionPartition $partition = null, - ) { + ): mixed { $partition ??= $this->permissionRegistrar()->resolvePartition(); $permissions = enum_value($permissions); @@ -2145,24 +1864,25 @@ protected function getStoredPermission( } /** - * Capture the partition and team for a direct permission operation. + * Capture the partition and team for a role or permission assignment operation. */ - private function permissionAssignmentContext(PermissionRegistrar $registrar): PermissionRelationContext + private function assignmentContext(PermissionRegistrar $registrar): PermissionRelationContext { $partition = $registrar->resolvePartition(); + $permissionRecord = $this instanceof Role || $this instanceof Permission; if ($partition) { $attributes = $this->getAttributes(); - if ($this instanceof Role - || $this instanceof Permission + if ($permissionRecord || (array_key_exists($partition->column, $attributes) && $attributes[$partition->column] !== null)) { $registrar->ensureModelMatchesPartition($this, $partition); } } - $teamScoped = $registrar->teams && ! $this instanceof Role; + // Role-permission assignments have no team column. + $teamScoped = $registrar->teams && ! $permissionRecord; return new PermissionRelationContext( $partition, @@ -2178,7 +1898,8 @@ private function ensurePermissionMatchesPartition( Permission $permission, ?PermissionPartition $partition, ): void { - if ($partition && $permission instanceof Model) { + if ($partition) { + /** @var Model&Permission $permission */ $this->permissionRegistrar()->ensureModelMatchesPartition($permission, $partition); } } @@ -2186,11 +1907,9 @@ private function ensurePermissionMatchesPartition( /** * Ensure the given role or permission uses one of the model's guards. * - * @param Permission|Role $roleOrPermission - * * @throws GuardDoesNotMatch */ - protected function ensureModelSharesGuard($roleOrPermission): void + protected function ensureModelSharesGuard(Permission|Role $roleOrPermission): void { if (! $this->getGuardNames()->contains($roleOrPermission->guard_name)) { throw GuardDoesNotMatch::create($roleOrPermission->guard_name, $this->getGuardNames()); @@ -2226,7 +1945,7 @@ public function forgetCachedPermissions(): void * * @param array|Collection|int|Permission|string|UnitEnum ...$permissions */ - public function hasAllDirectPermissions(...$permissions): bool + public function hasAllDirectPermissions(mixed ...$permissions): bool { $permissions = collect($permissions)->flatten(); @@ -2244,7 +1963,7 @@ public function hasAllDirectPermissions(...$permissions): bool * * @param array|Collection|int|Permission|string|UnitEnum ...$permissions */ - public function hasAnyDirectPermission(...$permissions): bool + public function hasAnyDirectPermission(mixed ...$permissions): bool { $permissions = collect($permissions)->flatten(); diff --git a/src/permission/src/Traits/HasRoles.php b/src/permission/src/Traits/HasRoles.php index b25a83b2cd..d9e84754c7 100644 --- a/src/permission/src/Traits/HasRoles.php +++ b/src/permission/src/Traits/HasRoles.php @@ -20,7 +20,6 @@ use Hypervel\Permission\Support\PermissionRelationContext; use Hypervel\Support\Arr; use Hypervel\Support\Collection; -use TypeError; use UnitEnum; use function Hypervel\Support\enum_value; @@ -32,7 +31,7 @@ trait HasRoles private ?string $roleClass = null; /** - * @var array, pivot: array, context: PermissionRelationContext, pivotClass: class-string}> + * @var array, context: PermissionRelationContext, pivotClass: class-string}> */ private array $queuedRoleAssignments = []; @@ -47,7 +46,7 @@ public static function bootHasRoles(): void } $registrar = Container::getInstance()->make(PermissionRegistrar::class); - $modelKey = static::requireDeletionModelKey($model); + $modelKey = $model->getKey(); if ($model instanceof Permission) { $partition = static::permissionRecordDeletionPartition($model, $registrar); @@ -172,7 +171,7 @@ protected function getCachedRoles(): Collection { $model = $this; $registrar = $this->permissionRegistrar(); - $context = $this->roleAssignmentContext($registrar); + $context = $this->assignmentContext($registrar); $this->forgetStalePermissionRelation($registrar, 'roles'); @@ -199,11 +198,14 @@ protected function getCachedRoles(): Collection * Scope the model query to certain roles only. * * @param Builder $query - * @param array|Collection|int|Role|string|UnitEnum $roles * @return Builder */ - public function scopeRole(Builder $query, $roles, ?string $guard = null, bool $without = false): Builder - { + public function scopeRole( + Builder $query, + array|Collection|int|Role|string|UnitEnum $roles, + ?string $guard = null, + bool $without = false, + ): Builder { if ($roles instanceof Collection) { $roles = $roles->all(); } @@ -247,10 +249,9 @@ public function scopeRole(Builder $query, $roles, ?string $guard = null, bool $w * Scope the model query to only those without certain roles. * * @param Builder $query - * @param array|Collection|int|Role|string|UnitEnum $roles * @return Builder */ - public function scopeWithoutRole(Builder $query, $roles, ?string $guard = null): Builder + public function scopeWithoutRole(Builder $query, array|Collection|int|Role|string|UnitEnum $roles, ?string $guard = null): Builder { return $this->scopeRole($query, $roles, $guard, true); } @@ -295,10 +296,9 @@ public function teams(): BelongsToMany * Scope the model query to certain teams only. * * @param Builder $query - * @param array|Collection|int|Model|string $teams * @return Builder */ - public function scopeTeam(Builder $query, $teams, bool $without = false): Builder + public function scopeTeam(Builder $query, array|Collection|int|Model|string $teams, bool $without = false): Builder { $teamModel = Config::teamModel(); @@ -336,10 +336,9 @@ function ($subQuery) use ($pivotTable, $morphKey, $query, $teamsKey, $teamIds, $ * Scope the model query to those without certain teams. * * @param Builder $query - * @param array|Collection|int|Model|string $teams * @return Builder */ - public function scopeWithoutTeam(Builder $query, $teams): Builder + public function scopeWithoutTeam(Builder $query, array|Collection|int|Model|string $teams): Builder { return $this->scopeTeam($query, $teams, true); } @@ -375,13 +374,12 @@ private function collectRoles( /** * Assign the given role to the model. * - * @param array|Collection|int|Role|string|UnitEnum ...$roles * @return $this */ - public function assignRole(...$roles): static + public function assignRole(array|Collection|int|Role|string|UnitEnum|null ...$roles): static { $registrar = $this->permissionRegistrar(); - $context = $this->roleAssignmentContext($registrar); + $context = $this->assignmentContext($registrar); $registrar->ensureTeamIsSelectedForMutation($context); $roles = $this->collectRoles($roles, $context->partition); @@ -392,12 +390,7 @@ public function assignRole(...$roles): static } if (! $this->exists) { - $this->queueRoleAssignments( - $roles, - $this->roleAssignmentPivot($context), - $context, - $registrar->getAssignmentPivotClass($this, 'roles'), - ); + $this->queueRoleAssignments($roles, $context, $registrar->getAssignmentPivotClass($this, 'roles')); $this->dispatchRoleAttachedEvent($roles); return $this; @@ -428,17 +421,7 @@ public function assignRole(...$roles): static $relation->attach($attachedRoles, $this->roleAssignmentPivot($context)); $this->unsetRelation('roles'); - - if ($this instanceof Permission) { - $registrar->invalidatePermissionCatalogAfterMutation($context->partition); - } else { - $registrar->invalidateModelRoleCacheAfterMutation( - $this, - $context->partition, - $context->team, - ); - } - + $this->invalidateRoleAssignmentCaches($registrar, $context); $this->dispatchRoleAttachedEvent($roles); return $this; @@ -448,16 +431,14 @@ public function assignRole(...$roles): static * Queue role assignments until the model is saved. * * @param array $roles - * @param array $pivot * @param class-string $pivotClass */ protected function queueRoleAssignments( array $roles, - array $pivot, PermissionRelationContext $context, string $pivotClass, ): void { - $identity = $context->identity() . ':' . PermissionPartition::encodeCacheSegment($pivotClass); + $identity = $context->identity(); $queuedRoles = $this->queuedRoleAssignments[$identity]['roles'] ?? []; foreach ($roles as $role) { @@ -468,7 +449,6 @@ protected function queueRoleAssignments( $this->queuedRoleAssignments[$identity] = [ 'roles' => $queuedRoles, - 'pivot' => $pivot, 'context' => $context, 'pivotClass' => $pivotClass, ]; @@ -478,66 +458,47 @@ protected function queueRoleAssignments( * Replace role assignments queued for a captured context. * * @param array $roles - * @param array $pivot * @param class-string $pivotClass */ protected function replaceQueuedRoleAssignments( array $roles, - array $pivot, PermissionRelationContext $context, string $pivotClass, ): void { - $identity = $context->identity() . ':' . PermissionPartition::encodeCacheSegment($pivotClass); + unset($this->queuedRoleAssignments[$context->identity()]); - if ($roles === []) { - unset($this->queuedRoleAssignments[$identity]); - - return; + if ($roles !== []) { + $this->queueRoleAssignments($roles, $context, $pivotClass); } - - $this->queuedRoleAssignments[$identity] = [ - 'roles' => $roles, - 'pivot' => $pivot, - 'context' => $context, - 'pivotClass' => $pivotClass, - ]; } /** * Remove role assignments queued for a captured context. * * @param array $roles - * @param class-string $pivotClass */ protected function removeQueuedRoleAssignments( array $roles, PermissionRelationContext $context, - string $pivotClass, ): void { - $identity = $context->identity() . ':' . PermissionPartition::encodeCacheSegment($pivotClass); - $assignment = $this->queuedRoleAssignments[$identity] ?? null; + $identity = $context->identity(); - if ($assignment === null) { + if (! isset($this->queuedRoleAssignments[$identity])) { return; } $remainingRoles = array_values(array_filter( - $assignment['roles'], + $this->queuedRoleAssignments[$identity]['roles'], fn (int|string $role): bool => ! in_array($role, $roles, true), )); - if ($remainingRoles === $assignment['roles']) { - return; - } - if ($remainingRoles === []) { unset($this->queuedRoleAssignments[$identity]); return; } - $assignment['roles'] = $remainingRoles; - $this->queuedRoleAssignments[$identity] = $assignment; + $this->queuedRoleAssignments[$identity]['roles'] = $remainingRoles; } /** @@ -545,8 +506,8 @@ protected function removeQueuedRoleAssignments( */ protected function flushQueuedPermissionAssignments(): void { - $roleAssignments = array_values($this->queuedRoleAssignments); - $permissionAssignments = $this->collapseQueuedPermissionAssignments(); + $roleAssignments = $this->queuedRoleAssignments; + $permissionAssignments = $this->queuedPermissionAssignments; if ($roleAssignments === [] && $permissionAssignments === []) { return; @@ -554,12 +515,6 @@ protected function flushQueuedPermissionAssignments(): void $registrar = $this->permissionRegistrar(); - foreach ($roleAssignments as $assignment) { - $registrar->ensureTeamIsSelectedForMutation($assignment['context']); - } - - $this->ensureQueuedPermissionAssignmentTeamsSelected($permissionAssignments, $registrar); - $registrar->getPermissionConnection()->transaction(function () use ($roleAssignments, $permissionAssignments): void { foreach ($roleAssignments as $assignment) { $relation = $this->roleAssignmentRelation($assignment['context']); @@ -568,14 +523,14 @@ protected function flushQueuedPermissionAssignments(): void $relation->using($assignment['pivotClass']); } - $relation->attach($assignment['roles'], $assignment['pivot']); + $relation->attach($assignment['roles'], $this->roleAssignmentPivot($assignment['context'])); } - $this->attachQueuedPermissionAssignmentBatches($permissionAssignments); + $this->attachQueuedPermissionAssignments($permissionAssignments); }); $this->queuedRoleAssignments = []; - $this->clearQueuedPermissionAssignments(); + $this->queuedPermissionAssignments = []; if ($roleAssignments !== []) { $this->unsetRelation('roles'); @@ -585,35 +540,29 @@ protected function flushQueuedPermissionAssignments(): void $this->unsetRelation('permissions'); } - if ($this instanceof Permission) { - $contexts = []; - - foreach ([...$roleAssignments, ...$permissionAssignments] as $assignment) { - $contexts[$assignment['context']->identity()] = $assignment['context']; - } - - foreach ($contexts as $context) { - $registrar->invalidatePermissionCatalogAfterMutation($context->partition); - } - - return; + foreach ($roleAssignments as $assignment) { + $this->invalidateRoleAssignmentCaches($registrar, $assignment['context']); } - $roleContexts = []; - - foreach ($roleAssignments as $assignment) { - $roleContexts[$assignment['context']->identity()] = $assignment['context']; + foreach ($permissionAssignments as $assignment) { + $this->invalidatePermissionAssignmentCaches($registrar, $assignment['context']); } + } - foreach ($roleContexts as $context) { - $registrar->invalidateModelRoleCacheAfterMutation( - $this, - $context->partition, - $context->team, - ); + /** + * Invalidate the caches a role assignment change affects. + */ + private function invalidateRoleAssignmentCaches( + PermissionRegistrar $registrar, + PermissionRelationContext $context, + ): void { + if ($this instanceof Permission) { + $registrar->invalidatePermissionCatalogAfterMutation($context->partition); + + return; } - $this->invalidateQueuedPermissionAssignmentContexts($permissionAssignments); + $registrar->invalidateModelRoleCacheAfterMutation($this, $context->partition, $context->team); } /** @@ -642,13 +591,12 @@ protected function roleAttachedEventIsListenedFor(): bool /** * Revoke the given role from the model. * - * @param array|Collection|int|Role|string|UnitEnum ...$role * @return $this */ - public function removeRole(...$role): static + public function removeRole(array|Collection|int|Role|string|UnitEnum|null ...$role): static { $registrar = $this->permissionRegistrar(); - $context = $this->roleAssignmentContext($registrar); + $context = $this->assignmentContext($registrar); $registrar->ensureTeamIsSelectedForMutation($context); $roles = $this->collectRoles($role, $context->partition); @@ -659,11 +607,7 @@ public function removeRole(...$role): static } if (! $this->exists) { - $this->removeQueuedRoleAssignments( - $roles, - $context, - $registrar->getAssignmentPivotClass($this, 'roles'), - ); + $this->removeQueuedRoleAssignments($roles, $context); $this->dispatchRoleDetachedEvent($roles); return $this; @@ -672,21 +616,10 @@ public function removeRole(...$role): static $this->requireModelKey($this); $relation = $this->roles(); - $context = $this->permissionRelationContext($relation); - $detached = $relation->detach($roles); - if ($detached > 0) { + if ($relation->detach($roles) > 0) { $this->unsetRelation('roles'); - - if ($this instanceof Permission) { - $registrar->invalidatePermissionCatalogAfterMutation($context->partition); - } else { - $registrar->invalidateModelRoleCacheAfterMutation( - $this, - $context->partition, - $context->team, - ); - } + $this->invalidateRoleAssignmentCaches($registrar, $this->permissionRelationContext($relation)); } $this->dispatchRoleDetachedEvent($roles); @@ -720,25 +653,17 @@ protected function roleDetachedEventIsListenedFor(): bool /** * Remove all current roles and set the given ones. * - * @param array|Collection|int|Role|string|UnitEnum ...$roles * @return $this */ - public function syncRoles(...$roles): static + public function syncRoles(array|Collection|int|Role|string|UnitEnum|null ...$roles): static { $registrar = $this->permissionRegistrar(); - $context = $this->roleAssignmentContext($registrar); + $context = $this->assignmentContext($registrar); $registrar->ensureTeamIsSelectedForMutation($context); $roles = $this->collectRoles($roles, $context->partition); if (! $this->exists) { - $pivotClass = $registrar->getAssignmentPivotClass($this, 'roles'); - - $this->replaceQueuedRoleAssignments( - $roles, - $this->roleAssignmentPivot($context), - $context, - $pivotClass, - ); + $this->replaceQueuedRoleAssignments($roles, $context, $registrar->getAssignmentPivotClass($this, 'roles')); $this->dispatchRoleAttachedEvent($roles); return $this; @@ -775,16 +700,7 @@ public function syncRoles(...$roles): static }); $this->unsetRelation('roles'); - - if ($this instanceof Permission) { - $registrar->invalidatePermissionCatalogAfterMutation($context->partition); - } else { - $registrar->invalidateModelRoleCacheAfterMutation( - $this, - $context->partition, - $context->team, - ); - } + $this->invalidateRoleAssignmentCaches($registrar, $context); } if ($detachedEventRoles !== []) { @@ -798,10 +714,8 @@ public function syncRoles(...$roles): static /** * Determine if the model has (one of) the given role(s). - * - * @param array|Collection|int|Role|string|UnitEnum $roles */ - public function hasRole($roles, ?string $guard = null): bool + public function hasRole(array|Collection|int|Role|string|UnitEnum $roles, ?string $guard = null): bool { $roleCollection = $this->getCachedRoles(); @@ -809,11 +723,10 @@ public function hasRole($roles, ?string $guard = null): bool $roles = $this->convertPipeToArray($roles); } + // An enum names a role, even when its value is an integer or a UUID. if ($roles instanceof UnitEnum) { - $roles = enum_value($roles); - } - - if (is_int($roles) || PermissionRegistrar::isUid($roles)) { + $roles = (string) enum_value($roles); + } elseif (is_int($roles) || PermissionRegistrar::isUid($roles)) { $key = Guard::getModelKeyName($this->getRoleClass()); return $guard !== null && $guard !== '' @@ -826,7 +739,7 @@ public function hasRole($roles, ?string $guard = null): bool ? $roleCollection->where('guard_name', $guard)->pluck('name') : $roleCollection->pluck('name'); - return $roleNames->contains(fn ($name): bool => enum_value($name) === $roles); + return $roleNames->contains(fn ($name): bool => (string) enum_value($name) === $roles); } if ($roles instanceof Role) { @@ -848,44 +761,36 @@ public function hasRole($roles, ?string $guard = null): bool return false; } - if ($roles instanceof Collection) { - $this->ensureRoleCollectionMatchesPartition($roles); - - return $roles->intersect( - $guard !== null && $guard !== '' ? $roleCollection->where('guard_name', $guard) : $roleCollection - )->isNotEmpty(); - } + $this->ensureRoleCollectionMatchesPartition($roles); - throw new TypeError('Unsupported type for $roles parameter to hasRole().'); + return $roles->intersect( + $guard !== null && $guard !== '' ? $roleCollection->where('guard_name', $guard) : $roleCollection + )->isNotEmpty(); } /** * Determine if the model has any of the given role(s). * * Alias to hasRole() but without Guard controls - * - * @param array|Collection|int|Role|string|UnitEnum $roles */ - public function hasAnyRole(...$roles): bool + public function hasAnyRole(array|Collection|int|Role|string|UnitEnum ...$roles): bool { return $this->hasRole($roles); } /** * Determine if the model has all of the given role(s). - * - * @param array|Collection|Role|string|UnitEnum $roles */ - public function hasAllRoles($roles, ?string $guard = null): bool + public function hasAllRoles(array|Collection|Role|string|UnitEnum $roles, ?string $guard = null): bool { $roleCollection = $this->getCachedRoles(); - $roles = enum_value($roles); - if (is_string($roles) && str_contains($roles, '|')) { $roles = $this->convertPipeToArray($roles); } + // Enums reach the name comparison below unconverted. Converting one here would + // send a UUID value to hasRole() as a string, which it looks up as a role key. if (is_string($roles)) { return $this->hasRole($roles, $guard); } @@ -914,10 +819,8 @@ public function hasAllRoles($roles, ?string $guard = null): bool /** * Determine if the model has exactly all of the given role(s). - * - * @param array|Collection|Role|string|UnitEnum $roles */ - public function hasExactRoles($roles, ?string $guard = null): bool + public function hasExactRoles(array|Collection|Role|string|UnitEnum $roles, ?string $guard = null): bool { $roleCollection = $this->getCachedRoles(); @@ -968,11 +871,10 @@ public function getRoleNames(): Collection /** * Get a stored role instance. * - * @param int|Role|string|UnitEnum $role * @return Model&Role */ protected function getStoredRole( - $role, + int|Role|string|UnitEnum $role, ?PermissionPartition $partition = null, ): Role { $partition ??= $this->permissionRegistrar()->resolvePartition(); @@ -997,32 +899,6 @@ protected function getStoredRole( return $role; } - /** - * Capture the partition and team for a role assignment operation. - */ - private function roleAssignmentContext(PermissionRegistrar $registrar): PermissionRelationContext - { - $partition = $registrar->resolvePartition(); - - if ($partition) { - $attributes = $this->getAttributes(); - - if ($this instanceof Permission - || (array_key_exists($partition->column, $attributes) - && $attributes[$partition->column] !== null)) { - $registrar->ensureModelMatchesPartition($this, $partition); - } - } - - $teamScoped = $registrar->teams && ! $this instanceof Permission; - - return new PermissionRelationContext( - $partition, - $teamScoped, - $teamScoped ? $registrar->getPermissionsTeamId() : null, - ); - } - /** * Build role assignment pivot attributes for a captured context. * @@ -1048,7 +924,8 @@ private function roleAssignmentPivot(PermissionRelationContext $context): array */ private function ensureRoleMatchesPartition(Role $role, ?PermissionPartition $partition): void { - if ($partition && $role instanceof Model) { + if ($partition) { + /** @var Model&Role $role */ $this->permissionRegistrar()->ensureModelMatchesPartition($role, $partition); } } diff --git a/tests/Permission/CacheTest.php b/tests/Permission/CacheTest.php index 8b9bf74a0b..3dbc73346f 100644 --- a/tests/Permission/CacheTest.php +++ b/tests/Permission/CacheTest.php @@ -46,10 +46,11 @@ public function testGlobalPermissionCacheStoresRoleKeysWithoutDuplicatingRoleAtt $this->assertSame([$deniedRole->getKey()], $permission['denied_roles']); } - public function testCatalogAndViaRoleModelsAreFreedWithoutTheCycleCollector(): void + public function testCatalogAndUserPermissionModelsAreFreedWithoutTheCycleCollector(): void { $this->testUserRole->givePermissionTo('edit-articles'); $this->testUser->assignRole('testRole'); + $this->testUser->givePermissionTo('edit-news'); $registrar = $this->app->make(PermissionRegistrar::class); $gcWasEnabled = gc_enabled(); @@ -60,15 +61,18 @@ public function testCatalogAndViaRoleModelsAreFreedWithoutTheCycleCollector(): v $catalogRole = $this->app->make(PermissionContract::class)::findByName('edit-articles')->roles->sole(); $user = User::findOrFail($this->testUser->getKey()); $viaRolePermission = $user->getPermissionsViaRoles()->sole(); + $directPermission = $user->getDirectPermissions()->sole(); $references = [ WeakReference::create($catalogRole), WeakReference::create($catalogRole->getRelation('pivot')), WeakReference::create($viaRolePermission), WeakReference::create($viaRolePermission->getRelation('pivot')), + WeakReference::create($directPermission), + WeakReference::create($directPermission->getRelation('pivot')), ]; - unset($catalogRole, $user, $viaRolePermission); + unset($catalogRole, $user, $viaRolePermission, $directPermission); $registrar->clearPermissionsCollection(); foreach ($references as $reference) { diff --git a/tests/Permission/CustomSchemaConfigTest.php b/tests/Permission/CustomSchemaConfigTest.php index c49d530a4a..41bb923cb2 100644 --- a/tests/Permission/CustomSchemaConfigTest.php +++ b/tests/Permission/CustomSchemaConfigTest.php @@ -57,7 +57,7 @@ public function testCustomTableNamesAreUsedBySchemaModelsAndRelations(): void $this->assertTrue($this->app->make(Permission::class)::where('name', 'edit-articles')->exists()); } - public function testDeniedPermissionUpdatesExistingCustomTableAssignmentEdge(): void + public function testDeniedPermissionUpdatesExistingCustomTableAssignment(): void { $this->testUser->givePermissionTo('edit-articles'); $this->testUser->denyPermissionTo('edit-articles'); diff --git a/tests/Permission/DeletionTest.php b/tests/Permission/DeletionTest.php index 22c3e7fa1c..84408024bd 100644 --- a/tests/Permission/DeletionTest.php +++ b/tests/Permission/DeletionTest.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Permission; use BadMethodCallException; -use Hypervel\Database\Eloquent\MissingAttributeException; use Hypervel\Database\Eloquent\Model; use Hypervel\Permission\Contracts\Permission as PermissionContract; use Hypervel\Permission\Contracts\Role as RoleContract; @@ -14,8 +13,6 @@ use Hypervel\Permission\Traits\HasRoles; use Hypervel\Support\Facades\DB; use Hypervel\Tests\Permission\Fixtures\Models\HasPermissionsOnlyUser; -use Hypervel\Tests\Permission\Fixtures\Models\SoftDeletingUser; -use Hypervel\Tests\Permission\Fixtures\Models\User; use UnitEnum; class DeletionTest extends TestCase @@ -66,79 +63,6 @@ public function testHardDeletingAHasRolesSubjectDeletesBothAssignmentKinds(): vo $this->assertSame(0, $this->directPermissionAssignmentCount($this->testUser)); } - public function testDeletingAKeylessPersistedSubjectFailsBeforeAssignmentCleanup(): void - { - Model::preventAccessingMissingAttributes(false); - - $this->testUser->assignRole($this->testUserRole); - $this->testUser->givePermissionTo($this->testUserPermission); - $keylessUser = User::query() - ->select('email') - ->where('email', $this->testUser->email) - ->firstOrFail(); - - $this->assertDeletionRejectsWithoutQueries($keylessUser); - $this->assertSame(1, $this->roleAssignmentCount($this->testUser)); - $this->assertSame(1, $this->directPermissionAssignmentCount($this->testUser)); - } - - public function testDeletingAKeylessPersistedRoleFailsBeforeAssignmentCleanup(): void - { - Model::preventAccessingMissingAttributes(false); - - $this->testUser->assignRole($this->testUserRole); - $this->testUserRole->givePermissionTo($this->testUserPermission); - $keylessRole = $this->testUserRole->newQuery() - ->select(['name', 'guard_name']) - ->where('name', $this->testUserRole->name) - ->firstOrFail(); - - $this->assertDeletionRejectsWithoutQueries($keylessRole); - $this->assertSame(1, DB::table(Config::modelHasRolesTable()) - ->where(app('config')->get('permission.column_names.role_pivot_key'), $this->testUserRole->getKey()) - ->count()); - $this->assertSame(1, DB::table(Config::roleHasPermissionsTable()) - ->where(app('config')->get('permission.column_names.role_pivot_key'), $this->testUserRole->getKey()) - ->count()); - } - - public function testDeletingAKeylessPersistedPermissionFailsBeforeAssignmentCleanup(): void - { - Model::preventAccessingMissingAttributes(false); - - $this->testUser->givePermissionTo($this->testUserPermission); - $this->testUserRole->givePermissionTo($this->testUserPermission); - $keylessPermission = $this->testUserPermission->newQuery() - ->select(['name', 'guard_name']) - ->where('name', $this->testUserPermission->name) - ->firstOrFail(); - - $this->assertDeletionRejectsWithoutQueries($keylessPermission); - $this->assertSame(1, DB::table(Config::modelHasPermissionsTable()) - ->where(app('config')->get('permission.column_names.permission_pivot_key'), $this->testUserPermission->getKey()) - ->count()); - $this->assertSame(1, DB::table(Config::roleHasPermissionsTable()) - ->where(app('config')->get('permission.column_names.permission_pivot_key'), $this->testUserPermission->getKey()) - ->count()); - } - - public function testSoftDeletingAKeylessPersistedSubjectUsesTheEloquentKeyGuard(): void - { - Model::preventAccessingMissingAttributes(false); - - $user = SoftDeletingUser::create(['email' => 'soft-delete-partial@example.com']); - $user->assignRole($this->testUserRole); - $user->givePermissionTo($this->testUserPermission); - $keylessUser = SoftDeletingUser::query() - ->select('email') - ->where('email', $user->email) - ->firstOrFail(); - - $this->assertDeletionRejectsWithoutQueries($keylessUser); - $this->assertSame(1, $this->roleAssignmentCount($user)); - $this->assertSame(1, $this->directPermissionAssignmentCount($user)); - } - public function testCustomRoleCleanupDoesNotDependOnRefreshesPermissionCache(): void { $role = StandaloneRole::query()->create([ @@ -257,27 +181,6 @@ private function directPermissionAssignmentCount(Model $model): int ->where('model_type', $model->getMorphClass()) ->count(); } - - /** - * Assert deletion rejects a missing model key before issuing queries. - */ - private function assertDeletionRejectsWithoutQueries(Model $model): void - { - DB::flushQueryLog(); - DB::enableQueryLog(); - - try { - $model->delete(); - $this->fail('Expected a missing model key exception was not thrown.'); - } catch (MissingAttributeException $exception) { - $this->assertStringContainsString($model->getKeyName(), $exception->getMessage()); - } finally { - $queries = DB::getQueryLog(); - DB::disableQueryLog(); - } - - $this->assertSame([], $queries); - } } class StandaloneRole extends Model implements RoleContract diff --git a/tests/Permission/DeniedPermissionTest.php b/tests/Permission/DeniedPermissionTest.php index 78a8ba5ad0..39570bd115 100644 --- a/tests/Permission/DeniedPermissionTest.php +++ b/tests/Permission/DeniedPermissionTest.php @@ -4,12 +4,9 @@ namespace Hypervel\Tests\Permission; -use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Permission\Contracts\Permission as PermissionContract; use Hypervel\Permission\Contracts\Role as RoleContract; use Hypervel\Permission\Exceptions\PermissionDoesNotExist; -use Hypervel\Permission\PermissionRegistrar; -use Hypervel\Permission\Support\Config; use Hypervel\Tests\Permission\Fixtures\Models\Permission; use Hypervel\Tests\Permission\Fixtures\Models\Role; use Hypervel\Tests\Permission\Fixtures\Models\User; @@ -130,7 +127,7 @@ public function testRoleDeniedPermissionOverridesAllowedRolePermission(): void $this->assertTrue($this->testUser->hasDeniedPermissionViaRoles('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); - $this->assertFalse($this->testUser->getPermissionsViaRoles()->contains('name', 'edit-articles')); + $this->assertSame([], $this->testUser->getPermissionsViaRoles()->all()); $this->assertFalse($this->testUser->getAllPermissions()->contains('name', 'edit-articles')); } @@ -167,7 +164,7 @@ public function testRoleDeniedPermissionForDifferentPermissionDoesNotDenyRequest $this->assertFalse($this->testUser->hasPermissionTo('edit-news')); } - public function testMissingPermissionStillThrowsOrChecksFalseWithRoleDeniedEdges(): void + public function testMissingPermissionStillThrowsOrChecksFalseWhenARoleHasDenies(): void { $this->testUserRole->denyPermissionTo('edit-articles'); $this->testUser->assignRole($this->testUserRole); @@ -202,7 +199,14 @@ public function testDeniedPermissionWinsWhenAllowedAndDeniedAreSyncedTogether(): denied: ['edit-news'], ); - $this->assertArrayHasKey('attached', $changes); + $this->assertSame([ + 'attached' => [ + $this->testUserPermission->getKey(), + $this->app->make(PermissionContract::class)::findByName('edit-news')->getKey(), + ], + 'detached' => [], + 'updated' => [], + ], $changes); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); $this->assertFalse($this->testUser->hasPermissionTo('edit-news')); $this->assertTrue($this->testUser->hasDeniedPermission('edit-news')); @@ -391,18 +395,6 @@ public function testDuplicateRoleGrantedPermissionsAreReturnedOnce(): void ); } - public function testDeniedDuplicateRolePermissionIsExcluded(): void - { - $allowedRole = $this->app->make(RoleContract::class)::create(['name' => 'duplicate-allowed']); - $deniedRole = $this->app->make(RoleContract::class)::create(['name' => 'duplicate-denied']); - - $allowedRole->givePermissionTo('edit-articles'); - $deniedRole->denyPermissionTo('edit-articles'); - $this->testUser->assignRole($allowedRole, $deniedRole); - - $this->assertSame([], $this->testUser->getPermissionsViaRoles()->pluck('name')->values()->all()); - } - public function testRoleDeniedSyncAffectsAllUsersWithRoleAfterCachesAreWarm(): void { $role = $this->app->make(RoleContract::class)::create(['name' => 'publisher']); @@ -466,75 +458,6 @@ public function testGetDeniedPermissionsReturnsDirectAndRoleDeniesOnce(): void ); } - public function testDirectPermissionChecksDenyWhenRelationContainsDuplicateEffects(): void - { - $permission = $this->app->make(PermissionContract::class)::findByName('edit-articles'); - $allowed = clone $permission; - $denied = clone $permission; - - $allowed->setRelation('pivot', Pivot::fromRawAttributes( - $this->testUser, - [ - $this->app->make(PermissionRegistrar::class)->pivotPermission => $permission->getKey(), - Config::morphKey() => $this->testUser->getKey(), - 'model_type' => $this->testUser->getMorphClass(), - 'is_denied' => false, - ], - Config::modelHasPermissionsTable(), - true, - )); - - $denied->setRelation('pivot', Pivot::fromRawAttributes( - $this->testUser, - [ - $this->app->make(PermissionRegistrar::class)->pivotPermission => $permission->getKey(), - Config::morphKey() => $this->testUser->getKey(), - 'model_type' => $this->testUser->getMorphClass(), - 'is_denied' => true, - ], - Config::modelHasPermissionsTable(), - true, - )); - - $this->testUser->setRelation('permissions', collect([$allowed, $denied])); - - $this->assertFalse($this->testUser->hasDirectPermission('edit-articles')); - } - - public function testRolePermissionChecksDenyWhenRelationContainsDuplicateEffects(): void - { - $permission = $this->app->make(PermissionContract::class)::findByName('edit-articles'); - $allowed = clone $permission; - $denied = clone $permission; - - $allowed->setRelation('pivot', Pivot::fromRawAttributes( - $this->testUserRole, - [ - $this->app->make(PermissionRegistrar::class)->pivotPermission => $permission->getKey(), - $this->app->make(PermissionRegistrar::class)->pivotRole => $this->testUserRole->getKey(), - 'is_denied' => false, - ], - Config::roleHasPermissionsTable(), - true, - )); - - $denied->setRelation('pivot', Pivot::fromRawAttributes( - $this->testUserRole, - [ - $this->app->make(PermissionRegistrar::class)->pivotPermission => $permission->getKey(), - $this->app->make(PermissionRegistrar::class)->pivotRole => $this->testUserRole->getKey(), - 'is_denied' => true, - ], - Config::roleHasPermissionsTable(), - true, - )); - - $this->testUserRole->setRelation('permissions', collect([$allowed, $denied])); - - $this->assertFalse($this->testUserRole->hasDirectPermission('edit-articles')); - $this->assertFalse($this->testUserRole->hasPermissionTo('edit-articles')); - } - public function testPermissionScopeExcludesDirectDeniedPermission(): void { $this->testUser->denyPermissionTo('edit-articles'); @@ -624,7 +547,7 @@ public function testWithoutPermissionScopeWithNoPermissionsMatchesAllModels(): v )); } - public function testRolePermissionScopeExcludesDeniedRolePermissionEdges(): void + public function testRolePermissionScopeExcludesRolesDeniedThePermission(): void { $this->testUserRole->denyPermissionTo('edit-articles'); diff --git a/tests/Permission/Events/EventTest.php b/tests/Permission/Events/EventTest.php index 9609d6b9fb..2cfde64c48 100644 --- a/tests/Permission/Events/EventTest.php +++ b/tests/Permission/Events/EventTest.php @@ -42,49 +42,6 @@ public function testRoleAttachedEventChecksListenersBeforeDispatching(): void $this->testUser->assignRole('testRole'); } - public function testRoleAttachedEventIsDispatchedWhenEnabledAndListenedFor(): void - { - $this->app->make('config')->set('permission.events_enabled', true); - - Event::fake([RoleAttachedEvent::class]); - - $this->testUser->assignRole('testRole'); - - Event::assertDispatched(RoleAttachedEvent::class, function (RoleAttachedEvent $event): bool { - return $event->model->is($this->testUser) - && $event->rolesOrIds === [$this->testUserRole->getKey()]; - }); - } - - public function testRoleDetachedEventIsDispatchedWhenEnabledAndListenedFor(): void - { - $this->testUser->assignRole('testRole'); - $this->app->make('config')->set('permission.events_enabled', true); - - Event::fake([RoleDetachedEvent::class]); - - $this->testUser->removeRole('testRole'); - - Event::assertDispatched(RoleDetachedEvent::class, function (RoleDetachedEvent $event): bool { - return $event->model->is($this->testUser) - && $event->rolesOrIds === [$this->testUserRole->getKey()]; - }); - } - - public function testPermissionAttachedEventIsDispatchedWhenEnabledAndListenedFor(): void - { - $this->app->make('config')->set('permission.events_enabled', true); - - Event::fake([PermissionAttachedEvent::class]); - - $this->testUser->givePermissionTo('edit-articles'); - - Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event): bool { - return $event->model->is($this->testUser) - && $event->permissionsOrIds === [$this->testUserPermission->getKey()]; - }); - } - public function testPermissionAttachedEventListenerSeesFreshWildcardIndexAfterPermissionAttach(): void { $this->app->make('config')->set('permission.enable_wildcard_permission', true); @@ -106,21 +63,6 @@ public function testPermissionAttachedEventListenerSeesFreshWildcardIndexAfterPe $this->assertTrue($listenerSawPermission); } - public function testSyncPermissionsDispatchesPermissionAttachedEventOnce(): void - { - $this->app->make('config')->set('permission.events_enabled', true); - - Event::fake([PermissionAttachedEvent::class]); - - $this->testUser->syncPermissions('edit-articles'); - - Event::assertDispatchedTimes(PermissionAttachedEvent::class, 1); - Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event): bool { - return $event->model->is($this->testUser) - && $event->permissionsOrIds === [$this->testUserPermission->getKey()]; - }); - } - public function testPermissionAttachedEventListenerSeesFreshWildcardIndexAfterPermissionSync(): void { $this->app->make('config')->set('permission.enable_wildcard_permission', true); @@ -142,26 +84,6 @@ public function testPermissionAttachedEventListenerSeesFreshWildcardIndexAfterPe $this->assertTrue($listenerSawPermission); } - public function testSyncPermissionEffectsDispatchesPermissionAttachedEventOnce(): void - { - $this->app->make('config')->set('permission.events_enabled', true); - - Event::fake([PermissionAttachedEvent::class]); - - $this->testUser->syncPermissionEffects( - allowed: ['edit-articles'], - denied: ['edit-news'], - ); - - $editNewsPermission = $this->app->make(PermissionContract::class)::findByName('edit-news'); - - Event::assertDispatchedTimes(PermissionAttachedEvent::class, 1); - Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event) use ($editNewsPermission): bool { - return $event->model->is($this->testUser) - && $event->permissionsOrIds === [$this->testUserPermission->getKey(), $editNewsPermission->getKey()]; - }); - } - public function testDeferredAssignmentsDispatchAtTheCallBoundaryWithoutSavedCallbackDuplicates(): void { $this->app->make('config')->set('permission.events_enabled', true); @@ -187,6 +109,8 @@ public function testDeferredAssignmentsDispatchAtTheCallBoundaryWithoutSavedCall $user->save(); + $this->assertSame(0, $user->permissions()->count()); + $this->assertSame(0, $user->roles()->count()); Event::assertDispatchedTimes(PermissionAttachedEvent::class, 1); Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event) use ($user): bool { return $event->model->is($user) @@ -238,24 +162,6 @@ public function testDeferredDeniedPermissionSyncDispatchesOnceBeforeSave(): void }); } - public function testPermissionDetachedEventIsDispatchedWhenEnabledAndListenedFor(): void - { - $this->testUser->givePermissionTo('edit-articles'); - $this->app->make('config')->set('permission.events_enabled', true); - - Event::fake([PermissionDetachedEvent::class]); - - $this->testUser->revokePermissionTo('edit-articles'); - - Event::assertDispatched(PermissionDetachedEvent::class, function (PermissionDetachedEvent $event): bool { - $permission = $event->permissionsOrIds; - - return $event->model->is($this->testUser) - && $permission instanceof PermissionContract - && $permission->getKey() === $this->testUserPermission->getKey(); - }); - } - public function testNoOpSavedAssignmentsDispatchRequestedPayloads(): void { $this->testUser->assignRole('testRole'); @@ -342,10 +248,12 @@ public function testPermissionSyncDispatchesCurrentDetachedAndRequestedAttachedP $this->testUser->syncPermissions('edit-articles', 'edit-news'); + Event::assertDispatchedTimes(PermissionAttachedEvent::class, 1); Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event) use ($permission): bool { return $event->model->is($this->testUser) && $event->permissionsOrIds === [$this->testUserPermission->getKey(), $permission->getKey()]; }); + Event::assertDispatchedTimes(PermissionDetachedEvent::class, 1); Event::assertDispatched(PermissionDetachedEvent::class, function (PermissionDetachedEvent $event): bool { return $event->model->is($this->testUser) && $event->permissionsOrIds->modelKeys() === [$this->testUserPermission->getKey()]; @@ -417,10 +325,12 @@ public function testPermissionEffectSyncReportsThePreOperationCollection(): void denied: ['edit-articles'], ); + Event::assertDispatchedTimes(PermissionDetachedEvent::class, 1); Event::assertDispatched(PermissionDetachedEvent::class, function (PermissionDetachedEvent $event): bool { return $event->model->is($this->testUser) && $event->permissionsOrIds->modelKeys() === [$this->testUserPermission->getKey()]; }); + Event::assertDispatchedTimes(PermissionAttachedEvent::class, 1); Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event) use ($permission): bool { return $event->model->is($this->testUser) && $event->permissionsOrIds === [$permission->getKey(), $this->testUserPermission->getKey()]; diff --git a/tests/Permission/PartitionModelTest.php b/tests/Permission/PartitionModelTest.php index fd4fb2721c..9a5acf2868 100644 --- a/tests/Permission/PartitionModelTest.php +++ b/tests/Permission/PartitionModelTest.php @@ -4,8 +4,6 @@ namespace Hypervel\Tests\Permission; -use Hypervel\Database\Eloquent\MissingAttributeException; -use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; use Hypervel\Permission\Exceptions\PermissionPartitionNotResolved; @@ -154,27 +152,6 @@ public function testStaleModelCannotBeDeletedQuietlyInAnotherPartition(): void $role->deleteQuietly(); } - public function testKeylessStaleModelReportsMissingIdentityBeforePartitionMismatch(): void - { - Model::preventAccessingMissingAttributes(false); - - $createdRole = PartitionedRole::create(['name' => 'owner']); - $keylessRole = PartitionedRole::query() - ->select(['name', 'guard_name', 'workspace_id']) - ->where('name', 'owner') - ->firstOrFail(); - $this->setPartition(self::PARTITION_B); - - try { - $keylessRole->delete(); - $this->fail('Expected a missing role key exception was not thrown.'); - } catch (MissingAttributeException $exception) { - $this->assertStringContainsString($keylessRole->getKeyName(), $exception->getMessage()); - } - - $this->assertTrue(DB::table('roles')->where('id', $createdRole->getKey())->exists()); - } - public function testStaleModelCannotBeRefreshedInAnotherPartition(): void { $permission = PartitionedPermission::create(['name' => 'articles.edit']); diff --git a/tests/Permission/PermissionCacheTransactionTest.php b/tests/Permission/PermissionCacheTransactionTest.php index 62cf6aa3ff..9ae819de00 100644 --- a/tests/Permission/PermissionCacheTransactionTest.php +++ b/tests/Permission/PermissionCacheTransactionTest.php @@ -619,6 +619,23 @@ public function testForwardAndReversePivotMutationsUsePermissionStorageConnectio $this->assertSame(0, $subjectConnection->table('model_has_roles')->count()); } + public function testCachedDirectPermissionPivotUsesPermissionStorageConnection(): void + { + [, $permissionConnection] = $this->setUpAliasedPermissionStorage(); + $user = AliasedPermissionUser::create(['email' => 'subject@example.com']); + $user->givePermissionTo(AliasedPermission::create(['name' => 'edit-articles'])); + + $cachedPivot = $user->getDirectPermissions()->sole()->pivot; + + $permissionConnection->beginTransaction(); + $cachedPivot->delete(); + $this->assertSame(0, $permissionConnection->table('model_has_permissions')->count()); + $permissionConnection->rollBack(); + + $this->assertSame(1, $permissionConnection->table('model_has_permissions')->count()); + $this->assertSame($user->permissions()->first()->pivot->getConnectionName(), $cachedPivot->getConnectionName()); + } + public function testCustomPermissionPivotUsesPermissionStorageConnection(): void { $this->setUpAliasedPermissionStorage(); diff --git a/tests/Permission/Traits/HasRolesTest.php b/tests/Permission/Traits/HasRolesTest.php index dfa21dfad0..dbd8cc662b 100644 --- a/tests/Permission/Traits/HasRolesTest.php +++ b/tests/Permission/Traits/HasRolesTest.php @@ -46,6 +46,16 @@ public function roles(): BelongsToMany } } +enum HasRolesIntegerRoleName: int +{ + case Seven = 7; +} + +enum HasRolesUuidRoleName: string +{ + case Auditor = '0b5c3f3e-9d7a-4c1e-8f2a-6b1d2e3f4a5b'; +} + class HasRolesTest extends TestCase { public function testItCanDetermineThatTheUserDoesNotHaveARole(): void @@ -120,6 +130,28 @@ public function testItCanAssignAndRemoveARoleUsingEnums(): void $this->assertFalse($this->testUser->hasRole($enum1)); } + public function testRoleChecksMatchBackedEnumValuesAgainstRoleNames(): void + { + $role = app(Role::class); + $roleWithKeySeven = $role->forceCreate([$role->getKeyName() => 7, 'name' => 'key-seven', 'guard_name' => 'web']); + $roleNamedSeven = $role->findOrCreate('7', 'web'); + $roleNamedUuid = $role->findOrCreate(HasRolesUuidRoleName::Auditor->value, 'web'); + + $this->testUser->assignRole($roleWithKeySeven); + + $this->assertFalse($this->testUser->hasRole(HasRolesIntegerRoleName::Seven)); + + $this->testUser->assignRole($roleNamedSeven, $roleNamedUuid); + + $this->assertTrue($this->testUser->hasRole(HasRolesIntegerRoleName::Seven)); + $this->assertTrue($this->testUser->hasRole(HasRolesUuidRoleName::Auditor, 'web')); + $this->assertFalse($this->testUser->hasRole(HasRolesUuidRoleName::Auditor, 'admin')); + $this->assertTrue($this->testUser->hasAnyRole('missing', HasRolesUuidRoleName::Auditor)); + $this->assertTrue($this->testUser->hasAllRoles(HasRolesUuidRoleName::Auditor)); + $this->assertTrue($this->testUser->hasAllRoles([HasRolesIntegerRoleName::Seven, HasRolesUuidRoleName::Auditor])); + $this->assertTrue($this->testUser->hasExactRoles(['key-seven', HasRolesIntegerRoleName::Seven, HasRolesUuidRoleName::Auditor])); + } + public function testItCanScopeARoleUsingEnums(): void { $enum1 = TestRolePermissionsEnum::UserManager; diff --git a/tests/Permission/Traits/TeamHasPermissionsTest.php b/tests/Permission/Traits/TeamHasPermissionsTest.php index fc64b25ca3..3a029df839 100644 --- a/tests/Permission/Traits/TeamHasPermissionsTest.php +++ b/tests/Permission/Traits/TeamHasPermissionsTest.php @@ -383,7 +383,7 @@ public function testAllowedPermissionFlipsExistingDeniedPermissionForCurrentTeam $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); } - public function testQueuedPermissionAssignmentsKeepSeparateTeamEdges(): void + public function testQueuedPermissionAssignmentsStaySeparatePerTeam(): void { $user = new User(['email' => 'queued-teams@example.com']); From be761cb81ce2387ef97eb9041fa760c9cd709ee7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:02:39 +0000 Subject: [PATCH 13/18] Simplify Permission partition machinery and run its suite on the database store Assess the partition machinery and its tests against spatie/laravel-permission main at 6615eefac655 (upstream has no partition support or tests). - Remove the provider's PermissionRegistrar singleton closure; the class is autowirable and auto-singletoned. flushState() no longer forgets the container instance: the test subscriber has already replaced the container, so the lookup only created an empty one. - resolvePartition() no longer wraps a non-scalar resolver result in an UnexpectedValueException; PermissionPartition's native types reject it. partitionFromRecord() no longer rejects an empty stored partition, which only raw SQL can write. - Partition relations marked eager-loaded collections in both initRelation() and match(). Builder::eagerLoadRelation() always passes the first's result to the second, so match() alone marks every model, including those without results. - Replace type-only instanceof checks missed in the previous slice with null checks in the registrar's settlement tokens, coroutine memos and key index, and in hasDeniedPermissionViaRoles(). - Tests: a sync's detached event payloads exclude the subject's assignments in another partition; an empty eager-loaded relation is marked current; exact role-assignment and removal query counts. Remove absence checks, cases built from states only raw SQL creates, cases reading back the tests' own schema, constructor reflection and duplicates. Use non-deprecated exception message expectations. - The partition query-count cases use the array permission cache, since a database store logs its statements between the counted queries. CI runs the Permission suite with the database cache store. Validation: Permission suite under ParaTest on the array, file, database and Redis stores; Postgres integration tests; the partition database test on SQLite and Postgres; php-cs-fixer and composer analyse. --- .github/workflows/tests.yml | 3 + src/permission/src/PermissionRegistrar.php | 31 ++--- .../src/PermissionServiceProvider.php | 8 -- .../Traits/EnforcesPermissionPartition.php | 46 ++----- src/permission/src/Traits/HasPermissions.php | 2 +- .../Database/PermissionPartitionTest.php | 25 +--- .../Commands/PartitionCommandTest.php | 20 +-- .../Permission/Events/PartitionEventTest.php | 104 ++-------------- .../Integration/PartitionQueryCountTest.php | 116 ++++-------------- tests/Permission/PartitionCacheTest.php | 18 --- tests/Permission/PartitionDeletionTest.php | 4 +- tests/Permission/PartitionModelTest.php | 42 ------- .../Permission/PartitionRegistrationTest.php | 99 ++++++--------- .../PartitionRelationProvenanceTest.php | 1 + tests/Permission/PartitionRelationsTest.php | 31 +---- tests/Permission/PartitionTeamsTest.php | 12 -- 16 files changed, 111 insertions(+), 451 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d65ed20b1..9baf8519a6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,6 +54,9 @@ jobs: - name: Run framework test suite run: vendor/bin/paratest -c phpunit.xml.dist + - name: Run Permission suite with the database cache store + run: CACHE_STORE=database vendor/bin/paratest -c phpunit.xml.dist tests/Permission + - name: Run Testbench package-mode suite run: php src/testbench/bin/testbench package:test --parallel tests/Testbench diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php index dd875925c0..eaf9c2159e 100644 --- a/src/permission/src/PermissionRegistrar.php +++ b/src/permission/src/PermissionRegistrar.php @@ -7,7 +7,6 @@ use Closure; use Hypervel\Cache\CacheManager; use Hypervel\Cache\ModelCacheCoordinator; -use Hypervel\Container\Container as BaseContainer; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Auth\Access\Authorizable; use Hypervel\Contracts\Auth\Access\Gate; @@ -41,7 +40,6 @@ use Hypervel\Support\Str; use InvalidArgumentException; use LogicException; -use UnexpectedValueException; use WeakMap; use WeakReference; @@ -199,14 +197,6 @@ public function resolvePartition(): ?PermissionPartition throw PermissionPartitionNotResolved::forColumn(static::$partitionColumn); } - if (! is_int($value) && ! is_string($value)) { - throw new UnexpectedValueException(sprintf( - 'Permission partition resolver for column "%s" returned %s; expected int, string, or null.', - static::$partitionColumn, - get_debug_type($value), - )); - } - return new PermissionPartition(static::$partitionColumn, $value); } @@ -233,7 +223,7 @@ public function partitionFromRecord(Model $model): PermissionPartition $value = null; } - if ((! is_int($value) && ! is_string($value)) || $value === '') { + if (! is_int($value) && ! is_string($value)) { throw PermissionPartitionViolation::forMissingRecordPartition($model, $column, $value); } @@ -575,7 +565,7 @@ private function afterCommitOnce( $settlement->deferred = true; $tokens = CoroutineContext::get(self::DIRTY_CACHE_TOKENS_CONTEXT_KEY, []); - if (($owner = $tokens[$cacheKey][$connectionName] ?? null) instanceof PermissionCacheSettlement) { + if (($owner = $tokens[$cacheKey][$connectionName] ?? null) !== null) { // Each nested record owns rollback cleanup without requiring a transaction-record identity map. $connection->afterRollBack(fn () => $rollBack($owner)); @@ -633,9 +623,8 @@ private function settlementForConnection( string $connection, ): ?PermissionCacheSettlement { $tokens = CoroutineContext::get(self::DIRTY_CACHE_TOKENS_CONTEXT_KEY, []); - $settlement = $tokens[$cacheKey][$connection] ?? null; - return $settlement instanceof PermissionCacheSettlement ? $settlement : null; + return $tokens[$cacheKey][$connection] ?? null; } /** @@ -888,7 +877,7 @@ public function rememberModelViaRolePermissions(Model $model, Closure $callback) $key = $this->modelRuntimeCacheKey($model); $items = CoroutineContext::get(self::MODEL_VIA_ROLE_PERMISSIONS_CONTEXT_KEY, []); - if (isset($items[$key]) && $items[$key] instanceof BaseCollection) { + if (isset($items[$key])) { return $items[$key]; } @@ -908,7 +897,7 @@ public function rememberModelDirectPermissions(Model $model, Closure $callback): $key = $this->modelRuntimeCacheKey($model); $items = CoroutineContext::get(self::MODEL_DIRECT_PERMISSIONS_CONTEXT_KEY, []); - if (isset($items[$key]) && $items[$key] instanceof BaseCollection) { + if (isset($items[$key])) { return $items[$key]; } @@ -1458,7 +1447,7 @@ private function modelClassCatalog(string $type, string $modelClass, Closure $lo ]); $catalogs = CoroutineContext::get(self::MODEL_CLASS_CATALOG_CONTEXT_KEY, []); - if (isset($catalogs[$key]) && $catalogs[$key] instanceof Collection) { + if (isset($catalogs[$key])) { return $catalogs[$key]; } @@ -1537,7 +1526,7 @@ protected function indexedModels(array $params, bool $onlyOne, string $modelType foreach ($ids as $id) { $model = $byKey[(string) $id] ?? null; - if (! $model instanceof Model) { + if ($model === null) { continue; } @@ -2120,11 +2109,5 @@ public static function flushState(): void static::$partitionColumn = null; static::$partitionResolver = null; static::$initialized = false; - - $app = BaseContainer::getInstance(); - - if ($app->bound(self::class)) { - $app->forgetInstance(self::class); - } } } diff --git a/src/permission/src/PermissionServiceProvider.php b/src/permission/src/PermissionServiceProvider.php index 01da98ceaf..424e34b7e3 100644 --- a/src/permission/src/PermissionServiceProvider.php +++ b/src/permission/src/PermissionServiceProvider.php @@ -5,11 +5,9 @@ namespace Hypervel\Permission; use Composer\InstalledVersions; -use Hypervel\Cache\ModelCacheCoordinator; use Hypervel\Container\Container; use Hypervel\Contracts\Auth\Access\Gate as GateContract; use Hypervel\Contracts\Auth\Factory as AuthFactory; -use Hypervel\Contracts\Foundation\Application; use Hypervel\Foundation\Console\AboutCommand; use Hypervel\Permission\Commands\AssignRoleCommand; use Hypervel\Permission\Commands\CacheResetCommand; @@ -39,12 +37,6 @@ public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/permission.php', 'permission'); - $this->app->singleton(PermissionRegistrar::class, fn (Application $app): PermissionRegistrar => new PermissionRegistrar( - $app->make('config'), - $app, - $app->make(ModelCacheCoordinator::class), - )); - $this->registerModelBindings(); $this->commands([ diff --git a/src/permission/src/Traits/EnforcesPermissionPartition.php b/src/permission/src/Traits/EnforcesPermissionPartition.php index 15f54c853b..e9b3932605 100644 --- a/src/permission/src/Traits/EnforcesPermissionPartition.php +++ b/src/permission/src/Traits/EnforcesPermissionPartition.php @@ -120,20 +120,6 @@ public function getResults(): Collection return $results; } - /** - * Initialize and mark eager-loaded relation collections. - * - * @param array $models - * @return array - */ - public function initRelation(array $models, string $relation): array - { - $models = parent::initRelation($models, $relation); - $this->markPermissionRelationCollections($models, $relation); - - return $models; - } - /** * Match and mark eager-loaded relation collections. * @@ -143,7 +129,16 @@ public function initRelation(array $models, string $relation): array public function match(array $models, EloquentCollection $results, string $relation): array { $models = parent::match($models, $results, $relation); - $this->markPermissionRelationCollections($models, $relation); + + // Models without results keep the empty collection initRelation() gave them, so they are marked too. + foreach ($models as $model) { + $this->permissionPartitionRegistrar->markLoadedRelation( + $model, + $relation, + $model->getRelation($relation), + $this->permissionRelationContext, + ); + } return $models; } @@ -174,25 +169,4 @@ private function ensureTeamIsSelectedForPivotMutation(): void $this->permissionRelationContext, ); } - - /** - * Mark the loaded collection attached to each eager-loaded model. - * - * @param array $models - */ - protected function markPermissionRelationCollections(array $models, string $relation): void - { - foreach ($models as $model) { - $collection = $model->getRelation($relation); - - if ($collection instanceof Collection) { - $this->permissionPartitionRegistrar->markLoadedRelation( - $model, - $relation, - $collection, - $this->permissionRelationContext, - ); - } - } - } } diff --git a/src/permission/src/Traits/HasPermissions.php b/src/permission/src/Traits/HasPermissions.php index 6fc55e44d7..3e435de806 100644 --- a/src/permission/src/Traits/HasPermissions.php +++ b/src/permission/src/Traits/HasPermissions.php @@ -1577,7 +1577,7 @@ public function hasDeniedPermissionViaRoles(int|Permission|string|UnitEnum $perm $guardName = $this->guardNameForPermissionMatch($permission, $guardName); $storedPermission = $this->permissionForMatch($permission, $guardName); - if (! $storedPermission instanceof Model) { + if ($storedPermission === null) { return false; } diff --git a/tests/Integration/Database/PermissionPartitionTest.php b/tests/Integration/Database/PermissionPartitionTest.php index b1cdd32828..2094fa0fb9 100644 --- a/tests/Integration/Database/PermissionPartitionTest.php +++ b/tests/Integration/Database/PermissionPartitionTest.php @@ -195,29 +195,6 @@ protected function setUpInCoroutine(): void $this->app->make(PermissionRegistrar::class)->forgetCachedPermissions(); } - public function testCompositeSchemaAndPartitionLeadingIndexesArePortable(): void - { - $roleIndexes = Schema::getIndexes('roles'); - $permissionIndexes = Schema::getIndexes('permissions'); - $modelRoleIndexes = Schema::getIndexes('model_has_roles'); - $modelPermissionIndexes = Schema::getIndexes('model_has_permissions'); - - $this->assertTrue(collect($roleIndexes)->contains( - fn (array $index): bool => $index['columns'] === ['workspace_id', 'id'] && $index['unique'], - )); - $this->assertTrue(collect($permissionIndexes)->contains( - fn (array $index): bool => $index['columns'] === ['workspace_id', 'id'] && $index['unique'], - )); - $this->assertTrue(collect($modelRoleIndexes)->contains( - fn (array $index): bool => $index['name'] === 'model_has_roles_partition_subject_index' - && $index['columns'] === ['workspace_id', 'model_type', 'model_test_id'], - )); - $this->assertTrue(collect($modelPermissionIndexes)->contains( - fn (array $index): bool => $index['name'] === 'model_has_permissions_partition_subject_index' - && $index['columns'] === ['workspace_id', 'model_type', 'model_test_id'], - )); - } - public function testSameNameAndGuardAreUniqueOnlyInsideAPartition(): void { $permissionA = PartitionedPermission::create(['name' => 'articles.edit']); @@ -233,7 +210,7 @@ public function testSameNameAndGuardAreUniqueOnlyInsideAPartition(): void PartitionedPermission::create(['name' => 'articles.edit']); } - public function testCompositeForeignKeysRejectCrossPartitionEdges(): void + public function testCompositeForeignKeysRejectCrossPartitionAssignments(): void { $roleA = PartitionedRole::create(['name' => 'editor']); diff --git a/tests/Permission/Commands/PartitionCommandTest.php b/tests/Permission/Commands/PartitionCommandTest.php index 6fcfcf30d9..1244d4cf49 100644 --- a/tests/Permission/Commands/PartitionCommandTest.php +++ b/tests/Permission/Commands/PartitionCommandTest.php @@ -111,6 +111,9 @@ public function testDataCommandsFailClosedWithoutPartitionContext(string $comman Artisan::call($command, $arguments); } + /** + * Get commands that read or write partitioned records. + */ public static function partitionRequiredCommands(): array { return [ @@ -135,21 +138,4 @@ public function testAssignRoleCommandFailsClosedWithoutPartitionContext(): void 'userModelNamespace' => GlobalPartitionUser::class, ]); } - - public function testCommandsDoNotInventAPartitionOption(): void - { - $commands = Artisan::all(); - - foreach ([ - 'permission:create-role', - 'permission:create-permission', - 'permission:assign-role', - 'permission:show', - 'permission:cache-reset', - 'permission:setup-teams', - ] as $name) { - $this->assertArrayHasKey($name, $commands); - $this->assertFalse($commands[$name]->getDefinition()->hasOption('partition')); - } - } } diff --git a/tests/Permission/Events/PartitionEventTest.php b/tests/Permission/Events/PartitionEventTest.php index 8f6f93bbf0..f2ee43593f 100644 --- a/tests/Permission/Events/PartitionEventTest.php +++ b/tests/Permission/Events/PartitionEventTest.php @@ -14,8 +14,6 @@ use Hypervel\Tests\Permission\Fixtures\Models\PartitionedPermission; use Hypervel\Tests\Permission\Fixtures\Models\PartitionedRole; use Hypervel\Tests\Permission\PartitionTestCase; -use ReflectionClass; -use ReflectionParameter; class PartitionEventTest extends PartitionTestCase { @@ -26,81 +24,22 @@ protected function defineEnvironment(ApplicationContract $app): void $app->make('config')->set('permission.events_enabled', true); } - public function testPartitionedRoleEventsKeepTheirExistingShape(): void + public function testSyncDetachedPayloadsOnlyIncludeTheCurrentPartition(): void { - Event::fake([RoleAttachedEvent::class, RoleDetachedEvent::class]); - $user = GlobalPartitionUser::create(['email' => 'global@example.com']); - $role = PartitionedRole::create(['name' => 'member']); - - $user->assignRole($role); - - Event::assertDispatched(RoleAttachedEvent::class, function (RoleAttachedEvent $event) use ($user, $role): bool { - return $event->model->is($user) - && $event->rolesOrIds === [$role->getKey()]; - }); - - $user->removeRole($role); - Event::assertDispatched(RoleDetachedEvent::class, function (RoleDetachedEvent $event) use ($user, $role): bool { - return $event->model->is($user) - && $event->rolesOrIds === [$role->getKey()]; - }); - - $this->assertEventConstructorIsUnchanged(RoleAttachedEvent::class, 'rolesOrIds'); - $this->assertEventConstructorIsUnchanged(RoleDetachedEvent::class, 'rolesOrIds'); - } - - public function testPartitionedPermissionEventsKeepTheirExistingShape(): void - { - Event::fake([PermissionAttachedEvent::class, PermissionDetachedEvent::class]); + $this->setPartition(self::PARTITION_B); + $user->assignRole(PartitionedRole::create(['name' => 'member'])); + $user->givePermissionTo(PartitionedPermission::create(['name' => 'articles.edit'])); - $user = GlobalPartitionUser::create(['email' => 'global@example.com']); - $permission = PartitionedPermission::create(['name' => 'articles.edit']); - - $user->givePermissionTo($permission); - - Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event) use ($user, $permission): bool { - return $event->model->is($user) - && $event->permissionsOrIds === [$permission->getKey()]; - }); - - $user->revokePermissionTo($permission); - - Event::assertDispatched(PermissionDetachedEvent::class, function (PermissionDetachedEvent $event) use ($user, $permission): bool { - return $event->model->is($user) - && $event->permissionsOrIds === $permission; - }); - - $this->assertEventConstructorIsUnchanged(PermissionAttachedEvent::class, 'permissionsOrIds'); - $this->assertEventConstructorIsUnchanged(PermissionDetachedEvent::class, 'permissionsOrIds'); - } - - public function testPartitionedNoOpAndSyncEventsPreserveRequestedUuidPayloads(): void - { - $user = GlobalPartitionUser::create(['email' => 'global@example.com']); + $this->setPartition(self::PARTITION_A); $member = PartitionedRole::create(['name' => 'member']); $owner = PartitionedRole::create(['name' => 'owner']); $edit = PartitionedPermission::create(['name' => 'articles.edit']); $publish = PartitionedPermission::create(['name' => 'articles.publish']); - $user->assignRole($member); $user->givePermissionTo($edit); - Event::fake([RoleAttachedEvent::class, PermissionAttachedEvent::class]); - - $user->assignRole($member); - $user->givePermissionTo($edit); - - Event::assertDispatched(RoleAttachedEvent::class, function (RoleAttachedEvent $event) use ($user, $member): bool { - return $event->model->is($user) - && $event->rolesOrIds === [$member->getKey()]; - }); - Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event) use ($user, $edit): bool { - return $event->model->is($user) - && $event->permissionsOrIds === [$edit->getKey()]; - }); - Event::fake([ PermissionAttachedEvent::class, PermissionDetachedEvent::class, @@ -108,41 +47,24 @@ public function testPartitionedNoOpAndSyncEventsPreserveRequestedUuidPayloads(): RoleDetachedEvent::class, ]); - $user->syncRoles($member, $owner); - $user->syncPermissions($edit, $publish); + $user->syncRoles($owner); + $user->syncPermissions($publish); Event::assertDispatched(RoleDetachedEvent::class, function (RoleDetachedEvent $event) use ($user, $member): bool { return $event->model->is($user) && $event->rolesOrIds === [$member->getKey()]; }); - Event::assertDispatched(RoleAttachedEvent::class, function (RoleAttachedEvent $event) use ($user, $member, $owner): bool { + Event::assertDispatched(RoleAttachedEvent::class, function (RoleAttachedEvent $event) use ($user, $owner): bool { return $event->model->is($user) - && $event->rolesOrIds === [$member->getKey(), $owner->getKey()]; - }); - Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event) use ($user, $edit, $publish): bool { - return $event->model->is($user) - && $event->permissionsOrIds === [$edit->getKey(), $publish->getKey()]; + && $event->rolesOrIds === [$owner->getKey()]; }); Event::assertDispatched(PermissionDetachedEvent::class, function (PermissionDetachedEvent $event) use ($user, $edit): bool { return $event->model->is($user) && $event->permissionsOrIds->modelKeys() === [$edit->getKey()]; }); - } - - /** - * Assert a permission assignment event retains its two public arguments. - * - * @param class-string $event - */ - private function assertEventConstructorIsUnchanged(string $event, string $assignmentArgument): void - { - $constructor = (new ReflectionClass($event))->getConstructor(); - - $this->assertNotNull($constructor); - $this->assertSame( - ['model', $assignmentArgument], - array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $constructor->getParameters()), - ); - $this->assertFalse((new ReflectionClass($event))->hasProperty('partition')); + Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event) use ($user, $publish): bool { + return $event->model->is($user) + && $event->permissionsOrIds === [$publish->getKey()]; + }); } } diff --git a/tests/Permission/Integration/PartitionQueryCountTest.php b/tests/Permission/Integration/PartitionQueryCountTest.php index 30746a3c29..37c98a199f 100644 --- a/tests/Permission/Integration/PartitionQueryCountTest.php +++ b/tests/Permission/Integration/PartitionQueryCountTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\Permission\Integration; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Permission\Events\RoleDetachedEvent; use Hypervel\Permission\PermissionRegistrar; -use Hypervel\Permission\Support\Config; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Event; use Hypervel\Tests\Permission\Fixtures\Models\GlobalPartitionUser; @@ -16,6 +16,15 @@ class PartitionQueryCountTest extends PartitionTestCase { + protected function defineEnvironment(ApplicationContract $app): void + { + parent::defineEnvironment($app); + + // These cases count permission-table queries. A database cache store would log its own + // statements between them; the cache tests count those. + $app->make('config')->set('permission.cache.store', 'array'); + } + public function testColdCatalogKeepsThreeQueriesAndAddsPartitionPredicates(): void { PartitionedRole::create(['name' => 'editor']); @@ -77,33 +86,26 @@ public function testColdAuthorizationRetainsCatalogAndAssignmentQueryShape(): vo ); } - public function testResolverLookupAndOrdinaryMutationsAddNoDiscoveryQuery(): void + public function testRoleAssignmentUsesOnePivotReadAndOneInsert(): void { - $registrar = $this->app->make(PermissionRegistrar::class); $user = GlobalPartitionUser::create(['email' => 'global@example.com']); $role = PartitionedRole::create(['name' => 'editor']); DB::enableQueryLog(); DB::flushQueryLog(); - $partition = $registrar->resolvePartition(); - - $this->assertNotNull($partition); - $this->assertSame([], DB::getQueryLog()); - $user->assignRole($role); $queries = DB::getQueryLog(); - $this->assertNotEmpty($queries); - $discoveryQueries = array_filter($queries, static function (array $query): bool { - $sql = strtolower($query['query']); + $this->assertCount(2, $queries); - return str_contains($sql, 'distinct') - && (str_contains($sql, Config::modelHasRolesTable()) - || str_contains($sql, Config::modelHasPermissionsTable())); - }); + foreach ($queries as $query) { + $this->assertStringContainsString('workspace_id', $query['query']); + $this->assertContains(self::PARTITION_A, $query['bindings']); + } - $this->assertSame([], $discoveryQueries); + $this->assertStringStartsWith('select', strtolower($queries[0]['query'])); + $this->assertStringContainsString('insert into', strtolower($queries[1]['query'])); } public function testRoleSyncUsesOnePivotReadAndOneBulkInsertWithoutListeners(): void @@ -236,94 +238,30 @@ public function testPermissionEffectSyncBatchesMixedFlipsIntoTwoUpdates(): void $this->assertNotContains($unchangedDenied->getKey(), $queries[2]['bindings']); } - public function testRoleRemovalWithoutAListenerUsesOneBlindDelete(): void - { - $user = GlobalPartitionUser::create(['email' => 'blind-delete@example.com']); - $role = PartitionedRole::create(['name' => 'member']); - $user->assignRole($role); - DB::enableQueryLog(); - DB::flushQueryLog(); - - $user->removeRole($role); - - $queries = DB::getQueryLog(); - - $this->assertCount(1, $queries); - $this->assertStringContainsString('delete from', strtolower($queries[0]['query'])); - $this->assertStringContainsString('workspace_id', $queries[0]['query']); - } - - public function testSingleRoleRemovalWithAListenerUsesOneDelete(): void - { - $user = GlobalPartitionUser::create(['email' => 'single-delete@example.com']); - $role = PartitionedRole::create(['name' => 'member']); - $user->assignRole($role); - $this->app->make('config')->set('permission.events_enabled', true); - Event::fake([RoleDetachedEvent::class]); - DB::enableQueryLog(); - DB::flushQueryLog(); - - $user->removeRole($role); - - $queries = DB::getQueryLog(); - - $this->assertCount(1, $queries); - $this->assertStringContainsString('delete from', strtolower($queries[0]['query'])); - Event::assertDispatched( - RoleDetachedEvent::class, - fn (RoleDetachedEvent $event): bool => $event->rolesOrIds === [$role->getKey()], - ); - } - - public function testMultipleRoleRemovalWithAListenerUsesOneBlindDelete(): void + public function testRoleRemovalUsesOneDeleteAndReportsTheRequestedRoles(): void { - $user = GlobalPartitionUser::create(['email' => 'multiple-delete@example.com']); - $firstRole = PartitionedRole::create(['name' => 'editor']); - $secondRole = PartitionedRole::create(['name' => 'publisher']); - $user->assignRole($firstRole, $secondRole); + $user = GlobalPartitionUser::create(['email' => 'remove@example.com']); + $assignedRole = PartitionedRole::create(['name' => 'editor']); + $unassignedRole = PartitionedRole::create(['name' => 'publisher']); + $user->assignRole($assignedRole); $this->app->make('config')->set('permission.events_enabled', true); Event::fake([RoleDetachedEvent::class]); DB::enableQueryLog(); DB::flushQueryLog(); - $user->removeRole($secondRole, $firstRole); + $user->removeRole($unassignedRole, $assignedRole); $queries = DB::getQueryLog(); $this->assertCount(1, $queries); - $this->assertStringContainsString('model_has_roles', $queries[0]['query']); $this->assertStringContainsString('delete from', strtolower($queries[0]['query'])); - Event::assertDispatched( - RoleDetachedEvent::class, - fn (RoleDetachedEvent $event): bool => $event->rolesOrIds === [ - $secondRole->getKey(), - $firstRole->getKey(), - ], - ); - } - - public function testEmptyMultipleRoleRemovalWithAListenerStillReportsTheRequest(): void - { - $user = GlobalPartitionUser::create(['email' => 'empty-delete@example.com']); - $firstRole = PartitionedRole::create(['name' => 'editor']); - $secondRole = PartitionedRole::create(['name' => 'publisher']); - $this->app->make('config')->set('permission.events_enabled', true); - Event::fake([RoleDetachedEvent::class]); - DB::enableQueryLog(); - DB::flushQueryLog(); - - $user->removeRole($firstRole, $secondRole); - - $queries = DB::getQueryLog(); - - $this->assertCount(1, $queries); $this->assertStringContainsString('model_has_roles', $queries[0]['query']); - $this->assertStringContainsString('delete from', strtolower($queries[0]['query'])); + $this->assertContains(self::PARTITION_A, $queries[0]['bindings']); Event::assertDispatched( RoleDetachedEvent::class, fn (RoleDetachedEvent $event): bool => $event->rolesOrIds === [ - $firstRole->getKey(), - $secondRole->getKey(), + $unassignedRole->getKey(), + $assignedRole->getKey(), ], ); } diff --git a/tests/Permission/PartitionCacheTest.php b/tests/Permission/PartitionCacheTest.php index f9fe92a146..5b23bca6b9 100644 --- a/tests/Permission/PartitionCacheTest.php +++ b/tests/Permission/PartitionCacheTest.php @@ -163,24 +163,6 @@ public function testReverseSyncRotatesOnlyTheAmbientPartitionToken(): void $this->assertSame($tokenB, $registrar->modelAssignmentCacheToken()); } - public function testCacheResetClearsOnlyTheAmbientPartition(): void - { - PartitionedRole::create(['name' => 'role-a']); - $registrar = $this->app->make(PermissionRegistrar::class); - $registrar->getPermissions(); - $keyA = $registrar->getCacheKey(); - - $this->setPartition(self::PARTITION_B); - PartitionedRole::create(['name' => 'role-b']); - $registrar->getPermissions(); - $keyB = $registrar->getCacheKey(); - - $registrar->forgetCachedPermissions(); - - $this->assertTrue($registrar->getCacheRepository()->has($keyA)); - $this->assertFalse($registrar->getCacheRepository()->has($keyB)); - } - public function testCacheSegmentsCannotCollideAcrossSeparatorsOrNullValues(): void { $first = new PermissionPartition('workspace_id', 'a:b'); diff --git a/tests/Permission/PartitionDeletionTest.php b/tests/Permission/PartitionDeletionTest.php index e076ee76cf..70a27b4ac2 100644 --- a/tests/Permission/PartitionDeletionTest.php +++ b/tests/Permission/PartitionDeletionTest.php @@ -14,7 +14,7 @@ class PartitionDeletionTest extends PartitionTestCase { - public function testRoleDeleteRemovesOnlyItsPartitionEdges(): void + public function testRoleDeleteRemovesOnlyItsPartitionAssignments(): void { $user = GlobalPartitionUser::create(['email' => 'global@example.com']); $roleA = PartitionedRole::create(['name' => 'member']); @@ -41,7 +41,7 @@ public function testRoleDeleteRemovesOnlyItsPartitionEdges(): void $this->assertSame(1, DB::table('role_has_permissions')->count()); } - public function testPermissionDeleteRemovesOnlyItsPartitionEdges(): void + public function testPermissionDeleteRemovesOnlyItsPartitionAssignments(): void { $roleA = PartitionedRole::create(['name' => 'member']); $permissionA = PartitionedPermission::create(['name' => 'articles.edit']); diff --git a/tests/Permission/PartitionModelTest.php b/tests/Permission/PartitionModelTest.php index 9a5acf2868..75fffb46d6 100644 --- a/tests/Permission/PartitionModelTest.php +++ b/tests/Permission/PartitionModelTest.php @@ -191,48 +191,6 @@ public function testNarrowedModelReportsItsMissingPersistedPartitionBeforeDeleti $this->assertTrue(DB::table('roles')->where('id', $createdRole->getKey())->exists()); } - public function testEmptyPersistedPartitionIsRenderedDistinctlyBeforeDeletion(): void - { - $role = PartitionedRole::create(['name' => 'owner']); - $role->setRawAttributes([ - ...$role->getAttributes(), - 'workspace_id' => '', - ], true); - - try { - $role->delete(); - $this->fail('Expected an empty persisted partition to fail.'); - } catch (PermissionPartitionViolation $exception) { - $this->assertSame( - 'Partitioned model `' . PartitionedRole::class . '` has no valid persisted value for permission partition column `workspace_id`; received `\'\' (empty string)`.', - $exception->getMessage(), - ); - } - - $this->assertTrue(DB::table('roles')->where('id', $role->getKey())->exists()); - } - - public function testNonScalarPersistedPartitionIsRenderedByTypeBeforeDeletion(): void - { - $role = PartitionedRole::create(['name' => 'owner']); - $role->setRawAttributes([ - ...$role->getAttributes(), - 'workspace_id' => [], - ], true); - - try { - $role->delete(); - $this->fail('Expected a non-scalar persisted partition to fail.'); - } catch (PermissionPartitionViolation $exception) { - $this->assertSame( - 'Partitioned model `' . PartitionedRole::class . '` has no valid persisted value for permission partition column `workspace_id`; received `array`.', - $exception->getMessage(), - ); - } - - $this->assertTrue(DB::table('roles')->where('id', $role->getKey())->exists()); - } - public function testCreatingListenerCannotReplaceTheCapturedPartition(): void { PartitionedRole::creating(function (PartitionedRole $role): void { diff --git a/tests/Permission/PartitionRegistrationTest.php b/tests/Permission/PartitionRegistrationTest.php index 1f446b954c..3c098187cf 100644 --- a/tests/Permission/PartitionRegistrationTest.php +++ b/tests/Permission/PartitionRegistrationTest.php @@ -18,15 +18,13 @@ use Hypervel\Permission\PermissionRegistrar; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; -use stdClass; -use UnexpectedValueException; use UnitEnum; class PartitionRegistrationTest extends TestCase { public function testPartitioningIsDisabledByDefault(): void { - PermissionRegistrar::flushState(); + $this->resetPermissionRegistrar(); $registrar = $this->app->make(PermissionRegistrar::class); @@ -39,7 +37,7 @@ public function testPartitioningIsDisabledByDefault(): void #[DataProvider('validPartitionValues')] public function testItResolvesValidPartitionValues(int|string $value): void { - PermissionRegistrar::flushState(); + $this->resetPermissionRegistrar(); PermissionRegistrar::resolvePartitionUsing('workspace_id', fn (): int|string => $value); $partition = $this->app->make(PermissionRegistrar::class)->resolvePartition(); @@ -49,6 +47,9 @@ public function testItResolvesValidPartitionValues(int|string $value): void $this->assertSame($value, $partition->value); } + /** + * Get partition values the resolver may return. + */ public static function validPartitionValues(): array { return [ @@ -63,15 +64,18 @@ public static function validPartitionValues(): array #[DataProvider('unresolvedPartitionValues')] public function testItFailsClosedWhenThePartitionCannotBeResolved(?string $value): void { - PermissionRegistrar::flushState(); + $this->resetPermissionRegistrar(); PermissionRegistrar::resolvePartitionUsing('workspace_id', fn (): ?string => $value); $this->expectException(PermissionPartitionNotResolved::class); - $this->expectExceptionMessage('workspace_id'); + $this->expectExceptionMessageIsOrContains('workspace_id'); $this->app->make(PermissionRegistrar::class)->resolvePartition(); } + /** + * Get resolver results that leave the partition unresolved. + */ public static function unresolvedPartitionValues(): array { return [ @@ -80,59 +84,20 @@ public static function unresolvedPartitionValues(): array ]; } - #[DataProvider('invalidPartitionValues')] - public function testItRejectsInvalidPartitionValues(mixed $value, string $type): void - { - PermissionRegistrar::flushState(); - PermissionRegistrar::resolvePartitionUsing('workspace_id', fn (): mixed => $value); - - $this->expectException(UnexpectedValueException::class); - $this->expectExceptionMessage($type); - - $this->app->make(PermissionRegistrar::class)->resolvePartition(); - } - - public static function invalidPartitionValues(): array - { - return [ - 'true' => [true, 'bool'], - 'false' => [false, 'bool'], - 'float' => [1.5, 'float'], - 'array' => [[], 'array'], - 'object' => [new stdClass, stdClass::class], - ]; - } - - public function testItRejectsAResourcePartitionValue(): void - { - $resource = fopen('php://memory', 'r+'); - - $this->assertIsResource($resource); - - try { - PermissionRegistrar::flushState(); - PermissionRegistrar::resolvePartitionUsing('workspace_id', fn () => $resource); - - $this->expectException(UnexpectedValueException::class); - $this->expectExceptionMessage('resource (stream)'); - - $this->app->make(PermissionRegistrar::class)->resolvePartition(); - } finally { - fclose($resource); - } - } - #[DataProvider('invalidPartitionColumns')] public function testItRejectsInvalidPartitionColumns(string $column): void { - PermissionRegistrar::flushState(); + $this->resetPermissionRegistrar(); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('simple SQL identifier'); + $this->expectExceptionMessageIsOrContains('simple SQL identifier'); PermissionRegistrar::resolvePartitionUsing($column, fn (): string => 'workspace-a'); } + /** + * Get column names that are not simple SQL identifiers. + */ public static function invalidPartitionColumns(): array { return [ @@ -147,7 +112,7 @@ public static function invalidPartitionColumns(): array public function testItRejectsDuplicateRegistration(): void { - PermissionRegistrar::flushState(); + $this->resetPermissionRegistrar(); PermissionRegistrar::resolvePartitionUsing('workspace_id', fn (): string => 'workspace-a'); $this->expectException(PermissionPartitionAlreadyConfigured::class); @@ -157,7 +122,7 @@ public function testItRejectsDuplicateRegistration(): void public function testItRejectsRegistrationAfterRegistrarInitialization(): void { - PermissionRegistrar::flushState(); + $this->resetPermissionRegistrar(); $this->app->make(PermissionRegistrar::class); $this->expectException(PermissionPartitionAlreadyConfigured::class); @@ -167,7 +132,7 @@ public function testItRejectsRegistrationAfterRegistrarInitialization(): void public function testProviderRegistrationCanConfigurePartitioningBeforeGateResolution(): void { - PermissionRegistrar::flushState(); + $this->resetPermissionRegistrar(); $this->app->forgetInstance(Gate::class); PermissionRegistrar::resolvePartitionUsing('workspace_id', fn (): string => 'workspace-a'); @@ -182,7 +147,7 @@ public function testProviderRegistrationCanConfigurePartitioningBeforeGateResolu public function testResolvingGateBeforeProviderRegistrationMakesLateConfigurationFail(): void { - PermissionRegistrar::flushState(); + $this->resetPermissionRegistrar(); $this->app->forgetInstance(Gate::class); $this->app->make(Gate::class); @@ -194,9 +159,9 @@ public function testResolvingGateBeforeProviderRegistrationMakesLateConfiguratio public function testFlushStateClearsRegistrationAndRegistrarInitialization(): void { - PermissionRegistrar::flushState(); + $this->resetPermissionRegistrar(); PermissionRegistrar::resolvePartitionUsing('workspace_id', fn (): string => 'workspace-a'); - $firstRegistrar = $this->app->make(PermissionRegistrar::class); + $this->app->make(PermissionRegistrar::class); PermissionRegistrar::flushState(); @@ -204,25 +169,26 @@ public function testFlushStateClearsRegistrationAndRegistrarInitialization(): vo $this->assertNull(PermissionRegistrar::partitionColumn()); PermissionRegistrar::resolvePartitionUsing('realm_id', fn (): string => 'realm-a'); - $secondRegistrar = $this->app->make(PermissionRegistrar::class); - $this->assertNotSame($firstRegistrar, $secondRegistrar); - $this->assertSame('realm_id', $secondRegistrar->resolvePartition()?->column); + $this->assertSame('realm_id', $this->app->make(PermissionRegistrar::class)->resolvePartition()?->column); } #[DataProvider('unsupportedPartitionedModels')] public function testPartitioningRejectsContractOnlyModels(string $configKey, string $model, string $requiredBase): void { - PermissionRegistrar::flushState(); + $this->resetPermissionRegistrar(); $this->app->make('config')->set($configKey, $model); PermissionRegistrar::resolvePartitionUsing('workspace_id', fn (): string => 'workspace-a'); $this->expectException(PermissionPartitionModelNotSupported::class); - $this->expectExceptionMessage("Partitioned permission model `{$model}` must extend `{$requiredBase}`."); + $this->expectExceptionMessageIs("Partitioned permission model `{$model}` must extend `{$requiredBase}`."); $this->app->make(PermissionRegistrar::class); } + /** + * Get contract-only models with the package model they must extend. + */ public static function unsupportedPartitionedModels(): array { return [ @@ -233,7 +199,7 @@ public static function unsupportedPartitionedModels(): array public function testUnpartitionedModeKeepsContractOnlyModelSupport(): void { - PermissionRegistrar::flushState(); + $this->resetPermissionRegistrar(); $this->app->make('config')->set([ 'permission.models.role' => ContractOnlyRole::class, 'permission.models.permission' => ContractOnlyPermission::class, @@ -244,6 +210,15 @@ public function testUnpartitionedModeKeepsContractOnlyModelSupport(): void $this->assertSame(ContractOnlyRole::class, $registrar->getRoleClass()); $this->assertSame(ContractOnlyPermission::class, $registrar->getPermissionClass()); } + + /** + * Clear the partition registration and the resolved registrar. + */ + private function resetPermissionRegistrar(): void + { + PermissionRegistrar::flushState(); + $this->app->forgetInstance(PermissionRegistrar::class); + } } class ContractOnlyRole extends Model implements RoleContract diff --git a/tests/Permission/PartitionRelationProvenanceTest.php b/tests/Permission/PartitionRelationProvenanceTest.php index 5955d46480..f3b82494fe 100644 --- a/tests/Permission/PartitionRelationProvenanceTest.php +++ b/tests/Permission/PartitionRelationProvenanceTest.php @@ -42,6 +42,7 @@ public function testEmptyLoadedRelationIsNotReusedInAnotherPartition(): void $user = GlobalPartitionUser::create(['email' => 'global@example.com']); $user->load('roles'); + $this->assertTrue($this->app->make(PermissionRegistrar::class)->loadedRelationIsCurrent($user, 'roles')); $this->assertFalse($user->hasRole('member')); $this->setPartition(self::PARTITION_B); diff --git a/tests/Permission/PartitionRelationsTest.php b/tests/Permission/PartitionRelationsTest.php index ba89e1eec8..a2dfc23701 100644 --- a/tests/Permission/PartitionRelationsTest.php +++ b/tests/Permission/PartitionRelationsTest.php @@ -274,7 +274,7 @@ public function testBulkPublicAttachRemainsOneInsert(): void $this->assertSame(3, DB::table(Config::modelHasRolesTable())->count()); } - public function testPivotUpdatesCannotMoveAnExistingEdge(): void + public function testPivotUpdatesCannotMoveAnExistingAssignment(): void { $user = GlobalPartitionUser::create(['email' => 'global@example.com']); $permission = PartitionedPermission::create(['name' => 'articles.edit']); @@ -1211,34 +1211,15 @@ public function testRelationExistenceQueriesStayInsideTheCurrentPartition(): voi $roleB = PartitionedRole::create(['name' => 'member']); $userB->assignRole($roleB); - $this->assertSame( - [$userB->getKey()], - GlobalPartitionUser::query()->whereHas('roles')->pluck('id')->all(), - ); - - $this->setPartition(self::PARTITION_A); - - $this->assertSame( - [$userA->getKey()], - GlobalPartitionUser::query()->whereHas('roles')->pluck('id')->all(), - ); - } - - public function testAllRelationExistenceShapesStayInsideTheCurrentPartition(): void - { - $userA = GlobalPartitionUser::create(['email' => 'a@example.com']); - $userB = GlobalPartitionUser::create(['email' => 'b@example.com']); - $roleA = PartitionedRole::create(['name' => 'member']); - $userA->assignRole($roleA); - - $this->setPartition(self::PARTITION_B); - $roleB = PartitionedRole::create(['name' => 'member']); - $userB->assignRole($roleB); - + $this->assertSame([$userB->getKey()], GlobalPartitionUser::query()->whereHas('roles')->pluck('id')->all()); $this->assertSame([$userB->getKey()], GlobalPartitionUser::query()->has('roles')->pluck('id')->all()); $this->assertSame([$userA->getKey()], GlobalPartitionUser::query()->whereDoesntHave('roles')->pluck('id')->all()); $this->assertSame(1, GlobalPartitionUser::query()->withCount('roles')->findOrFail($userB->getKey())->roles_count); $this->assertSame(0, GlobalPartitionUser::query()->withCount('roles')->findOrFail($userA->getKey())->roles_count); + + $this->setPartition(self::PARTITION_A); + + $this->assertSame([$userA->getKey()], GlobalPartitionUser::query()->whereHas('roles')->pluck('id')->all()); } public function testNarrowedPersistedRoleCannotConstructRelations(): void diff --git a/tests/Permission/PartitionTeamsTest.php b/tests/Permission/PartitionTeamsTest.php index 117563be65..f7a29b2b2b 100644 --- a/tests/Permission/PartitionTeamsTest.php +++ b/tests/Permission/PartitionTeamsTest.php @@ -9,7 +9,6 @@ use Hypervel\Permission\Support\Config; use Hypervel\Support\ClassInvoker; use Hypervel\Support\Facades\DB; -use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Permission\Fixtures\Models\GlobalPartitionPermissionsOnlyUser; use Hypervel\Tests\Permission\Fixtures\Models\GlobalPartitionUser; use Hypervel\Tests\Permission\Fixtures\Models\PartitionedPermission; @@ -21,17 +20,6 @@ class PartitionTeamsTest extends PartitionTestCase { protected bool $partitionTeams = true; - public function testOnlyRoleTeamColumnsAreNullable(): void - { - $roleColumns = collect(Schema::getColumns(Config::rolesTable())); - $roleAssignmentColumns = collect(Schema::getColumns(Config::modelHasRolesTable())); - $permissionAssignmentColumns = collect(Schema::getColumns(Config::modelHasPermissionsTable())); - - $this->assertTrue($roleColumns->firstWhere('name', 'team_test_id')['nullable']); - $this->assertFalse($roleAssignmentColumns->firstWhere('name', 'team_test_id')['nullable']); - $this->assertFalse($permissionAssignmentColumns->firstWhere('name', 'team_test_id')['nullable']); - } - public function testPartitionAndTeamRemainIndependentAssignmentDimensions(): void { $teamA1 = PartitionWorkspaceTeam::create(['workspace_id' => self::PARTITION_A, 'name' => 'A One']); From 89dfafb5c127bd41ea1878e6677dca67feb35edf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:45:21 +0000 Subject: [PATCH 14/18] Reconcile the Permission guide with upstream docs and fix cache resets Compare every spatie/laravel-permission docs page at main 6615eefac655 with the user guide, checking each claim against current source. - Port the applicable upstream coverage: authorizable user models and reserved trait names, default guards and guard resolution, permission-side role methods, ID and enum lookups, super-admin options, the can and package middleware with aliases, guards, pipes, priority and controller middleware, Blade directives, command arguments, team middleware and team roles, wildcard syntax, model extension, pivot timestamps, seeding, test seeding with the Seeder attribute, best practices and a policy example that always returns. - Correct stale or wrong guidance: sync query counts, UUID migration changes, global role uniqueness, reloading relations after switching teams, separate-connection limits and the default_model fallback. - Not ported: upstream's example app, PhpStorm, UI and upgrade pages, MySQL key-length notes and an attribute example with a static call, which is invalid PHP. The guide's differences list moves to the README, which gains the cache store's lock requirement and the worker-wide cache with partitioning for tenants. - Bulk allow/deny updates skipped updated_at on permission relations using withTimestamps(); they now set it like updateExistingPivot(). - The create migration and the teams stub forgot only the catalog key, while upstream's single key is its whole cache. Per-model assignment caches therefore survived recreated tables on shared stores: a model whose key was reused read the old role keys, and after enabling teams a check without a team read pre-teams assignments. Both also forget the assignment token now, so the guide's opening seeder reset goes. - The sync note maps the catalog and assignment caches, the relation builders and the diff-based syncs. A " #" in it had started a YAML comment that cut off the rest of the note. Validation: Permission suite under ParaTest on the array, database and Redis stores; Postgres integration tests; the migration regressions fail on the database and Redis stores without the fix; php-cs-fixer and composer analyse. --- docs/upstream-sync/sync.yaml | 2 +- src/docs/permission.md | 931 +++++++++++++----- src/permission/README.md | 2 + src/permission/config/permission.php | 2 +- ..._07_02_000000_create_permission_tables.php | 8 +- .../migrations/add_teams_fields.php.stub | 8 +- src/permission/src/Traits/HasPermissions.php | 21 +- tests/Permission/CacheTest.php | 39 + tests/Permission/CustomPivotTest.php | 52 + tests/Permission/PartitionCustomPivotTest.php | 4 + 10 files changed, 804 insertions(+), 265 deletions(-) diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index 1b0d258563..861cb71b2b 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -163,7 +163,7 @@ spatie/laravel-permission: checked_through: null last_reviewed_pr: null sync_date: null - notes: Upstream Pest files map to PHPUnit classes at the same paths under tests/Permission, with each it()/test() description as the method name. Upstream's permission.testing migration flag, which adds the roles team column while teams are off so cases can enable teams mid-test, is not ported; those cases enable teams before migrating with #[DefineEnvironment('usesTeams')]. The CACHE_DRIVER cache-driver test runs map to CACHE_STORE. Passport::actingAsClient() maps to TestCase::actingAsClient() with the PassportGuard fixture because Hypervel has no Passport package. Octane listeners and register_octane_reset_listener are not ported because team IDs and loaded catalogs are coroutine-local. + notes: Upstream Pest files map to PHPUnit classes at the same paths under tests/Permission, with each it()/test() description as the method name. Upstream's permission.testing migration flag, which adds the roles team column while teams are off so cases can enable teams mid-test, is not ported; those cases enable teams before migrating through the DefineEnvironment attribute's usesTeams method. The CACHE_DRIVER cache-driver test runs map to CACHE_STORE. Passport::actingAsClient() maps to TestCase::actingAsClient() with the PassportGuard fixture because Hypervel has no Passport package. Upstream's single cached permission collection maps to PermissionRegistrar's catalog payload (getSerializedPermissionsForCache() stores role keys per permission; permissionCatalog() and modelClassCatalog() hydrate it) plus the per-model assignment caches (rememberModel*()), which are filled through Cache\ModelCacheCoordinator and invalidated after commit; apply upstream cache or loading changes to both. Upstream relation definitions map to the Role and Permission relation methods built through BuildsPermissionRelations, whose Partitioned* relations add partition and team constraints and loaded-relation tracking. Upstream's detach-then-attach syncs map to HasRoles::syncRoles() and HasPermissions::synchronizePermissionAssignments(), which read the current pivots and write only the differences, keeping each permission's is_denied effect. Upstream docs map to sections of src/docs/permission.md; the example app, PhpStorm, UI options, schema diagram, upgrade and project pages are not ported. aimeos/laravel-nestedset: branch: master diff --git a/src/docs/permission.md b/src/docs/permission.md index 717b142e89..77e23fe9d4 100644 --- a/src/docs/permission.md +++ b/src/docs/permission.md @@ -32,6 +32,8 @@ - [Permission Middleware](#permission-middleware) - [Role Middleware](#role-middleware) - [Role Or Permission Middleware](#role-or-permission-middleware) + - [Middleware Aliases](#middleware-aliases) + - [Controller Middleware](#controller-middleware) - [Passport Client Credentials](#passport-client-credentials) - [Blade Directives](#blade-directives) - [Route Macros](#route-macros) @@ -46,25 +48,44 @@ - [Partition Cache and Performance](#partition-cache-and-performance) - [Raw and Bulk Writes](#raw-and-bulk-writes) - [Teams](#teams) + - [Setting the Current Team](#setting-the-current-team) + - [Team Roles](#team-roles) + - [Switching Teams](#switching-teams) - [Wildcard Permissions](#wildcard-permissions) - [Polymorphic Models](#polymorphic-models) - [Custom Models](#custom-models) + - [Adding Columns](#adding-columns) - [Permission Database Connection](#permission-database-connection) - [Custom Pivot Models](#custom-pivot-models) - [Deleting Models](#deleting-models) - [UUID and ULID Keys](#uuid-and-ulid-keys) - [Caching](#caching) - [Testing and Seeding](#testing-and-seeding) + - [Seeding](#seeding) + - [Testing](#testing) - [Best Practices](#best-practices) - [Performance](#performance) - [Exceptions](#exceptions) -- [Differences From Spatie Laravel Permission](#differences-from-spatie-laravel-permission) - [Credits](#credits) ## Introduction -Hypervel's permission package provides role-based access control for Eloquent models. A permission represents one ability, such as `edit articles`. A role is a named group of permissions, such as `editor`. You may assign roles and permissions to users or other models, then check access by role, direct permission, or permission inherited through a role. +Hypervel's permission package provides role-based access control for Eloquent models. A permission represents one ability, such as `edit articles`. A role is a named group of permissions, such as `editor`. You may assign roles and permissions to users or other models, then check access by role, direct permission, or permission inherited through a role: + +```php +$user->givePermissionTo('edit articles'); + +$role->givePermissionTo('edit articles'); + +$user->assignRole('writer'); +``` + +Permissions are registered with Hypervel's [authorization gate](/docs/{{version}}/authorization), so you may check them using `can`, `@can`, policies, and authorization middleware as usual: + +```php +$user->can('edit articles'); +``` The package also supports denied permissions, which explicitly reject an ability even when the model receives the same permission directly or through a role. @@ -113,12 +134,16 @@ The published migration creates the following tables: - `model_has_permissions` - `model_has_roles` -The `role_has_permissions` and `model_has_permissions` tables include an `is_denied` column used by denied permissions. +The `role_has_permissions` and `model_has_permissions` tables include an `is_denied` column used by [denied permissions](#denied-permissions). + +The migration reads its table and column names from the permission configuration file, so make these decisions before running it: -> [!WARNING] -> If you customize the table or column names in the permission configuration file, update the published migration before running it. +- Customize the [table and column names](#table-and-column-names) in the configuration file. +- Enable [teams](#teams) if you plan to use them, so the migration adds the team columns. +- Change the key column types in the published migration if your models use [UUID or ULID keys](#uuid-and-ulid-keys). +- Replace the migration with a [partitioned schema](#partitioned-schema) if you will use row partitioning. -The default migration uses `['name', 'guard_name']` uniqueness, so the same name may be used by different guards. Applications using row partitioning include the partition column first in that unique key. +The default migration makes `name` and `guard_name` unique together, so the same name may be used by different guards. ## Configuration @@ -130,14 +155,16 @@ You may customize the models used for roles and permissions: ```php 'models' => [ - 'role' => App\Models\Role::class, 'permission' => App\Models\Permission::class, + 'role' => App\Models\Role::class, + 'team' => App\Models\Team::class, + 'default_model' => null, ], ``` -Custom role models must implement the `Hypervel\Permission\Contracts\Role` contract. Custom permission models must implement the `Hypervel\Permission\Contracts\Permission` contract. The easiest way to satisfy these contracts is to extend the package's base models. +Custom role models must implement the `Hypervel\Permission\Contracts\Role` contract. Custom permission models must implement the `Hypervel\Permission\Contracts\Permission` contract. The easiest way to satisfy these contracts is to [extend the package's base models](#custom-models). -When row partitioning is enabled, custom Role and Permission models must extend the package's base models. This preserves the partition global scope and the protections for Eloquent instance writes, deletes, refreshes, restoration, and quiet operations. +The `team` model is used by the [teams](#teams) feature and may be `null` when teams are disabled. The `default_model` is used when you pass raw IDs to a role's [`assignToModels`](#assigning-models-to-a-role) method and similar methods; when it is `null`, the user model of the role's guard is used. ### Table and Column Names @@ -188,7 +215,7 @@ return [ ]; ``` -When `store` is omitted or set to `default`, the application's default cache store is used. A store that isn't defined in your cache configuration throws an exception. The expiration defaults to 24 hours when omitted. Separate keys isolate the permission catalog, model-role assignments, direct model permissions, and the assignment namespace token so mutations can invalidate only the affected data. Omitted key members use the package names shown in the example. The `column_names_except` list removes unneeded model attributes from the cached catalog; required identity, guard, team, and partition columns cannot be excluded. +When `store` is omitted or set to `default`, the application's default cache store is used. A store that isn't defined in your cache configuration throws an exception. The expiration defaults to 24 hours when omitted. The separate keys hold the role and permission catalog, each model's roles, each model's direct permissions, and a token that versions the assignment caches, so a change only clears the data it affects. Omitted keys use the names shown in the example. The `column_names_except` list removes columns you do not need from the cached roles and permissions; their key, name, guard, team, and partition columns cannot be excluded. You may include required role or permission names in authorization exception messages: @@ -211,21 +238,25 @@ declare(strict_types=1); namespace App\Models; -use Hypervel\Database\Eloquent\Model; +use Hypervel\Foundation\Auth\User as Authenticatable; use Hypervel\Permission\Traits\HasRoles; -class User extends Model +class User extends Authenticatable { use HasRoles; + + // ... } ``` -The `HasRoles` trait includes the permission methods, so a model using `HasRoles` may receive roles and direct permissions. +The `HasRoles` trait includes the permission methods, so a model using `HasRoles` may receive roles and direct permissions. Checks such as `can` and `@can` also require the model to implement the `Hypervel\Contracts\Auth\Access\Authorizable` contract, which the base `Authenticatable` user class already does. + +The trait defines `roles` and `permissions` relationships on your model. Your model should not have its own `role`, `roles`, `permission`, or `permissions` attributes, columns, methods, or relationships, since they would conflict with the trait. ## Multiple Guards -Roles and permissions are scoped by guard name. If your app uses multiple guards, create the role or permission for the guard that will authorize it: +Roles and permissions belong to a guard, so each guard has its own set of roles and permissions. When you create a role or permission without a `guard_name`, your application's default authentication guard is used. If your app uses multiple guards, create the role or permission for the guard that will authorize it: ```php use Hypervel\Permission\Models\Permission; @@ -234,6 +265,8 @@ use Hypervel\Permission\Models\Role; Role::create(['name' => 'manager', 'guard_name' => 'admin']); Permission::create(['name' => 'publish articles', 'guard_name' => 'admin']); + +Permission::create(['name' => 'publish articles', 'guard_name' => 'web']); ``` You may pass the guard name when checking a permission or role: @@ -244,7 +277,9 @@ $user->hasPermissionTo('publish articles', 'admin'); $user->hasRole('manager', 'admin'); ``` -When a model can use more than one guard, define a `guardName` method or `$guard_name` property: +A model may only receive roles and permissions for its own guards. The package determines a model's guards from its `guardName` method, then its `$guard_name` property, then the configured guards whose user provider uses the model's class. Names are looked up for the model's default guard, and assigning a role or permission model that belongs to another guard throws a `GuardDoesNotMatch` exception. + +When a model can use more than one guard, return each guard from the `guardName` method or `$guard_name` property: ```php public function guardName(): array @@ -253,7 +288,7 @@ public function guardName(): array } ``` -If your app uses a single guard for all roles and permissions, return that guard from the model so you do not need duplicate role and permission records: +If your app uses one set of roles and permissions for every guard, return a single guard from the model so you do not need duplicate role and permission records: ```php protected string $guard_name = 'web'; @@ -326,7 +361,31 @@ $role->givePermissionTo('edit articles'); $role->givePermissionTo('delete articles', 'publish articles'); +$role->revokePermissionTo('delete articles'); + $role->syncPermissions(['edit articles', 'publish articles']); + +if ($role->hasPermissionTo('edit articles')) { + // ... +} +``` + +You may also work from the permission side using the `assignRole`, `removeRole`, and `syncRoles` methods: + +```php +$permission->assignRole($role); + +$permission->removeRole($role); + +$permission->syncRoles(['writer', 'editor']); +``` + +Models with a role receive its permissions automatically. The role's `permissions` relationship returns every permission assigned to the role, including [denied permissions](#denied-permissions); each related model's `pivot->is_denied` attribute tells you which effect it has: + +```php +$names = $role->permissions->pluck('name'); + +$count = $role->permissions->count(); ``` To replace a role's allowed and denied permissions at the same time, use `syncPermissionEffects`: @@ -344,7 +403,7 @@ $role->syncPermissionEffects( ### Assigning Roles -You may assign roles by name, ID, enum, array, or variadic arguments: +You may assign roles by name, ID, enum, or `Role` model, and pass several roles as separate arguments, an array, or a collection: ```php $user->assignRole('writer'); @@ -354,8 +413,12 @@ $user->assignRole('writer', 'editor'); $user->assignRole(['writer', 'editor']); $user->assignRole($writer->id); + +$user->assignRole($writer); ``` +Integers and UUID or ULID strings are looked up by key; other strings are role names. + To replace all of a model's roles, use `syncRoles`: ```php @@ -365,21 +428,24 @@ $user->syncRoles('writer', 'editor'); ### Assigning Models to a Role -You may also assign models from the role side: +Sometimes it is more convenient to work from the role's side, such as on an admin screen listing every user with a role. The `assignToModels`, `removeFromModels`, and `syncModels` methods do the inverse of `assignRole`, `removeRole`, and `syncRoles`: ```php use Hypervel\Permission\Models\Role; $role = Role::findByName('writer'); +// Give the role to two users... $role->assignToModels([$userA, $userB]); +// Remove it from one user... $role->removeFromModels($userA); +// Replace every model that has this role... $role->syncModels([$userB, $userC]); ``` -These methods accept models, model IDs, arrays, and collections. When you pass raw IDs, pass the model class as the second argument or configure `permission.models.default_model`: +These methods accept a model, an ID, or an array or collection mixing models and IDs. Models may be of any class using `HasRoles`. When you pass raw IDs, the package uses the class given as the second argument, then the `default_model` configuration value, then the user model of the role's guard: ```php $role->assignToModels([1, 2, 3], App\Models\User::class); @@ -395,15 +461,35 @@ if ($user->hasRole('writer')) { // ... } -if ($user->hasAnyRole(['writer', 'editor'])) { +// The model has at least one of the roles... +if ($user->hasRole(['editor', 'moderator'])) { + // ... +} + +if ($user->hasAnyRole('writer', 'reader')) { // ... } if ($user->hasAllRoles(['writer', 'editor'])) { // ... } + +// The model has these roles and no others... +if ($user->hasExactRoles(['writer', 'editor'])) { + // ... +} +``` + +These methods also accept `Role` models, collections, and strings separated by `|`, such as `'writer|editor'`. The `hasRole`, `hasAllRoles`, and `hasExactRoles` methods accept a guard name as their second argument. + +You may retrieve the names of a model's roles using `getRoleNames`: + +```php +$roles = $user->getRoleNames(); ``` +Prefer [permission checks](#checking-permissions) for application behavior and keep role checks for the rare rules that depend on the role itself. See [best practices](#best-practices) for details. + ### Role and Team Scopes @@ -413,6 +499,18 @@ You may query models by assigned roles: $writers = User::role('writer')->get(); $usersWithoutWriterRole = User::withoutRole('writer')->get(); + +$managerCount = User::role('manager')->count(); +``` + +The scopes accept role names, IDs, enums, `Role` models, arrays, and collections, with an optional guard name as the second argument. Since roles and permissions are Eloquent relationships, you may also use the usual Eloquent methods: + +```php +$users = User::with('roles')->get(); + +$usersWithoutRoles = User::doesntHave('roles')->get(); + +$roleNames = Role::pluck('name'); ``` When teams are enabled, you may also scope models by team: @@ -465,18 +563,26 @@ The `hasPermissionTo` method checks direct permissions and permissions inherited if ($user->hasPermissionTo('edit articles')) { // ... } + +if ($user->hasPermissionTo($permission->id, 'admin')) { + // ... +} ``` -You may also check direct permissions, or inspect permissions inherited through roles: +You may pass a permission name, ID, enum, or `Permission` model, with an optional guard name as the second argument. Integers and UUID or ULID strings are looked up by key; other strings are permission names. The `hasPermissionTo` method throws a `PermissionDoesNotExist` exception when no matching permission exists, while `checkPermissionTo` returns `false` instead. + +A direct permission is assigned to the model itself rather than through one of its roles. For example, if the `writer` role may edit articles and the user is also given permission to delete articles, only the second is a direct permission: ```php -if ($user->hasDirectPermission('edit articles')) { - // ... -} +$role->givePermissionTo('edit articles'); -if ($user->getPermissionsViaRoles()->contains('name', 'edit articles')) { - // ... -} +$user->assignRole('writer'); +$user->givePermissionTo('delete articles'); + +$user->hasDirectPermission('delete articles'); // true +$user->hasDirectPermission('edit articles'); // false + +$user->getPermissionsViaRoles()->contains('name', 'edit articles'); // true ``` You may check whether a model has any or all of a given set of permissions: @@ -511,7 +617,7 @@ $editors = User::permission('edit articles')->get(); $usersWithoutEditPermission = User::withoutPermission('edit articles')->get(); ``` -The `permission` and `withoutPermission` query scopes filter by effective stored permissions. Direct and role-granted denies override allows for the same permission. Wildcard permission strings are evaluated by runtime permission checks such as `hasPermissionTo`; query scopes match stored concrete permission records. +The `permission` scope returns models that are allowed the permission directly or through a role and are not denied it, while `withoutPermission` returns the rest. The scopes accept permission names, IDs, enums, `Permission` models, arrays, and collections. They match assigned permissions exactly, so [wildcard permissions](#wildcard-permissions) apply to checks such as `hasPermissionTo` but not to these scopes. ### Gate and Super Admins @@ -524,9 +630,10 @@ if ($user->can('edit articles')) { } ``` -For super-admin behavior, register your own Gate `before` callback before normal policy checks: +To give a super-admin role every ability without assigning it every permission, register a Gate [`before` callback](/docs/{{version}}/authorization#intercepting-gate-checks) in the `boot` method of your application's `AppServiceProvider`: ```php +use App\Models\User; use Hypervel\Support\Facades\Gate; Gate::before(function (User $user, string $ability): ?bool { @@ -534,14 +641,26 @@ Gate::before(function (User $user, string $ability): ?bool { }); ``` -Direct package calls such as `hasPermissionTo` do not pass through Gate callbacks. Use `can`, `canAny`, policies, middleware, or Blade authorization checks when you want Gate-level behavior to apply. +The callback should return `null` rather than `false` for other users, since a `false` result denies the ability before policies and permissions are checked. If you only want super-admin access for some models, you may use a [policy's `before` method](/docs/{{version}}/authorization#policy-filters) instead. + +You may also use a Gate `after` callback, which runs after the policies and permissions. Its result is only used when the gate, policies, and permissions returned `null`, so super-admins are still refused abilities your policies deny to everyone, such as writing a second review: + +```php +Gate::after(function (User $user, string $ability): bool { + return $user->hasRole('super-admin'); +}); +``` + +Direct package calls such as `hasPermissionTo`, `hasAnyPermission`, and `hasDirectPermission` do not pass through the gate, so super-admin callbacks do not apply to them. Use `can`, `canAny`, policies, the permission middleware, or Blade authorization checks when you want the gate to apply. + +When [teams](#teams) are enabled, a global super-admin role still has to be assigned to the user within each team. ### Denied Permissions -Denied permissions explicitly reject access. The permission assignment tables store `is_denied` as the effect for the assignment edge, so a model or role has one row for a given permission in the current team context. +Denied permissions explicitly reject access. Each assignment row stores whether it allows or denies the permission in its `is_denied` column, so a model or role has at most one assignment for a given permission (per team, when teams are enabled). -Calling `denyPermissionTo` for an allowed permission flips that assignment to a deny. Calling `givePermissionTo` for a denied permission flips it back to an allow. A denied permission overrides an allowed permission, including permissions inherited through roles: +Calling `denyPermissionTo` for an allowed permission changes that assignment to a deny. Calling `givePermissionTo` for a denied permission changes it back to an allow. A denied permission overrides an allowed permission, including permissions inherited through roles: ```php $user->givePermissionTo('delete articles'); @@ -580,9 +699,7 @@ $user->syncPermissionEffects( ); ``` -When this method is called before a model is saved, the assignments are queued -until save and the returned change set is empty because no database rows changed -yet. +The method returns the IDs it `attached`, `detached`, and `updated`. When it is called before the model is saved, the assignments are written when the model is saved and the returned arrays are empty, since no rows have changed yet. ### Revoking Permissions @@ -595,39 +712,45 @@ $user->revokePermissionTo('edit articles'); $user->revokePermissionTo(['edit articles', 'delete articles']); ``` -This removes the assignment edge whether it is currently allowed or denied. +This removes the assignment whether it currently allows or denies the permission. ### Retrieving Permissions -You may retrieve the permissions a model receives directly and through roles: +You may retrieve the permissions a model receives directly, through its roles, or both: ```php -$permissions = $user->getAllPermissions(); -``` +// Permissions assigned directly to the model... +$permissions = $user->getDirectPermissions(); -To retrieve only permissions inherited through roles, use `getPermissionsViaRoles`: +// Permissions inherited from the model's roles... +$permissions = $user->getPermissionsViaRoles(); -```php -$rolePermissions = $user->getPermissionsViaRoles(); +// Direct and inherited permissions... +$permissions = $user->getAllPermissions(); + +// The names of the direct permissions... +$names = $user->getPermissionNames(); ``` -`getDirectPermissions`, `getPermissionsViaRoles`, `getAllPermissions`, and `getPermissionNames` return allowed permissions. Use `getDeniedPermissions` to retrieve [denied permissions](#denied-permissions). +These methods return the permissions the model is allowed and leave out [denied permissions](#denied-permissions), which you may retrieve using `getDeniedPermissions`. The model's `permissions` relationship returns every direct assignment, including denied ones; each related model's `pivot->is_denied` attribute tells you which effect it has. ## Using Enums -Role and permission methods accept backed enums and unit enums. Backed enums use their `value`; unit enums use their case `name`. +You may use enums in place of role and permission names. String-backed enums use their value as the name, while unit enums use their case name. Separate enums for roles and permissions are usually easier to manage: ```php -enum Permission: string +namespace App\Enums; + +enum PermissionName: string { case EditArticles = 'edit articles'; case DeleteArticles = 'delete articles'; case PublishArticles = 'publish articles'; } -enum Role: string +enum RoleName: string { case Writer = 'writer'; case Editor = 'editor'; @@ -635,19 +758,34 @@ enum Role: string } ``` -You may pass enum cases to role and permission methods: +You may pass enum cases when creating and finding roles and permissions, and to the role and permission methods: ```php -$user->assignRole(Role::Writer); +use App\Enums\PermissionName; +use App\Enums\RoleName; + +$role = Role::create(['name' => RoleName::Writer]); +$role = Role::findByName(RoleName::Writer); +$permission = Permission::findOrCreate(PermissionName::EditArticles); + +$user->assignRole(RoleName::Writer); + +$user->givePermissionTo(PermissionName::EditArticles); + +if ($user->hasPermissionTo(PermissionName::EditArticles)) { + // ... +} +``` -$user->givePermissionTo(Permission::EditArticles); +The gate [accepts enum abilities](/docs/{{version}}/authorization#enum-abilities) too, so you may pass the same cases to `can` and `@can`: -if ($user->hasPermissionTo(Permission::EditArticles)) { +```php +if ($user->can(PermissionName::EditArticles)) { // ... } ``` -Unit enum case names are used as the role or permission name: +Unit enums work the same way, using their case names: ```php enum SimplePermission @@ -662,7 +800,19 @@ $user->givePermissionTo(SimplePermission::EditArticles); ## Middleware -The package includes route middleware for checking roles and permissions. Middleware checks require the authenticated user model to use the matching permission methods. +Since permissions are registered with the gate, you may protect a route with a single permission using Hypervel's built-in [`can` middleware](/docs/{{version}}/authorization#via-middleware): + +```php +use Hypervel\Auth\Middleware\Authorize; + +Route::post('/articles', [ArticleController::class, 'store']) + ->middleware('can:publish articles'); + +Route::post('/articles', [ArticleController::class, 'store']) + ->middleware(Authorize::using('publish articles')); +``` + +The package also includes `PermissionMiddleware`, `RoleMiddleware`, and `RoleOrPermissionMiddleware` for checking several permissions or roles at once. They require the authenticated user model to use the `HasRoles` trait. When the user is not authenticated or lacks the required roles or permissions, they throw a `Hypervel\Permission\Exceptions\UnauthorizedException`, which renders a 403 response. ### Permission Middleware @@ -678,13 +828,18 @@ Route::get('/admin', [AdminController::class, 'index']) ->middleware(PermissionMiddleware::using('view admin')); ``` -When multiple permissions are provided, the user only needs one of them: +When multiple permissions are provided, the user only needs one of them. You may pass an array or a string separated by `|`, and a guard name as the second argument: ```php Route::get('/posts/edit', [PostController::class, 'edit']) ->middleware(PermissionMiddleware::using(['edit articles', 'edit all articles'])); + +Route::get('/api/posts/edit', [PostController::class, 'edit']) + ->middleware(PermissionMiddleware::using('edit articles|edit all articles', 'api')); ``` +The permission middleware checks each permission through the gate, so [super-admin callbacks](#gate-and-super-admins) apply to it. + ### Role Middleware @@ -706,14 +861,14 @@ Route::get('/editor', [EditorController::class, 'index']) ->middleware(RoleMiddleware::using(['editor', 'admin'])); ``` -Middleware may also receive enum cases: +Middleware may also receive [enum](#using-enums) cases: ```php Route::get('/admin', [AdminController::class, 'index']) - ->middleware(PermissionMiddleware::using(Permission::EditArticles)); + ->middleware(PermissionMiddleware::using(PermissionName::EditArticles)); Route::get('/editor', [EditorController::class, 'index']) - ->middleware(RoleMiddleware::using([Role::Editor, Role::Admin])); + ->middleware(RoleMiddleware::using([RoleName::Editor, RoleName::Admin])); ``` @@ -728,7 +883,65 @@ Route::get('/content', [ContentController::class, 'index']) ->middleware(RoleOrPermissionMiddleware::using(['editor', 'edit articles'])); ``` -If the user is not authenticated or does not have the required role or permission, the middleware throws `Hypervel\Permission\Exceptions\UnauthorizedException`. + +### Middleware Aliases + +The package registers the `role`, `permission`, and `role_or_permission` middleware aliases for you. Separate several names with `|`, and add a guard name after a comma: + +```php +Route::middleware('role:manager')->group(function () { + // ... +}); + +Route::get('/articles/create', [ArticleController::class, 'create']) + ->middleware('permission:publish articles|edit articles'); + +Route::get('/articles/{article}', [ArticleController::class, 'show']) + ->middleware('role_or_permission:manager|edit articles'); + +Route::get('/api/admin', [AdminController::class, 'index']) + ->middleware('role:manager,api'); +``` + +If a route returns a 404 response where you expect a 403, route model binding may be running before the permission check. You may [sort the middleware](/docs/{{version}}/middleware#sorting-middleware) so the package's middleware runs before `SubstituteBindings`: + +```php +use Hypervel\Foundation\Configuration\Middleware; +use Hypervel\Permission\Middleware\PermissionMiddleware; +use Hypervel\Routing\Middleware\SubstituteBindings; + +->withMiddleware(function (Middleware $middleware): void { + $middleware->prependToPriorityList( + before: SubstituteBindings::class, + prepend: PermissionMiddleware::class, + ); +}) +``` + + +### Controller Middleware + +You may also apply the middleware in a controller's [`middleware` method](/docs/{{version}}/controllers#controller-middleware) or with the [`Middleware` and `Authorize` attributes](/docs/{{version}}/controllers#middleware-attributes): + +```php +use Hypervel\Routing\Attributes\Controllers\Authorize; +use Hypervel\Routing\Attributes\Controllers\Middleware; + +#[Middleware('role:manager', except: ['show'])] +class ArticleController +{ + public function show() + { + // ... + } + + #[Authorize('publish articles')] + public function store() + { + // ... + } +} +``` ### Passport Client Credentials @@ -756,40 +969,52 @@ class Client extends BaseClient implements AuthorizableContract } ``` -Set the client model in Passport, then protect client-credentials routes with this package's role or permission middleware. The permission middleware will use the Passport client when the request has a bearer token and no normal authenticated user. +The client's `$guard_name` property or `guardName` method should return the name of your Passport guard. Set the client model in Passport, then protect client-credentials routes with this package's middleware. When the request has a bearer token and no authenticated user, the role, permission, and role-or-permission middleware authorize the client of the first guard using the `passport` driver. ## Blade Directives -The package registers Blade conditionals for roles and permissions: +Since permissions are registered with the gate, you may check them using Hypervel's `@can`, `@cannot`, and `@canany` directives. To check a permission for a specific guard, pass the guard name as the second argument: ```blade -@haspermission('edit articles') +@can('edit articles') ... -@endhaspermission +@endcan -@role('admin') +@can('edit articles', 'admin') ... +@endcan +``` + +The package also registers a `@haspermission` directive, which checks the permission without the gate. There is no `@hasanypermission` directive; use `@canany` instead. + +Although [permission checks are preferred](#best-practices), the package also provides directives for checking the authenticated user's roles: + +```blade +@role('writer') + I am a writer! +@else + I am not a writer... @endrole -@hasanyrole(['writer', 'editor']) - ... +@hasanyrole('writer|admin') + I am a writer, an admin, or both! @endhasanyrole -@hasallroles(['writer', 'editor']) - ... +@hasallroles(['writer', 'admin']) + I am both a writer and an admin! @endhasallroles -@hasexactroles(['writer', 'editor']) - ... +@hasexactroles('writer|admin') + I am a writer and an admin, and have no other roles! @endhasexactroles @unlessrole('guest') - ... + I am not a guest... @endunlessrole ``` -Pass the guard name as the second argument when needed: +The `@hasrole` directive is an alias of `@role`. Role directives accept a role name, an array, a collection, or names separated by `|`. To check the user of a specific authentication guard, pass the guard name as the second argument: ```blade @role('admin', 'api') @@ -814,19 +1039,20 @@ Route::get('/content', [ContentController::class, 'index']) ## Custom Permission Checks -By default, the package registers a Gate `before` callback that delegates permission checks to `hasPermissionTo`: +By default, the package registers a Gate `before` callback that checks each ability with the user's `checkPermissionTo` method. The callback returns `true` when the user has the permission and `null` otherwise, so your policies and other gate callbacks still decide the remaining abilities: ```php 'register_permission_check_method' => true, ``` -Set this to `false` only when you want to register your own Gate logic: +Set this to `false` only when you want to replace that check with your own logic. For example, if your application issues access tokens that carry the user's permissions, you might check the token instead of the database: ```php 'register_permission_check_method' => false, ``` ```php +use App\Models\User; use Hypervel\Support\Facades\Gate; Gate::before(function (User $user, string $ability): ?bool { @@ -834,37 +1060,49 @@ Gate::before(function (User $user, string $ability): ?bool { }); ``` +Here, `hasTokenPermission` is a method you would implement on your own model. + ## Events -Role and permission assignment events are disabled by default: +Role and permission assignment events are disabled by default. Enable them in the permission configuration file when your app listens for assignment changes: ```php -'events_enabled' => false, +'events_enabled' => true, ``` -Enable them when your app listens for assignment changes: +The package dispatches the following events. Each receives the affected `$model`, along with the roles or permissions involved: -```php -'events_enabled' => true, -``` +
-The package may dispatch these events: +| Event | Roles or Permissions Property | +| --- | --- | +| `Hypervel\Permission\Events\RoleAttachedEvent` | `$rolesOrIds` | +| `Hypervel\Permission\Events\RoleDetachedEvent` | `$rolesOrIds` | +| `Hypervel\Permission\Events\PermissionAttachedEvent` | `$permissionsOrIds` | +| `Hypervel\Permission\Events\PermissionDetachedEvent` | `$permissionsOrIds` | + +
+ +Events are only dispatched when they are enabled and have a listener: ```php -Hypervel\Permission\Events\RoleAttachedEvent::class; -Hypervel\Permission\Events\RoleDetachedEvent::class; -Hypervel\Permission\Events\PermissionAttachedEvent::class; -Hypervel\Permission\Events\PermissionDetachedEvent::class; -``` +use Hypervel\Permission\Events\RoleAttachedEvent; +use Hypervel\Support\Facades\Event; -Events are only dispatched when events are enabled and the event dispatcher has listeners for the event class. +Event::listen(function (RoleAttachedEvent $event) { + $user = $event->model; + $roleIds = $event->rolesOrIds; +}); +``` -Assignment events preserve Spatie's request-oriented payloads. Role attach and detach events, as well as permission attach events, contain the requested IDs, including already-satisfied or empty requests. A direct permission removal passes the stored Permission model or collection to `PermissionDetachedEvent`. +The roles or permissions may be IDs, a model, or an array or collection of either, so inspect the value before acting on it: -Saved permission replacement operations dispatch a complete replacement pair. `PermissionDetachedEvent` receives the direct Permission collection as it existed before the operation, including permissions retained by the replacement. `PermissionAttachedEvent` then receives the requested replacement IDs. A same-set replacement therefore dispatches both events. The detached event is dispatched first, after the transaction succeeds and any affected permission cache has been cleared. A failed transaction dispatches neither event. Unsaved models have no stored collection to detach, so their queued replacement dispatches only the attached event. +- Assigning, removing, and syncing roles, and giving or denying permissions, pass the requested IDs, even when nothing changed. +- Revoking a permission passes the `Permission` model or models. +- Syncing roles or permissions first dispatches a detached event with the model's previous role IDs or `Permission` collection, followed by an attached event with the requested IDs. The detached event is skipped when the model had none. -Assignments made before a subject model is saved are queued on that model and written atomically after save. Their events dispatch synchronously when the assignment method is called, in the caller's established context. The saved callback does not dispatch a duplicate event. +Events are dispatched after the database changes succeed, so a failed sync dispatches nothing. When you assign roles or permissions to a model that has not been saved yet, they are written when the model is saved, but their events are dispatched right away. A sync on an unsaved model only dispatches the attached event. ## Console Commands @@ -889,34 +1127,42 @@ The command supports the `default`, `borderless`, `compact`, and `box` table sty php artisan permission:show web compact ``` -Other commands are available for common setup and maintenance tasks: +You may create roles and permissions from the console, optionally passing a guard name as the second argument: ```shell php artisan permission:create-role writer + php artisan permission:create-permission "edit articles" -php artisan permission:create-role writer web "edit articles|publish articles" -php artisan permission:assign-role writer 1 web "App\Models\User" + +php artisan permission:create-permission "edit articles" web +``` + +When creating a role, you may also create and assign permissions by listing them, separated by `|`. When teams are enabled, the `--team-id` option sets the role's team: + +```shell +php artisan permission:create-role writer web "create articles|edit articles" + php artisan permission:create-role writer web --team-id=1 -php artisan permission:cache-reset -php artisan permission:setup-teams ``` -When row partitioning is enabled, data and cache commands operate inside the ambient application partition and fail closed when it is missing. Establish context before invoking them. Permission does not add a generic partition option because the application owns partition identity and enumeration. `permission:setup-teams` remains schema-only. +The `permission:assign-role` command assigns a role to a user, given the role name, the user's ID, and optionally the guard name and user model class: + +```shell +php artisan permission:assign-role writer 1 web "App\Models\User" +``` + +The `permission:cache-reset` command [clears the permission cache](#caching), and `permission:setup-teams` creates a migration that adds the [team](#teams) columns to existing tables. + +When row partitioning is enabled, the commands that read or write permission data run within the application's current partition and throw an exception when none is set, so set the [partition context](#partition-context) before running them. The `permission:setup-teams` command only creates a migration and does not need a partition. ## Row Partitioning -Row partitioning adds one application-defined scalar dimension to every Permission operation. It is useful when the same subject may have different authorization data in separate workspaces, installations, realms, organizations, environments, or, for example, tenants. +Row partitioning keeps the roles, permissions, and assignments of separate workspaces, organizations, or tenants apart while storing them in the same tables. It is useful when the same user may have different roles and permissions in each workspace. -Permission does not provide a partition model, middleware, command option, migration, or context store. The application owns that domain. Permission receives only a column name and the current opaque `int|string` value, then applies it consistently to: +You tell the package which column holds the partition and how to read the current partition value. The package then applies that value to every role and permission query, model write, relationship, assignment, query scope, wildcard check, console command, queued model, and cache entry. Your application owns the partitions themselves: the package does not provide a partition model, middleware, command option, migration, or context storage. -- Role and Permission model queries and lifecycle writes; -- role-permission, model-role, and model-permission relations and pivots; -- assignment, synchronization, reverse-assignment, query-scope, eager-load, wildcard, and denied-permission paths; -- console commands and queued Role or Permission restoration; -- shared cache keys, assignment tokens, wildcard indexes, coroutine-local memoization, and invalidation. - -Partitioning is opt-in. Without registration, the package retains its normal unpartitioned behavior and schema. +Partitioning is opt-in. Without registration, the package keeps its normal behavior and schema. ### Registering a Partition @@ -936,16 +1182,14 @@ public function register(): void } ``` -The registration is boot-only and persists for the worker lifetime. The resolver itself runs when Permission builds a query, relation, mutation, or cache identity and should read already-populated coroutine context. It must not query the database. - -The column must be a simple SQL identifier. Resolver values may be an integer, a non-empty string, or `null`; `0` and `'0'` are valid. The application must use one canonical representation for a partition throughout its lifetime. A missing or empty value throws `PermissionPartitionNotResolved` and never falls back to unpartitioned SQL or cache keys. +The registration applies for the life of the worker, so register it once while your application boots rather than in a configuration file. The resolver runs whenever the package queries or caches permission data, so it should read a value your application has already placed in the current request's, job's, or command's [context](/docs/{{version}}/context) rather than query the database. -Do not register partition callbacks through config files. Hypervel config is worker-lifetime state. Register the callback at boot and read request, job, or command state through `Context`. +The column must be a simple SQL identifier. The resolver may return an integer, a non-empty string, or `null`; `0` and `'0'` are valid values. Always use the same representation for a given partition. When the resolver returns `null` or an empty string, the package throws a `PermissionPartitionNotResolved` exception instead of running unpartitioned queries. ### Partitioned Schema -The stock Permission migration remains unpartitioned. Before running migrations, applications opting in must customize all five authorization tables with the same non-null native partition column: +The package's migration is not partitioned. If you use partitioning, replace it with your own migration that adds the same non-null partition column to all five tables: - `roles` - `permissions` @@ -953,7 +1197,7 @@ The stock Permission migration remains unpartitioned. Before running migrations, - `model_has_roles` - `model_has_permissions` -The following example uses UUID partition, Role, Permission, and subject IDs. Use native integer, UUID, or ULID columns consistently for your own key types: +The following example uses UUIDs for the partition, role, permission, and user keys. Use integer, UUID, or ULID columns to match your own keys: ```php use Hypervel\Database\Schema\Blueprint; @@ -1003,7 +1247,8 @@ Schema::create('role_has_permissions', function (Blueprint $table): void { Schema::create('model_has_roles', function (Blueprint $table): void { $table->uuid('workspace_id'); $table->uuid('role_id'); - $table->uuidMorphs('model'); + $table->string('model_type'); + $table->uuid('model_id'); $table->primary(['workspace_id', 'role_id', 'model_id', 'model_type']); $table->index( @@ -1020,7 +1265,8 @@ Schema::create('model_has_roles', function (Blueprint $table): void { Schema::create('model_has_permissions', function (Blueprint $table): void { $table->uuid('workspace_id'); $table->uuid('permission_id'); - $table->uuidMorphs('model'); + $table->string('model_type'); + $table->uuid('model_id'); $table->boolean('is_denied')->default(false); $table->primary(['workspace_id', 'permission_id', 'model_id', 'model_type']); @@ -1036,13 +1282,13 @@ Schema::create('model_has_permissions', function (Blueprint $table): void { }); ``` -The `['workspace_id', 'id']` unique keys are required targets for the composite foreign keys even when Role and Permission IDs are globally unique primary keys. The subject lookup indexes use explicit names because application-defined partition and morph-key names can otherwise produce generated identifiers longer than MySQL and MariaDB allow. You may also add a foreign key from `workspace_id` to your own partition-owner table. +The composite foreign keys reference the `['workspace_id', 'id']` unique keys, so the database rejects any assignment that links records from different partitions. The user lookup indexes have explicit names because generated names can exceed the identifier length MySQL and MariaDB allow. You may also add a foreign key from `workspace_id` to your own workspace table. -Partition-leading primary, unique, and lookup indexes let the database narrow each operation immediately. Keep the partition first wherever Permission always supplies it first. +Keep the partition column first in each primary key, unique key, and index, since every query the package runs filters by it. -Polymorphic `(model_type, model_id)` values must identify one subject globally across the shared Permission dataset. UUIDs and ULIDs are the simplest choice. Globally allocated integer IDs are also valid. Reusing the same subject integer ID for unrelated local records in different partitions is not supported because hard deletion must find and remove every assignment belonging to one subject identity. +Each `model_type` and `model_id` pair must identify one model across all partitions. UUIDs and ULIDs are the simplest choice, and integer IDs that are unique across partitions also work. Integer IDs that repeat between partitions for different records are not supported, since deleting a model removes its assignments in every partition. -Partition-enabled custom Role and Permission models must extend the package bases. UUID models may use `HasUuids` normally: +When partitioning is enabled, custom role and permission models must extend the package's models. UUID models may use the `HasUuids` trait as usual: ```php use Hypervel\Database\Eloquent\Concerns\HasUuids; @@ -1060,25 +1306,31 @@ class Permission extends BasePermission } ``` -Configure both model classes under `permission.models`. Extending the bases is required in partitioned mode because they protect Eloquent operations that intentionally bypass global scopes, including instance updates, deletes, refreshes, quiet operations, increments, and queued model restoration. +Configure both classes under `permission.models`. The package's models keep the partition check on Eloquent operations that skip global scopes, such as instance updates, deletes, refreshes, quiet operations, increments, and restoring queued models. ### Partition Context -Establish application context before authentication or any Permission operation. A typical request flow resolves the workspace, stores its key in `Context`, authenticates the subject, and then calls normal methods such as `$user->can(...)` or `$user->hasPermissionTo(...)`. +Set the current partition before authenticating the user or running any permission operation. Typically, a middleware finds the request's workspace and stores its key in `Context`, then the request authenticates and authorizes as usual: + +```php +use Hypervel\Support\Facades\Context; + +Context::add('workspace_id', $workspace->getKey()); +``` -Hypervel propagates Context into queued jobs and hydrates it before serialized Eloquent models are restored. Put the partition value in propagating Context before dispatch. Commands, scheduled tasks, and seeders must establish their own context before resolving Permission models, running Permission commands, or clearing cache. +Hypervel passes `Context` values to queued jobs and restores them before the job's models are restored, so a job dispatched within a workspace runs in that workspace. Console commands, scheduled tasks, and seeders must set the context themselves before using roles and permissions, running the package's commands, or clearing the cache. -Role and Permission records always belong to a non-null partition. A global subject model may receive different assignments in several partitions. If a subject itself has the configured partition attribute, Permission rejects assignments when that stored value conflicts with current context. +Roles and permissions always belong to a partition. A user model that is not partitioned may have different roles and permissions in each partition. If the user model has its own partition column, the package rejects assignments when its value differs from the current partition. -Permission writes the captured partition value to pivot inserts automatically. Caller-supplied pivot data may omit the partition or repeat the same value, but a conflicting value throws and pivot updates cannot move an existing edge between partitions. +The package writes the current partition to each assignment row. Pivot data you pass may leave out the partition column or repeat the current value, but a different value throws an exception, and pivot updates cannot move an assignment to another partition. -Models loaded with a narrowed `select()` that omits the partition column cannot safely build partitioned relations or perform later lifecycle mutations. Include the partition column whenever a Role or Permission instance will be related, refreshed, saved, restored, or deleted. +When you load roles or permissions using `select()`, include the partition column if you will use their relationships or later refresh, save, restore, or delete them. ### Partitions, Teams, and Guards -Partitions, teams, and guards are independent dimensions: +Partitions, teams, and guards are separate, and the package filters by each of them: ```sql where workspace_id = ? @@ -1086,7 +1338,7 @@ where workspace_id = ? and guard_name = ? ``` -A partition is not a Permission team. An application may use partitions without teams or many teams inside one partition. When teams are enabled, place the team column after the partition in relevant keys: +A partition is not a [team](#teams). You may use partitions without teams, or many teams within each partition. When teams are enabled, put the team column after the partition column in the keys: ```php $table->unique(['workspace_id', 'team_id', 'name', 'guard_name']); @@ -1094,124 +1346,172 @@ $table->primary(['workspace_id', 'team_id', 'role_id', 'model_id', 'model_type'] $table->primary(['workspace_id', 'team_id', 'permission_id', 'model_id', 'model_type']); ``` -The Role table's `team_id` may be nullable for global Roles. The `model_has_roles` and `model_has_permissions` team columns are non-null because every assignment stores the active team; assigning a global Role still writes the active team to `model_has_roles`. The non-null assignment columns can therefore participate in the composite primary keys above. +The `roles` table's team column is nullable, since global roles have no team. The assignment tables' team columns are not nullable: every assignment stores the current team, even for a global role, so they can be part of the primary keys above. -MySQL and MariaDB permit multiple `NULL` values inside a unique key. Applications requiring exactly one global-team Role per name should use a non-null sentinel or a database-appropriate normalized/generated uniqueness key. +All supported databases allow several `NULL` values in a unique key, so the unique key above does not stop two global roles from having the same name. If you need that guarantee, add a unique index over a generated column that replaces a `NULL` team with a fixed value. ### Partition Cache and Performance -The resolved partition is part of every catalog, model-assignment, assignment-token, wildcard, via-role, and coroutine-local cache identity. Built-in mutations invalidate only the affected partition and, where possible, only the affected subject/team entry. Changing a Role in workspace A does not clear workspace B's catalog or assignment token. - -`permission:cache-reset` and `PermissionRegistrar::forgetCachedPermissions()` clear only the ambient partition when partitioning is enabled. Missing context throws. Permission does not provide a global partition enumerator; cross-partition maintenance should enumerate the application's own partition domain, establish each context, and invoke the normal reset. - -Partitioning adds no database queries to authorization or normal mutation paths: +The current partition is part of every cache key the package uses, so each partition has its own cached roles, permissions, assignments, and wildcard indexes. The package's own changes only clear the cache entries of the partition they affect: changing a role in workspace A does not clear workspace B's cache. -- warm authorization checks remain zero-query; -- a cold permission catalog remains three queries; -- cold authorization and assignment-cache misses retain their unpartitioned query counts; -- synchronization uses the same query count in partitioned and unpartitioned modes; Role sync uses one delete and one bulk insert, while direct-permission sync adds the pivot read needed to compare denied effects; -- the resolver is an in-memory Context lookup; -- existing SQL receives one bound partition predicate; -- pivot inserts receive the partition value. +The `permission:cache-reset` command and `forgetCachedPermissions` method clear only the current partition and throw an exception when no partition is set. To reset every partition, loop over your own workspaces, set each one's context, and reset its cache. -With partition-leading indexes, the added predicate narrows the rows each query examines. It does not introduce a join or discovery query on ordinary operations. Hard subject deletion is the deliberate cold-path exception: when partitioning and/or teams are enabled, each assignment-owning trait performs one narrow discovery query for its own table so it can forget the exact partition/team cache identities it deletes. A model using only `HasPermissions` therefore uses one discovery query; a model using `HasRoles` uses one for each of the role and direct-permission assignment tables. No discovery query runs when both features are disabled. +Partitioning adds no queries to permission checks or assignments. Each query gains one partition condition, and each assignment row stores the partition value. Warm permission checks run no queries, and loading the role and permission catalog takes three queries, the same as without partitioning. -Role and Permission removal use one blind captured-scope delete regardless of listener presence because their public events report the already-known request. Role synchronization adds one pivot-only ID read only when `RoleDetachedEvent` has a listener, because that established payload is the pre-operation current Role set. The ordinary Role-sync path performs no discovery read. +Hard deleting a user model is the exception. When partitioning or teams are enabled, the package first reads which partitions and teams the model's assignments belong to, so it can clear exactly those cache entries. This adds one query for each assignment table the model uses. -Use your database's `EXPLAIN` command to confirm application indexes begin with the partition predicate used by the query, for example `workspace_id, name, guard_name` for name lookup or `workspace_id, model_type, model_id` for subject assignments. Optimizer plans differ by engine, so verify them against production data rather than relying on one fixed plan. +Use your database's `EXPLAIN` command to check that your indexes start with the partition column each query filters by, such as `workspace_id, name, guard_name` for finding roles by name or `workspace_id, model_type, model_id` for loading a user's assignments. Query plans differ between databases, so check them against production data. -Logical cache keys include the raw canonical partition through collision-safe length-prefixed segments. Swoole cache stores hash logical keys before native table lookup, and Hypervel's Swoole table wrapper rejects oversized values instead of silently truncating them. Size Swoole cache table row count and value capacity for the application's number of partitions and catalog size. +If you use the Swoole cache store, size its table for your number of partitions and the amount of role and permission data in each. The store rejects values larger than its configured size instead of truncating them. ### Raw and Bulk Writes -Package model and relation APIs apply partition predicates, invariant pivot values, and cache invalidation automatically. Package-owned multi-write operations such as synchronization and deferred multi-context flushing are transactional. Simple assignment and removal methods retain native Eloquent attach/detach semantics; wrap them in an application transaction when they must commit atomically with model touches or other application work. Low-level database APIs intentionally bypass some or all of those guarantees. +The package's models and methods add the partition condition, write the partition value, and clear the affected cache for you. Methods that make several writes, such as syncing, run in a transaction. Single assignment and removal methods behave like Eloquent's `attach` and `detach`, so wrap them in a transaction when they must commit together with other work. -Direct Query Builder writes, generic Pivot saves, `toBase()`, `getQuery()`, `newQueryWithoutScopes()`, explicit scope removal, truncation, insert-from-select, and builder force deletes require the application to supply the correct partition predicate/value, use an appropriate transaction, and reset every affected partition cache. +Lower-level database APIs skip some or all of this. When you use the query builder directly, save a generic pivot, call `toBase`, `getQuery`, or `newQueryWithoutScopes`, remove the partition scope, truncate a table, insert from a select, or force delete through a query, you must add the partition condition or value yourself, use a transaction where needed, and reset the cache of each affected partition. -Static Eloquent passthrough writes such as `insert`, `insertOrIgnore`, `insertGetId`, and `upsert` do not instantiate models and bypass partition insertion checks. Builder `update`, `increment`, and `decrement` retain the global partition predicate but bypass model lifecycle invalidation and must not change the partition column. Reset the affected ambient partition after any raw or bulk mutation. +Eloquent's `insert`, `insertOrIgnore`, `insertGetId`, and `upsert` methods do not create models, so they skip the partition checks. Query `update`, `increment`, and `decrement` calls keep the partition condition but skip cache clearing, and must not change the partition column. Reset the current partition's cache after any of these writes. ## Teams -Teams scope roles and role or permission assignments by a configured team foreign key. Enable teams before running the base permission migration if you want the base tables to include team columns: +Teams let a model have different roles and permissions in each team it belongs to, such as an organization or project. Roles may be global or belong to one team, and every role or permission assignment belongs to a team. + +Enable teams in the permission configuration file before running the package's migration, so it adds the team columns. If the tables already exist, run the `permission:setup-teams` command and then migrate: ```php 'teams' => true, + 'models' => [ 'team' => App\Models\Team::class, ], ``` -The default team resolver stores the active team ID in coroutine context. You may replace `team_resolver` with a class that implements `Hypervel\Permission\Contracts\PermissionsTeamResolver`. +The migration creates integer team columns named `team_id`. You may rename them using the `column_names.team_foreign_key` configuration value, and change their type in the published migration if your team keys are UUIDs or ULIDs. + + +### Setting the Current Team -Use the helpers to set the current team for the current coroutine: +The package checks and changes roles and permissions within the current team. Set the current team at the start of each request, typically in a middleware: ```php -setPermissionsTeamId($team->getKey()); +assignRole('writer'); -``` +namespace App\Http\Middleware; + +use Closure; +use Hypervel\Http\Request; +use Symfony\Component\HttpFoundation\Response; + +class SetPermissionsTeam +{ + public function handle(Request $request, Closure $next): Response + { + setPermissionsTeamId($request->session()->get('team_id')); -Select the current team before changing a model's roles or direct permissions, including when assigning a global role. Every assignment belongs to the active team, so a missing selection throws `TeamNotSelected` before the package queries or writes authorization data. + return $next($request); + } +} +``` -You may also pass a team model: +Add the middleware to the `web` group, and [sort it](/docs/{{version}}/middleware#sorting-middleware) before `SubstituteBindings` so the team is set before route model binding and authorization middleware run: ```php -setPermissionsTeamId($team); +use App\Http\Middleware\SetPermissionsTeam; +use Hypervel\Foundation\Configuration\Middleware; +use Hypervel\Routing\Middleware\SubstituteBindings; + +->withMiddleware(function (Middleware $middleware): void { + $middleware->web(append: [ + SetPermissionsTeam::class, + ]); + + $middleware->prependToPriorityList( + before: SubstituteBindings::class, + prepend: SetPermissionsTeam::class, + ); +}) ``` -Roles may be global or team-specific: +The `setPermissionsTeamId` helper also accepts a team model, and `getPermissionsTeamId` returns the current team's key. The current team belongs to the current request or job only, so it never carries over to other requests. Queued jobs and new coroutines start without a team, so set it again within them. + +Select the current team before changing a model's roles or direct permissions, including when assigning a global role. Without a current team, these methods throw a `TeamNotSelected` exception before touching the database. + +By default, the current team is stored in the request's [coroutine context](/docs/{{version}}/coroutine-context). You may replace the `team_resolver` configuration value with a class that implements `Hypervel\Permission\Contracts\PermissionsTeamResolver`. + + +### Team Roles + +A role created without a `team_id` belongs to the current team: ```php +// A global role, which may be assigned within any team... Role::create(['name' => 'writer', 'team_id' => null]); -Role::create(['name' => 'writer', 'team_id' => $team->getKey()]); +// A role for one team; other teams may have roles with the same name... +Role::create(['name' => 'reader', 'team_id' => $team->getKey()]); + +// A role for the current team... +Role::create(['name' => 'reviewer']); ``` -If teams are enabled after the package tables already exist, run `permission:setup-teams` and then migrate. +Assigning and removing roles and permissions works the same as without teams, within the current team. + + +### Switching Teams -When you change the active team during a request or job, package-loaded permission relations are reloaded automatically when their stored provenance no longer matches the active team: +You may change the current team during a request or job, such as when a user switches teams or an admin page manages a user's roles in each team. The package's methods reload roles and permissions that were loaded for the previous team automatically: ```php setPermissionsTeamId($newTeamId); $user->hasRole('writer'); +$user->can('edit articles'); +``` + +Reading the `roles` or `permissions` relationship directly returns what was loaded earlier, like any Eloquent relationship. Unset a relationship loaded for the previous team before reading it: + +```php +$roles = $user->unsetRelation('roles')->roles; ``` ## Wildcard Permissions -Wildcard permissions allow one stored permission to match many checks: +Wildcard permissions let one assigned permission match many checks. They are inspired by [Apache Shiro's permissions](https://shiro.apache.org/permissions.html). You may enable them in the permission configuration file: ```php 'enable_wildcard_permission' => true, ``` -```php -Permission::create(['name' => 'posts.*']); - -$user->givePermissionTo('posts.*'); - -$user->hasPermissionTo('posts.create'); -// true -``` +A wildcard permission is made of parts separated by dots, such as `posts.create.1`. The meaning of each part is up to your application. A common pattern is `{resource}.{action}.{target}`, but you may use as many parts as you like. -A wildcard permission string is split into dot-separated parts. The `*` part means all values for that part, not any permission in the system: +Any part may be `*`, which matches every value of that part: ```php Permission::create(['name' => 'posts.*']); $user->givePermissionTo('posts.*'); + +$user->hasPermissionTo('posts.create'); // true +$user->hasPermissionTo('posts.edit'); // true ``` -Subparts may be comma-separated: +A trailing `*` is implied, so assigning `posts` also grants `posts.create` and `posts.edit`. In a checked name, `*` means "all" rather than "any": checking `posts.*` passes when the user was given `posts.*` or `posts`, but not when they only have `posts.create`. -```php -Permission::create(['name' => 'posts,users.create,update,view']); +A part may also list several values separated by commas: +```php +// Create, update, and view posts and users... $user->givePermissionTo('posts,users.create,update,view'); + +// Create, update, and view any resource... +$user->givePermissionTo('*.create,update,view'); + +// Do anything to the posts with IDs 1, 4, and 6... +$user->givePermissionTo('posts.*.1,4,6'); ``` Like any permission, a wildcard permission must exist as a permission record before it can be assigned. The names you check do not need records of their own, so `hasPermissionTo('posts.create')` matches `posts.*` even when no `posts.create` permission exists. @@ -1260,6 +1560,20 @@ $team->assignRole('project-manager'); $team->givePermissionTo('manage projects'); ``` +Assignments are stored with the model's morph class, so a child model class has its own roles and permissions. If a child model should only use its parent's roles and permissions, you may return the parent's morph class from the child's `getMorphClass` method. The child then shares every assignment with the parent model of the same key: + +```php +use Hypervel\Database\Eloquent\Relations\Relation; + +class Admin extends User +{ + public function getMorphClass(): string + { + return (string) Relation::getMorphAlias(User::class); + } +} +``` + ## Custom Models @@ -1304,12 +1618,49 @@ After creating custom models, update the permission configuration: ], ``` +In the rare case that you replace the models instead of extending them, your models must implement the `Hypervel\Permission\Contracts\Role` and `Hypervel\Permission\Contracts\Permission` contracts. When [row partitioning](#row-partitioning) is enabled, custom models must extend the package's models. + +If you override `findByName`, `findOrCreate`, or `create` on your model, you may use the `enum_value` helper to accept [enums](#using-enums) as names: + +```php +use Hypervel\Permission\Contracts\Role as RoleContract; +use UnitEnum; + +use function Hypervel\Support\enum_value; + +public static function findByName(UnitEnum|string $name, ?string $guardName = null): RoleContract +{ + $name = enum_value($name); + + // ... +} +``` + +The package's models do not use soft deletes, and soft deletes are not recommended for roles and permissions. Deleting a role or permission should normally remove its assignments rather than leave them waiting to become active again. If a custom model uses `SoftDeletes`, soft deleting it hides it from permission checks but keeps its assignments, and restoring it makes them active again. Use hard deletes when assignments should be removed permanently. + + +### Adding Columns + +You may add your own columns to the roles and permissions tables with a migration, just like any other table. For example, the package does not include a description column, but you may add one: + +```php +Schema::table('permissions', function (Blueprint $table) { + $table->string('description')->nullable(); +}); + +Schema::table('roles', function (Blueprint $table) { + $table->string('description')->nullable(); +}); +``` + +Roles and permissions are cached with all of their columns except those listed in the `cache.column_names_except` configuration value. You may add large columns to that list to keep the cache small, but models read from the cache, such as those returned by `findByName`, will not have those columns. + ### Permission Database Connection -All five permission tables, including pivot writes, use the configured Permission model's database connection. The configured Role and Permission models must use that same connection name. Role relation reads use the Role connection, while pivot writes and cache settlement use the Permission connection, so separate connection names would break transaction consistency even when they point to the same database. +All five permission tables, including assignment writes, use the configured permission model's database connection. The configured role and permission models must use that same connection name. Role relationships are read through the role model's connection, while assignments are written and cache changes are applied through the permission model's connection, so different connection names would break transactions even when they point to the same database. -A subject model may use another connection. When it lives in a physically separate database, reverse relations and subject query scopes are unavailable because they compile joins to the permission tables on the subject connection. +Your user models may use another connection. When they live in a separate database, the `users` relationships of roles and permissions, and the role and permission query scopes on your models, are unavailable, because they join the permission tables using the user model's connection. ### Custom Pivot Models @@ -1346,33 +1697,42 @@ When a custom permission pivot is configured, `getDirectPermissions()` and `getA The reverse `assignToModels`, `removeFromModels`, and `syncModels` methods do not use the assigned model's relationship override. When your custom pivot behavior is required, perform the assignment through the model's `givePermissionTo`, `revokePermissionTo`, `syncPermissions`, `assignRole`, `removeRole`, or `syncRoles` methods. -In an unpartitioned application, models that replace rather than extend the package bases must implement `Hypervel\Permission\Contracts\Role` or `Hypervel\Permission\Contracts\Permission`. Partition-enabled Role and Permission models must extend the package bases so every unscoped Eloquent lifecycle path remains protected. +To record when assignments are made, add `timestamps` to the pivot tables in a migration and call `withTimestamps` on the aliased relationships. Assignments then store `created_at`, and allowing or denying an assigned permission updates its `updated_at`: -The package's default role and permission models do not use soft deletes, and soft deletes are not recommended for permission models. Roles and permissions are access-control records; deleting one should normally remove its assignments, not leave them waiting to become active again later. - -If you use a custom role or permission model that uses `SoftDeletes`, soft-deleting a role or permission hides it from normal permission checks, but its assignment rows remain in the database. If the role or permission is restored, those assignments become active again. For roles, previous user-role and role-permission assignments become active again. For permissions, previous direct model-permission and role-permission assignments become active again. +```php +public function permissions(): BelongsToMany +{ + return $this->traitPermissions()->withTimestamps(); +} +``` -Use hard deletes for roles and permissions when assignments should be removed permanently. +For the role-permission table, override the `permissions` method of a [custom role model](#custom-models) and the `roles` method of a custom permission model, calling `withTimestamps` on the parent's relationship. If you return roles or permissions as JSON, you may hide their pivot data by adding `pivot` to the custom model's `$hidden` property. ### Deleting Models -When you hard delete a subject model that uses `HasRoles` or `HasPermissions`, the package removes its assignments after the model row is deleted successfully. When the subject and permission storage use the same connection, the row deletion and assignment cleanup run in one transaction. When they use different connection names, assignment cleanup runs only after the subject transaction commits. If another transaction is already open on the subject connection, the delete uses a savepoint so a same-connection cleanup failure rolls back only that delete operation. +When you hard delete a model that uses `HasRoles` or `HasPermissions`, the package removes its assignments after the model's row is deleted. When the model and the permission tables use the same connection, the row and its assignments are deleted in one transaction. If a transaction is already open on that connection, the delete uses a savepoint, so a failure while removing the assignments rolls back only that delete. When they use different connections, the assignments are deleted after the model's transaction commits. -If your model defines its own `delete` method, it overrides the transaction supplied by the permission trait. Keep the model deletion and its events inside one transaction so the row and assignment cleanup cannot settle separately. +If your model defines its own `delete` method, it replaces the transaction the trait adds. Keep the model's deletion and its events inside one transaction so the row and its assignments cannot be deleted separately. -The `deleteQuietly` method intentionally skips model events, including permission validation and cleanup. Subject assignment tables do not have a foreign key to the subject model, so a quiet delete may leave assignment rows behind. Use the normal `delete` method when those assignments should be removed. +The `deleteQuietly` method skips model events, including the package's checks and cleanup. The assignment tables have no foreign key to your models, so a quiet delete may leave assignment rows behind. Use the normal `delete` method when those assignments should be removed. ## UUID and ULID Keys -If your user models use UUIDs or ULIDs, update the published migration before running it so `model_has_roles` and `model_has_permissions` use the correct morph key column type: +The published migration uses integer keys. If your user models use UUIDs, change the morph key column in both the `model_has_roles` and `model_has_permissions` tables before running the migration. Use `ulid` instead of `uuid` for ULIDs: ```php -$table->uuidMorphs('model'); +// Before... +$table->unsignedBigInteger($modelMorphKey); + +// After... +$table->uuid($modelMorphKey); ``` -If your role or permission models use UUIDs or ULIDs, extend the package models and set the primary key details on your custom models: +You may also rename the morph key column, for example to `model_uuid`, using the `column_names.model_morph_key` configuration value. + +If your role or permission models use UUIDs, [extend the package's models](#custom-models) and add the `HasUuids` trait: ```php use Hypervel\Database\Eloquent\Concerns\HasUuids; @@ -1381,31 +1741,23 @@ use Hypervel\Permission\Models\Role as BaseRole; class Role extends BaseRole { use HasUuids; - - protected string $primaryKey = 'uuid'; } ``` -Then update the published migration so the `roles`, `permissions`, and pivot tables use the same key type and references. You may also rename the model morph key in config: - -```php -'column_names' => [ - 'model_morph_key' => 'model_uuid', -], -``` - -Integer, UUID, and ULID Role, Permission, partition, and subject keys are supported. Keep every foreign and pivot column's native type identical to the key it references. In a partitioned schema, include the native partition column on all five authorization tables and use composite `(partition, related_id)` foreign keys as shown in [Partitioned Schema](#partitioned-schema). +Then change the `id` columns of the `roles` and `permissions` tables to `$table->uuid('id')->primary()`, and the role and permission key columns of the three assignment tables to `uuid` columns. Every key column must have the same type as the key it references. For a partitioned schema, see the [partitioned schema](#partitioned-schema) example, which uses UUIDs throughout. ## Caching -The permission registrar caches role and permission metadata using the configured cache store. Hot checks also use Hypervel's memo cache layer for the current coroutine, so repeated checks in one request or job avoid repeated cache-store reads. +The package caches the role and permission catalog and each model's assignments, so permission checks usually run no queries. Within a request or job, repeated checks also reuse the values already read from the cache store. + +The cache store must keep values and refreshable atomic locks on the same backend, such as the `redis`, `database`, `file`, `swoole`, or `array` stores. Stack and failover stores are not supported, since their values and locks may use different backends. The store is checked the first time the cache is filled. -The cache store must keep cached values and refreshable atomic locks on the same backend. Stack and failover stores are not supported because their values and locks may use different backends. Hypervel validates this requirement on the first cache miss, while cache hits remain lock-free. +To use a dedicated store, set `cache.store` in the permission configuration file to one of your cache stores. The `array` store keeps values only for the current request or job, which effectively disables caching between requests. -By default, cache identities are application-wide. When [row partitioning](#row-partitioning) is enabled, every relevant shared and coroutine-local identity includes the current partition automatically. Cache namespacing alone is not row isolation; use `resolvePartitionUsing` so database queries, pivot writes, relations, commands, cache entries, and invalidation all share the same fail-closed boundary. +The cache configuration applies to the whole worker, so do not switch the permission cache store or keys for each tenant. To keep each tenant's permission data separate, use [row partitioning](#row-partitioning), which separates their database rows and cache entries. -Built-in mutation methods refresh the relevant cache automatically: +The package's methods clear the affected cache entries for you: ```php $role->givePermissionTo('edit articles'); @@ -1424,30 +1776,26 @@ $user->syncPermissionEffects( ); ``` -Exact subject assignment mutations forget that subject's affected assignment entry. Replacing every subject assigned to a Role or Permission through `syncModels` advances the active partition's assignment token because a concurrent assignment cannot be enumerated safely across every supported database. Role or Permission catalog mutations invalidate only the affected partition's catalog. Hard or force deletion of a Role or Permission, and an explicit cache reset, also advance only that partition's assignment token. Raw or bulk writes bypass lifecycle invalidation and require an explicit reset in each affected established partition. +Changing a model's roles or direct permissions clears only that model's cached assignments. Changing a role's permissions, or creating, updating, or deleting a role or permission, clears the cached catalog. Calling `syncModels`, hard deleting a role or permission, and resetting the cache expire every model's cached assignments. -You may clear cached permission data with the command: +If you change the permission tables any other way, such as with raw queries, reset the cache yourself using the `permission:cache-reset` command or the `forgetCachedPermissions` method: ```shell php artisan permission:cache-reset ``` -When partitioning is enabled, this clears only the ambient partition and throws if context is missing. - -You may also clear it from code: - ```php use Hypervel\Permission\PermissionRegistrar; app(PermissionRegistrar::class)->forgetCachedPermissions(); ``` -If you reset the cache inside a database transaction, Hypervel applies the reset after the transaction commits and discards it when the transaction rolls back. The method returns `true` once a transactional reset has been registered. +If you reset the cache inside a database transaction, the reset is applied after the transaction commits and discarded if it rolls back. The method returns `true` once such a reset has been registered. When row partitioning is enabled, the reset applies only to the current partition. ## Testing and Seeding -Partition-enabled tests and seeders must establish application partition context before resolving Role or Permission models, assigning authorization data, or clearing caches: +When row partitioning is enabled, tests and seeders must set the partition context before using roles and permissions or clearing the cache: ```php use Hypervel\Support\Facades\Context; @@ -1455,56 +1803,94 @@ use Hypervel\Support\Facades\Context; Context::add('workspace_id', $workspace->getKey()); ``` -Do not disable partitioning or fall back to an unpartitioned cache during tests. Use the same context path as production so missing-context and isolation failures remain visible. +Keep partitioning enabled in your tests and set the context the same way production does, so a missing context or a leak between partitions fails your tests. -If tests create roles or permissions after the Gate has already registered its permission callback, clear the package cache in the test setup: + +### Seeding -```php -use Hypervel\Permission\PermissionRegistrar; - -protected function setUp(): void -{ - parent::setUp(); - - $this->app->make(PermissionRegistrar::class)->forgetCachedPermissions(); -} -``` - -Seeders that create roles and permissions should clear the cache before seeding. If your seeder disables model events, clear it again after creating roles and permissions and before assigning them: +Creating roles and permissions clears the permission cache through model events. The default `DatabaseSeeder` uses the `WithoutModelEvents` trait, so the seeders it calls run without model events. Clear the cache in them after creating roles and permissions and before assigning them: ```php use Hypervel\Database\Seeder; use Hypervel\Permission\Models\Permission; use Hypervel\Permission\Models\Role; use Hypervel\Permission\PermissionRegistrar; -use Hypervel\Support\Facades\Context; class RolesAndPermissionsSeeder extends Seeder { - private const string WORKSPACE_ID = '0198f311-7d47-7c41-962a-97a99d8638ef'; - public function run(): void { - Context::add('workspace_id', self::WORKSPACE_ID); - - app(PermissionRegistrar::class)->forgetCachedPermissions(); - Permission::create(['name' => 'edit articles']); + Permission::create(['name' => 'publish articles']); + Permission::create(['name' => 'unpublish articles']); app(PermissionRegistrar::class)->forgetCachedPermissions(); Role::create(['name' => 'writer']) ->givePermissionTo('edit articles'); + + Role::create(['name' => 'moderator']) + ->givePermissionTo(['publish articles', 'unpublish articles']); + + Role::create(['name' => 'admin']) + ->givePermissionTo(Permission::all()); } } ``` +You may assign roles to users created by a factory using an [`afterCreating` callback](/docs/{{version}}/eloquent-factories#factory-callbacks), for example in a factory state: + +```php +public function editor(): static +{ + return $this->afterCreating(function (User $user) { + $user->assignRole('editor'); + }); +} +``` + +When seeding a large number of permissions, Eloquent's `insert` method is faster than `create`, since it skips model events and the package's checks. Provide every required column, including the guard name and any partition column, and clear the cache afterwards: + +```php +Permission::insert([ + ['name' => 'edit articles', 'guard_name' => 'web'], + ['name' => 'delete articles', 'guard_name' => 'web'], +]); + +app(PermissionRegistrar::class)->forgetCachedPermissions(); +``` + + +### Testing + +When your tests use the `RefreshDatabase` trait, you may seed roles and permissions once after the database is migrated by adding the [`Seeder` attribute](/docs/{{version}}/database-testing#running-seeders) to your test class: + +```php +use Database\Seeders\RolesAndPermissionsSeeder; +use Hypervel\Foundation\Testing\Attributes\Seeder; +use Hypervel\Foundation\Testing\RefreshDatabase; + +#[Seeder(RolesAndPermissionsSeeder::class)] +class ArticleTest extends TestCase +{ + use RefreshDatabase; +} +``` + +If your application lets users define their own roles and permissions, you may want factories for them. [Extend the package's models](#custom-models), add the `HasFactory` trait, and define factories for your models. + ## Best Practices -Use permissions for application behavior and roles for grouping permissions. For example, check `can('edit articles')` in controllers, policies, middleware, and Blade, then assign that permission to whichever roles should receive it. +Assign permissions to roles, assign roles to users, and check permissions in your application: + +- users have roles; +- roles have permissions; +- your application checks permissions rather than roles wherever it can. -Use direct role checks for role-management screens or rare app rules that truly depend on the role itself: +Detailed permission names, such as `view documents` and `edit documents`, make access easy to control. Your views, policies, controllers, and routes check these permissions using `can` and `@can`, so your application rarely needs to know role names and you may rename or restructure roles freely. Give permissions directly to a user only when that user needs an exception to their roles. + +Keep direct role checks for role-management screens or rare rules that truly depend on the role itself: ```php if ($user->hasRole('admin')) { @@ -1512,16 +1898,54 @@ if ($user->hasRole('admin')) { } ``` -Prefer policies and Gate checks when authorization depends on both the user and a specific model instance. +When authorization depends on both the user and a specific model, combine permission checks with your application's rules in a [policy](/docs/{{version}}/authorization#creating-policies): + +```php +published) { + return true; + } + + if ($user === null) { + return false; + } + + if ($user->can('view unpublished posts')) { + return true; + } + + return $user->id === $post->user_id; + } + + public function update(User $user, Post $post): bool + { + if ($user->can('edit all posts')) { + return true; + } + + return $user->can('edit own posts') && $user->id === $post->user_id; + } +} +``` ## Performance -Permission checks use cached role and permission data after the first lookup. Model role assignments and direct permission assignments have their own cache keys. Those keys include the model type, model key, active partition when enabled, active team when team-scoped, and the partition-specific assignment token. +Permission checks are served from the [cache](#caching) after the first lookup, so warm checks run no queries. Loading the role and permission catalog takes three queries, and each model's role and direct permission assignments are cached separately, per team and partition when those features are enabled. -Warm authorization checks execute no database queries. A cold catalog uses three queries. Enabling row partitioning does not add queries: it adds one bound, indexed predicate to existing SQL and one value to pivot inserts. The partition resolver is an in-memory Context lookup. Exact subject mutations forget exact cache identities. Catalog changes invalidate only the affected catalog, while bulk reverse synchronization, hard or force deletion, and explicit cache resets advance the affected partition's assignment token so older entries expire naturally through the configured TTL. +Syncing roles or permissions reads the model's current assignments once, then inserts, deletes, or updates only what changed. When assignment events are enabled and `PermissionDetachedEvent` has a listener, syncing permissions also loads the model's current permissions for the event. -Saved permission replacements perform one additional relationship query only when assignment events are enabled and a listener is registered for `PermissionDetachedEvent`. When a custom pivot is configured, methods that return Permission models load the relationship once for the model in the current coroutine and reuse it on later calls. Authorization and permission-name checks remain query-free after the permission cache is warm. +When a [custom pivot model](#custom-pivot-models) is configured, methods that return `Permission` models load the relationship once per request or job and reuse it. Permission checks and `getPermissionNames` still use the cache. If you need to display a model's roles or permissions, eager load the relationships you will render: @@ -1534,16 +1958,19 @@ Eager loading is not required for normal `hasPermissionTo` or `hasRole` checks, ## Exceptions -Authorization failures throw `Hypervel\Permission\Exceptions\UnauthorizedException`. You may handle it with Hypervel's normal exception handling: +The package's middleware throws a `Hypervel\Permission\Exceptions\UnauthorizedException` when authorization fails. You may customize its response using Hypervel's [exception handling](/docs/{{version}}/errors#rendering-exceptions) in your application's `bootstrap/app.php` file: ```php +use Hypervel\Foundation\Configuration\Exceptions; use Hypervel\Permission\Exceptions\UnauthorizedException; -$exceptions->render(function (UnauthorizedException $exception) { - return response()->json([ - 'message' => 'You do not have the required authorization.', - ], 403); -}); +->withExceptions(function (Exceptions $exceptions): void { + $exceptions->render(function (UnauthorizedException $exception) { + return response()->json([ + 'message' => 'You do not have the required authorization.', + ], 403); + }); +}) ``` The exception exposes the required roles or permissions: @@ -1554,23 +1981,17 @@ $exception->getRequiredRoles(); $exception->getRequiredPermissions(); ``` -Configuration and context failures use focused exceptions: - -- `PermissionConnectionMismatch` when a Role or Permission model write uses a different connection name from the configured Permission model; -- `PermissionPartitionAlreadyConfigured` when registration is repeated or occurs after registrar initialization; -- `PermissionPartitionNotResolved` when enabled partition context is missing; -- `PermissionPartitionViolation` when a model or pivot conflicts with its captured partition, attempts to change an immutable partition, or lacks a valid persisted partition value; -- `PermissionPartitionModelNotSupported` when partition mode is configured with a Role or Permission model that does not extend the package base; -- `TeamNotSelected` when teams are enabled and a write is attempted without a selected current team. - - -## Differences From Spatie Laravel Permission +The package's other exceptions are in the `Hypervel\Permission\Exceptions` namespace. The most common are: -- Hypervel adds denied permissions. A denied assignment explicitly rejects an ability and wins over direct or role-granted allows. The `is_denied` flag is stored as the effect on the assignment row, so assigning allow or deny for the same model or role and permission updates the existing edge. -- `getDirectPermissions()`, `getPermissionsViaRoles()`, `getAllPermissions()`, and `getPermissionNames()` return effective allowed permissions. `getDeniedPermissions()` returns the denied ones, and `hasDeniedPermission()` and `hasDeniedPermissionViaRoles()` check them. -- Hypervel accepts pure unit enums anywhere enum names are valid role or permission inputs. Backed enums use their values; unit enums use their case names. -- Hypervel adds opt-in generic row partitioning through `PermissionRegistrar::resolvePartitionUsing(...)`. It scopes model lifecycle operations, every package relation and pivot, queries, commands, cache identities, and invalidation without depending on any partition domain. -- Hypervel's cache config uses `expiration_seconds` and separate named cache keys so role, model-role, model-permission, and assignment-token caches can be invalidated independently. +- `RoleDoesNotExist` and `PermissionDoesNotExist`, when a role or permission is not found by name or ID; +- `RoleAlreadyExists` and `PermissionAlreadyExists`, when creating a role or permission that already exists for the guard; +- `GuardDoesNotMatch`, when assigning a role or permission of another guard; +- `TeamNotSelected`, when teams are enabled and roles or permissions are changed without a current team; +- `PermissionConnectionMismatch`, when a role or permission model writes through a different database connection than the configured permission model; +- `PermissionPartitionNotResolved`, when partitioning is enabled and no partition is set; +- `PermissionPartitionViolation`, when a model or pivot row belongs to a different partition than the current one, or a write would change a record's partition; +- `PermissionPartitionAlreadyConfigured`, when the partition is registered twice or after the package has started; +- `PermissionPartitionModelNotSupported`, when partitioning is enabled with role or permission models that do not extend the package's models. ## Credits diff --git a/src/permission/README.md b/src/permission/README.md index 50547d5778..8a88b3d4db 100644 --- a/src/permission/README.md +++ b/src/permission/README.md @@ -10,6 +10,8 @@ Documentation: https://hypervel.org/docs/permission - Role and permission inputs accept [unit enums](https://hypervel.org/docs/permission#using-enums) as well as backed enums. Unit enums use their case names. - Hypervel adds opt-in [row partitioning](https://hypervel.org/docs/permission#row-partitioning) through `PermissionRegistrar::resolvePartitionUsing(...)`. The stock migration is unpartitioned; applications that enable partitioning own a [partitioned schema](https://hypervel.org/docs/permission#partitioned-schema). - The [cache configuration](https://hypervel.org/docs/permission#cache) uses `expiration_seconds` instead of `expiration_time`, with separate named cache keys so role, model-role, model-permission, and assignment-token caches can be invalidated independently. +- The [cache store](https://hypervel.org/docs/permission#caching) must keep values and refreshable atomic locks on the same backend, because concurrent cache fills are coordinated with a lock. Stack and failover stores are not supported. +- The cache store and keys are worker-wide, and `initializeCache()` is for boot and tests. Instead of switching the cache per tenant during a request, use row partitioning, which also separates each tenant's cache entries. - There is no Octane reset listener or `register_octane_reset_listener` option. The current team and the loaded permission catalog are coroutine-local, so nothing carries over between requests. Ported from: https://github.com/spatie/laravel-permission diff --git a/src/permission/config/permission.php b/src/permission/config/permission.php index cba21cc690..e17045a55c 100644 --- a/src/permission/config/permission.php +++ b/src/permission/config/permission.php @@ -36,7 +36,7 @@ /* * The model used when raw IDs are passed to reverse-assignment helpers. - * Set to null to use the authenticated guard's user model. + * Set to null to use the user model of the role's guard. */ 'default_model' => null, ], diff --git a/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php b/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php index aa9ece1ed9..ec80762873 100644 --- a/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php +++ b/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php @@ -118,10 +118,12 @@ public function up(): void }); $cacheStore = config()->string('permission.cache.store', 'default'); + $cache = app('cache')->store($cacheStore !== 'default' ? $cacheStore : null); - app('cache') - ->store($cacheStore !== 'default' ? $cacheStore : null) - ->forget(config()->string('permission.cache.keys.roles', PermissionRegistrar::ROLE_CATALOG_CACHE_KEY)); + $cache->forget(config()->string('permission.cache.keys.roles', PermissionRegistrar::ROLE_CATALOG_CACHE_KEY)); + + // A new assignment token stops models whose keys are reused from reading the old tables' cached assignments. + $cache->forget(config()->string('permission.cache.keys.model_token', PermissionRegistrar::MODEL_CACHE_TOKEN_KEY)); } /** diff --git a/src/permission/database/migrations/add_teams_fields.php.stub b/src/permission/database/migrations/add_teams_fields.php.stub index ea3c4f8b2c..a547075850 100644 --- a/src/permission/database/migrations/add_teams_fields.php.stub +++ b/src/permission/database/migrations/add_teams_fields.php.stub @@ -86,10 +86,12 @@ return new class extends Migration { } $cacheStore = config()->string('permission.cache.store', 'default'); + $cache = app('cache')->store($cacheStore !== 'default' ? $cacheStore : null); - app('cache') - ->store($cacheStore !== 'default' ? $cacheStore : null) - ->forget(config()->string('permission.cache.keys.roles', PermissionRegistrar::ROLE_CATALOG_CACHE_KEY)); + $cache->forget(config()->string('permission.cache.keys.roles', PermissionRegistrar::ROLE_CATALOG_CACHE_KEY)); + + // A new assignment token stops checks without a team from reading assignments cached before teams. + $cache->forget(config()->string('permission.cache.keys.model_token', PermissionRegistrar::MODEL_CACHE_TOKEN_KEY)); } /** diff --git a/src/permission/src/Traits/HasPermissions.php b/src/permission/src/Traits/HasPermissions.php index 3e435de806..ef041343a3 100644 --- a/src/permission/src/Traits/HasPermissions.php +++ b/src/permission/src/Traits/HasPermissions.php @@ -1019,6 +1019,23 @@ private function permissionAssignmentPivot( return $pivot; } + /** + * Build the values for a bulk permission effect update. + * + * @return array + */ + private function permissionEffectUpdate(BelongsToMany $relation, bool $isDenied): array + { + $values = ['is_denied' => $isDenied]; + + // Like updateExistingPivot(), keep a timestamped pivot's updated_at current. + if ($relation->hasPivotColumn($updatedAt = $relation->updatedAt())) { + $values[$updatedAt] = $this->freshTimestamp(); + } + + return $values; + } + /** * Build a collision-safe assignment ID identity. */ @@ -1190,7 +1207,7 @@ private function synchronizePermissionAssignments( if ($relation->getPivotClass() === Pivot::class) { $relation->newPivotQuery() ->whereIn($relatedPivotKey, $updateAllowed) - ->update(['is_denied' => false]); + ->update($this->permissionEffectUpdate($relation, false)); } else { foreach ($updateAllowed as $id) { $relation->updateExistingPivot($id, ['is_denied' => false], false); @@ -1202,7 +1219,7 @@ private function synchronizePermissionAssignments( if ($relation->getPivotClass() === Pivot::class) { $relation->newPivotQuery() ->whereIn($relatedPivotKey, $updateDenied) - ->update(['is_denied' => true]); + ->update($this->permissionEffectUpdate($relation, true)); } else { foreach ($updateDenied as $id) { $relation->updateExistingPivot($id, ['is_denied' => true], false); diff --git a/tests/Permission/CacheTest.php b/tests/Permission/CacheTest.php index 3dbc73346f..f590efb874 100644 --- a/tests/Permission/CacheTest.php +++ b/tests/Permission/CacheTest.php @@ -143,6 +143,45 @@ public function testPermissionCacheResetChangesModelAssignmentCacheToken(): void $this->assertTrue($this->testUser->hasRole('testRole')); } + public function testPermissionMigrationForgetsCachedModelAssignments(): void + { + $this->testUser->assignRole('testRole'); + + // New coroutines fill and read the shared store instead of this coroutine's memo. + $roleNames = fn (): array => User::findOrFail($this->testUser->getKey())->getRoleNames()->all(); + + $this->assertSame([['testRole']], parallel([$roleNames])); + + $migration = require dirname(__DIR__, 2) + . '/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php'; + $migration->down(); + $migration->up(); + + // The first role in the recreated tables takes the old role's key. + $this->app->make(RoleContract::class)::create(['name' => 'admin']); + + $this->assertSame([[]], parallel([$roleNames])); + } + + public function testTeamsMigrationForgetsCachedModelAssignments(): void + { + $this->testUser->assignRole('testRole'); + + // New coroutines fill and read the shared store instead of this coroutine's memo. + $roleNames = fn (): array => User::findOrFail($this->testUser->getKey())->getRoleNames()->all(); + + $this->assertSame([['testRole']], parallel([$roleNames])); + + config()->set('permission.teams', true); + $this->app->forgetInstance(PermissionRegistrar::class); + $migration = require dirname(__DIR__, 2) + . '/src/permission/database/migrations/add_teams_fields.php.stub'; + $migration->up(); + + // The migration moves existing assignments to team 1, so none apply without a current team. + $this->assertSame([[]], parallel([$roleNames])); + } + public function testCatalogOnlyMutationsDoNotRotateTheAssignmentToken(): void { $registrar = $this->app->make(PermissionRegistrar::class); diff --git a/tests/Permission/CustomPivotTest.php b/tests/Permission/CustomPivotTest.php index cb7fe6e714..d39617ec01 100644 --- a/tests/Permission/CustomPivotTest.php +++ b/tests/Permission/CustomPivotTest.php @@ -6,10 +6,13 @@ use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\MorphPivot; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Permission\Models\Permission; use Hypervel\Permission\Models\Role; use Hypervel\Permission\Traits\HasRoles; +use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\DB; +use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Permission\Fixtures\Models\UserWithoutHasRoles; class CustomPivotTest extends TestCase @@ -126,6 +129,32 @@ public function testDeferredAssignmentsRetainTheirCustomPivotClasses(): void $this->assertTrue($user->hasDeniedPermission('edit-news')); $this->assertTrue($user->hasRole('testRole')); } + + public function testTimestampedPivotRecordsEffectChanges(): void + { + Schema::table('model_has_permissions', function (Blueprint $table): void { + $table->timestamps(); + }); + + CarbonImmutable::setTestNow('2026-01-01 10:00:00'); + $user = TimestampedPivotTestUser::create(['email' => 'timestamped@example.com']); + $user->givePermissionTo('edit-articles'); + + CarbonImmutable::setTestNow('2026-01-02 10:00:00'); + $user->denyPermissionTo('edit-articles'); + + $pivot = DB::table('model_has_permissions')->where('model_test_id', $user->getKey())->first(); + $this->assertSame('2026-01-01 10:00:00', $pivot->created_at); + $this->assertSame('2026-01-02 10:00:00', $pivot->updated_at); + + CarbonImmutable::setTestNow('2026-01-03 10:00:00'); + $user->syncPermissionEffects(allowed: ['edit-articles']); + + $pivot = DB::table('model_has_permissions')->where('model_test_id', $user->getKey())->first(); + $this->assertSame('2026-01-01 10:00:00', $pivot->created_at); + $this->assertSame('2026-01-03 10:00:00', $pivot->updated_at); + $this->assertTrue($user->hasDirectPermission('edit-articles')); + } } class CustomPermissionPivotTestUser extends UserWithoutHasRoles @@ -138,6 +167,8 @@ class CustomPermissionPivotTestUser extends UserWithoutHasRoles protected string $guard_name = 'web'; /** + * Get the permissions through the custom pivot. + * * @return BelongsToMany */ public function permissions(): BelongsToMany @@ -146,6 +177,8 @@ public function permissions(): BelongsToMany } /** + * Get the roles through the custom pivot. + * * @return BelongsToMany */ public function roles(): BelongsToMany @@ -154,6 +187,25 @@ public function roles(): BelongsToMany } } +class TimestampedPivotTestUser extends UserWithoutHasRoles +{ + use HasRoles { + permissions as protected traitPermissions; + } + + protected string $guard_name = 'web'; + + /** + * Get the permissions with pivot timestamps. + * + * @return BelongsToMany + */ + public function permissions(): BelongsToMany + { + return $this->traitPermissions()->withTimestamps(); + } +} + class CustomPermissionPivotTestPermissionPivot extends MorphPivot { protected array $casts = [ diff --git a/tests/Permission/PartitionCustomPivotTest.php b/tests/Permission/PartitionCustomPivotTest.php index 701884d93c..06f6222c0e 100644 --- a/tests/Permission/PartitionCustomPivotTest.php +++ b/tests/Permission/PartitionCustomPivotTest.php @@ -181,6 +181,8 @@ class PartitionCustomPivotUser extends UserWithoutHasRoles protected string $guard_name = 'web'; /** + * Get the permissions through the custom pivot. + * * @return BelongsToMany */ public function permissions(): BelongsToMany @@ -189,6 +191,8 @@ public function permissions(): BelongsToMany } /** + * Get the roles through the custom pivot. + * * @return BelongsToMany */ public function roles(): BelongsToMany From 52bc27f884fc4f14aadbcb6a85d228c2ac25f696 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:46:04 +0000 Subject: [PATCH 15/18] Record the Permission sync checkpoint Set spatie/laravel-permission main 6615eefac655 as the checked-through revision after the full-package reconciliation of its tests, source and documentation. The assessment examined no pull requests, so its last reviewed PR stays unset. --- docs/upstream-sync/sync.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index 861cb71b2b..511f7690dc 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -160,9 +160,9 @@ spatie/laravel-data: spatie/laravel-permission: branch: main - checked_through: null + checked_through: 6615eefac655efcd652fe394a20cbdf4f72c609f last_reviewed_pr: null - sync_date: null + sync_date: '2026-10-05' notes: Upstream Pest files map to PHPUnit classes at the same paths under tests/Permission, with each it()/test() description as the method name. Upstream's permission.testing migration flag, which adds the roles team column while teams are off so cases can enable teams mid-test, is not ported; those cases enable teams before migrating through the DefineEnvironment attribute's usesTeams method. The CACHE_DRIVER cache-driver test runs map to CACHE_STORE. Passport::actingAsClient() maps to TestCase::actingAsClient() with the PassportGuard fixture because Hypervel has no Passport package. Upstream's single cached permission collection maps to PermissionRegistrar's catalog payload (getSerializedPermissionsForCache() stores role keys per permission; permissionCatalog() and modelClassCatalog() hydrate it) plus the per-model assignment caches (rememberModel*()), which are filled through Cache\ModelCacheCoordinator and invalidated after commit; apply upstream cache or loading changes to both. Upstream relation definitions map to the Role and Permission relation methods built through BuildsPermissionRelations, whose Partitioned* relations add partition and team constraints and loaded-relation tracking. Upstream's detach-then-attach syncs map to HasRoles::syncRoles() and HasPermissions::synchronizePermissionAssignments(), which read the current pivots and write only the differences, keeping each permission's is_denied effect. Upstream docs map to sections of src/docs/permission.md; the example app, PhpStorm, UI options, schema diagram, upgrade and project pages are not ported. aimeos/laravel-nestedset: From 62b9e65cf050f7f67f754e86372a8c05a488386c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:22:10 +0000 Subject: [PATCH 16/18] Report missing Permission config from the teams migration The teams stub read permission.teams and permission.table_names through typed getters before its "config not loaded" check, so missing config failed with a generic typed-getter error, and the check itself only caught an empty array. Upstream's stub and our create migration show the message that tells users to clear the config cache. The stub now checks table_names first, like the create migration. Validation: PermissionServiceProviderTest runs the missing-config case for both migrations (the stub case failed before the fix); SchemaConfig, Cache (array and database stores) and command tests; php-cs-fixer. --- .../migrations/add_teams_fields.php.stub | 6 ++++-- .../PermissionServiceProviderTest.php | 20 ++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/permission/database/migrations/add_teams_fields.php.stub b/src/permission/database/migrations/add_teams_fields.php.stub index a547075850..4cd288b9a0 100644 --- a/src/permission/database/migrations/add_teams_fields.php.stub +++ b/src/permission/database/migrations/add_teams_fields.php.stub @@ -14,8 +14,11 @@ return new class extends Migration { */ public function up(): void { + $tableNames = config()->get('permission.table_names'); + + throw_if(! is_array($tableNames) || $tableNames === [], 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.'); + $teams = config()->boolean('permission.teams'); - $tableNames = config()->array('permission.table_names'); $columnNames = config()->array('permission.column_names'); $pivotRole = $columnNames['role_pivot_key'] ?? PermissionRegistrar::DEFAULT_ROLE_PIVOT_KEY; $pivotPermission = $columnNames['permission_pivot_key'] ?? PermissionRegistrar::DEFAULT_PERMISSION_PIVOT_KEY; @@ -28,7 +31,6 @@ return new class extends Migration { return; } - throw_if($tableNames === [], 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.'); throw_if($teamForeignKey === '', 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.'); if (! Schema::hasColumn($tableNames['roles'], $teamForeignKey)) { diff --git a/tests/Permission/PermissionServiceProviderTest.php b/tests/Permission/PermissionServiceProviderTest.php index 942a7162e5..1b8d4f4e66 100644 --- a/tests/Permission/PermissionServiceProviderTest.php +++ b/tests/Permission/PermissionServiceProviderTest.php @@ -8,6 +8,7 @@ use Hypervel\Permission\PermissionRegistrar; use Hypervel\Permission\PermissionServiceProvider; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; class PermissionServiceProviderTest extends TestCase @@ -33,15 +34,28 @@ public function testCanonicalOptionalDefaultsAreDeclared(): void $this->assertArrayNotHasKey('wildcard_permission', $config); } - public function testMigrationReportsWhenPermissionConfigurationIsNotLoaded(): void + #[DataProvider('migrations')] + public function testMigrationReportsWhenPermissionConfigurationIsNotLoaded(string $file): void { config(['permission.table_names' => null]); - $migration = require dirname(__DIR__, 2) - . '/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php'; + $migration = require dirname(__DIR__, 2) . '/src/permission/database/migrations/' . $file; $this->expectException(RuntimeException::class); $this->expectExceptionMessageIsOrContains('Error: config/permission.php not loaded.'); $migration->up(); } + + /** + * Get the package migration files. + * + * @return array + */ + public static function migrations(): array + { + return [ + 'create permission tables' => ['2025_07_02_000000_create_permission_tables.php'], + 'add teams fields' => ['add_teams_fields.php.stub'], + ]; + } } From 7a9db22698785d809edeaacc8242b3668a98cef4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:19:55 +0000 Subject: [PATCH 17/18] Clarify Permission teams setup and partition cache resets The teams config comment said to enable teams before migrating or to run permission:setup-teams, which read as if the command works without enabling teams. It refuses to run until teams are enabled, so the comment now says to enable teams first in both cases. The partitioning guide explains how to reset every partition but not when that matters. The create migration clears only the unpartitioned cache keys, so a custom migration that recreates partitioned tables must reset each affected partition. Otherwise cached assignments from the old tables apply to new records that reuse their keys. A per-partition reset rotates that partition's assignment token, so it clears those entries. The enum example now imports the Role and Permission models it uses. --- src/docs/permission.md | 4 ++++ src/permission/config/permission.php | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/docs/permission.md b/src/docs/permission.md index 77e23fe9d4..aaf77f027d 100644 --- a/src/docs/permission.md +++ b/src/docs/permission.md @@ -763,6 +763,8 @@ You may pass enum cases when creating and finding roles and permissions, and to ```php use App\Enums\PermissionName; use App\Enums\RoleName; +use Hypervel\Permission\Models\Permission; +use Hypervel\Permission\Models\Role; $role = Role::create(['name' => RoleName::Writer]); $role = Role::findByName(RoleName::Writer); @@ -1357,6 +1359,8 @@ The current partition is part of every cache key the package uses, so each parti The `permission:cache-reset` command and `forgetCachedPermissions` method clear only the current partition and throw an exception when no partition is set. To reset every partition, loop over your own workspaces, set each one's context, and reset its cache. +When a custom migration recreates partitioned tables, reset each affected partition's cache this way. Otherwise, cached assignments from the old tables may apply to new records that reuse their keys. + Partitioning adds no queries to permission checks or assignments. Each query gains one partition condition, and each assignment row stores the partition value. Warm permission checks run no queries, and loading the role and permission catalog takes three queries, the same as without partitioning. Hard deleting a user model is the exception. When partitioning or teams are enabled, the package first reads which partitions and teams the model's assignments belong to, so it can clear exactly those cache entries. This adds one query for each assignment table the model uses. diff --git a/src/permission/config/permission.php b/src/permission/config/permission.php index e17045a55c..2e2e0bce25 100644 --- a/src/permission/config/permission.php +++ b/src/permission/config/permission.php @@ -91,9 +91,9 @@ |-------------------------------------------------------------------------- | | Teams scope roles and assignments by the configured team foreign key. - | Enable teams before running the migration, or run "permission:setup-teams" - | to add the team columns later. A custom resolver must implement the - | PermissionsTeamResolver contract. + | Enable teams before running the migration. To add the team columns to + | existing tables, enable teams and run "permission:setup-teams". A custom + | resolver must implement the PermissionsTeamResolver contract. | */ From 25cac729a4f3828ce61f32307183f49c96e280b5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:20:05 +0000 Subject: [PATCH 18/18] Tighten Permission sync event, metadata and policy tests testItFiresDetachEventWhenSyncingPermissions faked events before the initial grant, so the grant's own attached event satisfied the attached assertion, which checks the model's permissions only when it runs. The test passed even if the sync dispatched no attached event. The grant now runs before Event::fake(), so only the sync's event can match. Spatie's test has the same order; a comment keeps the change from being undone. PackageMetadataTest compared every non-Hypervel requirement with the root package, but dropping composer-runtime-api, nesbot/carbon or symfony/http-kernel from the Permission package would still have passed. It now asserts those three requirements are present. PolicyTest's comments said "view" where the assertions check "update". Validation: HasPermissionsTest and its two inherited variants (custom models, teams), PackageMetadataTest and PolicyTest; php-cs-fixer. --- tests/Permission/Integration/PolicyTest.php | 4 ++-- tests/Permission/PackageMetadataTest.php | 4 ++++ tests/Permission/Traits/HasPermissionsTest.php | 5 +++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/Permission/Integration/PolicyTest.php b/tests/Permission/Integration/PolicyTest.php index 55f753773f..e21a0da69d 100644 --- a/tests/Permission/Integration/PolicyTest.php +++ b/tests/Permission/Integration/PolicyTest.php @@ -23,11 +23,11 @@ public function testPolicyMethodsAndBeforeInterceptsCanAllowAndDeny(): void $this->assertTrue($this->testUser->can('update', $record2)); - // test that the Admin cannot yet view 'special admin content', because doesn't have Role yet + // test that the Admin cannot yet update 'special admin content', because doesn't have Role yet $this->assertFalse($this->testAdmin->can('update', $record1)); $this->testAdmin->assignRole($this->testAdminRole); - // test that the Admin can view 'special admin content' + // test that the Admin can update 'special admin content' $this->assertTrue($this->testAdmin->can('update', $record1)); $this->assertTrue($this->testAdmin->can('update', $record2)); } diff --git a/tests/Permission/PackageMetadataTest.php b/tests/Permission/PackageMetadataTest.php index 5105c7a297..2ec39bda1c 100644 --- a/tests/Permission/PackageMetadataTest.php +++ b/tests/Permission/PackageMetadataTest.php @@ -30,6 +30,10 @@ public function testDependenciesAndProviderAreDeclared(): void JSON_THROW_ON_ERROR, ); + foreach (['composer-runtime-api', 'nesbot/carbon', 'symfony/http-kernel'] as $dependency) { + $this->assertArrayHasKey($dependency, $composer['require']); + } + foreach ($composer['require'] as $dependency => $constraint) { // The root package replaces the Hypervel packages instead of requiring them. if (str_starts_with($dependency, 'hypervel/')) { diff --git a/tests/Permission/Traits/HasPermissionsTest.php b/tests/Permission/Traits/HasPermissionsTest.php index dd3c8f0131..8714dc069b 100644 --- a/tests/Permission/Traits/HasPermissionsTest.php +++ b/tests/Permission/Traits/HasPermissionsTest.php @@ -1012,11 +1012,12 @@ public function testItFiresAnEventWhenAPermissionIsRemoved(): void public function testItFiresDetachEventWhenSyncingPermissions(): void { + // Grant before faking, so the attached assertion only matches the sync's event. + $this->testUser->givePermissionTo('edit-articles', 'edit-news'); + Event::fake([PermissionDetachedEvent::class, PermissionAttachedEvent::class]); app('config')->set('permission.events_enabled', true); - $this->testUser->givePermissionTo('edit-articles', 'edit-news'); - $this->testUser->syncPermissions('edit-articles'); $this->assertTrue($this->testUser->hasPermissionTo('edit-articles'));