diff --git a/TODO.md b/TODO.md index ef70508..cdbfa22 100644 --- a/TODO.md +++ b/TODO.md @@ -12,12 +12,13 @@ - [ ] Test workspace-scoped search results - **Estimated effort:** 3-4 hours -- [ ] **Test Coverage: Form Components** - Test authorization props - - [ ] Test Button component with :can/:cannot props - - [ ] Test Input component with authorization - - [ ] Test Select/Checkbox/Toggle with permissions - - [ ] Test workspace context in form components - - **Estimated effort:** 2-3 hours +- [x] **Test Coverage: Form Components** - Test authorization props + - [x] Test Button component with :can/:cannot props + - [x] Test Input component with authorization + - [x] Test Select/Checkbox/Toggle with permissions + - [x] Test workspace context in form components + - **Completed:** January 2026 + - **File:** `tests/Feature/Forms/AuthorizationTest.php` - [ ] **Test Coverage: Livewire Modals** - Test modal system - [ ] Test modal opening/closing @@ -223,5 +224,6 @@ - [x] **Guide: Creating Admin Panels** - Menu registration, modals, authorization, example module - [x] **Guide: HLCRF Deep Dive** - Layout combinations, ID system, responsive patterns - [x] **API Reference: Components** - Form component props with authorization examples +- [x] **Test Coverage: Form Components** - Authorization props testing for Button/Input/Select/Checkbox/Toggle/Textarea (52 tests) *See `changelog/2026/jan/` for completed features.* diff --git a/tests/Feature/Forms/AuthorizationTest.php b/tests/Feature/Forms/AuthorizationTest.php new file mode 100644 index 0000000..4d15c07 --- /dev/null +++ b/tests/Feature/Forms/AuthorizationTest.php @@ -0,0 +1,755 @@ +forgetInstance(Gate::class); +}); + +afterEach(function () { + Mockery::close(); +}); + +/** + * Create a mock user that can/cannot perform an action. + */ +function mockUserWithPermission(bool $canPerform): void +{ + $user = Mockery::mock(Authorizable::class); + $user->shouldReceive('can') + ->andReturn($canPerform); + + test()->actingAs($user); +} + +/** + * Create a mock resource for testing authorization. + */ +function mockResource(): object +{ + return new class + { + public int $id = 1; + + public int $workspace_id = 1; + }; +} + +// ============================================================================= +// Button Component Authorization Tests +// ============================================================================= + +describe('Button component authorization', function () { + it('is enabled when no authorization props are provided', function () { + mockUserWithPermission(true); + + $button = new Button( + type: 'submit', + variant: 'primary', + ); + + expect($button->disabled)->toBeFalse() + ->and($button->hidden)->toBeFalse(); + }); + + it('is enabled when user has permission', function () { + mockUserWithPermission(true); + + $button = new Button( + type: 'submit', + variant: 'primary', + canGate: 'update', + canResource: mockResource(), + ); + + expect($button->disabled)->toBeFalse() + ->and($button->hidden)->toBeFalse(); + }); + + it('is disabled when user lacks permission', function () { + mockUserWithPermission(false); + + $button = new Button( + type: 'submit', + variant: 'primary', + canGate: 'update', + canResource: mockResource(), + ); + + expect($button->disabled)->toBeTrue() + ->and($button->hidden)->toBeFalse(); + }); + + it('is hidden when user lacks permission and canHide is true', function () { + mockUserWithPermission(false); + + $button = new Button( + type: 'submit', + variant: 'danger', + canGate: 'delete', + canResource: mockResource(), + canHide: true, + ); + + expect($button->disabled)->toBeTrue() + ->and($button->hidden)->toBeTrue(); + }); + + it('is visible when user has permission and canHide is true', function () { + mockUserWithPermission(true); + + $button = new Button( + type: 'submit', + variant: 'danger', + canGate: 'delete', + canResource: mockResource(), + canHide: true, + ); + + expect($button->disabled)->toBeFalse() + ->and($button->hidden)->toBeFalse(); + }); + + it('respects explicit disabled state over authorization', function () { + mockUserWithPermission(true); + + $button = new Button( + type: 'submit', + variant: 'primary', + disabled: true, + canGate: 'update', + canResource: mockResource(), + ); + + // Even with permission, explicit disabled takes precedence + expect($button->disabled)->toBeTrue(); + }); + + it('does not check authorization when only canGate is provided', function () { + mockUserWithPermission(false); + + // Without canResource, authorization check should not happen + $button = new Button( + type: 'submit', + variant: 'primary', + canGate: 'update', + canResource: null, + ); + + expect($button->disabled)->toBeFalse(); + }); + + it('is disabled when no authenticated user', function () { + // No user authenticated + $button = new Button( + type: 'submit', + variant: 'primary', + canGate: 'update', + canResource: mockResource(), + ); + + // Should be disabled when no user is authenticated + expect($button->disabled)->toBeTrue(); + }); +}); + +// ============================================================================= +// Input Component Authorization Tests +// ============================================================================= + +describe('Input component authorization', function () { + it('is enabled when no authorization props are provided', function () { + mockUserWithPermission(true); + + $input = new Input( + id: 'name', + label: 'Name', + ); + + expect($input->disabled)->toBeFalse() + ->and($input->hidden)->toBeFalse(); + }); + + it('is enabled when user has permission', function () { + mockUserWithPermission(true); + + $input = new Input( + id: 'name', + label: 'Name', + canGate: 'update', + canResource: mockResource(), + ); + + expect($input->disabled)->toBeFalse() + ->and($input->hidden)->toBeFalse(); + }); + + it('is disabled when user lacks permission', function () { + mockUserWithPermission(false); + + $input = new Input( + id: 'name', + label: 'Name', + canGate: 'update', + canResource: mockResource(), + ); + + expect($input->disabled)->toBeTrue() + ->and($input->hidden)->toBeFalse(); + }); + + it('is hidden when user lacks permission and canHide is true', function () { + mockUserWithPermission(false); + + $input = new Input( + id: 'secret_key', + label: 'Secret Key', + canGate: 'viewSecrets', + canResource: mockResource(), + canHide: true, + ); + + expect($input->disabled)->toBeTrue() + ->and($input->hidden)->toBeTrue(); + }); + + it('respects explicit disabled state', function () { + mockUserWithPermission(true); + + $input = new Input( + id: 'readonly_field', + label: 'Read Only', + disabled: true, + canGate: 'update', + canResource: mockResource(), + ); + + expect($input->disabled)->toBeTrue(); + }); +}); + +// ============================================================================= +// Select Component Authorization Tests +// ============================================================================= + +describe('Select component authorization', function () { + it('is enabled when no authorization props are provided', function () { + mockUserWithPermission(true); + + $select = new Select( + id: 'status', + options: ['draft' => 'Draft', 'published' => 'Published'], + label: 'Status', + ); + + expect($select->disabled)->toBeFalse() + ->and($select->hidden)->toBeFalse(); + }); + + it('is enabled when user has permission', function () { + mockUserWithPermission(true); + + $select = new Select( + id: 'status', + options: ['draft' => 'Draft', 'published' => 'Published'], + label: 'Status', + canGate: 'update', + canResource: mockResource(), + ); + + expect($select->disabled)->toBeFalse() + ->and($select->hidden)->toBeFalse(); + }); + + it('is disabled when user lacks permission', function () { + mockUserWithPermission(false); + + $select = new Select( + id: 'status', + options: ['draft' => 'Draft', 'published' => 'Published'], + label: 'Status', + canGate: 'update', + canResource: mockResource(), + ); + + expect($select->disabled)->toBeTrue() + ->and($select->hidden)->toBeFalse(); + }); + + it('is hidden when user lacks permission and canHide is true', function () { + mockUserWithPermission(false); + + $select = new Select( + id: 'role', + options: ['admin' => 'Admin', 'user' => 'User'], + label: 'Role', + canGate: 'assignRoles', + canResource: mockResource(), + canHide: true, + ); + + expect($select->disabled)->toBeTrue() + ->and($select->hidden)->toBeTrue(); + }); + + it('respects explicit disabled state', function () { + mockUserWithPermission(true); + + $select = new Select( + id: 'locked_field', + options: ['a' => 'A', 'b' => 'B'], + label: 'Locked', + disabled: true, + canGate: 'update', + canResource: mockResource(), + ); + + expect($select->disabled)->toBeTrue(); + }); +}); + +// ============================================================================= +// Checkbox Component Authorization Tests +// ============================================================================= + +describe('Checkbox component authorization', function () { + it('is enabled when no authorization props are provided', function () { + mockUserWithPermission(true); + + $checkbox = new Checkbox( + id: 'is_active', + label: 'Active', + ); + + expect($checkbox->disabled)->toBeFalse() + ->and($checkbox->hidden)->toBeFalse(); + }); + + it('is enabled when user has permission', function () { + mockUserWithPermission(true); + + $checkbox = new Checkbox( + id: 'is_active', + label: 'Active', + canGate: 'update', + canResource: mockResource(), + ); + + expect($checkbox->disabled)->toBeFalse() + ->and($checkbox->hidden)->toBeFalse(); + }); + + it('is disabled when user lacks permission', function () { + mockUserWithPermission(false); + + $checkbox = new Checkbox( + id: 'is_active', + label: 'Active', + canGate: 'update', + canResource: mockResource(), + ); + + expect($checkbox->disabled)->toBeTrue() + ->and($checkbox->hidden)->toBeFalse(); + }); + + it('is hidden when user lacks permission and canHide is true', function () { + mockUserWithPermission(false); + + $checkbox = new Checkbox( + id: 'is_admin', + label: 'Administrator', + canGate: 'promoteToAdmin', + canResource: mockResource(), + canHide: true, + ); + + expect($checkbox->disabled)->toBeTrue() + ->and($checkbox->hidden)->toBeTrue(); + }); + + it('respects explicit disabled state', function () { + mockUserWithPermission(true); + + $checkbox = new Checkbox( + id: 'locked_option', + label: 'Locked', + disabled: true, + canGate: 'update', + canResource: mockResource(), + ); + + expect($checkbox->disabled)->toBeTrue(); + }); +}); + +// ============================================================================= +// Toggle Component Authorization Tests +// ============================================================================= + +describe('Toggle component authorization', function () { + it('is enabled when no authorization props are provided', function () { + mockUserWithPermission(true); + + $toggle = new Toggle( + id: 'is_public', + label: 'Public', + ); + + expect($toggle->disabled)->toBeFalse() + ->and($toggle->hidden)->toBeFalse(); + }); + + it('is enabled when user has permission', function () { + mockUserWithPermission(true); + + $toggle = new Toggle( + id: 'is_public', + label: 'Public', + canGate: 'update', + canResource: mockResource(), + ); + + expect($toggle->disabled)->toBeFalse() + ->and($toggle->hidden)->toBeFalse(); + }); + + it('is disabled when user lacks permission', function () { + mockUserWithPermission(false); + + $toggle = new Toggle( + id: 'is_public', + label: 'Public', + canGate: 'update', + canResource: mockResource(), + ); + + expect($toggle->disabled)->toBeTrue() + ->and($toggle->hidden)->toBeFalse(); + }); + + it('is hidden when user lacks permission and canHide is true', function () { + mockUserWithPermission(false); + + $toggle = new Toggle( + id: 'enable_feature', + label: 'Enable Feature', + canGate: 'manageFeatures', + canResource: mockResource(), + canHide: true, + ); + + expect($toggle->disabled)->toBeTrue() + ->and($toggle->hidden)->toBeTrue(); + }); + + it('respects explicit disabled state', function () { + mockUserWithPermission(true); + + $toggle = new Toggle( + id: 'locked_toggle', + label: 'Locked', + disabled: true, + canGate: 'update', + canResource: mockResource(), + ); + + expect($toggle->disabled)->toBeTrue(); + }); + + it('wireChange returns null when instantSave is disabled', function () { + $toggle = new Toggle( + id: 'test', + instantSave: false, + ); + + expect($toggle->wireChange())->toBeNull(); + }); + + it('wireChange returns default save method when instantSave is enabled', function () { + $toggle = new Toggle( + id: 'test', + instantSave: true, + ); + + expect($toggle->wireChange())->toBe('save'); + }); + + it('wireChange returns custom method when specified', function () { + $toggle = new Toggle( + id: 'test', + instantSave: true, + instantSaveMethod: 'updateSetting', + ); + + expect($toggle->wireChange())->toBe('updateSetting'); + }); +}); + +// ============================================================================= +// Textarea Component Authorization Tests +// ============================================================================= + +describe('Textarea component authorization', function () { + it('is enabled when no authorization props are provided', function () { + mockUserWithPermission(true); + + $textarea = new Textarea( + id: 'description', + label: 'Description', + ); + + expect($textarea->disabled)->toBeFalse() + ->and($textarea->hidden)->toBeFalse(); + }); + + it('is enabled when user has permission', function () { + mockUserWithPermission(true); + + $textarea = new Textarea( + id: 'description', + label: 'Description', + canGate: 'update', + canResource: mockResource(), + ); + + expect($textarea->disabled)->toBeFalse() + ->and($textarea->hidden)->toBeFalse(); + }); + + it('is disabled when user lacks permission', function () { + mockUserWithPermission(false); + + $textarea = new Textarea( + id: 'description', + label: 'Description', + canGate: 'update', + canResource: mockResource(), + ); + + expect($textarea->disabled)->toBeTrue() + ->and($textarea->hidden)->toBeFalse(); + }); + + it('is hidden when user lacks permission and canHide is true', function () { + mockUserWithPermission(false); + + $textarea = new Textarea( + id: 'internal_notes', + label: 'Internal Notes', + canGate: 'viewInternalNotes', + canResource: mockResource(), + canHide: true, + ); + + expect($textarea->disabled)->toBeTrue() + ->and($textarea->hidden)->toBeTrue(); + }); + + it('respects explicit disabled state', function () { + mockUserWithPermission(true); + + $textarea = new Textarea( + id: 'readonly_notes', + label: 'Notes', + disabled: true, + canGate: 'update', + canResource: mockResource(), + ); + + expect($textarea->disabled)->toBeTrue(); + }); +}); + +// ============================================================================= +// Workspace Context Tests +// ============================================================================= + +describe('Workspace context in authorization', function () { + it('Button works with workspace-scoped resource', function () { + $workspaceResource = new class + { + public int $id = 1; + + public int $workspace_id = 42; + + public string $name = 'Test Resource'; + }; + + mockUserWithPermission(true); + + $button = new Button( + type: 'submit', + variant: 'primary', + canGate: 'update', + canResource: $workspaceResource, + ); + + expect($button->disabled)->toBeFalse() + ->and($button->canResource)->toBe($workspaceResource) + ->and($button->canResource->workspace_id)->toBe(42); + }); + + it('Input works with workspace-scoped resource', function () { + $workspaceResource = new class + { + public int $id = 1; + + public int $workspace_id = 42; + }; + + mockUserWithPermission(false); + + $input = new Input( + id: 'workspace_field', + label: 'Workspace Field', + canGate: 'update', + canResource: $workspaceResource, + ); + + expect($input->disabled)->toBeTrue() + ->and($input->canResource->workspace_id)->toBe(42); + }); +}); + +// ============================================================================= +// Edge Cases and Boundary Tests +// ============================================================================= + +describe('Edge cases in authorization', function () { + it('button with null resource does not check authorization', function () { + $button = new Button( + type: 'submit', + variant: 'primary', + canGate: 'update', + canResource: null, + ); + + expect($button->disabled)->toBeFalse() + ->and($button->hidden)->toBeFalse(); + }); + + it('button with empty canGate does not check authorization', function () { + mockUserWithPermission(false); + + $button = new Button( + type: 'submit', + variant: 'primary', + canGate: '', + canResource: mockResource(), + ); + + // Empty gate = no check = enabled + expect($button->disabled)->toBeFalse(); + }); + + it('canHide without canGate does nothing', function () { + $button = new Button( + type: 'submit', + variant: 'primary', + canHide: true, + ); + + expect($button->hidden)->toBeFalse(); + }); + + it('canHide without canResource does nothing', function () { + $button = new Button( + type: 'submit', + variant: 'primary', + canGate: 'delete', + canResource: null, + canHide: true, + ); + + expect($button->hidden)->toBeFalse(); + }); +}); + +// ============================================================================= +// Cross-Component Consistency Tests +// ============================================================================= + +describe('Cross-component consistency', function () { + it('all components disable consistently when user lacks permission', function () { + mockUserWithPermission(false); + $resource = mockResource(); + + $components = [ + 'Button' => new Button(type: 'submit', variant: 'primary', canGate: 'update', canResource: $resource), + 'Input' => new Input(id: 'test', canGate: 'update', canResource: $resource), + 'Select' => new Select(id: 'test', options: [], canGate: 'update', canResource: $resource), + 'Checkbox' => new Checkbox(id: 'test', canGate: 'update', canResource: $resource), + 'Toggle' => new Toggle(id: 'test', canGate: 'update', canResource: $resource), + 'Textarea' => new Textarea(id: 'test', canGate: 'update', canResource: $resource), + ]; + + foreach ($components as $name => $component) { + expect($component->disabled)->toBeTrue("$name should be disabled when user lacks permission"); + expect($component->hidden)->toBeFalse("$name should not be hidden without canHide flag"); + } + }); + + it('all components hide consistently when canHide is true', function () { + mockUserWithPermission(false); + $resource = mockResource(); + + $components = [ + 'Button' => new Button(type: 'submit', variant: 'primary', canGate: 'update', canResource: $resource, canHide: true), + 'Input' => new Input(id: 'test', canGate: 'update', canResource: $resource, canHide: true), + 'Select' => new Select(id: 'test', options: [], canGate: 'update', canResource: $resource, canHide: true), + 'Checkbox' => new Checkbox(id: 'test', canGate: 'update', canResource: $resource, canHide: true), + 'Toggle' => new Toggle(id: 'test', canGate: 'update', canResource: $resource, canHide: true), + 'Textarea' => new Textarea(id: 'test', canGate: 'update', canResource: $resource, canHide: true), + ]; + + foreach ($components as $name => $component) { + expect($component->disabled)->toBeTrue("$name should be disabled"); + expect($component->hidden)->toBeTrue("$name should be hidden with canHide flag"); + } + }); + + it('all components enable consistently when user has permission', function () { + mockUserWithPermission(true); + $resource = mockResource(); + + $components = [ + 'Button' => new Button(type: 'submit', variant: 'primary', canGate: 'update', canResource: $resource), + 'Input' => new Input(id: 'test', canGate: 'update', canResource: $resource), + 'Select' => new Select(id: 'test', options: [], canGate: 'update', canResource: $resource), + 'Checkbox' => new Checkbox(id: 'test', canGate: 'update', canResource: $resource), + 'Toggle' => new Toggle(id: 'test', canGate: 'update', canResource: $resource), + 'Textarea' => new Textarea(id: 'test', canGate: 'update', canResource: $resource), + ]; + + foreach ($components as $name => $component) { + expect($component->disabled)->toBeFalse("$name should be enabled when user has permission"); + expect($component->hidden)->toBeFalse("$name should be visible when user has permission"); + } + }); +});