TenantAtlas/apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php
ahmido 2f45ff5a84 feat: add portfolio triage review state tracking (#220)
## Summary
- add tenant triage review-state persistence, fingerprinting, resolver logic, service layer, and migration for current affected-set tracking
- surface review-state and affected-set progress across tenant registry, tenant dashboard arrival continuity, and workspace overview
- extend RBAC, audit/badge support, specs, and test coverage for portfolio triage review-state workflows
- suppress expected hidden-page background transport failures in the global unhandled rejection logger while keeping visible-page failures logged

## Validation
- targeted Pest coverage added for tenant registry, workspace overview, arrival context, RBAC authorization, badges, fingerprinting, resolver behavior, and logger asset behavior
- code formatted with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Notes
- full suite was not re-run in this final step
- branch includes the spec artifacts under `specs/189-portfolio-triage-review-state/`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #220
2026-04-10 21:35:17 +00:00

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);
});