test(066): add RBAC UI enforcement + guard regression tests
This commit is contained in:
parent
538c6ea268
commit
cea137c390
@ -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));
|
||||
});
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
98
tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php
Normal file
98
tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php
Normal file
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* CI guard: prevent new ad-hoc auth patterns in Filament.
|
||||
*
|
||||
* Rationale:
|
||||
* - We want UiEnforcement (and centralized RBAC services) to be the default.
|
||||
* - Gate::allows/denies, abort_if/unless, and similar ad-hoc patterns tend to drift.
|
||||
* - We allowlist legacy files so CI only fails on NEW violations.
|
||||
*
|
||||
* If you migrate a legacy file to UiEnforcement, remove it from the allowlist.
|
||||
*/
|
||||
describe('Filament auth guard (no new ad-hoc patterns)', function () {
|
||||
it('fails if new files introduce forbidden auth patterns under app/Filament/**', function () {
|
||||
$filamentDir = base_path('app/Filament');
|
||||
|
||||
expect(is_dir($filamentDir))->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<string, array<int, string>> $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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 () {
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet;
|
||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('Backup items relation manager UI enforcement', function () {
|
||||
it('shows add policies as visible but disabled for readonly members', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$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')
|
||||
->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');
|
||||
});
|
||||
});
|
||||
37
tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php
Normal file
37
tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('Create restore run page authorization', function () {
|
||||
it('returns 404 for non-members (deny as not found)', function () {
|
||||
$user = User::factory()->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);
|
||||
});
|
||||
});
|
||||
65
tests/Feature/Rbac/DriftLandingUiEnforcementTest.php
Normal file
65
tests/Feature/Rbac/DriftLandingUiEnforcementTest.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\InventorySyncRun;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Livewire\Livewire;
|
||||
|
||||
describe('Drift landing generate permission', function () {
|
||||
it('blocks generation for readonly members (no tenant sync)', function () {
|
||||
Bus::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
InventorySyncRun::factory()->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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
|
||||
use App\Models\ProviderConnection;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
describe('Edit provider connection actions UI enforcement', function () {
|
||||
it('shows enable 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' => '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');
|
||||
});
|
||||
});
|
||||
54
tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php
Normal file
54
tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
describe('Edit tenant archive action UI enforcement', function () {
|
||||
it('shows archive action as visible but disabled for manager members', function () {
|
||||
$tenant = Tenant::factory()->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();
|
||||
});
|
||||
});
|
||||
66
tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php
Normal file
66
tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource\Pages\ListEntraGroupSyncRuns;
|
||||
use App\Jobs\EntraGroupSyncJob;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
describe('Entra group sync runs UI enforcement', function () {
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
Notification::fake();
|
||||
});
|
||||
|
||||
it('hides sync action for non-members', function () {
|
||||
// Mount as a valid tenant member first, then revoke membership mid-session.
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
|
||||
describe('Inventory item resource authorization', function () {
|
||||
it('is not visible for non-members', function () {
|
||||
$user = User::factory()->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();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
|
||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('Policy versions relation manager restore-to-Intune UI enforcement', function () {
|
||||
it('disables restore action for readonly members', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$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' => [],
|
||||
]);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
describe('Provider connections create action UI enforcement', function () {
|
||||
it('shows create 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(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');
|
||||
});
|
||||
});
|
||||
23
tests/Feature/Rbac/RegisterTenantAuthorizationTest.php
Normal file
23
tests/Feature/Rbac/RegisterTenantAuthorizationTest.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
|
||||
describe('Register tenant page authorization', function () {
|
||||
it('is not visible for readonly members', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||
use App\Filament\Resources\TenantResource\RelationManagers\TenantMembershipsRelationManager;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('Tenant memberships relation manager UI enforcement', function () {
|
||||
it('shows membership actions as visible but disabled for manager members', function () {
|
||||
$tenant = Tenant::factory()->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.';
|
||||
});
|
||||
});
|
||||
});
|
||||
58
tests/Feature/Rbac/TenantResourceAuthorizationTest.php
Normal file
58
tests/Feature/Rbac/TenantResourceAuthorizationTest.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
|
||||
describe('Tenant resource authorization', function () {
|
||||
it('cannot be created by non-members', function () {
|
||||
$user = User::factory()->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();
|
||||
});
|
||||
});
|
||||
59
tests/Feature/Rbac/UiEnforcementDestructiveTest.php
Normal file
59
tests/Feature/Rbac/UiEnforcementDestructiveTest.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Tests for destructive action behavior in UiEnforcement
|
||||
*
|
||||
* These tests verify that:
|
||||
* - Destructive actions are configured with confirmation modal
|
||||
* - Modal heading/description are set correctly
|
||||
* - Action only executes after confirmation
|
||||
*/
|
||||
describe('Destructive actions require confirmation', function () {
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
bindFailHardGraphClient();
|
||||
});
|
||||
|
||||
it('mounts sync action for modal confirmation', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->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.');
|
||||
});
|
||||
});
|
||||
92
tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php
Normal file
92
tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Tests for US1: Tenant member sees consistent disabled UX
|
||||
*
|
||||
* These tests verify that UiEnforcement correctly handles:
|
||||
* - Members WITH capability → action enabled, can execute
|
||||
* - Members WITHOUT capability → action visible but disabled with tooltip, cannot execute
|
||||
*
|
||||
* Note: In Filament v5, disabled actions don't throw 403 - they silently fail.
|
||||
* The server-side guard is a defense-in-depth measure that only triggers if
|
||||
* somehow the disabled check is bypassed.
|
||||
*/
|
||||
describe('US1: Member without capability sees disabled action + tooltip', function () {
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
});
|
||||
|
||||
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(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);
|
||||
});
|
||||
});
|
||||
152
tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php
Normal file
152
tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php
Normal file
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Tests for US2: Non-members cannot infer tenant resources
|
||||
*
|
||||
* These tests verify that UiEnforcement correctly handles:
|
||||
* - Non-members → action hidden in UI (prevents discovery)
|
||||
* - Non-members → action blocked from execution (no side effects)
|
||||
* - Membership revoked mid-session → still enforces protection
|
||||
*
|
||||
* Note on 404 behavior:
|
||||
* In Filament v5, hidden actions are treated as disabled and return 200 (no execution)
|
||||
* rather than 404. This is because Filament's action system doesn't support custom
|
||||
* HTTP status codes for blocked actions. The security guarantee is:
|
||||
* - Non-members cannot discover actions (hidden in UI)
|
||||
* - Non-members cannot execute actions (blocked by Filament's isHidden check)
|
||||
* - No side effects occur (jobs not pushed, data not modified)
|
||||
*
|
||||
* True 404 enforcement happens at the page/routing level via tenant middleware.
|
||||
*/
|
||||
describe('US2: Non-member sees action hidden in UI', function () {
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
});
|
||||
|
||||
it('hides sync action for users who are not members of the tenant', function () {
|
||||
// Create user without membership to the tenant
|
||||
$user = User::factory()->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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
|
||||
84
tests/Unit/Support/Rbac/UiEnforcementTest.php
Normal file
84
tests/Unit/Support/Rbac/UiEnforcementTest.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\TenantAccessContext;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
|
||||
describe('TenantAccessContext', function () {
|
||||
it('correctly identifies non-member as deny-as-not-found', function () {
|
||||
$context = new TenantAccessContext(
|
||||
user: User::factory()->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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user