## 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
398 lines
16 KiB
PHP
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);
|
|
});
|