From cea137c3903f8a41c096176484b0b07f73cabb17 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Fri, 30 Jan 2026 17:39:22 +0100 Subject: [PATCH] test(066): add RBAC UI enforcement + guard regression tests --- .../DriftBulkAcknowledgeAuthorizationTest.php | 26 ++- .../Filament/BackupItemsBulkRemoveTest.php | 2 + tests/Feature/Filament/TenantMembersTest.php | 9 +- .../NoAdHocFilamentAuthPatternsTest.php | 98 +++++++++++ .../Inventory/InventorySyncButtonTest.php | 2 +- .../ProviderConnectionAuthorizationTest.php | 8 +- ...pItemsRelationManagerUiEnforcementTest.php | 98 +++++++++++ .../CreateRestoreRunAuthorizationTest.php | 37 +++++ .../Rbac/DriftLandingUiEnforcementTest.php | 65 ++++++++ ...ditProviderConnectionUiEnforcementTest.php | 80 +++++++++ .../EditTenantArchiveUiEnforcementTest.php | 54 +++++++ .../EntraGroupSyncRunsUiEnforcementTest.php | 66 ++++++++ ...InventoryItemResourceAuthorizationTest.php | 56 +++++++ ...rsionsRestoreToIntuneUiEnforcementTest.php | 93 +++++++++++ ...iderConnectionsCreateUiEnforcementTest.php | 54 +++++++ .../Rbac/RegisterTenantAuthorizationTest.php | 23 +++ .../Rbac/RoleMatrix/ManagerAccessTest.php | 2 + .../Rbac/RoleMatrix/OperatorAccessTest.php | 2 + .../Rbac/RoleMatrix/OwnerAccessTest.php | 2 + .../Rbac/RoleMatrix/ReadonlyAccessTest.php | 2 + ...rshipsRelationManagerUiEnforcementTest.php | 48 ++++++ .../Rbac/TenantResourceAuthorizationTest.php | 58 +++++++ .../Rbac/UiEnforcementDestructiveTest.php | 59 +++++++ .../Rbac/UiEnforcementMemberDisabledTest.php | 92 +++++++++++ .../Rbac/UiEnforcementNonMemberHiddenTest.php | 152 ++++++++++++++++++ tests/Feature/RunStartAuthorizationTest.php | 2 +- tests/Unit/Support/Rbac/UiEnforcementTest.php | 84 ++++++++++ 27 files changed, 1249 insertions(+), 25 deletions(-) create mode 100644 tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php create mode 100644 tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php create mode 100644 tests/Feature/Rbac/DriftLandingUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php create mode 100644 tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/RegisterTenantAuthorizationTest.php create mode 100644 tests/Feature/Rbac/TenantMembershipsRelationManagerUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/TenantResourceAuthorizationTest.php create mode 100644 tests/Feature/Rbac/UiEnforcementDestructiveTest.php create mode 100644 tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php create mode 100644 tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php create mode 100644 tests/Unit/Support/Rbac/UiEnforcementTest.php diff --git a/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php b/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php index 4903049..da4d808 100644 --- a/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php +++ b/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php @@ -18,17 +18,16 @@ 'status' => Finding::STATUS_NEW, ]); - $thrown = null; + $component = Livewire::test(ListFindings::class) + ->assertTableBulkActionVisible('acknowledge_selected') + ->assertTableBulkActionDisabled('acknowledge_selected'); try { - Livewire::test(ListFindings::class) - ->callTableBulkAction('acknowledge_selected', $findings); - } catch (Throwable $exception) { - $thrown = $exception; + $component->callTableBulkAction('acknowledge_selected', $findings); + } catch (Throwable) { + // Filament actions may abort/throw when forced to execute. } - expect($thrown)->not->toBeNull(); - $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW)); }); @@ -45,16 +44,15 @@ 'status' => Finding::STATUS_NEW, ]); - $thrown = null; + $component = Livewire::test(ListFindings::class) + ->assertActionVisible('acknowledge_all_matching') + ->assertActionDisabled('acknowledge_all_matching'); try { - Livewire::test(ListFindings::class) - ->callAction('acknowledge_all_matching'); - } catch (Throwable $exception) { - $thrown = $exception; + $component->callAction('acknowledge_all_matching'); + } catch (Throwable) { + // Filament actions may abort/throw when forced to execute. } - expect($thrown)->not->toBeNull(); - $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW)); }); diff --git a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php index a2cecee..a96591b 100644 --- a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php +++ b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php @@ -7,6 +7,7 @@ use App\Models\OperationRun; use App\Models\Policy; use App\Models\Tenant; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -18,6 +19,7 @@ $tenant = Tenant::factory()->create(); $tenant->makeCurrent(); + Filament::setTenant($tenant, true); [$user] = createUserWithTenant(tenant: $tenant, role: 'owner'); diff --git a/tests/Feature/Filament/TenantMembersTest.php b/tests/Feature/Filament/TenantMembersTest.php index fd77013..5f7da44 100644 --- a/tests/Feature/Filament/TenantMembersTest.php +++ b/tests/Feature/Filament/TenantMembersTest.php @@ -74,9 +74,12 @@ 'ownerRecord' => $tenant, 'pageClass' => ViewTenant::class, ]) - ->assertTableActionHidden('add_member') - ->assertTableActionHidden('change_role', $membership) - ->assertTableActionHidden('remove', $membership); + ->assertTableActionVisible('add_member') + ->assertTableActionDisabled('add_member') + ->assertTableActionVisible('change_role', $membership) + ->assertTableActionDisabled('change_role', $membership) + ->assertTableActionVisible('remove', $membership) + ->assertTableActionDisabled('remove', $membership); }); it('prevents removing or demoting the last owner', function (): void { diff --git a/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php b/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php new file mode 100644 index 0000000..c6f5bc9 --- /dev/null +++ b/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php @@ -0,0 +1,98 @@ +toBeTrue("Filament directory not found: {$filamentDir}"); + + /** + * Legacy allowlist: these files currently contain forbidden patterns. + * + * IMPORTANT: + * - Do NOT add new entries casually. + * - The goal is to shrink this list over time. + * + * Paths are workspace-relative (e.g. app/Filament/Resources/Foo.php). + */ + $legacyAllowlist = [ + // Pages (page-level authorization or legacy patterns) + ]; + + $patterns = [ + // Gate facade usage + '/\\bGate::(allows|denies|check|authorize)\\b/', + '/^\\s*use\\s+Illuminate\\\\Support\\\\Facades\\\\Gate\\s*;\\s*$/m', + + // Ad-hoc abort helpers + '/\\babort_(if|unless)\\s*\\(/', + ]; + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($filamentDir, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + /** @var array> $violations */ + $violations = []; + + foreach ($iterator as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $absolutePath = $file->getPathname(); + $relativePath = str_replace(base_path().DIRECTORY_SEPARATOR, '', $absolutePath); + $relativePath = str_replace(DIRECTORY_SEPARATOR, '/', $relativePath); + + if (in_array($relativePath, $legacyAllowlist, true)) { + continue; + } + + $content = file_get_contents($absolutePath); + if (! is_string($content)) { + continue; + } + + $lines = preg_split('/\\R/', $content) ?: []; + + foreach ($lines as $lineNumber => $line) { + foreach ($patterns as $pattern) { + if (preg_match($pattern, $line) === 1) { + $violations[$relativePath][] = ($lineNumber + 1).': '.trim($line); + } + } + } + } + + if ($violations !== []) { + $messageLines = [ + 'Forbidden ad-hoc auth patterns detected in app/Filament/**.', + 'Migrate to UiEnforcement (preferred) or add a justified temporary entry to the legacy allowlist.', + '', + ]; + + foreach ($violations as $path => $hits) { + $messageLines[] = $path; + foreach ($hits as $hit) { + $messageLines[] = ' - '.$hit; + } + } + + expect($violations)->toBeEmpty(implode("\n", $messageLines)); + } + + expect(true)->toBeTrue(); + }); +}); diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php index 449db2f..841c9ab 100644 --- a/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -161,7 +161,7 @@ Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) - ->assertStatus(403); + ->assertSuccessful(); Queue::assertNothingPushed(); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php index 4db82c7..5f05487 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php @@ -46,9 +46,7 @@ $this->actingAs($user) ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) - ->assertOk() - ->assertDontSee('Update credentials') - ->assertDontSee('Disable connection'); + ->assertOk(); }); test('readonly users can view provider connections but cannot manage them', function () { @@ -69,9 +67,7 @@ $this->actingAs($user) ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) - ->assertOk() - ->assertDontSee('Update credentials') - ->assertDontSee('Disable connection'); + ->assertOk(); }); test('provider connection edit is not accessible cross-tenant', function () { diff --git a/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php b/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php new file mode 100644 index 0000000..6bc9e6c --- /dev/null +++ b/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php @@ -0,0 +1,98 @@ +actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + $item = BackupItem::factory()->for($backupSet)->for($tenant)->create(); + + Livewire::test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => EditBackupSet::class, + ]) + ->assertTableActionVisible('addPolicies') + ->assertTableActionDisabled('addPolicies') + ->assertTableActionExists('addPolicies', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to add policies.'; + }) + ->assertTableBulkActionVisible('bulk_remove') + ->assertTableBulkActionDisabled('bulk_remove', [$item]); + }); + + it('shows add policies as enabled for owner members', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + $item = BackupItem::factory()->for($backupSet)->for($tenant)->create(); + + Livewire::test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => EditBackupSet::class, + ]) + ->assertTableActionVisible('addPolicies') + ->assertTableActionEnabled('addPolicies') + ->assertTableBulkActionVisible('bulk_remove') + ->assertTableBulkActionEnabled('bulk_remove', [$item]); + }); + + it('hides actions after membership is revoked mid-session', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + BackupItem::factory()->for($backupSet)->for($tenant)->create(); + + $component = Livewire::test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => EditBackupSet::class, + ]) + ->assertTableActionVisible('addPolicies') + ->assertTableActionEnabled('addPolicies') + ->assertTableBulkActionVisible('bulk_remove'); + + $user->tenants()->detach($tenant->getKey()); + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + $component + ->call('$refresh') + ->assertTableActionHidden('addPolicies') + ->assertTableBulkActionHidden('bulk_remove'); + }); +}); diff --git a/tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php b/tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php new file mode 100644 index 0000000..cb96bb3 --- /dev/null +++ b/tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php @@ -0,0 +1,37 @@ +create(); + $tenant = Tenant::factory()->create(); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(CreateRestoreRun::class) + ->assertStatus(404); + }); + + it('returns 403 for members without tenant manage capability', function () { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(CreateRestoreRun::class) + ->assertStatus(403); + }); +}); diff --git a/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php b/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php new file mode 100644 index 0000000..8337673 --- /dev/null +++ b/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php @@ -0,0 +1,65 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'finished_at' => now()->subDays(2), + ]); + + InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'finished_at' => now()->subDay(), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(DriftLanding::class) + ->assertSet('state', 'blocked') + ->assertSet('message', 'You can view existing drift findings and run history, but you do not have permission to generate drift.'); + + Bus::assertNotDispatched(GenerateDriftFindingsJob::class); + }); + + it('starts generation for owner members (tenant sync allowed)', function () { + Bus::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'finished_at' => now()->subDays(2), + ]); + + $latestRun = InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'finished_at' => now()->subDay(), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(DriftLanding::class) + ->assertSet('state', 'generating') + ->assertSet('scopeKey', (string) $latestRun->selection_hash); + + $operationRunId = $component->get('operationRunId'); + expect($operationRunId)->toBeInt()->toBeGreaterThan(0); + + Bus::assertDispatched(GenerateDriftFindingsJob::class); + }); +}); diff --git a/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php b/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php new file mode 100644 index 0000000..d16758d --- /dev/null +++ b/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php @@ -0,0 +1,80 @@ +actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'disabled', + ]); + + Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) + ->assertActionVisible('enable_connection') + ->assertActionDisabled('enable_connection') + ->assertActionExists('enable_connection', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage provider connections.'; + }) + ->mountAction('enable_connection') + ->callMountedAction() + ->assertSuccessful(); + + $connection->refresh(); + expect($connection->status)->toBe('disabled'); + }); + + it('shows disable connection action as visible but disabled for readonly members', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'connected', + ]); + + Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) + ->assertActionVisible('disable_connection') + ->assertActionDisabled('disable_connection') + ->assertActionExists('disable_connection', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage provider connections.'; + }) + ->mountAction('disable_connection') + ->callMountedAction() + ->assertSuccessful(); + + $connection->refresh(); + expect($connection->status)->toBe('connected'); + }); + + it('shows enable connection action as enabled for owner members', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'disabled', + ]); + + Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) + ->assertActionVisible('enable_connection') + ->assertActionEnabled('enable_connection'); + }); +}); diff --git a/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php b/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php new file mode 100644 index 0000000..5768b3b --- /dev/null +++ b/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php @@ -0,0 +1,54 @@ +create(); + [$user] = createUserWithTenant(tenant: $tenant, role: 'manager'); + + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()]) + ->assertActionVisible('archive') + ->assertActionDisabled('archive') + ->assertActionExists('archive', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to archive tenants.'; + }) + ->mountAction('archive') + ->callMountedAction() + ->assertSuccessful(); + + $tenant->refresh(); + expect($tenant->trashed())->toBeFalse(); + }); + + it('allows owner members to archive tenant', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()]) + ->assertActionVisible('archive') + ->assertActionEnabled('archive') + ->mountAction('archive') + ->callMountedAction() + ->assertHasNoActionErrors(); + + $tenant->refresh(); + expect($tenant->trashed())->toBeTrue(); + }); +}); diff --git a/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php b/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php new file mode 100644 index 0000000..94d7c7d --- /dev/null +++ b/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php @@ -0,0 +1,66 @@ +actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListEntraGroupSyncRuns::class) + ->assertActionVisible('sync_groups'); + + $user->tenants()->detach($tenant->getKey()); + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + $component->assertActionHidden('sync_groups'); + + Queue::assertNothingPushed(); + }); + + it('shows sync action as visible but disabled for readonly members', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListEntraGroupSyncRuns::class) + ->assertActionVisible('sync_groups') + ->assertActionDisabled('sync_groups'); + + Queue::assertNothingPushed(); + }); + + it('allows owner members to execute sync action (dispatches job)', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListEntraGroupSyncRuns::class) + ->assertActionVisible('sync_groups') + ->assertActionEnabled('sync_groups') + ->mountAction('sync_groups') + ->callMountedAction() + ->assertHasNoActionErrors(); + + Queue::assertPushed(EntraGroupSyncJob::class); + }); +}); diff --git a/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php b/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php new file mode 100644 index 0000000..bf0c0f7 --- /dev/null +++ b/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php @@ -0,0 +1,56 @@ +create(); + $tenant = Tenant::factory()->create(); + + $this->actingAs($user); + $tenant->makeCurrent(); + + expect(InventoryItemResource::canViewAny())->toBeFalse(); + }); + + it('is visible for readonly members', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + $tenant->makeCurrent(); + + expect(InventoryItemResource::canViewAny())->toBeTrue(); + }); + + it('prevents viewing inventory items from other tenants', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $otherTenant = Tenant::factory()->create(); + + $this->actingAs($user); + $tenant->makeCurrent(); + + $record = InventoryItem::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + ]); + + expect(InventoryItemResource::canView($record))->toBeFalse(); + }); + + it('allows viewing inventory items from the current tenant', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + $tenant->makeCurrent(); + + $record = InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + ]); + + expect(InventoryItemResource::canView($record))->toBeTrue(); + }); +}); diff --git a/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php b/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php new file mode 100644 index 0000000..a0b6755 --- /dev/null +++ b/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php @@ -0,0 +1,93 @@ +makeCurrent(); + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'metadata' => [], + ]); + + Livewire::actingAs($user) + ->test(VersionsRelationManager::class, [ + 'ownerRecord' => $policy, + 'pageClass' => ViewPolicy::class, + ]) + ->assertTableActionDisabled('restore_to_intune', $version); + }); + + it('disables restore action for metadata-only snapshots', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'metadata' => ['source' => 'metadata_only'], + ]); + + Livewire::actingAs($user) + ->test(VersionsRelationManager::class, [ + 'ownerRecord' => $policy, + 'pageClass' => ViewPolicy::class, + ]) + ->assertTableActionDisabled('restore_to_intune', $version); + }); + + it('hides restore action after membership is revoked mid-session', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'metadata' => [], + ]); + + $component = Livewire::actingAs($user) + ->test(VersionsRelationManager::class, [ + 'ownerRecord' => $policy, + 'pageClass' => ViewPolicy::class, + ]); + + $user->tenants()->detach($tenant->getKey()); + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + $component + ->call('$refresh') + ->assertTableActionHidden('restore_to_intune', $version); + }); +}); diff --git a/tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php b/tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php new file mode 100644 index 0000000..18f3456 --- /dev/null +++ b/tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php @@ -0,0 +1,54 @@ +actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListProviderConnections::class) + ->assertActionVisible('create') + ->assertActionDisabled('create') + ->assertActionExists('create', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to create provider connections.'; + }); + }); + + it('shows create action as enabled for owner members', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListProviderConnections::class) + ->assertActionVisible('create') + ->assertActionEnabled('create'); + }); + + it('hides create action after membership is revoked mid-session', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListProviderConnections::class) + ->assertActionVisible('create') + ->assertActionEnabled('create'); + + $user->tenants()->detach($tenant->getKey()); + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + $component + ->call('$refresh') + ->assertActionHidden('create'); + }); +}); diff --git a/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php b/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php new file mode 100644 index 0000000..9a1871f --- /dev/null +++ b/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php @@ -0,0 +1,23 @@ +actingAs($user); + $tenant->makeCurrent(); + + expect(RegisterTenant::canView())->toBeFalse(); + }); + + it('is visible for owner members', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + + expect(RegisterTenant::canView())->toBeTrue(); + }); +}); diff --git a/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php b/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php index 71ead6c..e89d3fa 100644 --- a/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php +++ b/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php @@ -10,6 +10,8 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue(); diff --git a/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php b/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php index ebc7c0f..6248a0f 100644 --- a/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php +++ b/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php @@ -10,6 +10,8 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue(); diff --git a/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php b/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php index fb024ba..c980ec8 100644 --- a/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php +++ b/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php @@ -10,6 +10,8 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue(); diff --git a/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php b/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php index dfc7a16..dbf5209 100644 --- a/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php +++ b/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php @@ -15,6 +15,8 @@ expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse(); diff --git a/tests/Feature/Rbac/TenantMembershipsRelationManagerUiEnforcementTest.php b/tests/Feature/Rbac/TenantMembershipsRelationManagerUiEnforcementTest.php new file mode 100644 index 0000000..0dd683f --- /dev/null +++ b/tests/Feature/Rbac/TenantMembershipsRelationManagerUiEnforcementTest.php @@ -0,0 +1,48 @@ +create(); + [$user] = createUserWithTenant(tenant: $tenant, role: 'manager'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $otherUser = User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $otherUser, role: 'readonly'); + + Livewire::test(TenantMembershipsRelationManager::class, [ + 'ownerRecord' => $tenant, + 'pageClass' => EditTenant::class, + ]) + ->assertTableActionVisible('add_member') + ->assertTableActionDisabled('add_member') + ->assertTableActionExists('add_member', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage tenant memberships.'; + }) + ->assertTableActionVisible('change_role') + ->assertTableActionDisabled('change_role') + ->assertTableActionExists('change_role', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage tenant memberships.'; + }) + ->assertTableActionVisible('remove') + ->assertTableActionDisabled('remove') + ->assertTableActionExists('remove', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage tenant memberships.'; + }); + }); +}); diff --git a/tests/Feature/Rbac/TenantResourceAuthorizationTest.php b/tests/Feature/Rbac/TenantResourceAuthorizationTest.php new file mode 100644 index 0000000..2a46054 --- /dev/null +++ b/tests/Feature/Rbac/TenantResourceAuthorizationTest.php @@ -0,0 +1,58 @@ +create(); + + $this->actingAs($user); + + expect(TenantResource::canCreate())->toBeFalse(); + }); + + it('can be created by managers (TENANT_MANAGE)', function () { + [$user] = createUserWithTenant(role: 'manager'); + + $this->actingAs($user); + + expect(TenantResource::canCreate())->toBeTrue(); + }); + + it('can be edited by managers (TENANT_MANAGE)', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $this->actingAs($user); + + expect(TenantResource::canEdit($tenant))->toBeTrue(); + }); + + it('cannot be deleted by managers (TENANT_DELETE)', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $this->actingAs($user); + + expect(TenantResource::canDelete($tenant))->toBeFalse(); + }); + + it('can be deleted by owners (TENANT_DELETE)', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + expect(TenantResource::canDelete($tenant))->toBeTrue(); + }); + + it('cannot edit tenants it cannot access', function () { + [$user] = createUserWithTenant(role: 'manager'); + $otherTenant = Tenant::factory()->create(); + + $this->actingAs($user); + + expect(TenantResource::canEdit($otherTenant))->toBeFalse(); + }); +}); diff --git a/tests/Feature/Rbac/UiEnforcementDestructiveTest.php b/tests/Feature/Rbac/UiEnforcementDestructiveTest.php new file mode 100644 index 0000000..749bdee --- /dev/null +++ b/tests/Feature/Rbac/UiEnforcementDestructiveTest.php @@ -0,0 +1,59 @@ +actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + // mountAction shows the confirmation modal + // assertActionMounted confirms it was mounted (awaiting confirmation) + Livewire::test(ListPolicies::class) + ->assertActionVisible('sync') + ->assertActionEnabled('sync') + ->mountAction('sync') + ->assertActionMounted('sync'); + }); + + it('does not execute destructive action without calling confirm', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + // Mount but don't call - verify no side effects + Livewire::test(ListPolicies::class) + ->mountAction('sync'); + + // No job should be dispatched yet + Queue::assertNothingPushed(); + }); + + it('has confirmation modal configured with correct title', function () { + // Verify UiTooltips constants are set correctly + expect(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)->toBe('Are you sure?'); + expect(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)->toBe('This action cannot be undone.'); + }); +}); diff --git a/tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php b/tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php new file mode 100644 index 0000000..af7ed3b --- /dev/null +++ b/tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php @@ -0,0 +1,92 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionVisible('sync') + ->assertActionDisabled('sync'); + + Queue::assertNothingPushed(); + }); + + it('does not execute sync action for readonly members (silently blocked by Filament)', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + // When a disabled action is called, Filament blocks it silently (200 response, no execution) + Livewire::test(ListPolicies::class) + ->mountAction('sync') + ->callMountedAction() + ->assertSuccessful(); + + // The action should NOT have executed + Queue::assertNothingPushed(); + }); +}); + +describe('US1: Member with capability sees enabled action + can execute', function () { + beforeEach(function () { + Queue::fake(); + }); + + it('shows sync action as enabled for owner members', function () { + bindFailHardGraphClient(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionVisible('sync') + ->assertActionEnabled('sync'); + }); + + it('allows owner members to execute sync action successfully', function () { + bindFailHardGraphClient(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->mountAction('sync') + ->callMountedAction() + ->assertHasNoActionErrors(); + + Queue::assertPushed(SyncPoliciesJob::class); + }); +}); diff --git a/tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php b/tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php new file mode 100644 index 0000000..e8f3ec9 --- /dev/null +++ b/tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php @@ -0,0 +1,152 @@ +create(); + $tenant = Tenant::factory()->create(); + // No membership created + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionHidden('sync'); + + Queue::assertNothingPushed(); + }); + + it('hides sync action for authenticated users accessing wrong tenant', function () { + // User is member of tenantA but accessing tenantB + [$user, $tenantA] = createUserWithTenant(role: 'owner'); + $tenantB = Tenant::factory()->create(); + // User has no membership to tenantB + + $this->actingAs($user); + $tenantB->makeCurrent(); + Filament::setTenant($tenantB, true); + + Livewire::test(ListPolicies::class) + ->assertActionHidden('sync'); + + Queue::assertNothingPushed(); + }); +}); + +describe('US2: Non-member action execution is blocked', function () { + beforeEach(function () { + Queue::fake(); + }); + + it('blocks action execution for non-members (no side effects)', function () { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + // No membership + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + // Hidden actions are treated as disabled by Filament + // The action call returns 200 but no execution occurs + Livewire::test(ListPolicies::class) + ->mountAction('sync') + ->callMountedAction() + ->assertSuccessful(); + + // Verify no side effects + Queue::assertNothingPushed(); + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); + }); +}); + +describe('US2: Membership revoked mid-session still enforces protection', function () { + beforeEach(function () { + Queue::fake(); + }); + + it('blocks action execution when membership is revoked between page load and action click', function () { + bindFailHardGraphClient(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + // Start the test - action should be visible for member + $component = Livewire::test(ListPolicies::class) + ->assertActionVisible('sync') + ->assertActionEnabled('sync'); + + // Simulate membership revocation mid-session + $user->tenants()->detach($tenant->getKey()); + + // Clear capability cache to ensure fresh check + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + // Now try to execute - action is now hidden (via fresh isVisible evaluation) + // Filament blocks execution (returns 200 but no side effects) + $component + ->mountAction('sync') + ->callMountedAction() + ->assertSuccessful(); + + // Verify no side effects + Queue::assertNothingPushed(); + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); + }); + + it('hides action in UI after membership revocation on re-render', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + // Initial state - action visible + Livewire::test(ListPolicies::class) + ->assertActionVisible('sync'); + + // Revoke membership + $user->tenants()->detach($tenant->getKey()); + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + // New component instance (simulates page refresh) + Livewire::test(ListPolicies::class) + ->assertActionHidden('sync'); + + Queue::assertNothingPushed(); + }); +}); diff --git a/tests/Feature/RunStartAuthorizationTest.php b/tests/Feature/RunStartAuthorizationTest.php index 83fbb70..ceb8d0b 100644 --- a/tests/Feature/RunStartAuthorizationTest.php +++ b/tests/Feature/RunStartAuthorizationTest.php @@ -25,7 +25,7 @@ Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) - ->assertStatus(403); + ->assertSuccessful(); Queue::assertNothingPushed(); diff --git a/tests/Unit/Support/Rbac/UiEnforcementTest.php b/tests/Unit/Support/Rbac/UiEnforcementTest.php new file mode 100644 index 0000000..4f5f0a2 --- /dev/null +++ b/tests/Unit/Support/Rbac/UiEnforcementTest.php @@ -0,0 +1,84 @@ +make(), + tenant: Tenant::factory()->make(), + isMember: false, + hasCapability: false, + ); + + expect($context->shouldDenyAsNotFound())->toBeTrue(); + expect($context->shouldDenyAsForbidden())->toBeFalse(); + expect($context->isAuthorized())->toBeFalse(); + }); + + it('correctly identifies member without capability as forbidden', function () { + $context = new TenantAccessContext( + user: User::factory()->make(), + tenant: Tenant::factory()->make(), + isMember: true, + hasCapability: false, + ); + + expect($context->shouldDenyAsNotFound())->toBeFalse(); + expect($context->shouldDenyAsForbidden())->toBeTrue(); + expect($context->isAuthorized())->toBeFalse(); + }); + + it('correctly identifies authorized member', function () { + $context = new TenantAccessContext( + user: User::factory()->make(), + tenant: Tenant::factory()->make(), + isMember: true, + hasCapability: true, + ); + + expect($context->shouldDenyAsNotFound())->toBeFalse(); + expect($context->shouldDenyAsForbidden())->toBeFalse(); + expect($context->isAuthorized())->toBeTrue(); + }); +}); + +describe('UiTooltips', function () { + it('has non-empty insufficient permission message', function () { + expect(UiTooltips::INSUFFICIENT_PERMISSION)->toBeString(); + expect(UiTooltips::INSUFFICIENT_PERMISSION)->not->toBeEmpty(); + }); + + it('has non-empty destructive confirmation messages', function () { + expect(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)->toBeString(); + expect(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)->not->toBeEmpty(); + expect(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)->toBeString(); + expect(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)->not->toBeEmpty(); + }); +}); + +describe('UiEnforcement', function () { + it('throws when unknown capability is passed', function () { + $action = \Filament\Actions\Action::make('test') + ->action(fn () => null); + + expect(fn () => UiEnforcement::forAction($action) + ->requireCapability('unknown.capability') + )->toThrow(\InvalidArgumentException::class, 'Unknown capability'); + }); + + it('accepts known capabilities from registry', function () { + $action = \Filament\Actions\Action::make('test') + ->action(fn () => null); + + $enforcement = UiEnforcement::forAction($action) + ->requireCapability(Capabilities::PROVIDER_MANAGE); + + expect($enforcement)->toBeInstanceOf(UiEnforcement::class); + }); +});