diff --git a/src/Authorizer.php b/src/Authorizer.php index a704f70..1af093c 100644 --- a/src/Authorizer.php +++ b/src/Authorizer.php @@ -44,6 +44,16 @@ class Authorizer */ protected $afterCallbacks = []; + /** + * @var array Ability aliases + */ + protected $abilityAliases = []; + + /** + * @var array Conditional ability callbacks + */ + protected $conditionalAbilities = []; + /** * Register a authorizer for a given class. * @@ -68,6 +78,70 @@ public function define($ability, callable $callback): void $this->abilities[$ability] = $callback; } + /** + * Register an alias that maps to an existing ability name. + * + * Example: + * Guard::alias('edit', 'update-post'); + * Guard::allows('edit', $post); + * + * @param string $alias + * @param string $ability + * @return $this + */ + public function alias(string $alias, string $ability): self + { + $this->abilityAliases[$alias] = $ability; + + return $this; + } + + /** + * Register a wildcard ability pattern. + * + * Supported pattern styles: + * 'post.*' matches post.create, post.edit, post.delete, … + * '*.create' matches post.create, comment.create, … + * 'admin.*.*' matches admin.users.delete, admin.settings.edit, … + * '*' matches every ability + * + * Example: + * Guard::wildcard('post.*', fn($user) => $user->isEditor); + * Guard::allows('post.delete'); + * + * @param string $pattern + * @param callable $callback + * @return $this + */ + public function wildcard(string $pattern, callable $callback): self + { + $this->abilities[$pattern] = $callback; + + return $this; + } + + /** + * Register a conditional ability that is only active when a runtime condition passes. + * + * Example — weekend-only access: + * Guard::condition('weekend-export', fn() => now()->isWeekend()); + * Guard::define('weekend-export', fn($user) => $user->isPremium); + * + * Example — feature-flag gated ability: + * Guard::condition('beta-dashboard', fn() => config('features.beta')); + * Guard::define('beta-dashboard', fn($user) => true); + * + * @param string $ability + * @param callable $condition + * @return $this + */ + public function condition(string $ability, callable $condition): self + { + $this->conditionalAbilities[$ability] = $condition; + + return $this; + } + /** * Determine if the given ability should be granted for the current user. * @@ -191,14 +265,102 @@ public function getChildren($ability): array } /** - * Enhanced check method with all new features. + * Resolve an ability name through the alias chain. * * @param string $ability - * @param array $arguments + * @return string + */ + public function resolveAlias(string $ability): string + { + $visited = []; + + while (isset($this->abilityAliases[$ability])) { + if (in_array($ability, $visited, true)) { + // Circular alias chain — break out and return current + break; + } + $visited[] = $ability; + $ability = $this->abilityAliases[$ability]; + } + + return $ability; + } + + /** + * Find the first wildcard pattern in $this->abilities that matches + * the given ability name and return the pattern key, or null if none match. + * + * @param string $ability + * @return string|null + */ + public function matchWildcard(string $ability): ?string + { + foreach (array_keys($this->abilities) as $pattern) { + if (strpos($pattern, '*') === false) { + continue; // not a wildcard pattern + } + + if ($this->wildcardPatternMatches($pattern, $ability)) { + return $pattern; + } + } + + return null; + } + + /** + * Test whether a wildcard pattern matches a concrete ability name. + * + * @param string $pattern + * @param string $ability + * @return bool + */ + protected function wildcardPatternMatches(string $pattern, string $ability): bool + { + if ($pattern === '*') { + return true; + } + + // Convert the pattern into a regex: + // escape dots, then replace * with [^.]+ (non-dot chars) + $regex = '/^' . str_replace('\*', '[^.]+', preg_quote($pattern, '/')) . '$/'; + + return (bool) preg_match($regex, $ability); + } + + /** + * Return the registered aliases map. + * + * @return array + */ + public function aliases(): array + { + return $this->abilityAliases; + } + + /** + * Return the registered conditional ability callbacks. + * + * @return array + */ + public function conditions(): array + { + return $this->conditionalAbilities; + } + + /** + * Enhanced check method with all features. + * + * @param string $ability + * @param array $arguments + * @param array $visited * @return bool */ public function check($ability, array $arguments = [], array $visited = []): bool { + // Resolve alias + $ability = $this->resolveAlias($ability); + if (in_array($ability, $visited)) { return false; } @@ -206,27 +368,37 @@ public function check($ability, array $arguments = [], array $visited = []): boo $user = $this->resolveUser(); - // Run global before callbacks + // Global before callbacks foreach ($this->beforeCallbacks as $callback) { $callbackArgs = array_merge([$user, $ability], $arguments); - $result = call_user_func_array($callback, $callbackArgs); + $result = call_user_func_array($callback, $callbackArgs); if ($result !== null) { - // Run after callbacks even if before callback returns a result foreach ($this->afterCallbacks as $afterCallback) { $afterArgs = array_merge([$user, $ability, $result], $arguments); call_user_func_array($afterCallback, $afterArgs); } - return (bool)$result; + return (bool) $result; + } + } + + // Conditional guard — if a condition is registered and fails, deny immediately + if (isset($this->conditionalAbilities[$ability])) { + if (!call_user_func($this->conditionalAbilities[$ability])) { + $result = false; + foreach ($this->afterCallbacks as $afterCallback) { + $afterArgs = array_merge([$user, $ability, $result], $arguments); + call_user_func_array($afterCallback, $afterArgs); + } + return $result; } } - // Check temporary abilities first + // Temporary abilities if (isset($this->temporaryAbilities[$ability])) { $callback = $this->temporaryAbilities[$ability]; unset($this->temporaryAbilities[$ability]); $result = $this->callAuthCallback($user, $callback, $arguments); - // Run after callbacks foreach ($this->afterCallbacks as $afterCallback) { $afterArgs = array_merge([$user, $ability, $result], $arguments); call_user_func_array($afterCallback, $afterArgs); @@ -235,11 +407,10 @@ public function check($ability, array $arguments = [], array $visited = []): boo return $result; } - // Check for directly defined abilities + // Directly defined ability if (isset($this->abilities[$ability])) { $result = $this->callAuthCallback($user, $this->abilities[$ability], $arguments); - // Run after callbacks foreach ($this->afterCallbacks as $afterCallback) { $afterArgs = array_merge([$user, $ability, $result], $arguments); call_user_func_array($afterCallback, $afterArgs); @@ -248,9 +419,21 @@ public function check($ability, array $arguments = [], array $visited = []): boo return $result; } - // Check if ability is a parent in any hierarchy + // Wildcard ability match + $wildcardPattern = $this->matchWildcard($ability); + if ($wildcardPattern !== null) { + $result = $this->callAuthCallback($user, $this->abilities[$wildcardPattern], $arguments); + + foreach ($this->afterCallbacks as $afterCallback) { + $afterArgs = array_merge([$user, $ability, $result], $arguments); + call_user_func_array($afterCallback, $afterArgs); + } + + return $result; + } + + // Hierarchy: parent -> children if (isset($this->abilityHierarchies[$ability])) { - // If any child ability is allowed, the parent is allowed foreach ($this->abilityHierarchies[$ability] as $childAbility) { if ($this->check($childAbility, $arguments, $visited)) { return true; @@ -258,7 +441,7 @@ public function check($ability, array $arguments = [], array $visited = []): boo } } - // Check parent abilities using getParents() + // Hierarchy: child -> parents $parentAbilities = $this->getParents($ability); foreach ($parentAbilities as $parentAbility) { if ($this->check($parentAbility, $arguments, $visited)) { @@ -266,11 +449,10 @@ public function check($ability, array $arguments = [], array $visited = []): boo } } - // Check for policy-based authorization + // Policy-based authorization if (!empty($arguments)) { $result = $this->authorizeViaPolicy($ability, $user, $arguments); - // Run global after callbacks foreach ($this->afterCallbacks as $callback) { $this->callAuthCallback($user, $callback, [$ability, $result] + $arguments); } @@ -278,7 +460,7 @@ public function check($ability, array $arguments = [], array $visited = []): boo return $result; } - // Run global after callbacks + // After callbacks — denied foreach ($this->afterCallbacks as $callback) { $this->callAuthCallback($user, $callback, [$ability, false] + $arguments); } @@ -297,6 +479,7 @@ public function getParents($ability): array return $parents; } + /** * Attempt authorization via policy methods. * @@ -307,26 +490,19 @@ public function getParents($ability): array */ protected function authorizeViaPolicy($ability, $user, array $arguments): bool { - $model = $arguments[0]; + $model = $arguments[0]; $policy = $this->getPolicyFor($model); if (!$policy) { return false; } - // If policy is a class name string, instantiate it if (is_string($policy)) { $policy = new $policy; } - // Check if the ability exists on the policy if (method_exists($policy, $ability)) { - return $this->callPolicyMethod( - $policy, - $ability, - $user, - $arguments - ); + return $this->callPolicyMethod($policy, $ability, $user, $arguments); } return false; @@ -342,14 +518,10 @@ protected function authorizeViaPolicy($ability, $user, array $arguments): bool */ protected function callAuthCallback($user, callable $callback, array $arguments = []): bool { - // If no user is provided - // the callback expects a user parameter, return false if ($user === null) { $reflection = new \ReflectionFunction($callback); $parameters = $reflection->getParameters(); - // If the first parameter is a user parameter, - // return false for null users if (!empty($parameters) && $parameters[0]->getName() === 'user') { return false; } @@ -359,6 +531,7 @@ protected function callAuthCallback($user, callable $callback, array $arguments return call_user_func_array($callback, $arguments) === true; } + /** * Call a policy method. * @@ -394,13 +567,11 @@ protected function getPolicyFor($class): ?object return null; } - // Check for exact match if (isset($this->policies[$class])) { $policy = $this->policies[$class]; return is_string($policy) ? new $policy : $policy; } - // Optional: Check for parent class policies foreach ($this->policies as $policyClass => $policy) { if (is_a($class, $policyClass, true)) { return is_string($policy) ? new $policy : $policy; @@ -464,8 +635,10 @@ public function abilities(): array */ public function clear(): self { - $this->policies = []; - $this->abilities = []; + $this->policies = []; + $this->abilities = []; + $this->abilityAliases = []; + $this->conditionalAbilities = []; return $this; } @@ -507,33 +680,37 @@ public function all(array $abilities, array $arguments = []): bool } /** - * Check if ability exists (defined, temporary, or in hierarchy). + * Check if ability exists (defined, temporary, in hierarchy, or a wildcard pattern). * * @param string $ability * @return bool */ public function hasAbility($ability): bool { - // Check direct abilities - if ( - isset($this->abilities[$ability]) || - isset($this->temporaryAbilities[$ability]) - ) { + if (isset($this->abilities[$ability]) || isset($this->temporaryAbilities[$ability])) { return true; } - // Check if it's a parent in any hierarchy if (isset($this->abilityHierarchies[$ability])) { return true; } - // Check if it's a child in any hierarchy foreach ($this->abilityHierarchies as $children) { if (in_array($ability, $children)) { return true; } } + // Check alias resolution + if (isset($this->abilityAliases[$ability])) { + return true; + } + + // Check wildcard match + if ($this->matchWildcard($ability) !== null) { + return true; + } + return false; } diff --git a/src/Support/Facades/Guard.php b/src/Support/Facades/Guard.php index 923f2be..f3ad360 100644 --- a/src/Support/Facades/Guard.php +++ b/src/Support/Facades/Guard.php @@ -14,7 +14,8 @@ * @method static \Doppar\Authorizer\Authorizer after(callable $callback): self * @method static \Doppar\Authorizer\Authorizer inGroup($groupName, $ability): bool * @method static \Doppar\Authorizer\Authorizer getChildren($ability): array - * @method static \Doppar\Authorizer\Authorizer check($ability, array $arguments = []): bool + * @method static \Doppar\Authorizer\Authorizer getParents($ability): array + * @method static \Doppar\Authorizer\Authorizer check($ability, array $arguments = [], array $visited = []): bool * @method static \Doppar\Authorizer\Authorizer resolveUserUsing(callable $userResolver): self * @method static \Doppar\Authorizer\Authorizer resolveUser(): mixed * @method static \Doppar\Authorizer\Authorizer policies(): array @@ -24,6 +25,13 @@ * @method static \Doppar\Authorizer\Authorizer all(array $abilities, array $arguments = []): bool * @method static \Doppar\Authorizer\Authorizer hasAbility($ability): bool * @method static \Doppar\Authorizer\Authorizer getAllAbilities(): array + * @method static \Doppar\Authorizer\Authorizer alias(string $alias, string $ability): self + * @method static \Doppar\Authorizer\Authorizer aliases(): array + * @method static \Doppar\Authorizer\Authorizer resolveAlias(string $ability): string + * @method static \Doppar\Authorizer\Authorizer wildcard(string $pattern, callable $callback): self + * @method static \Doppar\Authorizer\Authorizer matchWildcard(string $ability): ?string + * @method static \Doppar\Authorizer\Authorizer condition(string $ability, callable $condition): self + * @method static \Doppar\Authorizer\Authorizer conditions(): array * @see \Doppar\Authorizer\Authorizer */ diff --git a/tests/Unit/AbilityAlliasTest.php b/tests/Unit/AbilityAlliasTest.php new file mode 100644 index 0000000..5d1d264 --- /dev/null +++ b/tests/Unit/AbilityAlliasTest.php @@ -0,0 +1,671 @@ +authorizer = new Authorizer(); + } + + // ===================================================== + // ABILITY ALIASES + // ===================================================== + + public function testAliasResolvesToTargetAbility(): void + { + $this->authorizer->define('update-post', fn($user) => $user->isEditor); + $this->authorizer->alias('edit', 'update-post'); + + $editor = new class { + public $isEditor = true; + }; + $this->authorizer->resolveUserUsing(fn() => $editor); + + $this->assertTrue($this->authorizer->allows('edit')); + } + + public function testAliasDeniesWhenTargetDenies(): void + { + $this->authorizer->define('update-post', fn($user) => $user->isEditor); + $this->authorizer->alias('edit', 'update-post'); + + $viewer = new class { + public $isEditor = false; + }; + $this->authorizer->resolveUserUsing(fn() => $viewer); + + $this->assertFalse($this->authorizer->allows('edit')); + $this->assertTrue($this->authorizer->denies('edit')); + } + + public function testAliasPassesArgumentsToTargetAbility(): void + { + $this->authorizer->define('update-post', function ($user, $post) { + return (int) $user->id === (int) $post->user_id; + }); + $this->authorizer->alias('edit', 'update-post'); + + $user = new class { + public $id = 1; + }; + $post = new class { + public $user_id = 1; + }; + + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertTrue($this->authorizer->allows('edit', $post)); + } + + public function testAliasChaining(): void + { + // 'modify' -> 'edit' -> 'update-post' + $this->authorizer->define('update-post', fn($user) => $user->isEditor); + $this->authorizer->alias('edit', 'update-post'); + $this->authorizer->alias('modify', 'edit'); + + $editor = new class { + public $isEditor = true; + }; + $this->authorizer->resolveUserUsing(fn() => $editor); + + $this->assertTrue($this->authorizer->allows('modify')); + } + + public function testCircularAliasDoesNotInfiniteLoop(): void + { + $this->authorizer->alias('a', 'b'); + $this->authorizer->alias('b', 'a'); + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + // Should return false without hanging + $this->assertFalse($this->authorizer->allows('a')); + } + + public function testAliasToUndefinedAbilityReturnsFalse(): void + { + $this->authorizer->alias('shortcut', 'nonexistent-ability'); + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertFalse($this->authorizer->allows('shortcut')); + } + + public function testAliasReturnsSelf(): void + { + $result = $this->authorizer->alias('a', 'b'); + $this->assertInstanceOf(Authorizer::class, $result); + } + + public function testAliasesAccessor(): void + { + $this->authorizer->alias('edit', 'update-post'); + $this->authorizer->alias('remove', 'delete-post'); + + $aliases = $this->authorizer->aliases(); + + $this->assertArrayHasKey('edit', $aliases); + $this->assertArrayHasKey('remove', $aliases); + $this->assertEquals('update-post', $aliases['edit']); + $this->assertEquals('delete-post', $aliases['remove']); + } + + public function testResolveAliasReturnsOriginalForNonAlias(): void + { + $resolved = $this->authorizer->resolveAlias('update-post'); + $this->assertEquals('update-post', $resolved); + } + + public function testResolveAliasFollowsChain(): void + { + $this->authorizer->alias('a', 'b'); + $this->authorizer->alias('b', 'c'); + $this->authorizer->alias('c', 'real-ability'); + + $this->assertEquals('real-ability', $this->authorizer->resolveAlias('a')); + } + + public function testAliasWorksWithPolicyAuthorization(): void + { + $policy = new class { + public function update($user, $model) + { + return $user->id === $model->owner_id; + } + }; + + $model = new class { + public $owner_id = 7; + }; + $user = new class { + public $id = 7; + }; + + $this->authorizer->authorize(get_class($model), get_class($policy)); + $this->authorizer->alias('save', 'update'); + + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertTrue($this->authorizer->allows('save', $model)); + } + + public function testAliasWorksInsideAnyCheck(): void + { + $this->authorizer->define('update-post', fn($user) => $user->isEditor); + $this->authorizer->alias('edit', 'update-post'); + + $editor = new class { + public $isEditor = true; + }; + $this->authorizer->resolveUserUsing(fn() => $editor); + + $this->assertTrue($this->authorizer->any(['edit', 'nonexistent'])); + } + + public function testHasAbilityReturnsTrueForAlias(): void + { + $this->authorizer->define('update-post', fn() => true); + $this->authorizer->alias('edit', 'update-post'); + + $this->assertTrue($this->authorizer->hasAbility('edit')); + } + + public function testClearRemovesAliases(): void + { + $this->authorizer->alias('edit', 'update-post'); + $this->assertNotEmpty($this->authorizer->aliases()); + + $this->authorizer->clear(); + + $this->assertEmpty($this->authorizer->aliases()); + } + + // ===================================================== + // WILDCARD ABILITIES + // ===================================================== + + public function testWildcardPrefixPatternMatchesAllSuffixes(): void + { + $this->authorizer->wildcard('post.*', fn($user) => $user->isEditor); + + $editor = new class { + public $isEditor = true; + }; + $this->authorizer->resolveUserUsing(fn() => $editor); + + $this->assertTrue($this->authorizer->allows('post.create')); + $this->assertTrue($this->authorizer->allows('post.edit')); + $this->assertTrue($this->authorizer->allows('post.delete')); + $this->assertTrue($this->authorizer->allows('post.publish')); + } + + public function testWildcardPrefixPatternDoesNotMatchOtherPrefixes(): void + { + $this->authorizer->wildcard('post.*', fn($user) => $user->isEditor); + + $editor = new class { + public $isEditor = true; + }; + $this->authorizer->resolveUserUsing(fn() => $editor); + + $this->assertFalse($this->authorizer->allows('comment.create')); + $this->assertFalse($this->authorizer->allows('user.delete')); + } + + public function testWildcardSuffixPatternMatchesAllPrefixes(): void + { + $this->authorizer->wildcard('*.create', fn($user) => $user->canCreate); + + $user = new class { + public $canCreate = true; + }; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertTrue($this->authorizer->allows('post.create')); + $this->assertTrue($this->authorizer->allows('comment.create')); + $this->assertTrue($this->authorizer->allows('tag.create')); + } + + public function testWildcardSuffixPatternDoesNotMatchOtherSuffixes(): void + { + $this->authorizer->wildcard('*.create', fn($user) => $user->canCreate); + + $user = new class { + public $canCreate = true; + }; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertFalse($this->authorizer->allows('post.delete')); + $this->assertFalse($this->authorizer->allows('post.edit')); + } + + public function testGlobalWildcardMatchesEverything(): void + { + $this->authorizer->wildcard('*', fn($user) => $user->isAdmin); + + $admin = new class { + public $isAdmin = true; + }; + $this->authorizer->resolveUserUsing(fn() => $admin); + + $this->assertTrue($this->authorizer->allows('anything')); + $this->assertTrue($this->authorizer->allows('post.create')); + $this->assertTrue($this->authorizer->allows('some.deeply.nested.ability')); + } + + public function testWildcardDeniesWhenCallbackReturnsFalse(): void + { + $this->authorizer->wildcard('post.*', fn($user) => false); + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertFalse($this->authorizer->allows('post.create')); + } + + public function testExactAbilityTakesPrecedenceOverWildcard(): void + { + // Exact ability explicitly denies + $this->authorizer->define('post.delete', fn($user) => false); + // Wildcard would allow + $this->authorizer->wildcard('post.*', fn($user) => true); + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + // Exact match is checked before wildcard + $this->assertFalse($this->authorizer->allows('post.delete')); + // Other abilities fall through to wildcard + $this->assertTrue($this->authorizer->allows('post.create')); + } + + public function testWildcardWithMultipleSegments(): void + { + $this->authorizer->wildcard('admin.*.*', fn($user) => $user->isAdmin); + + $admin = new class { + public $isAdmin = true; + }; + $this->authorizer->resolveUserUsing(fn() => $admin); + + $this->assertTrue($this->authorizer->allows('admin.users.delete')); + $this->assertTrue($this->authorizer->allows('admin.settings.edit')); + $this->assertFalse($this->authorizer->allows('admin.single')); // only one segment after admin + } + + public function testWildcardPassesArgumentsToCallback(): void + { + $this->authorizer->wildcard('post.*', function ($user, $post) { + return (int) $user->id === (int) $post->owner_id; + }); + + $user = new class { + public $id = 3; + }; + $post = new class { + public $owner_id = 3; + }; + $notPost = new class { + public $owner_id = 99; + }; + + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertTrue($this->authorizer->allows('post.edit', $post)); + $this->assertFalse($this->authorizer->allows('post.edit', $notPost)); + } + + public function testMultipleWildcardsFirstMatchWins(): void + { + $this->authorizer->wildcard('post.*', fn($user) => false); // registered first — denies + $this->authorizer->wildcard('*.edit', fn($user) => true); // registered second — allows + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + // 'post.edit' matches 'post.*' first — it should deny + $this->assertFalse($this->authorizer->allows('post.edit')); + } + + public function testWildcardReturnsSelf(): void + { + $result = $this->authorizer->wildcard('post.*', fn() => true); + $this->assertInstanceOf(Authorizer::class, $result); + } + + public function testMatchWildcardReturnsPatternKey(): void + { + $this->authorizer->wildcard('post.*', fn() => true); + + $this->assertEquals('post.*', $this->authorizer->matchWildcard('post.create')); + $this->assertEquals('post.*', $this->authorizer->matchWildcard('post.delete')); + $this->assertNull($this->authorizer->matchWildcard('comment.create')); + } + + public function testMatchWildcardReturnsNullForNoMatch(): void + { + $this->authorizer->wildcard('post.*', fn() => true); + + $this->assertNull($this->authorizer->matchWildcard('user.update')); + } + + public function testHasAbilityReturnsTrueForWildcardMatch(): void + { + $this->authorizer->wildcard('post.*', fn() => true); + + $this->assertTrue($this->authorizer->hasAbility('post.create')); + $this->assertTrue($this->authorizer->hasAbility('post.delete')); + $this->assertFalse($this->authorizer->hasAbility('comment.create')); + } + + public function testWildcardWorksWithAliases(): void + { + $this->authorizer->wildcard('post.*', fn($user) => $user->isEditor); + $this->authorizer->alias('write-post', 'post.create'); + + $editor = new class { + public $isEditor = true; + }; + $this->authorizer->resolveUserUsing(fn() => $editor); + + // alias resolves to 'post.create', wildcard matches 'post.*' + $this->assertTrue($this->authorizer->allows('write-post')); + } + + public function testWildcardWorksWithBeforeCallback(): void + { + $this->authorizer->wildcard('post.*', fn($user) => true); + + $this->authorizer->before(fn($user, $ability) => false); // deny everything + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertFalse($this->authorizer->allows('post.create')); + } + + // ===================================================== + // CONDITIONAL ABILITIES + // ===================================================== + + public function testConditionalAbilityAllowsWhenConditionPasses(): void + { + $this->authorizer->condition('weekend-export', fn() => true); // condition passes + $this->authorizer->define('weekend-export', fn($user) => $user->isPremium); + + $premium = new class { + public $isPremium = true; + }; + $this->authorizer->resolveUserUsing(fn() => $premium); + + $this->assertTrue($this->authorizer->allows('weekend-export')); + } + + public function testConditionalAbilityDeniesWhenConditionFails(): void + { + $this->authorizer->condition('weekend-export', fn() => false); // condition fails + $this->authorizer->define('weekend-export', fn($user) => $user->isPremium); + + $premium = new class { + public $isPremium = true; + }; + $this->authorizer->resolveUserUsing(fn() => $premium); + + // Even though user is premium, condition blocks access + $this->assertFalse($this->authorizer->allows('weekend-export')); + } + + public function testConditionalAbilityDeniesEvenIfUserWouldBeAllowed(): void + { + $featureEnabled = false; + + $this->authorizer->condition('beta-feature', function () use (&$featureEnabled) { + return $featureEnabled; + }); + $this->authorizer->define('beta-feature', fn() => true); + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertFalse($this->authorizer->allows('beta-feature')); + + // Enable the feature flag + $featureEnabled = true; + + $this->assertTrue($this->authorizer->allows('beta-feature')); + } + + public function testConditionalAbilityWithTimeBoundLogic(): void + { + $now = new \DateTimeImmutable(); + + // Simulate "only during business hours" (we control the clock via closure) + $isBusinessHours = true; + + $this->authorizer->condition('schedule-meeting', function () use (&$isBusinessHours) { + return $isBusinessHours; + }); + $this->authorizer->define('schedule-meeting', fn($user) => true); + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertTrue($this->authorizer->allows('schedule-meeting')); + + $isBusinessHours = false; + $this->assertFalse($this->authorizer->allows('schedule-meeting')); + } + + public function testConditionalAbilityWithoutAbilityDefinedReturnsFalse(): void + { + // Condition passes but no ability defined + $this->authorizer->condition('ghost-ability', fn() => true); + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertFalse($this->authorizer->allows('ghost-ability')); + } + + public function testAbilityWithoutConditionIsUnrestricted(): void + { + $this->authorizer->define('normal-ability', fn($user) => true); + // No condition registered — should pass normally + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertTrue($this->authorizer->allows('normal-ability')); + } + + public function testMultipleConditionsAreIndependent(): void + { + $this->authorizer->condition('feature-a', fn() => true); + $this->authorizer->condition('feature-b', fn() => false); + + $this->authorizer->define('feature-a', fn() => true); + $this->authorizer->define('feature-b', fn() => true); + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertTrue($this->authorizer->allows('feature-a')); + $this->assertFalse($this->authorizer->allows('feature-b')); + } + + public function testConditionalAbilityReturnsSelf(): void + { + $result = $this->authorizer->condition('my-ability', fn() => true); + $this->assertInstanceOf(Authorizer::class, $result); + } + + public function testConditionsAccessor(): void + { + $condA = fn() => true; + $condB = fn() => false; + + $this->authorizer->condition('ability-a', $condA); + $this->authorizer->condition('ability-b', $condB); + + $conditions = $this->authorizer->conditions(); + + $this->assertArrayHasKey('ability-a', $conditions); + $this->assertArrayHasKey('ability-b', $conditions); + } + + public function testConditionalAbilityFiresAfterCallbackOnDeny(): void + { + $afterCalled = false; + $capturedResult = null; + + $this->authorizer->condition('locked-ability', fn() => false); + $this->authorizer->define('locked-ability', fn() => true); + + $this->authorizer->after(function ($user, $ability, $result) use (&$afterCalled, &$capturedResult) { + $afterCalled = true; + $capturedResult = $result; + }); + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertFalse($this->authorizer->allows('locked-ability')); + $this->assertTrue($afterCalled); + $this->assertFalse($capturedResult); + } + + public function testConditionalAbilityBeforeCallbackCanStillOverride(): void + { + // Condition blocks the ability + $this->authorizer->condition('restricted', fn() => false); + $this->authorizer->define('restricted', fn() => true); + + // But a before callback (e.g. super-admin) can still override + $this->authorizer->before(fn($user, $ability) => $user->isSuperAdmin ? true : null); + + $superAdmin = new class { + public $isSuperAdmin = true; + }; + $this->authorizer->resolveUserUsing(fn() => $superAdmin); + + // before runs before the condition check, so super-admin bypasses it + $this->assertTrue($this->authorizer->allows('restricted')); + } + + public function testConditionalAbilityWorksWithAlias(): void + { + $this->authorizer->condition('export-data', fn() => true); + $this->authorizer->define('export-data', fn($user) => $user->canExport); + $this->authorizer->alias('export', 'export-data'); + + $user = new class { + public $canExport = true; + }; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertTrue($this->authorizer->allows('export')); + } + + public function testConditionalAbilityBlockedViaAliasWhenConditionFails(): void + { + $this->authorizer->condition('export-data', fn() => false); + $this->authorizer->define('export-data', fn($user) => $user->canExport); + $this->authorizer->alias('export', 'export-data'); + + $user = new class { + public $canExport = true; + }; + $this->authorizer->resolveUserUsing(fn() => $user); + + $this->assertFalse($this->authorizer->allows('export')); + } + + public function testClearRemovesConditions(): void + { + $this->authorizer->condition('my-ability', fn() => true); + $this->assertNotEmpty($this->authorizer->conditions()); + + $this->authorizer->clear(); + + $this->assertEmpty($this->authorizer->conditions()); + } + + // ===================================================== + // COMBINED SCENARIOS + // ===================================================== + + public function testAllThreeFeaturesWorkTogether(): void + { + // Wildcard pattern covers all post abilities + $this->authorizer->wildcard('post.*', fn($user) => $user->isEditor); + + // Alias 'write' -> 'post.create' + $this->authorizer->alias('write', 'post.create'); + + // Condition: feature is enabled + $featureOn = true; + $this->authorizer->condition('post.create', function () use (&$featureOn) { + return $featureOn; + }); + + $editor = new class { + public $isEditor = true; + }; + $this->authorizer->resolveUserUsing(fn() => $editor); + + // 'write' -> resolves alias -> 'post.create' + // condition passes -> wildcard 'post.*' -> user is editor -> true + $this->assertTrue($this->authorizer->allows('write')); + + // Turn off feature flag + $featureOn = false; + $this->assertFalse($this->authorizer->allows('write')); + } + + public function testWildcardAndAliasWithInheritance(): void + { + $this->authorizer->wildcard('content.*', fn($user) => $user->isEditor); + $this->authorizer->alias('write', 'content.create'); + $this->authorizer->inherit('editor-access', ['content.create', 'content.edit']); + + $editor = new class { + public $isEditor = true; + }; + $this->authorizer->resolveUserUsing(fn() => $editor); + + $this->assertTrue($this->authorizer->allows('write')); // alias -> wildcard + $this->assertTrue($this->authorizer->allows('editor-access')); // hierarchy -> wildcard + } + + public function testConditionalWithAnyBulkCheck(): void + { + $this->authorizer->condition('premium-export', fn() => false); // blocked + $this->authorizer->define('premium-export', fn() => true); + $this->authorizer->define('basic-export', fn() => true); + + $user = new class {}; + $this->authorizer->resolveUserUsing(fn() => $user); + + // premium-export blocked by condition, but basic-export passes + $this->assertTrue($this->authorizer->any(['premium-export', 'basic-export'])); + + // all() fails because premium-export is blocked + $this->assertFalse($this->authorizer->all(['premium-export', 'basic-export'])); + } +}