## Summary - amend the operator UI constitution and related SpecKit templates for the new UI/UX governance rules - add Spec 168 artifacts plus the tenant governance aggregate implementation used by the tenant dashboard, banner, and baseline compare landing surfaces - normalize Filament action surfaces around clickable-row inspection, grouped secondary actions, and explicit action-surface declarations across enrolled resources and pages - fix post-suite regressions in membership cache priming, finding workflow state refresh, tenant review derived-state invalidation, and tenant-bound backup-set related navigation ## Commit Series - `docs: amend operator UI constitution` - `spec: add tenant governance aggregate contract` - `feat: add tenant governance aggregate contract` - `refactor: normalize filament action surfaces` - `fix: resolve post-suite state regressions` ## Testing - `vendor/bin/sail artisan test --compact` - Result: `3176 passed, 8 skipped (17384 assertions)` ## Notes - Livewire v4 / Filament v5 stack remains unchanged - no provider registration changes; `bootstrap/providers.php` remains the relevant location - no new global-search resources or asset-registration changes in this branch Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #199
1710 lines
68 KiB
PHP
1710 lines
68 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Pages\InventoryCoverage;
|
|
use App\Filament\Pages\Monitoring\Alerts;
|
|
use App\Filament\Pages\Monitoring\Operations;
|
|
use App\Filament\Pages\NoAccess;
|
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
|
use App\Filament\Pages\TenantDiagnostics;
|
|
use App\Filament\Pages\TenantRequiredPermissions;
|
|
use App\Filament\Resources\AlertDeliveryResource;
|
|
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
|
use App\Filament\Resources\AlertDestinationResource;
|
|
use App\Filament\Resources\AlertDestinationResource\Pages\ListAlertDestinations;
|
|
use App\Filament\Resources\AlertRuleResource;
|
|
use App\Filament\Resources\AlertRuleResource\Pages\ListAlertRules;
|
|
use App\Filament\Resources\BackupScheduleResource;
|
|
use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule;
|
|
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
|
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
|
use App\Filament\Resources\BackupSetResource;
|
|
use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet;
|
|
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
|
use App\Filament\Resources\BaselineProfileResource;
|
|
use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles;
|
|
use App\Filament\Resources\BaselineSnapshotResource;
|
|
use App\Filament\Resources\EntraGroupResource;
|
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots;
|
|
use App\Filament\Resources\FindingExceptionResource;
|
|
use App\Filament\Resources\FindingExceptionResource\Pages\ListFindingExceptions;
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
|
use App\Filament\Resources\InventoryItemResource;
|
|
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
|
use App\Filament\Resources\OperationRunResource;
|
|
use App\Filament\Resources\PolicyResource;
|
|
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
|
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
|
|
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
|
use App\Filament\Resources\PolicyVersionResource;
|
|
use App\Filament\Resources\ProviderConnectionResource;
|
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
|
use App\Filament\Resources\RestoreRunResource;
|
|
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
|
use App\Filament\Resources\ReviewPackResource;
|
|
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
|
use App\Filament\Resources\TenantResource;
|
|
use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships;
|
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
|
use App\Filament\Resources\TenantResource\RelationManagers\TenantMembershipsRelationManager;
|
|
use App\Filament\Resources\TenantReviewResource;
|
|
use App\Filament\Resources\TenantReviewResource\Pages\ListTenantReviews;
|
|
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
|
|
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
|
|
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
|
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
|
use App\Filament\System\Pages\Directory\Tenants as SystemDirectoryTenantsPage;
|
|
use App\Filament\System\Pages\Directory\Workspaces as SystemDirectoryWorkspacesPage;
|
|
use App\Filament\System\Pages\Ops\Failures as SystemFailuresPage;
|
|
use App\Filament\System\Pages\Ops\Runs as SystemRunsPage;
|
|
use App\Filament\System\Pages\Ops\Stuck as SystemStuckPage;
|
|
use App\Filament\System\Pages\Security\AccessLogs as SystemAccessLogsPage;
|
|
use App\Jobs\SyncPoliciesJob;
|
|
use App\Models\AlertDelivery;
|
|
use App\Models\AlertDestination;
|
|
use App\Models\AlertRule;
|
|
use App\Models\AuditLog;
|
|
use App\Models\BackupItem;
|
|
use App\Models\BackupSchedule;
|
|
use App\Models\BackupSet;
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\Finding;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\OperationRun;
|
|
use App\Models\PlatformUser;
|
|
use App\Models\Policy;
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\RestoreRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantMembership;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Support\Auth\PlatformCapabilities;
|
|
use App\Support\Evidence\EvidenceCompletenessState;
|
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\System\SystemDirectoryLinks;
|
|
use App\Support\System\SystemOperationRunLinks;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Filament\Actions\ActionGroup;
|
|
use Filament\Actions\BulkActionGroup;
|
|
use Filament\Facades\Filament;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|
{
|
|
Filament::setCurrentPanel('system');
|
|
Filament::setTenant(null, true);
|
|
Filament::bootCurrentPanel();
|
|
|
|
$platformUser = PlatformUser::factory()->create([
|
|
'capabilities' => array_values(array_unique(array_merge([
|
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
], $capabilities))),
|
|
'is_active' => true,
|
|
]);
|
|
|
|
test()->actingAs($platformUser, 'platform');
|
|
|
|
return $platformUser;
|
|
}
|
|
|
|
it('passes the action surface contract guard for current repository state', function (): void {
|
|
$result = ActionSurfaceValidator::withBaselineExemptions()->validate();
|
|
|
|
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
|
});
|
|
|
|
it('excludes widgets from action surface discovery scope', function (): void {
|
|
$classes = array_map(
|
|
static fn ($component): string => $component->className,
|
|
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
|
|
);
|
|
|
|
$widgetClasses = array_values(array_filter($classes, static function (string $className): bool {
|
|
return str_starts_with($className, 'App\\Filament\\Widgets\\');
|
|
}));
|
|
|
|
expect($widgetClasses)->toBeEmpty();
|
|
});
|
|
|
|
it('keeps baseline exemptions explicit and does not auto-exempt unknown classes', function (): void {
|
|
$exemptions = ActionSurfaceExemptions::baseline();
|
|
|
|
expect($exemptions->hasClass('App\\Filament\\Resources\\ActionSurfaceUnknownResource'))->toBeFalse();
|
|
});
|
|
|
|
it('maps tenant/admin panel scope metadata from discovery sources', function (): void {
|
|
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
|
|
->keyBy('className');
|
|
|
|
$tenantResource = $components->get(\App\Filament\Resources\TenantResource::class);
|
|
$policyResource = $components->get(\App\Filament\Resources\PolicyResource::class);
|
|
|
|
expect($tenantResource)->not->toBeNull();
|
|
expect($tenantResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
|
|
|
|
expect($policyResource)->not->toBeNull();
|
|
expect($policyResource?->hasPanelScope(ActionSurfacePanelScope::Tenant))->toBeTrue();
|
|
});
|
|
|
|
it('requires non-empty reasons for every baseline exemption', function (): void {
|
|
$reasons = ActionSurfaceExemptions::baseline()->all();
|
|
|
|
foreach ($reasons as $className => $reason) {
|
|
expect(trim($reason))->not->toBe('', "Baseline exemption reason is empty for {$className}");
|
|
}
|
|
});
|
|
|
|
it('discovers the baseline profile resource and validates its declaration', function (): void {
|
|
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
|
|
->keyBy('className');
|
|
|
|
$baselineResource = $components->get(BaselineProfileResource::class);
|
|
|
|
expect($baselineResource)->not->toBeNull('BaselineProfileResource should be discovered by action surface discovery');
|
|
expect($baselineResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
|
|
|
|
$declaration = BaselineProfileResource::actionSurfaceDeclaration();
|
|
$profiles = new ActionSurfaceProfileDefinition;
|
|
|
|
foreach ($profiles->requiredSlots($declaration->profile) as $slot) {
|
|
expect($declaration->slot($slot))
|
|
->not->toBeNull("Missing required slot {$slot->value} in BaselineProfileResource declaration");
|
|
}
|
|
});
|
|
|
|
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$profile = BaselineProfile::factory()->active()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
]);
|
|
|
|
$declaration = BaselineProfileResource::actionSurfaceDeclaration();
|
|
$details = $declaration->slot(ActionSurfaceSlot::ListRowMoreMenu)?->details;
|
|
|
|
expect($details)->toBeString();
|
|
expect($details)->toContain('archive');
|
|
|
|
$this->actingAs($user);
|
|
|
|
$livewire = Livewire::test(ListBaselineProfiles::class)
|
|
->assertCanSeeTableRecords([$profile]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
|
|
$rowActions = $table->getActions();
|
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
|
|
|
expect($moreGroup)->toBeInstanceOf(ActionGroup::class);
|
|
expect($moreGroup?->getLabel())->toBe('More');
|
|
|
|
$primaryRowActionNames = collect($rowActions)
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($primaryRowActionNames)->toBe([])
|
|
->and($table->getRecordUrl($profile))->toBe(BaselineProfileResource::getUrl('view', ['record' => $profile]));
|
|
|
|
$primaryRowActionCount = count($primaryRowActionNames);
|
|
expect($primaryRowActionCount)->toBeLessThanOrEqual(2);
|
|
|
|
$moreActionNames = collect($moreGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($moreActionNames)->toContain('archive');
|
|
expect($table->getBulkActions())->toBeEmpty();
|
|
});
|
|
|
|
it('keeps backup schedules on clickable-row edit without duplicate Edit actions in More', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$schedule = BackupSchedule::query()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'name' => 'Nightly backup',
|
|
'is_enabled' => true,
|
|
'timezone' => 'UTC',
|
|
'frequency' => 'daily',
|
|
'time_of_day' => '01:00:00',
|
|
'days_of_week' => null,
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'include_foundations' => true,
|
|
'retention_keep_last' => 30,
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(ListBackupSchedules::class)
|
|
->assertCanSeeTableRecords([$schedule]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActions = $table->getActions();
|
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
|
|
|
expect($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
|
->and($moreGroup?->getLabel())->toBe('More');
|
|
|
|
$primaryRowActionNames = collect($rowActions)
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($primaryRowActionNames)->toBe([])
|
|
->and($table->getRecordUrl($schedule))->toBe(BackupScheduleResource::getUrl('edit', ['record' => $schedule]));
|
|
|
|
$moreActionNames = collect($moreGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($moreActionNames)->toContain('runNow', 'retry', 'archive')
|
|
->and($moreActionNames)->not->toContain('edit');
|
|
|
|
$bulkActions = $table->getBulkActions();
|
|
$bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup);
|
|
|
|
expect($bulkGroup)->toBeInstanceOf(BulkActionGroup::class)
|
|
->and($bulkGroup?->getLabel())->toBe('More');
|
|
|
|
$bulkActionNames = collect($bulkGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($bulkActionNames)->toEqualCanonicalizing(['bulk_run_now', 'bulk_retry']);
|
|
});
|
|
|
|
it('uses clickable rows without extra row actions on backup schedule executions', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$schedule = BackupSchedule::query()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'name' => 'Nightly backup',
|
|
'is_enabled' => true,
|
|
'timezone' => 'UTC',
|
|
'frequency' => 'daily',
|
|
'time_of_day' => '01:00:00',
|
|
'days_of_week' => null,
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'include_foundations' => true,
|
|
'retention_keep_last' => 30,
|
|
]);
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'type' => 'backup_schedule_run',
|
|
'context' => ['backup_schedule_id' => (int) $schedule->getKey()],
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(BackupScheduleOperationRunsRelationManager::class, [
|
|
'ownerRecord' => $schedule,
|
|
'pageClass' => EditBackupSchedule::class,
|
|
])
|
|
->assertCanSeeTableRecords([$run]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
|
|
expect($table->getActions())->toBeEmpty()
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($run))->toBe(OperationRunLinks::view($run, $tenant));
|
|
});
|
|
|
|
it('uses clickable rows while keeping remove grouped under More on backup items', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$policy = Policy::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
]);
|
|
|
|
$version = PolicyVersion::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'policy_id' => (int) $policy->getKey(),
|
|
'metadata' => [],
|
|
]);
|
|
|
|
$backupSet = BackupSet::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
]);
|
|
|
|
$backupItem = BackupItem::factory()->for($backupSet)->for($tenant)->create([
|
|
'policy_id' => (int) $policy->getKey(),
|
|
'policy_version_id' => (int) $version->getKey(),
|
|
'metadata' => [],
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(BackupItemsRelationManager::class, [
|
|
'ownerRecord' => $backupSet,
|
|
'pageClass' => EditBackupSet::class,
|
|
])
|
|
->assertCanSeeTableRecords([$backupItem]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActions = $table->getActions();
|
|
$primaryRowActionNames = collect($rowActions)
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
|
$moreActionNames = collect($moreGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$bulkActions = $table->getBulkActions();
|
|
$bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup);
|
|
$bulkActionNames = collect($bulkGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($primaryRowActionNames)->toBe([])
|
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
|
->and($moreGroup?->getLabel())->toBe('More')
|
|
->and($moreActionNames)->toEqualCanonicalizing(['remove'])
|
|
->and($bulkGroup)->toBeInstanceOf(BulkActionGroup::class)
|
|
->and($bulkGroup?->getLabel())->toBe('More')
|
|
->and($bulkActionNames)->toEqualCanonicalizing(['bulk_remove'])
|
|
->and($table->getRecordUrl($backupItem))->toBe(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
|
});
|
|
|
|
it('keeps tenant memberships inline without a separate inspect affordance', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$member = User::factory()->create();
|
|
$member->tenants()->syncWithoutDetaching([
|
|
$tenant->getKey() => ['role' => 'readonly'],
|
|
]);
|
|
|
|
$membership = TenantMembership::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('user_id', (int) $member->getKey())
|
|
->firstOrFail();
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(TenantMembershipsRelationManager::class, [
|
|
'ownerRecord' => $tenant,
|
|
'pageClass' => ViewTenant::class,
|
|
])
|
|
->assertCanSeeTableRecords([$membership]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActionNames = collect($table->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->toEqualCanonicalizing(['change_role', 'remove'])
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($membership))->toBeNull();
|
|
});
|
|
|
|
it('keeps workspace memberships inline without a separate inspect affordance', function (): void {
|
|
$workspace = Workspace::factory()->create();
|
|
$owner = User::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'user_id' => (int) $owner->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
$member = User::factory()->create();
|
|
|
|
$membership = WorkspaceMembership::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'user_id' => (int) $member->getKey(),
|
|
'role' => 'readonly',
|
|
]);
|
|
|
|
$this->actingAs($owner);
|
|
|
|
$livewire = Livewire::test(WorkspaceMembershipsRelationManager::class, [
|
|
'ownerRecord' => $workspace,
|
|
'pageClass' => ViewWorkspace::class,
|
|
])
|
|
->assertCanSeeTableRecords([$membership]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActionNames = collect($table->getActions())
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$moreGroup = collect($table->getActions())
|
|
->first(static fn ($action): bool => $action instanceof ActionGroup);
|
|
$moreActionNames = collect($moreGroup?->getActions() ?? [])
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->toEqualCanonicalizing(['change_role'])
|
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
|
->and($moreGroup?->getLabel())->toBe('More')
|
|
->and($moreActionNames)->toEqualCanonicalizing(['remove'])
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($membership))->toBeNull();
|
|
});
|
|
|
|
it('renders the policy versions relation manager on the policy detail page', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$policy = Policy::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
]);
|
|
|
|
PolicyVersion::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'policy_id' => (int) $policy->getKey(),
|
|
'created_by' => 'versions-surface@example.test',
|
|
'metadata' => [],
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$this->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant))
|
|
->assertOk()
|
|
->assertSee('Versions');
|
|
});
|
|
|
|
it('renders tenant memberships only on the dedicated memberships page', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$member = User::factory()->create([
|
|
'email' => 'tenant-members-surface@example.test',
|
|
]);
|
|
$member->tenants()->syncWithoutDetaching([
|
|
$tenant->getKey() => ['role' => 'readonly'],
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
|
->get(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'))
|
|
->assertOk()
|
|
->assertDontSeeLivewire(TenantMembershipsRelationManager::class);
|
|
|
|
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
|
|
|
$membershipsPage = Livewire::actingAs($user)
|
|
->test(ManageTenantMemberships::class, ['record' => $tenant->getRouteKey()]);
|
|
|
|
expect($membershipsPage->instance()->getRelationManagers())
|
|
->toContain(TenantMembershipsRelationManager::class);
|
|
|
|
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
|
->get(TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin'))
|
|
->assertOk()
|
|
->assertSeeLivewire(TenantMembershipsRelationManager::class);
|
|
});
|
|
|
|
it('renders the backup items relation manager on the backup set detail page', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$policy = Policy::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'display_name' => 'Backup Items Surface Policy',
|
|
]);
|
|
|
|
$backupSet = BackupSet::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
]);
|
|
|
|
BackupItem::factory()->for($backupSet)->for($tenant)->create([
|
|
'policy_id' => (int) $policy->getKey(),
|
|
'policy_version_id' => null,
|
|
'metadata' => [],
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
|
|
->assertOk()
|
|
->assertSee('Items');
|
|
});
|
|
|
|
it('renders the workspace memberships relation manager on the workspace detail page', function (): void {
|
|
$workspace = Workspace::factory()->create();
|
|
$owner = User::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'user_id' => (int) $owner->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
$member = User::factory()->create([
|
|
'email' => 'workspace-members-surface@example.test',
|
|
]);
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'user_id' => (int) $member->getKey(),
|
|
'role' => 'readonly',
|
|
]);
|
|
|
|
$this->actingAs($owner);
|
|
|
|
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
->get(WorkspaceResource::getUrl('view', ['record' => $workspace]))
|
|
->assertOk()
|
|
->assertSee('Memberships');
|
|
});
|
|
|
|
it('keeps inventory coverage as derived metadata without inspect or row action affordances', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$conditionalAccessKey = 'policy:conditionalAccessPolicy';
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(InventoryCoverage::class)
|
|
->assertCanSeeTableRecords([$conditionalAccessKey])
|
|
->assertTableEmptyStateActionsExistInOrder(['clear_filters']);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$declaration = InventoryCoverage::actionSurfaceDeclaration();
|
|
|
|
expect($table->getActions())->toBeEmpty()
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getEmptyStateActions())->toHaveCount(1)
|
|
->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? ''))
|
|
->toContain('runtime-derived metadata');
|
|
});
|
|
|
|
it('ensures representative declarations satisfy required slots', function (): void {
|
|
$profiles = new ActionSurfaceProfileDefinition;
|
|
|
|
$declarations = [
|
|
InventoryCoverage::class => InventoryCoverage::actionSurfaceDeclaration(),
|
|
NoAccess::class => NoAccess::actionSurfaceDeclaration(),
|
|
TenantlessOperationRunViewer::class => TenantlessOperationRunViewer::actionSurfaceDeclaration(),
|
|
TenantDiagnostics::class => TenantDiagnostics::actionSurfaceDeclaration(),
|
|
TenantRequiredPermissions::class => TenantRequiredPermissions::actionSurfaceDeclaration(),
|
|
AlertDeliveryResource::class => AlertDeliveryResource::actionSurfaceDeclaration(),
|
|
BackupScheduleResource::class => BackupScheduleResource::actionSurfaceDeclaration(),
|
|
BackupScheduleOperationRunsRelationManager::class => BackupScheduleOperationRunsRelationManager::actionSurfaceDeclaration(),
|
|
BackupSetResource::class => BackupSetResource::actionSurfaceDeclaration(),
|
|
BackupItemsRelationManager::class => BackupItemsRelationManager::actionSurfaceDeclaration(),
|
|
BaselineSnapshotResource::class => BaselineSnapshotResource::actionSurfaceDeclaration(),
|
|
EntraGroupResource::class => EntraGroupResource::actionSurfaceDeclaration(),
|
|
EvidenceSnapshotResource::class => EvidenceSnapshotResource::actionSurfaceDeclaration(),
|
|
FindingExceptionResource::class => FindingExceptionResource::actionSurfaceDeclaration(),
|
|
Operations::class => Operations::actionSurfaceDeclaration(),
|
|
PolicyResource::class => PolicyResource::actionSurfaceDeclaration(),
|
|
OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(),
|
|
ReviewPackResource::class => ReviewPackResource::actionSurfaceDeclaration(),
|
|
RestoreRunResource::class => RestoreRunResource::actionSurfaceDeclaration(),
|
|
TenantMembershipsRelationManager::class => TenantMembershipsRelationManager::actionSurfaceDeclaration(),
|
|
VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(),
|
|
BaselineProfileResource::class => BaselineProfileResource::actionSurfaceDeclaration(),
|
|
WorkspaceMembershipsRelationManager::class => WorkspaceMembershipsRelationManager::actionSurfaceDeclaration(),
|
|
WorkspaceResource::class => WorkspaceResource::actionSurfaceDeclaration(),
|
|
SystemRunsPage::class => SystemRunsPage::actionSurfaceDeclaration(),
|
|
SystemFailuresPage::class => SystemFailuresPage::actionSurfaceDeclaration(),
|
|
SystemStuckPage::class => SystemStuckPage::actionSurfaceDeclaration(),
|
|
SystemDirectoryTenantsPage::class => SystemDirectoryTenantsPage::actionSurfaceDeclaration(),
|
|
SystemDirectoryWorkspacesPage::class => SystemDirectoryWorkspacesPage::actionSurfaceDeclaration(),
|
|
SystemAccessLogsPage::class => SystemAccessLogsPage::actionSurfaceDeclaration(),
|
|
];
|
|
|
|
foreach ($declarations as $className => $declaration) {
|
|
foreach ($profiles->requiredSlots($declaration->profile) as $slot) {
|
|
expect($declaration->slot($slot))
|
|
->not->toBeNull("Missing required slot {$slot->value} in declaration for {$className}");
|
|
}
|
|
}
|
|
});
|
|
|
|
it('requires every first-slice tenant-owned resource to be discovered without relying on baseline action-surface exemptions', function (): void {
|
|
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
|
|
->keyBy('className');
|
|
|
|
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
|
|
|
foreach (TenantOwnedModelFamilies::firstSlice() as $familyName => $family) {
|
|
$resourceClass = $family['resource'];
|
|
|
|
expect($components->has($resourceClass))
|
|
->toBeTrue("{$familyName} resource should be discoverable by the action-surface validator.");
|
|
|
|
$hasDeclaration = method_exists($resourceClass, 'actionSurfaceDeclaration');
|
|
$hasBaselineExemption = $baselineExemptions->hasClass($resourceClass);
|
|
|
|
expect($hasDeclaration || $hasBaselineExemption)
|
|
->toBeTrue("{$familyName} resource must either define actionSurfaceDeclaration() or carry an explicit baseline exemption.");
|
|
|
|
if ($hasDeclaration) {
|
|
expect($hasBaselineExemption)
|
|
->toBeFalse("{$familyName} resource should not keep a stale baseline exemption once actionSurfaceDeclaration() exists.");
|
|
|
|
continue;
|
|
}
|
|
|
|
expect(trim((string) $baselineExemptions->reasonForClass($resourceClass)))
|
|
->not->toBe('', "{$familyName} resource baseline exemption reason must stay explicit.");
|
|
}
|
|
});
|
|
|
|
it('keeps first-slice tenant-owned action-surface exemptions registry-backed and explicit', function (): void {
|
|
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
|
$registeredExemptions = TenantOwnedModelFamilies::actionSurfaceBaselineExemptions();
|
|
$declaredExemptions = collect(TenantOwnedModelFamilies::firstSlice())
|
|
->filter(static fn (array $family): bool => $family['action_surface'] === 'baseline_exemption')
|
|
->mapWithKeys(static fn (array $family): array => [$family['resource'] => $family['action_surface_reason']])
|
|
->all();
|
|
|
|
expect($registeredExemptions)->toBe($declaredExemptions);
|
|
|
|
foreach ($registeredExemptions as $className => $reason) {
|
|
expect($baselineExemptions->reasonForClass($className))
|
|
->toBe($reason);
|
|
}
|
|
|
|
foreach (TenantOwnedModelFamilies::firstSlice() as $familyName => $family) {
|
|
if ($family['action_surface'] !== 'baseline_exemption') {
|
|
continue;
|
|
}
|
|
|
|
expect(trim($family['action_surface_reason']))
|
|
->not->toBe('', "{$familyName} baseline exemption reason must stay explicit in the registry.");
|
|
}
|
|
});
|
|
|
|
it('keeps first-slice trusted-state page action-surface status explicit', function (): void {
|
|
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
|
|
|
expect(method_exists(TenantRequiredPermissions::class, 'actionSurfaceDeclaration'))->toBeTrue()
|
|
->and($baselineExemptions->hasClass(TenantRequiredPermissions::class))->toBeFalse();
|
|
|
|
expect(method_exists(Alerts::class, 'actionSurfaceDeclaration'))->toBeFalse()
|
|
->and($baselineExemptions->hasClass(Alerts::class))->toBeTrue()
|
|
->and((string) $baselineExemptions->reasonForClass(Alerts::class))->toContain('cluster entry');
|
|
|
|
expect($baselineExemptions->hasClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toBeTrue()
|
|
->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toContain('dedicated conformance tests');
|
|
|
|
expect(method_exists(\App\Filament\System\Pages\Ops\Runbooks::class, 'actionSurfaceDeclaration'))->toBeFalse()
|
|
->and($baselineExemptions->hasClass(\App\Filament\System\Pages\Ops\Runbooks::class))->toBeFalse();
|
|
});
|
|
|
|
it('keeps enrolled system panel pages declaration-backed without stale baseline exemptions', function (): void {
|
|
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
|
|
|
foreach ([
|
|
SystemRunsPage::class,
|
|
SystemFailuresPage::class,
|
|
SystemStuckPage::class,
|
|
SystemDirectoryTenantsPage::class,
|
|
SystemDirectoryWorkspacesPage::class,
|
|
SystemAccessLogsPage::class,
|
|
] as $className) {
|
|
expect(method_exists($className, 'actionSurfaceDeclaration'))
|
|
->toBeTrue("{$className} should declare its action surface once enrolled.");
|
|
|
|
expect($baselineExemptions->hasClass($className))
|
|
->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment.");
|
|
}
|
|
});
|
|
|
|
it('keeps enrolled relation managers declaration-backed without stale baseline exemptions', function (): void {
|
|
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
|
|
|
foreach ([
|
|
BackupItemsRelationManager::class,
|
|
TenantMembershipsRelationManager::class,
|
|
WorkspaceMembershipsRelationManager::class,
|
|
] as $className) {
|
|
expect(method_exists($className, 'actionSurfaceDeclaration'))
|
|
->toBeTrue("{$className} should declare its action surface once enrolled.");
|
|
|
|
expect($baselineExemptions->hasClass($className))
|
|
->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment.");
|
|
}
|
|
});
|
|
|
|
it('keeps enrolled monitoring pages declaration-backed without stale baseline exemptions', function (): void {
|
|
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
|
|
|
foreach ([
|
|
Operations::class,
|
|
] as $className) {
|
|
expect(method_exists($className, 'actionSurfaceDeclaration'))
|
|
->toBeTrue("{$className} should declare its action surface once enrolled.");
|
|
|
|
expect($baselineExemptions->hasClass($className))
|
|
->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment.");
|
|
}
|
|
});
|
|
|
|
it('keeps enrolled tenant table pages declaration-backed without stale baseline exemptions', function (): void {
|
|
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
|
|
|
foreach ([
|
|
InventoryCoverage::class,
|
|
] as $className) {
|
|
expect(method_exists($className, 'actionSurfaceDeclaration'))
|
|
->toBeTrue("{$className} should declare its action surface once enrolled.");
|
|
|
|
expect($baselineExemptions->hasClass($className))
|
|
->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment.");
|
|
}
|
|
});
|
|
|
|
it('keeps enrolled canonical detail pages declaration-backed without stale baseline exemptions', function (): void {
|
|
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
|
|
|
foreach ([
|
|
TenantlessOperationRunViewer::class,
|
|
] as $className) {
|
|
expect(method_exists($className, 'actionSurfaceDeclaration'))
|
|
->toBeTrue("{$className} should declare its action surface once enrolled.");
|
|
|
|
expect($baselineExemptions->hasClass($className))
|
|
->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment.");
|
|
}
|
|
});
|
|
|
|
it('keeps enrolled singleton tenant pages declaration-backed without stale baseline exemptions', function (): void {
|
|
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
|
|
|
foreach ([
|
|
NoAccess::class,
|
|
TenantDiagnostics::class,
|
|
] as $className) {
|
|
expect(method_exists($className, 'actionSurfaceDeclaration'))
|
|
->toBeTrue("{$className} should declare its action surface once enrolled.");
|
|
|
|
expect($baselineExemptions->hasClass($className))
|
|
->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment.");
|
|
}
|
|
});
|
|
|
|
it('keeps enrolled guided workspace diagnostic pages declaration-backed without stale baseline exemptions', function (): void {
|
|
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
|
|
|
foreach ([
|
|
TenantRequiredPermissions::class,
|
|
] as $className) {
|
|
expect(method_exists($className, 'actionSurfaceDeclaration'))
|
|
->toBeTrue("{$className} should declare its action surface once enrolled.");
|
|
|
|
expect($baselineExemptions->hasClass($className))
|
|
->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment.");
|
|
}
|
|
});
|
|
|
|
it('keeps finding exception v1 list exemptions explicit and omits grouped or bulk mutations', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$declaration = FindingExceptionResource::actionSurfaceDeclaration();
|
|
|
|
expect((string) ($declaration->exemption(ActionSurfaceSlot::ListRowMoreMenu)?->reason ?? ''))
|
|
->toContain('avoids a More menu');
|
|
expect((string) ($declaration->exemption(ActionSurfaceSlot::ListBulkMoreGroup)?->reason ?? ''))
|
|
->toContain('omit bulk actions');
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(ListFindingExceptions::class)
|
|
->assertTableEmptyStateActionsExistInOrder(['open_findings']);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActions = $table->getActions();
|
|
|
|
expect(collect($rowActions)->contains(static fn ($action): bool => $action instanceof ActionGroup))->toBeFalse();
|
|
expect(collect($rowActions)->map(static fn ($action): ?string => $action->getName())->filter()->values()->all())
|
|
->toEqualCanonicalizing(['renew_exception', 'revoke_exception']);
|
|
expect($table->getBulkActions())->toBeEmpty();
|
|
});
|
|
|
|
it('documents the guided alert delivery empty state without introducing a list-header CTA', function (): void {
|
|
$declaration = AlertDeliveryResource::actionSurfaceDeclaration();
|
|
|
|
expect((string) ($declaration->slot(ActionSurfaceSlot::ListEmptyState)?->details ?? ''))
|
|
->toContain('View alert rules');
|
|
});
|
|
|
|
it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(ListPolicies::class)
|
|
->assertTableEmptyStateActionsExistInOrder(['sync']);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
|
|
$rowActions = $table->getActions();
|
|
$rowGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
|
|
|
expect($rowGroup)->toBeInstanceOf(ActionGroup::class);
|
|
expect($rowGroup?->getLabel())->toBe('More');
|
|
|
|
$primaryRowActionCount = collect($rowActions)
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->count();
|
|
|
|
expect($primaryRowActionCount)->toBeLessThanOrEqual(2);
|
|
|
|
$bulkActions = $table->getBulkActions();
|
|
$bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup);
|
|
|
|
expect($bulkGroup)->toBeInstanceOf(BulkActionGroup::class);
|
|
expect($bulkGroup?->getLabel())->toBe('More');
|
|
});
|
|
|
|
it('keeps evidence snapshots on the declared clickable-row, two-action surface', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
Livewire::test(ListEvidenceSnapshots::class)
|
|
->assertTableEmptyStateActionsExistInOrder(['create_first_snapshot']);
|
|
|
|
$snapshot = EvidenceSnapshot::query()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'status' => EvidenceSnapshotStatus::Active->value,
|
|
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
|
'summary' => ['finding_count' => 1, 'missing_dimensions' => 0],
|
|
'generated_at' => now(),
|
|
]);
|
|
|
|
$livewire = Livewire::test(ListEvidenceSnapshots::class)
|
|
->assertCanSeeTableRecords([$snapshot]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActions = $table->getActions();
|
|
$primaryRowActionNames = collect($rowActions)
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
|
$moreActionNames = collect($moreGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($primaryRowActionNames)->toBe([])
|
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
|
->and($moreGroup?->getLabel())->toBe('More')
|
|
->and($moreActionNames)->toEqualCanonicalizing(['expire'])
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot]));
|
|
});
|
|
|
|
it('uses clickable rows without a duplicate View action on the tenant reviews list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$review = composeTenantReviewForTest($tenant, $user);
|
|
|
|
$this->actingAs($user);
|
|
setTenantPanelContext($tenant);
|
|
|
|
$livewire = Livewire::test(ListTenantReviews::class)
|
|
->assertCanSeeTableRecords([$review]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActionNames = collect($table->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->toEqualCanonicalizing(['export_executive_pack'])
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($review))->toBe(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant));
|
|
});
|
|
|
|
it('uses clickable rows while keeping direct download and expire shortcuts on the review packs list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$pack = ReviewPack::factory()->ready()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'initiated_by_user_id' => (int) $user->getKey(),
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(ListReviewPacks::class)
|
|
->assertCanSeeTableRecords([$pack]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActionNames = collect($table->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->toEqualCanonicalizing(['download', 'expire'])
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($pack))->toBe(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant));
|
|
});
|
|
|
|
it('uses clickable rows while grouping restore-run maintenance actions under More', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$backupSet = BackupSet::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'status' => 'completed',
|
|
]);
|
|
|
|
$restoreRun = RestoreRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'backup_set_id' => (int) $backupSet->getKey(),
|
|
'status' => 'completed',
|
|
'deleted_at' => null,
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(ListRestoreRuns::class)
|
|
->assertCanSeeTableRecords([$restoreRun]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActions = $table->getActions();
|
|
$primaryRowActionNames = collect($rowActions)
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
|
$moreActionNames = collect($moreGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$bulkActions = $table->getBulkActions();
|
|
$bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup);
|
|
$bulkActionNames = collect($bulkGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($primaryRowActionNames)->toBe([])
|
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
|
->and($moreGroup?->getLabel())->toBe('More')
|
|
->and($moreActionNames)->toEqualCanonicalizing(['rerun', 'restore', 'archive', 'forceDelete'])
|
|
->and($bulkGroup)->toBeInstanceOf(BulkActionGroup::class)
|
|
->and($bulkGroup?->getLabel())->toBe('More')
|
|
->and($bulkActionNames)->toEqualCanonicalizing(['bulk_delete', 'bulk_restore', 'bulk_force_delete'])
|
|
->and($table->getRecordUrl($restoreRun))->toBe(RestoreRunResource::getUrl('view', ['record' => $restoreRun], tenant: $tenant));
|
|
});
|
|
|
|
it('keeps findings on clickable-row inspection with a single related drill-down and grouped workflow actions', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$finding = Finding::factory()->for($tenant)->create([
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(ListFindings::class)
|
|
->assertCanSeeTableRecords([$finding]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActions = $table->getActions();
|
|
$primaryRowActionNames = collect($rowActions)
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
|
$moreActionNames = collect($moreGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$bulkActions = $table->getBulkActions();
|
|
$bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup);
|
|
$bulkActionNames = collect($bulkGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($primaryRowActionNames)->toEqualCanonicalizing(['primary_drill_down'])
|
|
->and($table->getRecordUrl($finding))->toBe(FindingResource::getUrl('view', ['record' => $finding]))
|
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
|
->and($moreGroup?->getLabel())->toBe('More')
|
|
->and($moreActionNames)->toEqualCanonicalizing([
|
|
'triage',
|
|
'start_progress',
|
|
'assign',
|
|
'resolve',
|
|
'close',
|
|
'request_exception',
|
|
'renew_exception',
|
|
'revoke_exception',
|
|
'reopen',
|
|
])
|
|
->and($bulkGroup)->toBeInstanceOf(BulkActionGroup::class)
|
|
->and($bulkGroup?->getLabel())->toBe('More')
|
|
->and($bulkActionNames)->toEqualCanonicalizing([
|
|
'triage_selected',
|
|
'assign_selected',
|
|
'resolve_selected',
|
|
'close_selected',
|
|
]);
|
|
});
|
|
|
|
it('uses clickable rows with restore as the only inline shortcut on the policy versions relation manager', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$policy = Policy::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
]);
|
|
|
|
$version = PolicyVersion::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'policy_id' => (int) $policy->getKey(),
|
|
'metadata' => [],
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(VersionsRelationManager::class, [
|
|
'ownerRecord' => $policy,
|
|
'pageClass' => ViewPolicy::class,
|
|
])
|
|
->assertCanSeeTableRecords([$version]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActionNames = collect($table->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->toEqualCanonicalizing(['restore_to_intune'])
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($version))->toBe(PolicyVersionResource::getUrl('view', ['record' => $version]));
|
|
});
|
|
|
|
it('uses canonical tenantless View run links on representative operation links', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
$run = OperationRun::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'workspace_id' => $tenant->workspace_id,
|
|
]);
|
|
|
|
expect(OperationRunLinks::view($run, $tenant))
|
|
->toBe(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
|
});
|
|
|
|
it('uses clickable rows without a lone View action on the monitoring operations list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'type' => 'policy.sync',
|
|
'status' => 'queued',
|
|
'outcome' => 'pending',
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(Operations::class)
|
|
->assertCanSeeTableRecords([$run]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
|
|
expect($table->getActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($run))->toBe(OperationRunLinks::tenantlessView($run));
|
|
});
|
|
|
|
it('keeps tenantless run detail header actions on the canonical viewer without list affordances', function (): void {
|
|
$workspace = Workspace::factory()->create();
|
|
$user = User::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'tenant_id' => null,
|
|
'type' => 'provider.connection.check',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
]);
|
|
|
|
session()->forget(WorkspaceContext::SESSION_KEY);
|
|
|
|
Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
|
->assertActionVisible('operate_hub_scope_run_detail')
|
|
->assertActionVisible('operate_hub_back_to_operations')
|
|
->assertActionVisible('refresh');
|
|
|
|
$declaration = TenantlessOperationRunViewer::actionSurfaceDeclaration();
|
|
|
|
expect((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? ''))
|
|
->toContain('canonical detail destination')
|
|
->and((string) ($declaration->slot(ActionSurfaceSlot::DetailHeader)?->details ?? ''))
|
|
->toContain('refresh');
|
|
});
|
|
|
|
it('keeps tenant diagnostics as a singleton repair surface with header actions only', function (): void {
|
|
[$manager, $tenant] = createUserWithTenant(role: 'manager');
|
|
|
|
$this->actingAs($manager);
|
|
Filament::setTenant($tenant, true);
|
|
|
|
Livewire::test(TenantDiagnostics::class)
|
|
->assertActionVisible('bootstrapOwner')
|
|
->assertActionEnabled('bootstrapOwner');
|
|
|
|
$declaration = TenantDiagnostics::actionSurfaceDeclaration();
|
|
|
|
expect((string) ($declaration->slot(ActionSurfaceSlot::ListHeader)?->details ?? ''))
|
|
->toContain('repair actions')
|
|
->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? ''))
|
|
->toContain('singleton diagnostic surface');
|
|
});
|
|
|
|
it('keeps the no-access page as a singleton recovery surface with a header action', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user);
|
|
|
|
Livewire::test(NoAccess::class)
|
|
->assertActionVisible('createWorkspace')
|
|
->assertActionEnabled('createWorkspace');
|
|
|
|
$declaration = NoAccess::actionSurfaceDeclaration();
|
|
|
|
expect((string) ($declaration->slot(ActionSurfaceSlot::ListHeader)?->details ?? ''))
|
|
->toContain('create-workspace recovery action')
|
|
->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? ''))
|
|
->toContain('singleton recovery surface');
|
|
});
|
|
|
|
it('keeps required permissions as a guided diagnostic page with inline filters and empty-state guidance', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
|
|
|
$response = $this->actingAs($user)
|
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions");
|
|
|
|
$response->assertOk()
|
|
->assertSee('Copy missing application permissions')
|
|
->assertSee('Copy missing delegated permissions')
|
|
->assertSee('Re-run verification')
|
|
->assertSee('Start verification');
|
|
|
|
$declaration = TenantRequiredPermissions::actionSurfaceDeclaration();
|
|
|
|
expect((string) ($declaration->exemption(ActionSurfaceSlot::ListHeader)?->reason ?? ''))
|
|
->toContain('body sections')
|
|
->and((string) ($declaration->slot(ActionSurfaceSlot::ListEmptyState)?->details ?? ''))
|
|
->toContain('no-data');
|
|
});
|
|
|
|
it('uses clickable rows with direct triage actions on the system runs list', function (): void {
|
|
$run = OperationRun::factory()->create([
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Failed->value,
|
|
'type' => 'inventory_sync',
|
|
]);
|
|
|
|
actionSurfaceSystemPanelContext([
|
|
PlatformCapabilities::OPERATIONS_VIEW,
|
|
PlatformCapabilities::OPERATIONS_MANAGE,
|
|
]);
|
|
|
|
$livewire = Livewire::test(SystemRunsPage::class)
|
|
->assertCanSeeTableRecords([$run]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActionNames = collect($table->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->toEqualCanonicalizing(['retry', 'cancel', 'mark_investigated'])
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run));
|
|
});
|
|
|
|
it('uses clickable rows with direct triage actions on the system failures list', function (): void {
|
|
$run = OperationRun::factory()->create([
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Failed->value,
|
|
'type' => 'inventory_sync',
|
|
]);
|
|
|
|
actionSurfaceSystemPanelContext([
|
|
PlatformCapabilities::OPERATIONS_VIEW,
|
|
PlatformCapabilities::OPERATIONS_MANAGE,
|
|
]);
|
|
|
|
$livewire = Livewire::test(SystemFailuresPage::class)
|
|
->assertCanSeeTableRecords([$run]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActionNames = collect($table->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->toEqualCanonicalizing(['retry', 'cancel', 'mark_investigated'])
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run));
|
|
});
|
|
|
|
it('uses clickable rows with direct triage actions on the system stuck list', function (): void {
|
|
$run = OperationRun::factory()->create([
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subHours(2),
|
|
'started_at' => null,
|
|
'type' => 'inventory_sync',
|
|
]);
|
|
|
|
actionSurfaceSystemPanelContext([
|
|
PlatformCapabilities::OPERATIONS_VIEW,
|
|
PlatformCapabilities::OPERATIONS_MANAGE,
|
|
]);
|
|
|
|
$livewire = Livewire::test(SystemStuckPage::class)
|
|
->assertCanSeeTableRecords([$run]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActionNames = collect($table->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->toEqualCanonicalizing(['retry', 'cancel', 'mark_investigated'])
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run));
|
|
});
|
|
|
|
it('uses clickable rows without extra row actions on the system tenants directory', function (): void {
|
|
$workspace = Workspace::factory()->create([
|
|
'name' => 'System Directory Workspace',
|
|
]);
|
|
|
|
$tenant = Tenant::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'name' => 'System Directory Tenant',
|
|
]);
|
|
|
|
actionSurfaceSystemPanelContext([
|
|
PlatformCapabilities::DIRECTORY_VIEW,
|
|
]);
|
|
|
|
$livewire = Livewire::test(SystemDirectoryTenantsPage::class)
|
|
->assertCanSeeTableRecords([$tenant]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
|
|
expect($table->getActions())->toBeEmpty()
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($tenant))->toBe(SystemDirectoryLinks::tenantDetail($tenant));
|
|
});
|
|
|
|
it('uses clickable rows without extra row actions on the system workspaces directory', function (): void {
|
|
$workspace = Workspace::factory()->create([
|
|
'name' => 'System Directory Workspace',
|
|
]);
|
|
|
|
actionSurfaceSystemPanelContext([
|
|
PlatformCapabilities::DIRECTORY_VIEW,
|
|
]);
|
|
|
|
$livewire = Livewire::test(SystemDirectoryWorkspacesPage::class)
|
|
->assertCanSeeTableRecords([$workspace]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
|
|
expect($table->getActions())->toBeEmpty()
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($workspace))->toBe(SystemDirectoryLinks::workspaceDetail($workspace));
|
|
});
|
|
|
|
it('keeps system access logs scan-only without row or bulk actions', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$log = AuditLog::query()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'action' => 'platform.auth.login',
|
|
'status' => 'success',
|
|
'metadata' => ['attempted_email' => 'operator@tenantpilot.test'],
|
|
'recorded_at' => now(),
|
|
]);
|
|
|
|
actionSurfaceSystemPanelContext([
|
|
PlatformCapabilities::CONSOLE_VIEW,
|
|
]);
|
|
|
|
$livewire = Livewire::test(SystemAccessLogsPage::class)
|
|
->assertCanSeeTableRecords([$log]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
|
|
expect($table->getActions())->toBeEmpty()
|
|
->and($table->getBulkActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($log))->toBeNull();
|
|
});
|
|
|
|
it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$item = InventoryItem::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
]);
|
|
|
|
$livewire = Livewire::test(ListInventoryItems::class);
|
|
$table = $livewire->instance()->getTable();
|
|
|
|
expect($table->getActions())->toBeEmpty();
|
|
|
|
$recordUrl = $table->getRecordUrl($item);
|
|
|
|
expect($recordUrl)->not->toBeNull();
|
|
expect($recordUrl)->toBe(InventoryItemResource::getUrl('view', ['record' => $item]));
|
|
});
|
|
|
|
it('uses clickable rows without a lone View action on the workspaces list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$this->actingAs($user);
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
Filament::setTenant(null, true);
|
|
|
|
$workspace = $tenant->workspace;
|
|
|
|
$livewire = Livewire::test(ListWorkspaces::class)
|
|
->assertCanSeeTableRecords([$workspace]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActionNames = collect($table->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->toContain('edit')
|
|
->and($rowActionNames)->not->toContain('view')
|
|
->and($table->getRecordUrl($workspace))->toBe(WorkspaceResource::getUrl('view', ['record' => $workspace]));
|
|
});
|
|
|
|
it('uses clickable rows without a lone View action on the policies list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$policy = Policy::query()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'external_id' => 'policy-action-surface-1',
|
|
'policy_type' => 'deviceConfiguration',
|
|
'display_name' => 'Policy Action Surface',
|
|
'platform' => 'windows',
|
|
'last_synced_at' => now(),
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(ListPolicies::class)
|
|
->assertCanSeeTableRecords([$policy]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActionNames = collect($table->getActions())
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->not->toContain('view')
|
|
->and($table->getRecordUrl($policy))->toBe(PolicyResource::getUrl('view', ['record' => $policy]));
|
|
});
|
|
|
|
it('uses clickable rows without a duplicate Edit action on the alert rules list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$workspaceId = (int) $tenant->workspace_id;
|
|
$rule = AlertRule::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
|
Filament::setTenant(null, true);
|
|
|
|
$livewire = Livewire::test(ListAlertRules::class)
|
|
->assertCanSeeTableRecords([$rule]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActions = $table->getActions();
|
|
$rowActionNames = collect($rowActions)
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
|
$moreActionNames = collect($moreGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->not->toContain('edit')
|
|
->and($table->getRecordUrl($rule))->toBe(AlertRuleResource::getUrl('edit', ['record' => $rule]))
|
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
|
->and($moreGroup?->getLabel())->toBe('More')
|
|
->and($moreActionNames)->toEqualCanonicalizing(['toggle_enabled', 'delete'])
|
|
->and($table->getBulkActions())->toBeEmpty();
|
|
});
|
|
|
|
it('uses clickable rows without a duplicate Edit action on the alert destinations list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$workspaceId = (int) $tenant->workspace_id;
|
|
$destination = AlertDestination::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
|
Filament::setTenant(null, true);
|
|
|
|
$livewire = Livewire::test(ListAlertDestinations::class)
|
|
->assertCanSeeTableRecords([$destination]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActions = $table->getActions();
|
|
$rowActionNames = collect($rowActions)
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
|
$moreActionNames = collect($moreGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->not->toContain('edit')
|
|
->and($table->getRecordUrl($destination))->toBe(AlertDestinationResource::getUrl('edit', ['record' => $destination]))
|
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
|
->and($moreGroup?->getLabel())->toBe('More')
|
|
->and($moreActionNames)->toEqualCanonicalizing(['toggle_enabled', 'delete'])
|
|
->and($table->getBulkActions())->toBeEmpty();
|
|
});
|
|
|
|
it('uses clickable-row view with all secondary provider connection actions grouped under More', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
|
|
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'provider' => 'microsoft',
|
|
'status' => 'connected',
|
|
'is_default' => false,
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$livewire = Livewire::test(ListProviderConnections::class)
|
|
->assertCanSeeTableRecords([$connection]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
$rowActions = $table->getActions();
|
|
$rowActionNames = collect($rowActions)
|
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
|
$moreActionNames = collect($moreGroup?->getActions())
|
|
->map(static fn ($action): ?string => $action->getName())
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
expect($rowActionNames)->toBeEmpty()
|
|
->and($table->getRecordUrl($connection))->toBe(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant))
|
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
|
->and($moreGroup?->getLabel())->toBe('More')
|
|
->and($moreActionNames)->toEqualCanonicalizing([
|
|
'edit',
|
|
'check_connection',
|
|
'inventory_sync',
|
|
'compliance_snapshot',
|
|
'set_default',
|
|
'enable_dedicated_override',
|
|
'rotate_dedicated_credential',
|
|
'delete_dedicated_credential',
|
|
'revert_to_platform',
|
|
'enable_connection',
|
|
'disable_connection',
|
|
])
|
|
->and($table->getBulkActions())->toBeEmpty();
|
|
});
|
|
|
|
it('uses clickable rows without extra row actions on the alert deliveries list', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$workspaceId = (int) $tenant->workspace_id;
|
|
$rule = AlertRule::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
]);
|
|
$destination = AlertDestination::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
]);
|
|
$delivery = AlertDelivery::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'alert_rule_id' => (int) $rule->getKey(),
|
|
'alert_destination_id' => (int) $destination->getKey(),
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
|
Filament::setTenant(null, true);
|
|
|
|
$livewire = Livewire::test(ListAlertDeliveries::class)
|
|
->assertCanSeeTableRecords([$delivery]);
|
|
|
|
$table = $livewire->instance()->getTable();
|
|
|
|
expect($table->getActions())->toBeEmpty()
|
|
->and($table->getRecordUrl($delivery))->toBe(AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'));
|
|
});
|
|
|
|
it('keeps representative operation-start actions observable with actor and scope metadata', function (): void {
|
|
Queue::fake();
|
|
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);
|
|
|
|
$run = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'policy.sync')
|
|
->latest('id')
|
|
->first();
|
|
|
|
expect($run)->not->toBeNull();
|
|
expect((int) $run?->tenant_id)->toBe((int) $tenant->getKey());
|
|
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
|
|
expect((string) $run?->initiator_name)->toBe((string) $user->name);
|
|
});
|