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,
|
'status' => Finding::STATUS_NEW,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$thrown = null;
|
$component = Livewire::test(ListFindings::class)
|
||||||
|
->assertTableBulkActionVisible('acknowledge_selected')
|
||||||
|
->assertTableBulkActionDisabled('acknowledge_selected');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Livewire::test(ListFindings::class)
|
$component->callTableBulkAction('acknowledge_selected', $findings);
|
||||||
->callTableBulkAction('acknowledge_selected', $findings);
|
} catch (Throwable) {
|
||||||
} catch (Throwable $exception) {
|
// Filament actions may abort/throw when forced to execute.
|
||||||
$thrown = $exception;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect($thrown)->not->toBeNull();
|
|
||||||
|
|
||||||
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -45,16 +44,15 @@
|
|||||||
'status' => Finding::STATUS_NEW,
|
'status' => Finding::STATUS_NEW,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$thrown = null;
|
$component = Livewire::test(ListFindings::class)
|
||||||
|
->assertActionVisible('acknowledge_all_matching')
|
||||||
|
->assertActionDisabled('acknowledge_all_matching');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Livewire::test(ListFindings::class)
|
$component->callAction('acknowledge_all_matching');
|
||||||
->callAction('acknowledge_all_matching');
|
} catch (Throwable) {
|
||||||
} catch (Throwable $exception) {
|
// Filament actions may abort/throw when forced to execute.
|
||||||
$thrown = $exception;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect($thrown)->not->toBeNull();
|
|
||||||
|
|
||||||
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -18,6 +19,7 @@
|
|||||||
|
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
|||||||
@ -74,9 +74,12 @@
|
|||||||
'ownerRecord' => $tenant,
|
'ownerRecord' => $tenant,
|
||||||
'pageClass' => ViewTenant::class,
|
'pageClass' => ViewTenant::class,
|
||||||
])
|
])
|
||||||
->assertTableActionHidden('add_member')
|
->assertTableActionVisible('add_member')
|
||||||
->assertTableActionHidden('change_role', $membership)
|
->assertTableActionDisabled('add_member')
|
||||||
->assertTableActionHidden('remove', $membership);
|
->assertTableActionVisible('change_role', $membership)
|
||||||
|
->assertTableActionDisabled('change_role', $membership)
|
||||||
|
->assertTableActionVisible('remove', $membership)
|
||||||
|
->assertTableActionDisabled('remove', $membership);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents removing or demoting the last owner', function (): void {
|
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)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
|
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
|
||||||
->assertStatus(403);
|
->assertSuccessful();
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
|
|
||||||
|
|||||||
@ -46,9 +46,7 @@
|
|||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
|
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk();
|
||||||
->assertDontSee('Update credentials')
|
|
||||||
->assertDontSee('Disable connection');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readonly users can view provider connections but cannot manage them', function () {
|
test('readonly users can view provider connections but cannot manage them', function () {
|
||||||
@ -69,9 +67,7 @@
|
|||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
|
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk();
|
||||||
->assertDontSee('Update credentials')
|
|
||||||
->assertDontSee('Disable connection');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('provider connection edit is not accessible cross-tenant', function () {
|
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_VIEW, $tenant))->toBeTrue();
|
||||||
expect($gate->allows(Capabilities::TENANT_SYNC, $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_MANAGE, $tenant))->toBeTrue();
|
||||||
|
|
||||||
expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $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_VIEW, $tenant))->toBeTrue();
|
||||||
expect($gate->allows(Capabilities::TENANT_SYNC, $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();
|
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_VIEW, $tenant))->toBeTrue();
|
||||||
expect($gate->allows(Capabilities::TENANT_SYNC, $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_MANAGE, $tenant))->toBeTrue();
|
||||||
expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue();
|
||||||
expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $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_MEMBERSHIP_MANAGE, $tenant))->toBeFalse();
|
||||||
|
|
||||||
expect($gate->allows(Capabilities::TENANT_SYNC, $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_MANAGE, $tenant))->toBeFalse();
|
||||||
expect($gate->allows(Capabilities::TENANT_DELETE, $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)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
|
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
|
||||||
->assertStatus(403);
|
->assertSuccessful();
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
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