254 lines
12 KiB
PHP
254 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Pages\TenantDashboard;
|
|
use App\Filament\Resources\BackupSetResource;
|
|
use App\Filament\Resources\RestoreRunResource;
|
|
use App\Filament\Resources\TenantResource;
|
|
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
|
|
use App\Models\AuditLog;
|
|
use App\Models\TenantTriageReview;
|
|
use Filament\Actions\Action;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
|
use App\Support\Rbac\UiTooltips;
|
|
use App\Support\RestoreSafety\RestoreResultAttention;
|
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Livewire\Livewire;
|
|
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
|
|
|
use function Pest\Laravel\mock;
|
|
|
|
uses(BuildsPortfolioTriageFixtures::class);
|
|
|
|
function tenantDashboardArrivalUrl(\App\Models\Tenant $tenant, array $state): string
|
|
{
|
|
return TenantDashboard::getUrl([
|
|
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($state),
|
|
], panel: 'tenant', tenant: $tenant);
|
|
}
|
|
|
|
function tenantDashboardArrivalWidget(\App\Models\User $user, \App\Models\Tenant $tenant, array $state): mixed
|
|
{
|
|
test()->actingAs($user);
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
setTenantPanelContext($tenant);
|
|
request()->attributes->remove('portfolio_triage.arrival_context');
|
|
|
|
return Livewire::withQueryParams([
|
|
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($state),
|
|
])->actingAs($user)->test(TenantTriageArrivalContinuity::class);
|
|
}
|
|
|
|
it('renders source surface, concern, next step, and return flow for workspace backup arrivals', function (): void {
|
|
[$user, $tenant] = $this->makePortfolioTriageActor('Workspace Backup Tenant');
|
|
$this->actingAs($user);
|
|
|
|
$arrivalUrl = tenantDashboardArrivalUrl($tenant, [
|
|
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
|
|
'tenantRouteKey' => (string) $tenant->external_id,
|
|
'workspaceId' => (int) $tenant->workspace_id,
|
|
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
|
|
'concernReason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
|
]);
|
|
|
|
$this->get($arrivalUrl)
|
|
->assertOk()
|
|
->assertSee('Triage arrival')
|
|
->assertSee('Workspace overview triage')
|
|
->assertSee('Backup health')
|
|
->assertSee('Absent')
|
|
->assertSee('Opened from workspace overview triage because no usable backup basis was visible.')
|
|
->assertSee('Open backup sets')
|
|
->assertSee('Return to workspace overview')
|
|
->assertSee(BackupSetResource::getUrl('index', [
|
|
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
|
], panel: 'tenant', tenant: $tenant), false)
|
|
->assertSee(route('admin.home'), false);
|
|
});
|
|
|
|
it('renders registry arrival continuity with restore follow-up and preserved return filters', function (): void {
|
|
[$user, $tenant] = $this->makePortfolioTriageActor('Registry Recovery Tenant');
|
|
$restoreRun = $this->seedPortfolioRecoveryConcern($tenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP);
|
|
$this->actingAs($user);
|
|
|
|
$returnUrl = TenantResource::getUrl('index', [
|
|
'recovery_evidence' => [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
|
|
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
|
], panel: 'admin');
|
|
|
|
$arrivalUrl = tenantDashboardArrivalUrl($tenant, [
|
|
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
|
|
'tenantRouteKey' => (string) $tenant->external_id,
|
|
'workspaceId' => (int) $tenant->workspace_id,
|
|
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
|
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
|
|
'concernReason' => RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
|
|
'returnFilters' => [
|
|
'recovery_evidence' => [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
|
|
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
|
],
|
|
]);
|
|
|
|
$this->get($arrivalUrl)
|
|
->assertOk()
|
|
->assertSee('Tenant registry triage')
|
|
->assertSee('Recovery evidence')
|
|
->assertSee('Weakened')
|
|
->assertSee('Open restore run')
|
|
->assertSee('Return to tenant triage')
|
|
->assertSee(RestoreRunResource::getUrl('view', [
|
|
'record' => (int) $restoreRun?->getKey(),
|
|
'recovery_posture_reason' => RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
|
|
], panel: 'tenant', tenant: $tenant), false)
|
|
->assertSee($returnUrl, false);
|
|
});
|
|
|
|
it('suppresses the continuity block for generic or malformed sessions', function (): void {
|
|
[$user, $tenant] = $this->makePortfolioTriageActor('Generic Tenant Session');
|
|
$this->actingAs($user);
|
|
|
|
$this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
|
->assertOk()
|
|
->assertDontSee('Triage arrival');
|
|
|
|
$this->get(TenantDashboard::getUrl([
|
|
PortfolioArrivalContextToken::QUERY_PARAMETER => 'not-base64url',
|
|
], panel: 'tenant', tenant: $tenant))
|
|
->assertOk()
|
|
->assertDontSee('Triage arrival');
|
|
});
|
|
|
|
it('keeps the continuity block truthful when current truth has changed and multiple concerns remain visible', function (): void {
|
|
[$user, $tenant] = $this->makePortfolioTriageActor('Truth Shift Tenant');
|
|
$backupSet = $this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
$this->seedPortfolioRecoveryConcern($tenant, RestoreResultAttention::STATE_PARTIAL, $backupSet);
|
|
$this->actingAs($user);
|
|
|
|
$arrivalUrl = tenantDashboardArrivalUrl($tenant, [
|
|
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
|
|
'tenantRouteKey' => (string) $tenant->external_id,
|
|
'workspaceId' => (int) $tenant->workspace_id,
|
|
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
|
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
|
|
'concernReason' => 'no_history',
|
|
'returnFilters' => [
|
|
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
|
],
|
|
]);
|
|
|
|
$this->get($arrivalUrl)
|
|
->assertOk()
|
|
->assertSee('Current recovery evidence now looks Weakened.')
|
|
->assertSee('Backup posture also still needs follow-up.')
|
|
->assertSee('Open restore run');
|
|
});
|
|
|
|
it('degrades the next-step CTA when the operator cannot open deeper follow-up surfaces', function (): void {
|
|
[$user, $tenant] = $this->makePortfolioTriageActor('Restricted Arrival Tenant');
|
|
$this->actingAs($user);
|
|
|
|
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
|
|
$mock->shouldReceive('isMember')
|
|
->andReturnUsing(static fn ($user, $resolvedTenant): bool => (int) $resolvedTenant->getKey() === (int) $tenant->getKey());
|
|
|
|
$mock->shouldReceive('can')
|
|
->andReturnUsing(static function ($user, $resolvedTenant, string $capability) use ($tenant): bool {
|
|
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
|
|
|
|
return $capability !== Capabilities::TENANT_VIEW;
|
|
});
|
|
});
|
|
|
|
$arrivalUrl = tenantDashboardArrivalUrl($tenant, [
|
|
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
|
|
'tenantRouteKey' => (string) $tenant->external_id,
|
|
'workspaceId' => (int) $tenant->workspace_id,
|
|
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
|
|
'concernReason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
|
]);
|
|
|
|
$this->get($arrivalUrl)
|
|
->assertOk()
|
|
->assertSee('Triage arrival')
|
|
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
->assertDontSee(BackupSetResource::getUrl('index', [
|
|
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
|
], panel: 'tenant', tenant: $tenant), false);
|
|
});
|
|
|
|
it('shows review-state context and requires preview confirmation before marking the current concern reviewed', function (): void {
|
|
[$user, $tenant] = $this->makePortfolioTriageActor('Dashboard Review Tenant');
|
|
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
|
|
$component = tenantDashboardArrivalWidget($user, $tenant, [
|
|
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
|
|
'tenantRouteKey' => (string) $tenant->external_id,
|
|
'workspaceId' => (int) $tenant->workspace_id,
|
|
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
|
|
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
|
'returnFilters' => $this->portfolioReturnFilters(
|
|
[TenantBackupHealthAssessment::POSTURE_STALE],
|
|
),
|
|
])
|
|
->assertSee('Not reviewed')
|
|
->assertActionVisible('markReviewed')
|
|
->assertActionExists('markReviewed', fn (Action $action): bool => $action->isConfirmationRequired()
|
|
&& str_contains((string) $action->getModalDescription(), 'Concern family: Backup health')
|
|
&& str_contains((string) $action->getModalDescription(), 'Target state: Reviewed')
|
|
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'))
|
|
->mountAction('markReviewed');
|
|
|
|
expect(TenantTriageReview::query()->count())->toBe(0);
|
|
|
|
$component
|
|
->callMountedAction()
|
|
->assertSee('Reviewed');
|
|
|
|
expect(TenantTriageReview::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
|
|
->where('current_state', TenantTriageReview::STATE_REVIEWED)
|
|
->whereNull('resolved_at')
|
|
->exists())->toBeTrue()
|
|
->and(AuditLog::query()
|
|
->where('workspace_id', (int) $tenant->workspace_id)
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('action', AuditActionId::TenantTriageReviewMarkedReviewed->value)
|
|
->exists())->toBeTrue();
|
|
});
|
|
|
|
it('renders changed-since-review when the current concern fingerprint no longer matches the stored review', function (): void {
|
|
[$user, $tenant] = $this->makePortfolioTriageActor('Dashboard Changed Tenant');
|
|
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
$this->seedPortfolioTriageReview(
|
|
$tenant,
|
|
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
TenantTriageReview::STATE_REVIEWED,
|
|
$user,
|
|
changedFingerprint: true,
|
|
);
|
|
|
|
tenantDashboardArrivalWidget($user, $tenant, [
|
|
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
|
|
'tenantRouteKey' => (string) $tenant->external_id,
|
|
'workspaceId' => (int) $tenant->workspace_id,
|
|
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
|
|
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
|
'returnFilters' => $this->portfolioReturnFilters(
|
|
[TenantBackupHealthAssessment::POSTURE_STALE],
|
|
),
|
|
])
|
|
->assertSee('Changed since review')
|
|
->assertSee($user->name);
|
|
});
|