TenantAtlas/apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php
ahmido 9fbd3e5ec7 Spec 186: implement tenant registry recovery triage (#217)
## 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
2026-04-09 19:20:48 +00:00

398 lines
16 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Support\BackupHealth\BackupFreshnessEvaluation;
use App\Support\BackupHealth\BackupScheduleFollowUpEvaluation;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use function Pest\Laravel\mock;
uses(RefreshDatabase::class);
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function tenantRegistryBaseContext(string $anchorName = 'Anchor Tenant'): array
{
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => $anchorName,
]);
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
workspaceRole: 'readonly',
);
return [$user, $tenant];
}
function tenantRegistryPeer(User $user, Tenant $workspaceTenant, string $name): Tenant
{
$tenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $workspaceTenant->workspace_id,
'name' => $name,
]);
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'owner',
workspaceRole: 'readonly',
);
return $tenant;
}
function tenantRegistryList(Tenant $workspaceTenant, User $user, array $query = [])
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceTenant->workspace_id);
Filament::setTenant(null, true);
request()->attributes->remove('tenant_resource.posture_snapshot');
session()->forget('tables.'.md5(ListTenants::class).'_filters');
session()->forget('tables.'.md5(ListTenants::class).'_search');
session()->forget('tables.'.md5(ListTenants::class).'_sort');
$factory = $query !== []
? Livewire::withQueryParams($query)->actingAs($user)
: Livewire::actingAs($user);
return $factory->test(ListTenants::class);
}
function tenantRegistryBackupAssessment(
int $tenantId,
string $posture,
?string $reason = null,
?string $supportingMessage = null,
): TenantBackupHealthAssessment {
return new TenantBackupHealthAssessment(
tenantId: $tenantId,
posture: $posture,
primaryReason: $reason,
headline: str($posture)->headline()->toString(),
supportingMessage: $supportingMessage,
latestRelevantBackupSetId: null,
latestRelevantCompletedAt: now()->subMinutes(10),
qualitySummary: null,
freshnessEvaluation: new BackupFreshnessEvaluation(
latestCompletedAt: now()->subMinutes(10),
cutoffAt: now()->subHour(),
isFresh: true,
),
scheduleFollowUp: new BackupScheduleFollowUpEvaluation(
hasEnabledSchedules: false,
enabledScheduleCount: 0,
overdueScheduleCount: 0,
failedRecentRunCount: 0,
neverSuccessfulCount: 0,
needsFollowUp: false,
primaryScheduleId: null,
summaryMessage: null,
),
healthyClaimAllowed: $posture === TenantBackupHealthAssessment::POSTURE_HEALTHY,
primaryActionTarget: null,
positiveClaimBoundary: 'Recent backup history does not prove tenant recovery.',
);
}
function tenantRegistryRecoveryEvidence(
string $overviewState,
string $summary = 'Bounded recovery evidence summary.',
string $reason = 'no_recent_issues_visible',
): array {
return [
'overview_state' => $overviewState,
'summary' => $summary,
'claim_boundary' => 'Tenant-wide recovery is not proven.',
'reason' => $reason,
'latest_relevant_restore_run' => null,
'latest_relevant_attention' => null,
'latest_relevant_attention_state' => null,
];
}
it('shows separate backup posture and recovery evidence signals without turning metadata into recovery truth', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
[$user, $absentTenant] = tenantRegistryBaseContext('Absent Backup Tenant');
workspaceOverviewSeedQuietTenantTruth($absentTenant);
$weakenedTenant = tenantRegistryPeer($user, $absentTenant, 'Weakened Recovery Tenant');
workspaceOverviewSeedQuietTenantTruth($weakenedTenant);
$weakenedBackup = workspaceOverviewSeedHealthyBackup($weakenedTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($weakenedTenant, $weakenedBackup, 'follow_up');
$metadataTenant = tenantRegistryPeer($user, $absentTenant, 'Metadata Drift Tenant');
workspaceOverviewSeedQuietTenantTruth($metadataTenant);
$metadataBackup = workspaceOverviewSeedHealthyBackup($metadataTenant, [
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($metadataTenant, $metadataBackup, 'completed');
Policy::factory()->for($metadataTenant)->create([
'display_name' => 'Stale sync policy',
'last_synced_at' => now()->subDays(14),
]);
$component = tenantRegistryList($absentTenant, $user)
->assertTableColumnExists('backup_posture')
->assertTableColumnExists('recovery_evidence')
->assertTableColumnVisible('backup_posture')
->assertTableColumnVisible('recovery_evidence')
->assertTableColumnFormattedStateSet('backup_posture', 'Absent', $absentTenant)
->assertTableColumnFormattedStateSet('recovery_evidence', 'Weakened', $weakenedTenant)
->assertTableColumnFormattedStateSet('backup_posture', 'Healthy', $metadataTenant)
->assertTableColumnFormattedStateSet('recovery_evidence', 'No recent issues visible', $metadataTenant)
->assertTableActionVisible('openTenant', $weakenedTenant)
->assertTableActionHasUrl('openTenant', TenantDashboard::getUrl(panel: 'tenant', tenant: $weakenedTenant), $weakenedTenant)
->assertDontSee('recoverable')
->assertDontSee('recovery proven')
->assertDontSee('validated overall');
expect($component->instance()->getTable()->getRecordUrl($weakenedTenant))
->toBe(TenantResource::getUrl('view', ['record' => $weakenedTenant], panel: 'admin'));
});
it('filters the registry to exact backup and recovery posture slices', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
[$user, $calmTenant] = tenantRegistryBaseContext('Calm Tenant');
workspaceOverviewSeedQuietTenantTruth($calmTenant);
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
'completed_at' => now()->subMinutes(10),
]);
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
$degradedTenant = tenantRegistryPeer($user, $calmTenant, 'Degraded Backup Tenant');
workspaceOverviewSeedQuietTenantTruth($degradedTenant);
$degradedBackup = workspaceOverviewSeedHealthyBackup($degradedTenant, [
'completed_at' => now()->subMinutes(12),
'item_count' => 2,
], [
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
workspaceOverviewSeedRestoreHistory($degradedTenant, $degradedBackup, 'completed');
$unvalidatedTenant = tenantRegistryPeer($user, $calmTenant, 'Unvalidated Recovery Tenant');
workspaceOverviewSeedQuietTenantTruth($unvalidatedTenant);
workspaceOverviewSeedHealthyBackup($unvalidatedTenant, [
'completed_at' => now()->subMinutes(11),
]);
tenantRegistryList($calmTenant, $user)
->assertTableColumnFormattedStateSet('recovery_evidence', 'Unvalidated', $unvalidatedTenant);
$tenantResourceReflection = new ReflectionClass(TenantResource::class);
$postureSnapshot = $tenantResourceReflection->getMethod('postureSnapshot');
$postureSnapshot->setAccessible(true);
expect($postureSnapshot->invoke(null)['recovery_evidence_ids']['unvalidated'] ?? [])
->toBe([(int) $unvalidatedTenant->getKey()]);
$backupFiltered = tenantRegistryList($calmTenant, $user)
->filterTable('backup_posture', [TenantBackupHealthAssessment::POSTURE_DEGRADED]);
expect($backupFiltered->instance()->getFilteredTableQuery()?->pluck('tenants.name')->all())
->toBe(['Degraded Backup Tenant']);
$recoveryFiltered = tenantRegistryList($calmTenant, $user)
->filterTable('recovery_evidence', ['unvalidated'])
->assertSet('tableFilters.recovery_evidence.values', ['unvalidated']);
expect($recoveryFiltered->instance()->getFilteredTableQuery()?->pluck('tenants.name')->all())
->toBe(['Unvalidated Recovery Tenant']);
});
it('orders the visible tenant registry worst-first with stable tenant-name tie breaks when triage sorting is requested', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
[$user, $absentTenant] = tenantRegistryBaseContext('Absent Backup Tenant');
workspaceOverviewSeedQuietTenantTruth($absentTenant);
$alphaWeakenedTenant = tenantRegistryPeer($user, $absentTenant, 'Alpha Weakened Tenant');
workspaceOverviewSeedQuietTenantTruth($alphaWeakenedTenant);
$alphaWeakenedBackup = workspaceOverviewSeedHealthyBackup($alphaWeakenedTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($alphaWeakenedTenant, $alphaWeakenedBackup, 'follow_up');
$zetaWeakenedTenant = tenantRegistryPeer($user, $absentTenant, 'Zeta Weakened Tenant');
workspaceOverviewSeedQuietTenantTruth($zetaWeakenedTenant);
$zetaWeakenedBackup = workspaceOverviewSeedHealthyBackup($zetaWeakenedTenant, [
'completed_at' => now()->subMinutes(21),
]);
workspaceOverviewSeedRestoreHistory($zetaWeakenedTenant, $zetaWeakenedBackup, 'failed');
$staleTenant = tenantRegistryPeer($user, $absentTenant, 'Stale Backup Tenant');
workspaceOverviewSeedQuietTenantTruth($staleTenant);
$staleBackup = workspaceOverviewSeedHealthyBackup($staleTenant, [
'completed_at' => now()->subDays(2),
]);
workspaceOverviewSeedRestoreHistory($staleTenant, $staleBackup, 'completed');
$unvalidatedTenant = tenantRegistryPeer($user, $absentTenant, 'Unvalidated Recovery Tenant');
workspaceOverviewSeedQuietTenantTruth($unvalidatedTenant);
workspaceOverviewSeedHealthyBackup($unvalidatedTenant, [
'completed_at' => now()->subMinutes(18),
]);
$degradedTenant = tenantRegistryPeer($user, $absentTenant, 'Degraded Backup Tenant');
workspaceOverviewSeedQuietTenantTruth($degradedTenant);
$degradedBackup = workspaceOverviewSeedHealthyBackup($degradedTenant, [
'completed_at' => now()->subMinutes(17),
'item_count' => 2,
], [
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
workspaceOverviewSeedRestoreHistory($degradedTenant, $degradedBackup, 'completed');
$calmTenant = tenantRegistryPeer($user, $absentTenant, 'Calm Tenant');
workspaceOverviewSeedQuietTenantTruth($calmTenant);
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
tenantRegistryList($absentTenant, $user, [
'triage_sort' => 'worst_first',
])
->assertSet('tableFilters.triage_sort.value', 'worst_first')
->assertCanSeeTableRecords([
$absentTenant,
$alphaWeakenedTenant,
$zetaWeakenedTenant,
$staleTenant,
$unvalidatedTenant,
$degradedTenant,
$calmTenant,
], inOrder: true);
});
it('loads backup posture and recovery evidence with one batch per registry render instead of per-row fanout', function (): void {
[$user, $firstTenant] = tenantRegistryBaseContext('Batch Tenant Alpha');
$secondTenant = tenantRegistryPeer($user, $firstTenant, 'Batch Tenant Beta');
$backupAssessments = [
(int) $firstTenant->getKey() => tenantRegistryBackupAssessment(
tenantId: (int) $firstTenant->getKey(),
posture: TenantBackupHealthAssessment::POSTURE_STALE,
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
supportingMessage: 'The latest backup is outside the configured freshness window.',
),
(int) $secondTenant->getKey() => tenantRegistryBackupAssessment(
tenantId: (int) $secondTenant->getKey(),
posture: TenantBackupHealthAssessment::POSTURE_HEALTHY,
),
];
$expectedTenantIds = [
(int) $firstTenant->getKey(),
(int) $secondTenant->getKey(),
];
$backupResolver = new class($expectedTenantIds, $backupAssessments)
{
public int $assessManyCalls = 0;
/**
* @param list<int> $expectedTenantIds
* @param array<int, TenantBackupHealthAssessment> $assessments
*/
public function __construct(
private array $expectedTenantIds,
private array $assessments,
) {}
/**
* @return array<int, TenantBackupHealthAssessment>
*/
public function assessMany(iterable $tenantIds): array
{
$this->assessManyCalls++;
expect(array_values(is_array($tenantIds) ? $tenantIds : iterator_to_array($tenantIds, false)))
->toBe($this->expectedTenantIds);
return $this->assessments;
}
};
$restoreSafetyResolver = new class($expectedTenantIds, $backupAssessments)
{
public int $dashboardEvidenceCalls = 0;
/**
* @param list<int> $expectedTenantIds
* @param array<int, TenantBackupHealthAssessment> $expectedAssessments
*/
public function __construct(
private array $expectedTenantIds,
private array $expectedAssessments,
) {}
/**
* @param array<int, TenantBackupHealthAssessment> $resolvedAssessments
* @return array<int, array<string, mixed>>
*/
public function dashboardRecoveryEvidenceForTenants(array $tenantIds, array $resolvedAssessments): array
{
$this->dashboardEvidenceCalls++;
expect($tenantIds)->toBe($this->expectedTenantIds)
->and($resolvedAssessments)->toBe($this->expectedAssessments);
return [
$tenantIds[0] => tenantRegistryRecoveryEvidence(
overviewState: 'unvalidated',
summary: 'No recent restore history is available for this tenant.',
reason: 'no_history',
),
$tenantIds[1] => tenantRegistryRecoveryEvidence(
overviewState: 'no_recent_issues_visible',
),
];
}
};
app()->instance(TenantBackupHealthResolver::class, $backupResolver);
app()->instance(RestoreSafetyResolver::class, $restoreSafetyResolver);
tenantRegistryList($firstTenant, $user)
->assertCanSeeTableRecords([$firstTenant, $secondTenant])
->assertTableColumnFormattedStateSet('backup_posture', 'Stale', $firstTenant)
->assertTableColumnFormattedStateSet('recovery_evidence', 'No recent issues visible', $secondTenant);
expect($backupResolver->assessManyCalls)->toBe(1)
->and($restoreSafetyResolver->dashboardEvidenceCalls)->toBe(1);
});