## Summary - turn the tenant registry into a workspace-scoped recovery triage surface with backup posture and recovery evidence columns - preserve workspace overview backup and recovery drilldown intent by routing multi-tenant cases into filtered tenant registry slices - add the Spec 186 planning artifacts, focused regression coverage, and shared triage presentation helpers ## Testing - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php` ## Notes - no schema change - no new persisted recovery truth - branch includes the full Spec 186 spec, plan, research, data model, contract, quickstart, and tasks artifacts Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #217
374 lines
16 KiB
PHP
374 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
use App\Filament\Resources\TenantReviewResource;
|
|
use App\Filament\Widgets\Dashboard\NeedsAttention as TenantNeedsAttention;
|
|
use App\Filament\Widgets\Workspace\WorkspaceNeedsAttention;
|
|
use App\Models\AlertDelivery;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
|
use App\Support\Workspaces\WorkspaceOverviewBuilder;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Carbon\CarbonImmutable;
|
|
use Filament\Facades\Filament;
|
|
use Livewire\Livewire;
|
|
|
|
afterEach(function (): void {
|
|
CarbonImmutable::setTestNow();
|
|
});
|
|
|
|
it('preserves canonical findings, compare, alerts, and operations drill-through continuity from the workspace overview', function (): void {
|
|
$tenantDashboard = Tenant::factory()->create(['status' => 'active']);
|
|
[$user, $tenantDashboard] = createUserWithTenant($tenantDashboard, role: 'owner', workspaceRole: 'readonly');
|
|
[$dashboardProfile, $dashboardSnapshot] = seedActiveBaselineForTenant($tenantDashboard);
|
|
seedBaselineCompareRun($tenantDashboard, $dashboardProfile, $dashboardSnapshot, workspaceOverviewCompareCoverage());
|
|
$tenantDashboardBackup = workspaceOverviewSeedHealthyBackup($tenantDashboard, [
|
|
'completed_at' => now()->subMinutes(15),
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($tenantDashboard, $tenantDashboardBackup, 'completed');
|
|
|
|
Finding::factory()->riskAccepted()->create([
|
|
'workspace_id' => (int) $tenantDashboard->workspace_id,
|
|
'tenant_id' => (int) $tenantDashboard->getKey(),
|
|
]);
|
|
|
|
$tenantFindings = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $tenantDashboard->workspace_id,
|
|
]);
|
|
createUserWithTenant($tenantFindings, $user, role: 'owner', workspaceRole: 'readonly');
|
|
[$findingsProfile, $findingsSnapshot] = seedActiveBaselineForTenant($tenantFindings);
|
|
seedBaselineCompareRun($tenantFindings, $findingsProfile, $findingsSnapshot, workspaceOverviewCompareCoverage());
|
|
$tenantFindingsBackup = workspaceOverviewSeedHealthyBackup($tenantFindings, [
|
|
'completed_at' => now()->subMinutes(14),
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($tenantFindings, $tenantFindingsBackup, 'completed');
|
|
|
|
Finding::factory()->for($tenantFindings)->create([
|
|
'workspace_id' => (int) $tenantFindings->workspace_id,
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
'due_at' => now()->subDay(),
|
|
]);
|
|
|
|
$tenantCompare = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $tenantDashboard->workspace_id,
|
|
'name' => 'Compare Tenant',
|
|
]);
|
|
createUserWithTenant($tenantCompare, $user, role: 'owner', workspaceRole: 'readonly');
|
|
[$compareProfile, $compareSnapshot] = seedActiveBaselineForTenant($tenantCompare);
|
|
seedBaselineCompareRun(
|
|
$tenantCompare,
|
|
$compareProfile,
|
|
$compareSnapshot,
|
|
workspaceOverviewCompareCoverage(),
|
|
completedAt: now()->subDays(10),
|
|
);
|
|
$tenantCompareBackup = workspaceOverviewSeedHealthyBackup($tenantCompare, [
|
|
'completed_at' => now()->subMinutes(13),
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($tenantCompare, $tenantCompareBackup, 'completed');
|
|
|
|
$tenantOperations = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $tenantDashboard->workspace_id,
|
|
'name' => 'Operations Tenant',
|
|
]);
|
|
createUserWithTenant($tenantOperations, $user, role: 'owner', workspaceRole: 'readonly');
|
|
[$operationsProfile, $operationsSnapshot] = seedActiveBaselineForTenant($tenantOperations);
|
|
seedBaselineCompareRun($tenantOperations, $operationsProfile, $operationsSnapshot, workspaceOverviewCompareCoverage());
|
|
$tenantOperationsBackup = workspaceOverviewSeedHealthyBackup($tenantOperations, [
|
|
'completed_at' => now()->subMinutes(12),
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($tenantOperations, $tenantOperationsBackup, 'completed');
|
|
|
|
OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenantOperations->getKey(),
|
|
'workspace_id' => (int) $tenantOperations->workspace_id,
|
|
'type' => OperationRunType::PolicySync->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Failed->value,
|
|
]);
|
|
|
|
$tenantAlerts = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $tenantDashboard->workspace_id,
|
|
'name' => 'Alerts Tenant',
|
|
]);
|
|
createUserWithTenant($tenantAlerts, $user, role: 'owner', workspaceRole: 'readonly');
|
|
[$alertsProfile, $alertsSnapshot] = seedActiveBaselineForTenant($tenantAlerts);
|
|
seedBaselineCompareRun($tenantAlerts, $alertsProfile, $alertsSnapshot, workspaceOverviewCompareCoverage());
|
|
$tenantAlertsBackup = workspaceOverviewSeedHealthyBackup($tenantAlerts, [
|
|
'completed_at' => now()->subMinutes(11),
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($tenantAlerts, $tenantAlertsBackup, 'completed');
|
|
|
|
AlertDelivery::factory()->create([
|
|
'tenant_id' => (int) $tenantAlerts->getKey(),
|
|
'workspace_id' => (int) $tenantAlerts->workspace_id,
|
|
'status' => AlertDelivery::STATUS_FAILED,
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
$workspace = $tenantDashboard->workspace()->firstOrFail();
|
|
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
|
|
$items = collect($overview['attention_items'])->keyBy('key');
|
|
|
|
expect($items->get('tenant_lapsed_governance')['destination']['kind'])->toBe('tenant_dashboard')
|
|
->and($items->get('tenant_overdue_findings')['destination']['kind'])->toBe('tenant_findings')
|
|
->and($items->get('tenant_overdue_findings')['destination']['url'])->toContain('tab=overdue')
|
|
->and($items->get('tenant_compare_attention')['destination']['kind'])->toBe('baseline_compare_landing')
|
|
->and($items->get('tenant_operations_terminal_follow_up')['destination']['kind'])->toBe('operations_index')
|
|
->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('activeTab=terminal_follow_up')
|
|
->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('problemClass=terminal_follow_up')
|
|
->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('tenant_id='.(string) $tenantOperations->getKey())
|
|
->and($items->get('tenant_alert_delivery_failures')['destination']['kind'])->toBe('alerts_overview')
|
|
->and($items->get('tenant_alert_delivery_failures')['destination']['url'])->toContain('nav%5Bback_url%5D=');
|
|
});
|
|
|
|
it('renders evidence and review actions through the shared attention-item contract', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'readonly');
|
|
$this->actingAs($user);
|
|
|
|
$evidenceUrl = EvidenceSnapshotResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
$reviewUrl = TenantReviewResource::tenantScopedUrl('index', [], $tenant);
|
|
|
|
$items = [
|
|
[
|
|
'key' => 'tenant_evidence_attention',
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'tenant_label' => (string) $tenant->name,
|
|
'tenant_route_key' => (string) $tenant->external_id,
|
|
'family' => 'evidence',
|
|
'urgency' => 'high',
|
|
'title' => 'Evidence needs refresh',
|
|
'body' => 'Current evidence should be refreshed before relying on it.',
|
|
'supporting_message' => null,
|
|
'badge' => 'Evidence',
|
|
'badge_color' => 'warning',
|
|
'destination' => [
|
|
'kind' => 'tenant_evidence',
|
|
'url' => $evidenceUrl,
|
|
'tenant_route_key' => (string) $tenant->external_id,
|
|
'label' => 'Open evidence',
|
|
'disabled' => false,
|
|
'helper_text' => null,
|
|
'filters' => null,
|
|
],
|
|
'action_disabled' => false,
|
|
'helper_text' => null,
|
|
'url' => $evidenceUrl,
|
|
],
|
|
[
|
|
'key' => 'tenant_review_attention',
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'tenant_label' => (string) $tenant->name,
|
|
'tenant_route_key' => (string) $tenant->external_id,
|
|
'family' => 'review',
|
|
'urgency' => 'medium',
|
|
'title' => 'Review needs publication work',
|
|
'body' => 'The current review is still internal-only.',
|
|
'supporting_message' => null,
|
|
'badge' => 'Review',
|
|
'badge_color' => 'warning',
|
|
'destination' => [
|
|
'kind' => 'tenant_reviews',
|
|
'url' => $reviewUrl,
|
|
'tenant_route_key' => (string) $tenant->external_id,
|
|
'label' => 'Open review',
|
|
'disabled' => false,
|
|
'helper_text' => null,
|
|
'filters' => null,
|
|
],
|
|
'action_disabled' => false,
|
|
'helper_text' => null,
|
|
'url' => $reviewUrl,
|
|
],
|
|
];
|
|
|
|
$component = Livewire::test(WorkspaceNeedsAttention::class, [
|
|
'items' => $items,
|
|
'emptyState' => [],
|
|
])
|
|
->assertSee('Evidence needs refresh')
|
|
->assertSee('Open evidence')
|
|
->assertSee('Review needs publication work')
|
|
->assertSee('Open review');
|
|
|
|
expect($component->html())
|
|
->toContain($evidenceUrl)
|
|
->toContain($reviewUrl);
|
|
});
|
|
|
|
it('hydrates filtered tenant-registry triage state from multi-tenant workspace backup and recovery metrics', function (): void {
|
|
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
|
|
|
|
$absentTenant = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'name' => 'Absent Backup Tenant',
|
|
]);
|
|
[$user, $absentTenant] = createUserWithTenant($absentTenant, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($absentTenant);
|
|
|
|
$staleTenant = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $absentTenant->workspace_id,
|
|
'name' => 'Stale Backup Tenant',
|
|
]);
|
|
createUserWithTenant($staleTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($staleTenant);
|
|
$staleBackup = workspaceOverviewSeedHealthyBackup($staleTenant, [
|
|
'completed_at' => now()->subDays(2),
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($staleTenant, $staleBackup, 'completed');
|
|
|
|
$degradedTenant = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $absentTenant->workspace_id,
|
|
'name' => 'Degraded Backup Tenant',
|
|
]);
|
|
createUserWithTenant($degradedTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($degradedTenant);
|
|
$degradedBackup = workspaceOverviewSeedHealthyBackup($degradedTenant, [
|
|
'completed_at' => now()->subMinutes(20),
|
|
], [
|
|
'payload' => [],
|
|
'metadata' => [
|
|
'source' => 'metadata_only',
|
|
'assignments_fetch_failed' => true,
|
|
],
|
|
'assignments' => [],
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($degradedTenant, $degradedBackup, 'completed');
|
|
|
|
$weakenedTenant = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $absentTenant->workspace_id,
|
|
'name' => 'Weakened Recovery Tenant',
|
|
]);
|
|
createUserWithTenant($weakenedTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($weakenedTenant);
|
|
$weakenedBackup = workspaceOverviewSeedHealthyBackup($weakenedTenant, [
|
|
'completed_at' => now()->subMinutes(18),
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($weakenedTenant, $weakenedBackup, 'follow_up');
|
|
|
|
$unvalidatedTenant = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $absentTenant->workspace_id,
|
|
'name' => 'Unvalidated Recovery Tenant',
|
|
]);
|
|
createUserWithTenant($unvalidatedTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($unvalidatedTenant);
|
|
workspaceOverviewSeedHealthyBackup($unvalidatedTenant, [
|
|
'completed_at' => now()->subMinutes(16),
|
|
]);
|
|
|
|
$calmTenant = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $absentTenant->workspace_id,
|
|
'name' => 'Calm Tenant',
|
|
]);
|
|
createUserWithTenant($calmTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($calmTenant);
|
|
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
|
|
'completed_at' => now()->subMinutes(14),
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
|
|
|
|
$workspace = $absentTenant->workspace()->firstOrFail();
|
|
$metrics = collect(app(WorkspaceOverviewBuilder::class)->build($workspace, $user)['summary_metrics'])->keyBy('key');
|
|
|
|
$this->actingAs($user);
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
Filament::setTenant(null, true);
|
|
|
|
parse_str((string) parse_url((string) $metrics->get('backup_attention_tenants')['destination_url'], PHP_URL_QUERY), $backupQuery);
|
|
|
|
$backupRegistry = Livewire::withQueryParams($backupQuery)
|
|
->actingAs($user)
|
|
->test(ListTenants::class)
|
|
->assertSet('tableFilters.backup_posture.values', [
|
|
'absent',
|
|
'stale',
|
|
'degraded',
|
|
])
|
|
->assertSet('tableFilters.triage_sort.value', TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
|
|
|
|
expect($backupRegistry->instance()->getFilteredSortedTableQuery()?->pluck('tenants.name')->all())
|
|
->toBe([
|
|
'Absent Backup Tenant',
|
|
'Stale Backup Tenant',
|
|
'Degraded Backup Tenant',
|
|
]);
|
|
|
|
parse_str((string) parse_url((string) $metrics->get('recovery_attention_tenants')['destination_url'], PHP_URL_QUERY), $recoveryQuery);
|
|
|
|
$recoveryRegistry = Livewire::withQueryParams($recoveryQuery)
|
|
->actingAs($user)
|
|
->test(ListTenants::class)
|
|
->assertSet('tableFilters.recovery_evidence.values', [
|
|
'weakened',
|
|
'unvalidated',
|
|
])
|
|
->assertSet('tableFilters.triage_sort.value', TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
|
|
|
|
expect($recoveryRegistry->instance()->getFilteredSortedTableQuery()?->pluck('tenants.name')->all())
|
|
->toBe([
|
|
'Absent Backup Tenant',
|
|
'Weakened Recovery Tenant',
|
|
'Unvalidated Recovery Tenant',
|
|
]);
|
|
});
|
|
|
|
it('routes backup and recovery workspace attention into tenant dashboards that still show the same weakness', function (): void {
|
|
$backupTenant = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'name' => 'Backup Weak Tenant',
|
|
]);
|
|
[$user, $backupTenant] = createUserWithTenant($backupTenant, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($backupTenant);
|
|
|
|
$recoveryTenant = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $backupTenant->workspace_id,
|
|
'name' => 'Recovery Weak Tenant',
|
|
]);
|
|
createUserWithTenant($recoveryTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($recoveryTenant);
|
|
$recoveryBackup = workspaceOverviewSeedHealthyBackup($recoveryTenant, [
|
|
'completed_at' => now()->subMinutes(20),
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($recoveryTenant, $recoveryBackup, 'follow_up');
|
|
|
|
$workspace = $backupTenant->workspace()->firstOrFail();
|
|
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
|
|
$items = collect($overview['attention_items'])->keyBy('key');
|
|
|
|
$backupDestination = $items->get('tenant_backup_absent')['destination'];
|
|
$recoveryDestination = $items->get('tenant_recovery_weakened')['destination'];
|
|
|
|
expect($backupDestination['kind'])->toBe('tenant_dashboard')
|
|
->and($recoveryDestination['kind'])->toBe('tenant_dashboard');
|
|
|
|
$this->actingAs($user);
|
|
|
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
|
Filament::setTenant($backupTenant, true);
|
|
Livewire::test(TenantNeedsAttention::class)
|
|
->assertSee('No usable backup basis');
|
|
|
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
|
Filament::setTenant($recoveryTenant, true);
|
|
Livewire::test(TenantNeedsAttention::class)
|
|
->assertSee('Recent restore needs follow-up');
|
|
});
|