TenantAtlas/apps/platform/tests/Feature/Rbac/TriageReviewStateAuthorizationTest.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

141 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Support\Audit\AuditActionId;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Actions\Action;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
uses(BuildsPortfolioTriageFixtures::class);
function triageReviewArrivalState(Tenant $tenant): array
{
return [
'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' => [
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
],
];
}
function triageReviewDashboardWidget(User $user, 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('returns 404 for non-members on the tenant dashboard triage route', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
$foreignTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenant->workspace_id,
]);
$this->seedPortfolioBackupConcern($foreignTenant, TenantBackupHealthAssessment::POSTURE_STALE);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantDashboard::getUrl([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode(triageReviewArrivalState($foreignTenant)),
], panel: 'tenant', tenant: $foreignTenant))
->assertNotFound();
});
it('shows review actions as disabled for readonly members and still rejects a bypassed mutation with 403', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
$component = triageReviewDashboardWidget($user, $tenant, triageReviewArrivalState($tenant))
->assertActionVisible('markReviewed')
->assertActionDisabled('markReviewed');
$instance = $component->instance();
$componentReflection = new ReflectionObject($instance);
$cachedActionsProperty = $componentReflection->getProperty('cachedActions');
$cachedActionsProperty->setAccessible(true);
$cachedActions = $cachedActionsProperty->getValue($instance);
$action = $cachedActions['markReviewed'] ?? null;
expect($action)->not->toBeNull();
$actionReflection = new ReflectionObject($action);
$disabledProperty = $actionReflection->getProperty('isDisabled');
$disabledProperty->setAccessible(true);
$disabledProperty->setValue($action, false);
$cachedActionsProperty->setValue($instance, $cachedActions);
$instance->mountAction('markReviewed');
$mountedAction = $instance->getMountedAction();
expect($mountedAction)->not->toBeNull();
$mountedReflection = new ReflectionObject($mountedAction);
$mountedDisabledProperty = $mountedReflection->getProperty('isDisabled');
$mountedDisabledProperty->setAccessible(true);
$mountedDisabledProperty->setValue($mountedAction, false);
try {
$instance->callMountedAction();
$this->fail('Expected a 403 when bypassing the disabled action.');
} catch (HttpException $exception) {
expect($exception->getStatusCode())->toBe(403);
}
expect(TenantTriageReview::query()->count())->toBe(0);
});
it('writes review progress and audit state only after the preview-confirmed action executes', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Authorization Success Tenant');
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
$component = triageReviewDashboardWidget($user, $tenant, triageReviewArrivalState($tenant))
->assertActionExists('markFollowUpNeeded', fn (Action $action): bool => $action->isConfirmationRequired()
&& str_contains((string) $action->getModalDescription(), 'Target state: Follow-up needed')
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'))
->mountAction('markFollowUpNeeded');
expect(TenantTriageReview::query()->count())->toBe(0);
$component
->callMountedAction();
expect(TenantTriageReview::query()
->where('tenant_id', (int) $tenant->getKey())
->where('current_state', TenantTriageReview::STATE_FOLLOW_UP_NEEDED)
->whereNull('resolved_at')
->exists())->toBeTrue()
->and(AuditLog::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('action', AuditActionId::TenantTriageReviewMarkedFollowUpNeeded->value)
->exists())->toBeTrue();
Filament::setTenant(null, true);
});