TenantAtlas/apps/platform/tests/Feature/Filament/TableStatePersistenceTest.php
ahmido e02799b383 feat: implement spec 198 monitoring page state contract (#238)
## Summary
- implement Spec 198 monitoring page-state contracts across Operations, Audit Log, Finding Exceptions Queue, Evidence Overview, Baseline Compare Landing, and Baseline Compare Matrix
- align selected-record and draft/apply behavior with query/session restoration semantics, including canonical navigation and tenant-filter normalization helpers
- add Spec 198 feature and browser coverage, update closure/spec artifacts, and refresh affected regression tests that asserted pre-contract behavior

## Verification
- focused Spec 198 feature pack passed through Sail
- Spec 198 browser smoke passed through Sail
- existing Spec 190 and Spec 194 browser smokes passed through Sail
- targeted fallout tests were updated and rerun during full-suite triage

## Notes
- Livewire v4 / Filament v5 compliant only; no legacy API reintroduction
- no provider registration changes; Laravel 11+ provider registration remains in `bootstrap/providers.php`
- no global-search behavior changed for any resource
- destructive queue decision actions remain confirmation-gated and authorization-backed
- no new Filament assets were added; existing deploy step for `php artisan filament:assets` remains unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #238
2026-04-15 21:59:42 +00:00

449 lines
14 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\Monitoring\Operations;
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
use App\Filament\Resources\BaselineSnapshotResource\Pages\ListBaselineSnapshots;
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
/**
* @param array<string, mixed> $parameters
*/
function spec125AssertPersistedTableState(
string $componentClass,
array $parameters,
string $search,
string $sortColumn,
string $sortDirection,
string $filterPath,
mixed $filterValue,
array $queryParams = [],
): void {
$component = Livewire::withQueryParams($queryParams)
->test($componentClass, $parameters)
->searchTable($search)
->call('sortTable', $sortColumn, $sortDirection)
->set($filterPath, $filterValue);
$instance = $component->instance();
expect(session()->get($instance->getTableSearchSessionKey()))->toBe($search);
expect(session()->get($instance->getTableSortSessionKey()))->toBe("{$sortColumn}:{$sortDirection}");
expect(data_get(session()->get($instance->getTableFiltersSessionKey()), str($filterPath)->after('tableFilters.')->value()))->toBe($filterValue);
Livewire::withQueryParams($queryParams)
->test($componentClass, $parameters)
->assertSet('tableSearch', $search)
->assertSet('tableSort', "{$sortColumn}:{$sortDirection}")
->assertSet($filterPath, $filterValue);
}
it('persists tenant list search, sort, and filter state across remounts', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
spec125AssertPersistedTableState(
ListTenants::class,
[],
'Tenant',
'name',
'desc',
'tableFilters.environment.value',
'prod',
);
});
it('persists policy list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListPolicies::class,
[],
'Policy',
'display_name',
'desc',
'tableFilters.visibility.value',
'active',
);
});
it('persists backup-set list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListBackupSets::class,
[],
'Backup',
'name',
'desc',
'tableFilters.trashed.value',
1,
);
});
it('persists backup-schedule list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListBackupSchedules::class,
[],
'Schedule',
'name',
'desc',
'tableFilters.enabled_state.value',
'enabled',
);
});
it('persists provider-connections list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListProviderConnections::class,
[],
'Contoso',
'display_name',
'desc',
'tableFilters.default_only.isActive',
true,
);
});
it('persists findings list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListFindings::class,
[],
'drift',
'created_at',
'asc',
'tableFilters.status.value',
'new',
);
});
it('persists inventory item list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListInventoryItems::class,
[],
'Policy',
'display_name',
'asc',
'tableFilters.platform.value',
'windows',
);
});
it('persists policy version list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListPolicyVersions::class,
[],
'Policy',
'captured_at',
'asc',
'tableFilters.platform.value',
'windows',
);
});
it('persists restore run list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListRestoreRuns::class,
[],
'Restore',
'started_at',
'asc',
'tableFilters.status.value',
'completed',
);
});
it('persists alert delivery list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListAlertDeliveries::class,
[],
'alert',
'created_at',
'asc',
'tableFilters.status.value',
'sent',
);
});
it('persists Entra group list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListEntraGroups::class,
[],
'Group',
'display_name',
'desc',
'tableFilters.group_type.value',
'security',
);
});
it('persists baseline snapshot list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
spec125AssertPersistedTableState(
ListBaselineSnapshots::class,
[],
'Baseline',
'captured_at',
'asc',
'tableFilters.snapshot_state.value',
'with_gaps',
);
});
it('persists monitoring operations search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
Operations::class,
[],
'policy',
'type',
'desc',
'tableFilters.status.value',
'queued',
);
});
it('restores operations table state while the requested tab stays query-driven', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
Operations::class,
[],
'baseline',
'created_at',
'desc',
'tableFilters.status.value',
'failed',
['activeTab' => 'failed'],
);
Livewire::withQueryParams(['activeTab' => 'failed'])
->actingAs($user)
->test(Operations::class)
->assertSet('activeTab', 'failed');
});
it('clears selected audit event state when persisted filters no longer contain the record', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$selectedAudit = AuditLog::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_email' => 'owner@example.com',
'actor_name' => 'Owner',
'actor_type' => 'human',
'action' => 'workspace.selected',
'status' => 'success',
'resource_type' => 'workspace',
'resource_id' => (string) $tenant->workspace_id,
'target_label' => 'Workspace '.$tenant->workspace_id,
'summary' => 'Selected audit event',
'recorded_at' => now(),
]);
AuditLog::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_email' => 'owner@example.com',
'actor_name' => 'Owner',
'actor_type' => 'human',
'action' => 'operation_run.failed',
'status' => 'failure',
'resource_type' => 'operation_run',
'resource_id' => '1',
'target_label' => 'Run #1',
'summary' => 'Failure event',
'recorded_at' => now()->addSecond(),
]);
$this->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$auditComponent = Livewire::withQueryParams(['event' => (int) $selectedAudit->getKey()])
->actingAs($user)
->test(AuditLogPage::class);
session()->put($auditComponent->instance()->getTableFiltersSessionKey(), [
'outcome' => ['value' => 'failure'],
]);
Livewire::withQueryParams(['event' => (int) $selectedAudit->getKey()])
->actingAs($user)
->test(AuditLogPage::class)
->assertSet('selectedAuditLogId', null);
});
it('clears selected exception state when persisted queue filters no longer contain the record', function (): void {
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()->for($tenant)->create();
$selectedException = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $approver->getKey(),
'owner_user_id' => (int) $approver->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Selected queue exception',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$rejectedFinding = Finding::factory()->for($tenant)->create();
FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $rejectedFinding->getKey(),
'requested_by_user_id' => (int) $approver->getKey(),
'owner_user_id' => (int) $approver->getKey(),
'status' => FindingException::STATUS_REJECTED,
'current_validity_state' => FindingException::VALIDITY_REJECTED,
'request_reason' => 'Rejected queue exception',
'requested_at' => now()->subDay(),
'rejected_at' => now()->subHour(),
'rejection_reason' => 'No longer needed',
'evidence_summary' => ['reference_count' => 0],
]);
$this->actingAs($approver);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$queueComponent = Livewire::withQueryParams(['exception' => (int) $selectedException->getKey()])
->test(FindingExceptionsQueue::class);
session()->put($queueComponent->instance()->getTableFiltersSessionKey(), [
'status' => ['value' => FindingException::STATUS_REJECTED],
]);
Livewire::withQueryParams(['exception' => (int) $selectedException->getKey()])
->test(FindingExceptionsQueue::class)
->assertSet('selectedFindingExceptionId', null);
});
it('reseeds the provider-connections tenant filter when the remembered admin tenant changes', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
$workspaceId = (int) $tenantA->workspace_id;
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $tenantA->getKey(),
]);
Livewire::actingAs($user)->test(ListProviderConnections::class)
->assertSet('tableFilters.tenant.value', (string) $tenantA->external_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $tenantB->getKey(),
]);
Livewire::actingAs($user)->test(ListProviderConnections::class)
->assertSet('tableFilters.tenant.value', (string) $tenantB->external_id);
});