session = Session::fromArray($sessionId, $clock, $csrfGenerator, []); $this->eventDispatcher = new ComponentEventDispatcher(); $this->authChecker = new SessionBasedAuthorizationChecker($this->session); $this->handler = new LiveComponentHandler( $this->eventDispatcher, $this->session, $this->authChecker ); }); describe('RequiresPermission Attribute', function () { it('validates permission attribute requires at least one permission', function () { expect(fn () => new RequiresPermission()) ->toThrow(\InvalidArgumentException::class, 'at least one permission'); }); it('checks if user has required permission', function () { $attribute = new RequiresPermission('posts.edit'); expect($attribute->isAuthorized(['posts.edit', 'posts.view']))->toBeTrue(); expect($attribute->isAuthorized(['posts.view']))->toBeFalse(); expect($attribute->isAuthorized([]))->toBeFalse(); }); it('checks multiple permissions with OR logic', function () { $attribute = new RequiresPermission('posts.edit', 'posts.admin'); expect($attribute->isAuthorized(['posts.edit']))->toBeTrue(); expect($attribute->isAuthorized(['posts.admin']))->toBeTrue(); expect($attribute->isAuthorized(['posts.view']))->toBeFalse(); }); it('provides permission info methods', function () { $attribute = new RequiresPermission('posts.edit', 'posts.admin'); expect($attribute->getPermissions())->toBe(['posts.edit', 'posts.admin']); expect($attribute->getPrimaryPermission())->toBe('posts.edit'); expect($attribute->hasMultiplePermissions())->toBeTrue(); }); }); describe('SessionBasedAuthorizationChecker', function () { it('identifies unauthenticated users', function () { expect($this->authChecker->isAuthenticated())->toBeFalse(); expect($this->authChecker->getUserPermissions())->toBe([]); }); it('identifies authenticated users', function () { $this->session->set('user', [ 'id' => 123, 'permissions' => ['posts.view', 'posts.edit'], ]); expect($this->authChecker->isAuthenticated())->toBeTrue(); expect($this->authChecker->getUserPermissions())->toBe(['posts.view', 'posts.edit']); }); it('checks specific permission', function () { $this->session->set('user', [ 'id' => 123, 'permissions' => ['posts.view', 'posts.edit'], ]); expect($this->authChecker->hasPermission('posts.edit'))->toBeTrue(); expect($this->authChecker->hasPermission('posts.delete'))->toBeFalse(); }); it('allows access when no permission attribute present', function () { $component = createTestComponent(); $isAuthorized = $this->authChecker->isAuthorized( $component, 'someMethod', null ); expect($isAuthorized)->toBeTrue(); }); it('denies access for unauthenticated user with permission requirement', function () { $component = createTestComponent(); $attribute = new RequiresPermission('posts.edit'); $isAuthorized = $this->authChecker->isAuthorized( $component, 'editPost', $attribute ); expect($isAuthorized)->toBeFalse(); }); it('allows access when user has required permission', function () { $this->session->set('user', [ 'id' => 123, 'permissions' => ['posts.view', 'posts.edit'], ]); $component = createTestComponent(); $attribute = new RequiresPermission('posts.edit'); $isAuthorized = $this->authChecker->isAuthorized( $component, 'editPost', $attribute ); expect($isAuthorized)->toBeTrue(); }); it('denies access when user lacks required permission', function () { $this->session->set('user', [ 'id' => 123, 'permissions' => ['posts.view'], ]); $component = createTestComponent(); $attribute = new RequiresPermission('posts.edit'); $isAuthorized = $this->authChecker->isAuthorized( $component, 'editPost', $attribute ); expect($isAuthorized)->toBeFalse(); }); }); describe('LiveComponentHandler Authorization', function () { it('executes action without permission requirement', function () { $componentId = ComponentId::fromString('test:component'); // Generate CSRF token $formId = 'livecomponent:' . $componentId->toString(); $csrfToken = $this->session->csrf->generateToken($formId); $component = new class ($componentId) implements LiveComponentContract { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray(['count' => 0]); } public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData { return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []); } // Action without RequiresPermission attribute public function increment(): ComponentData { return ComponentData::fromArray(['count' => 1]); } }; $params = ActionParameters::fromArray([], $csrfToken); $result = $this->handler->handle($component, 'increment', $params); expect($result->state->data['count'])->toBe(1); }); it('throws exception for unauthenticated user with permission requirement', function () { $componentId = ComponentId::fromString('test:component'); // Generate CSRF token $formId = 'livecomponent:' . $componentId->toString(); $csrfToken = $this->session->csrf->generateToken($formId); $component = new class ($componentId) implements LiveComponentContract { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray([]); } public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData { return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []); } #[RequiresPermission('posts.delete')] public function deletePost(string $postId): ComponentData { return ComponentData::fromArray(['deleted' => true]); } }; $params = ActionParameters::fromArray(['postId' => '123'], $csrfToken); expect(fn () => $this->handler->handle($component, 'deletePost', $params)) ->toThrow(UnauthorizedActionException::class, 'requires authentication'); }); it('throws exception for user without required permission', function () { // User with only 'posts.view' permission $this->session->set('user', [ 'id' => 123, 'permissions' => ['posts.view'], ]); $componentId = ComponentId::fromString('test:component'); // Generate CSRF token $formId = 'livecomponent:' . $componentId->toString(); $csrfToken = $this->session->csrf->generateToken($formId); $component = new class ($componentId) implements LiveComponentContract { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray([]); } public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData { return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []); } #[RequiresPermission('posts.delete')] public function deletePost(string $postId): ComponentData { return ComponentData::fromArray(['deleted' => true]); } }; $params = ActionParameters::fromArray(['postId' => '123'], $csrfToken); expect(fn () => $this->handler->handle($component, 'deletePost', $params)) ->toThrow(UnauthorizedActionException::class, 'requires permission'); }); it('executes action when user has required permission', function () { // User with 'posts.delete' permission $this->session->set('user', [ 'id' => 123, 'permissions' => ['posts.view', 'posts.delete'], ]); $componentId = ComponentId::fromString('test:component'); // Generate CSRF token $formId = 'livecomponent:' . $componentId->toString(); $csrfToken = $this->session->csrf->generateToken($formId); $component = new class ($componentId) implements LiveComponentContract { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray([]); } public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData { return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []); } #[RequiresPermission('posts.delete')] public function deletePost(string $postId): ComponentData { return ComponentData::fromArray(['deleted' => true, 'postId' => $postId]); } }; $params = ActionParameters::fromArray(['postId' => '456'], $csrfToken); $result = $this->handler->handle($component, 'deletePost', $params); expect($result->state->data['deleted'])->toBeTrue(); expect($result->state->data['postId'])->toBe('456'); }); it('supports multiple permissions with OR logic', function () { // User has 'posts.admin' but not 'posts.edit' $this->session->set('user', [ 'id' => 123, 'permissions' => ['posts.admin'], ]); $componentId = ComponentId::fromString('test:component'); // Generate CSRF token $formId = 'livecomponent:' . $componentId->toString(); $csrfToken = $this->session->csrf->generateToken($formId); $component = new class ($componentId) implements LiveComponentContract { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray([]); } public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData { return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []); } // Requires EITHER posts.edit OR posts.admin #[RequiresPermission('posts.edit', 'posts.admin')] public function editPost(string $postId): ComponentData { return ComponentData::fromArray(['edited' => true]); } }; $params = ActionParameters::fromArray(['postId' => '789'], $csrfToken); $result = $this->handler->handle($component, 'editPost', $params); expect($result->state->data['edited'])->toBeTrue(); }); }); describe('UnauthorizedActionException', function () { it('provides user-friendly error messages', function () { $exception = UnauthorizedActionException::forUnauthenticatedUser('PostsList', 'deletePost'); expect($exception->getUserMessage())->toBe('Please log in to perform this action'); expect($exception->isAuthenticationIssue())->toBeTrue(); }); it('includes missing permissions in context', function () { $attribute = new RequiresPermission('posts.delete', 'posts.admin'); $userPermissions = ['posts.view', 'posts.edit']; $exception = UnauthorizedActionException::forMissingPermission( 'PostsList', 'deletePost', $attribute, $userPermissions ); expect($exception->getUserMessage())->toBe('You do not have permission to perform this action'); expect($exception->getMissingPermissions())->toBe(['posts.delete', 'posts.admin']); expect($exception->isAuthenticationIssue())->toBeFalse(); }); }); // Helper function function createTestComponent(): LiveComponentContract { return new class (ComponentId::fromString('test:component')) implements LiveComponentContract { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray([]); } public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData { return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []); } }; }