## Summary - add Spec 185 workspace recovery posture visibility artifacts under `specs/185-workspace-recovery-posture-visibility` - promote tenant backup health and recovery evidence onto the workspace overview with separate metrics, attention ordering, calmness coverage, and tenant-dashboard drill-throughs - batch visible-tenant backup/recovery derivation to keep the workspace overview query-bounded - align follow-up fixes from the authoritative suite rerun, including dashboard truth-alignment fixtures, canonical backup schedule tenant context, guard-path cleanup, smoke-fixture credential removal, and robust theme asset manifest handling ## Testing - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Filament/PanelThemeAssetTest.php tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` - focused regression pack for the previously failing cases passed - full suite JUnit run passed: `3401` tests, `18849` assertions, `0` failures, `0` errors, `8` skips ## Notes - no new schema or persisted workspace recovery model - no provider-registration changes; Filament/Livewire stack remains on Filament v5 and Livewire v4 - no new destructive actions or global search changes Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #216
211 lines
9.8 KiB
PHP
211 lines
9.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AlertDelivery;
|
|
use App\Models\Finding;
|
|
use App\Models\FindingException;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Support\Workspaces\WorkspaceOverviewBuilder;
|
|
use Carbon\CarbonImmutable;
|
|
|
|
afterEach(function (): void {
|
|
CarbonImmutable::setTestNow();
|
|
});
|
|
|
|
it('counts governance attention by affected tenant instead of raw issue totals', function (): void {
|
|
$tenantOverdue = Tenant::factory()->create(['status' => 'active']);
|
|
[$user, $tenantOverdue] = createUserWithTenant($tenantOverdue, role: 'owner', workspaceRole: 'readonly');
|
|
[$overdueProfile, $overdueSnapshot] = seedActiveBaselineForTenant($tenantOverdue);
|
|
seedBaselineCompareRun($tenantOverdue, $overdueProfile, $overdueSnapshot, workspaceOverviewCompareCoverage());
|
|
|
|
Finding::factory()->count(3)->for($tenantOverdue)->create([
|
|
'workspace_id' => (int) $tenantOverdue->workspace_id,
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
'due_at' => now()->subDay(),
|
|
]);
|
|
|
|
$tenantExpiring = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $tenantOverdue->workspace_id,
|
|
'name' => 'Expiring Tenant',
|
|
]);
|
|
createUserWithTenant($tenantExpiring, $user, role: 'owner', workspaceRole: 'readonly');
|
|
[$expiringProfile, $expiringSnapshot] = seedActiveBaselineForTenant($tenantExpiring);
|
|
seedBaselineCompareRun($tenantExpiring, $expiringProfile, $expiringSnapshot, workspaceOverviewCompareCoverage());
|
|
|
|
$finding = Finding::factory()->riskAccepted()->create([
|
|
'workspace_id' => (int) $tenantExpiring->workspace_id,
|
|
'tenant_id' => (int) $tenantExpiring->getKey(),
|
|
]);
|
|
|
|
FindingException::query()->create([
|
|
'workspace_id' => (int) $tenantExpiring->workspace_id,
|
|
'tenant_id' => (int) $tenantExpiring->getKey(),
|
|
'finding_id' => (int) $finding->getKey(),
|
|
'requested_by_user_id' => (int) $user->getKey(),
|
|
'owner_user_id' => (int) $user->getKey(),
|
|
'approved_by_user_id' => (int) $user->getKey(),
|
|
'status' => FindingException::STATUS_EXPIRING,
|
|
'current_validity_state' => FindingException::VALIDITY_EXPIRING,
|
|
'request_reason' => 'Pending governance review',
|
|
'approval_reason' => 'Short lived exception',
|
|
'requested_at' => now()->subDays(2),
|
|
'approved_at' => now()->subDay(),
|
|
'effective_from' => now()->subDay(),
|
|
'expires_at' => now()->addDay(),
|
|
'review_due_at' => now()->addDay(),
|
|
'evidence_summary' => ['reference_count' => 0],
|
|
]);
|
|
|
|
$tenantStale = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $tenantOverdue->workspace_id,
|
|
'name' => 'Stale Tenant',
|
|
]);
|
|
createUserWithTenant($tenantStale, $user, role: 'owner', workspaceRole: 'readonly');
|
|
[$staleProfile, $staleSnapshot] = seedActiveBaselineForTenant($tenantStale);
|
|
seedBaselineCompareRun(
|
|
$tenantStale,
|
|
$staleProfile,
|
|
$staleSnapshot,
|
|
workspaceOverviewCompareCoverage(),
|
|
completedAt: now()->subDays(10),
|
|
);
|
|
|
|
$tenantFailedCompare = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $tenantOverdue->workspace_id,
|
|
'name' => 'Failed Compare Tenant',
|
|
]);
|
|
createUserWithTenant($tenantFailedCompare, $user, role: 'owner', workspaceRole: 'readonly');
|
|
[$failedProfile, $failedSnapshot] = seedActiveBaselineForTenant($tenantFailedCompare);
|
|
seedBaselineCompareRun(
|
|
$tenantFailedCompare,
|
|
$failedProfile,
|
|
$failedSnapshot,
|
|
workspaceOverviewCompareCoverage(),
|
|
outcome: \App\Support\OperationRunOutcome::Failed->value,
|
|
);
|
|
|
|
$workspace = $tenantOverdue->workspace()->firstOrFail();
|
|
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
|
|
$metrics = collect($overview['summary_metrics'])->keyBy('key');
|
|
|
|
expect($metrics->get('governance_attention_tenants')['value'])->toBe(4)
|
|
->and($metrics->get('governance_attention_tenants')['category'])->toBe('governance_risk')
|
|
->and($metrics->get('governance_attention_tenants')['destination']['kind'])->toBe('choose_tenant');
|
|
});
|
|
|
|
it('keeps activity and alerts metrics separate from governance risk', function (): void {
|
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'readonly');
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
|
|
seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage());
|
|
|
|
OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'status' => \App\Support\OperationRunStatus::Running->value,
|
|
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
|
|
]);
|
|
|
|
AlertDelivery::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'status' => AlertDelivery::STATUS_FAILED,
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
$workspace = $tenant->workspace()->firstOrFail();
|
|
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
|
|
$metrics = collect($overview['summary_metrics'])->keyBy('key');
|
|
|
|
expect($metrics->get('governance_attention_tenants')['value'])->toBe(0)
|
|
->and($metrics->get('active_operations')['value'])->toBe(1)
|
|
->and($metrics->get('active_operations')['category'])->toBe('activity')
|
|
->and($metrics->get('active_operations')['destination']['kind'])->toBe('operations_index')
|
|
->and($metrics->get('alert_failures')['value'])->toBe(1)
|
|
->and($metrics->get('alert_failures')['category'])->toBe('alerts');
|
|
});
|
|
|
|
it('counts backup and recovery attention tenants separately and chooses precise or fallback destinations', function (): void {
|
|
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
|
|
|
|
$singleRecoveryTenant = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'name' => 'Single Recovery Tenant',
|
|
]);
|
|
[$user, $singleRecoveryTenant] = createUserWithTenant($singleRecoveryTenant, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($singleRecoveryTenant);
|
|
$singleRecoveryBackup = workspaceOverviewSeedHealthyBackup($singleRecoveryTenant, [
|
|
'completed_at' => now()->subMinutes(20),
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($singleRecoveryTenant, $singleRecoveryBackup, 'follow_up');
|
|
|
|
$backupTenantA = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $singleRecoveryTenant->workspace_id,
|
|
'name' => 'Backup Tenant A',
|
|
]);
|
|
createUserWithTenant($backupTenantA, $user, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($backupTenantA);
|
|
|
|
$backupTenantB = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $singleRecoveryTenant->workspace_id,
|
|
'name' => 'Backup Tenant B',
|
|
]);
|
|
createUserWithTenant($backupTenantB, $user, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($backupTenantB);
|
|
workspaceOverviewSeedHealthyBackup($backupTenantB, [
|
|
'completed_at' => now()->subDays(2),
|
|
]);
|
|
|
|
$calmTenant = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $singleRecoveryTenant->workspace_id,
|
|
'name' => 'Calm Tenant',
|
|
]);
|
|
createUserWithTenant($calmTenant, $user, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($calmTenant);
|
|
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
|
|
'completed_at' => now()->subMinutes(15),
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
|
|
|
|
$workspace = $singleRecoveryTenant->workspace()->firstOrFail();
|
|
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
|
|
$metrics = collect($overview['summary_metrics'])->keyBy('key');
|
|
|
|
expect($metrics->get('backup_attention_tenants')['value'])->toBe(2)
|
|
->and($metrics->get('backup_attention_tenants')['category'])->toBe('backup_health')
|
|
->and($metrics->get('backup_attention_tenants')['destination']['kind'])->toBe('choose_tenant')
|
|
->and($metrics->get('recovery_attention_tenants')['value'])->toBe(1)
|
|
->and($metrics->get('recovery_attention_tenants')['category'])->toBe('recovery_evidence')
|
|
->and($metrics->get('recovery_attention_tenants')['destination']['kind'])->toBe('tenant_dashboard')
|
|
->and($metrics->get('recovery_attention_tenants')['destination']['tenant_route_key'])->toBe((string) $singleRecoveryTenant->external_id)
|
|
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toContain('/admin/t/');
|
|
});
|
|
|
|
it('keeps backup and recovery attention counts at zero for calm visible tenants', function (): void {
|
|
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
|
|
|
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'readonly');
|
|
workspaceOverviewSeedQuietTenantTruth($tenant);
|
|
$backupSet = workspaceOverviewSeedHealthyBackup($tenant, [
|
|
'completed_at' => now()->subMinutes(10),
|
|
]);
|
|
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
|
|
|
|
$workspace = $tenant->workspace()->firstOrFail();
|
|
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
|
|
$metrics = collect($overview['summary_metrics'])->keyBy('key');
|
|
|
|
expect($metrics->get('backup_attention_tenants')['value'])->toBe(0)
|
|
->and($metrics->get('recovery_attention_tenants')['value'])->toBe(0)
|
|
->and($overview['calmness']['checked_domains'])->toContain('backup_health', 'recovery_evidence');
|
|
});
|