## Summary - rename the tenant dashboard operations KPI to attention-first wording and keep the primary header CTA derived from the highest-priority recommended action - restyle the `Operations requiring attention` card to match the existing neutral dashboard card language while keeping only a subtle per-item attention accent - replace technical operation identifiers on the dashboard with calmer timing/copy, including provider-consent follow-up messaging for blocked permission posture checks - refresh the local Spec Kit artifacts for spec 273 so the branch documentation matches the implemented attention-only dashboard scope ## Validation - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/TenantDashboardProductizationSummaryTest.php tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php tests/Feature/Dashboard/TenantDashboardProductizationAuthorizationTest.php tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Browser/Dashboard/TenantDashboardProductizationSmokeTest.php` - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #338
324 lines
14 KiB
PHP
324 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Pages\TenantDashboard;
|
|
use App\Filament\Resources\FindingExceptionResource;
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Models\Finding;
|
|
use App\Models\FindingException;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
|
use App\Support\Links\RequiredPermissionsLinks;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
|
|
|
use function Pest\Laravel\mock;
|
|
|
|
function mockTenantDashboardActionPermissions(array $overview = []): void
|
|
{
|
|
mock(TenantRequiredPermissionsViewModelBuilder::class, function ($mock) use ($overview): void {
|
|
$mock->shouldReceive('build')->andReturn([
|
|
'overview' => array_replace_recursive([
|
|
'overall' => 'ready',
|
|
'counts' => [
|
|
'missing_application' => 0,
|
|
'missing_delegated' => 0,
|
|
],
|
|
'freshness' => [
|
|
'is_stale' => false,
|
|
'last_refreshed_at' => now()->toIso8601String(),
|
|
],
|
|
], $overview),
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
function tenantDashboardButtonClassesForXPath(string $content, string $xpathExpression): array
|
|
{
|
|
$dom = new \DOMDocument();
|
|
|
|
libxml_use_internal_errors(true);
|
|
$dom->loadHTML($content);
|
|
libxml_clear_errors();
|
|
|
|
$xpath = new \DOMXPath($dom);
|
|
$nodes = $xpath->query($xpathExpression);
|
|
|
|
if ($nodes === false) {
|
|
return [];
|
|
}
|
|
|
|
return collect(iterator_to_array($nodes))
|
|
->map(static fn (\DOMNode $node): string => (string) $node->attributes?->getNamedItem('class')?->nodeValue)
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
it('builds the canonical operations follow-up baseline with tenant continuity', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create();
|
|
|
|
expect(OperationRunLinks::index($tenant, activeTab: 'active'))
|
|
->toBe(route('admin.operations.index', [
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'activeTab' => 'active',
|
|
]))
|
|
->and(OperationRunLinks::index(
|
|
$tenant,
|
|
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
|
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
|
))
|
|
->toBe(route('admin.operations.index', [
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'activeTab' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
|
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
|
]));
|
|
});
|
|
|
|
it('builds the required-permissions follow-up baseline with tenant continuity', function (): void {
|
|
$tenant = ManagedEnvironment::factory()->create([
|
|
'external_id' => 'tenant-dashboard-productization',
|
|
]);
|
|
|
|
expect(RequiredPermissionsLinks::requiredPermissions($tenant, ['source' => 'tenant_dashboard']))
|
|
->toBe('/admin/tenants/'.urlencode((string) $tenant->external_id).'/required-permissions?source=tenant_dashboard');
|
|
});
|
|
|
|
it('prioritizes operations requiring attention below permissions and high severity findings and keeps canonical hub links', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
mockTenantDashboardActionPermissions([
|
|
'overall' => 'blocked',
|
|
'counts' => [
|
|
'missing_application' => 1,
|
|
'missing_delegated' => 0,
|
|
],
|
|
]);
|
|
|
|
Finding::factory()->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'type' => 'inventory_sync',
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Failed->value,
|
|
'created_at' => now()->subMinute(),
|
|
'started_at' => now()->subMinutes(2),
|
|
'completed_at' => now()->subMinute(),
|
|
]);
|
|
|
|
$summary = app(TenantDashboardSummaryBuilder::class)
|
|
->build($tenant, $user)
|
|
->toArray();
|
|
|
|
$activeOperationSummary = $summary['activeOperationSummary'] ?? null;
|
|
$recommendedActions = $summary['recommendedActions'] ?? [];
|
|
|
|
expect($activeOperationSummary)
|
|
->not->toBeNull()
|
|
->and($activeOperationSummary['items'][0]['primaryActionLabel'] ?? null)->toBe('Review operation')
|
|
->and($activeOperationSummary['items'][0]['primaryActionUrl'] ?? null)->toBe(OperationRunLinks::view($run, $tenant))
|
|
->and($activeOperationSummary['secondaryActionLabel'] ?? null)->toBe('Open operations hub')
|
|
->and($activeOperationSummary['secondaryActionUrl'] ?? null)->toBe(OperationRunLinks::index($tenant, activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP))
|
|
->and(array_column($recommendedActions, 'key'))->toBe([
|
|
'required_permissions',
|
|
'high_severity_findings',
|
|
'operations_requiring_attention',
|
|
])
|
|
->and($recommendedActions[2]['title'] ?? null)->toBe('Review operations requiring attention')
|
|
->and($recommendedActions[2]['reason'] ?? null)->toBe('One or more operations finished with an outcome that needs follow-up.')
|
|
->and($recommendedActions[2]['impact'] ?? null)->toBe('The tenant should not be treated as fully healthy until the operation outcome has been reviewed.')
|
|
->and($recommendedActions[2]['actionLabel'] ?? null)->toBe('Review operations')
|
|
->and($recommendedActions[2]['actionUrl'] ?? null)->toBe(OperationRunLinks::index($tenant, activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP));
|
|
});
|
|
|
|
it('uses review permissions as the top recommended-action CTA when permissions are the highest follow-up', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
mockTenantDashboardActionPermissions([
|
|
'overall' => 'blocked',
|
|
'counts' => [
|
|
'missing_application' => 2,
|
|
'missing_delegated' => 0,
|
|
],
|
|
]);
|
|
|
|
$recommendedActions = app(TenantDashboardSummaryBuilder::class)
|
|
->build($tenant, $user)
|
|
->toArray()['recommendedActions'];
|
|
|
|
expect($recommendedActions[0]['key'] ?? null)->toBe('required_permissions')
|
|
->and($recommendedActions[0]['title'] ?? null)->toBe('Review permissions')
|
|
->and($recommendedActions[0]['actionLabel'] ?? null)->toBe('Review permissions')
|
|
->and($recommendedActions[0]['actionUrl'] ?? null)->toBe(RequiredPermissionsLinks::requiredPermissions($tenant));
|
|
});
|
|
|
|
it('orders productized recommended actions by priority and caps the visible list at three repo-real CTAs', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
mockTenantDashboardActionPermissions([
|
|
'overall' => 'blocked',
|
|
'counts' => [
|
|
'missing_application' => 2,
|
|
'missing_delegated' => 0,
|
|
],
|
|
]);
|
|
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
|
|
|
|
seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage());
|
|
|
|
$backupSet = workspaceOverviewSeedHealthyBackup($tenant);
|
|
|
|
workspaceOverviewSeedRestoreHistory($tenant, $backupSet);
|
|
|
|
Finding::factory()->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
|
|
$riskFinding = Finding::factory()->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'severity' => Finding::SEVERITY_LOW,
|
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
|
]);
|
|
|
|
FindingException::query()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'finding_id' => (int) $riskFinding->getKey(),
|
|
'requested_by_user_id' => (int) $user->getKey(),
|
|
'owner_user_id' => (int) $user->getKey(),
|
|
'approved_by_user_id' => (int) $user->getKey(),
|
|
'status' => FindingException::STATUS_ACTIVE,
|
|
'current_validity_state' => FindingException::VALIDITY_EXPIRED,
|
|
'request_reason' => 'Expired risk acceptance for productization ordering',
|
|
'approval_reason' => 'Approved for regression',
|
|
'requested_at' => now()->subDays(7),
|
|
'approved_at' => now()->subDays(6),
|
|
'effective_from' => now()->subDays(6),
|
|
'review_due_at' => now()->subDay(),
|
|
'expires_at' => now()->subDay(),
|
|
'evidence_summary' => ['reference_count' => 0],
|
|
]);
|
|
|
|
$summary = app(TenantDashboardSummaryBuilder::class)
|
|
->build($tenant, $user)
|
|
->toArray();
|
|
|
|
$actions = $summary['recommendedActions'];
|
|
|
|
expect(array_column($actions, 'key'))
|
|
->toBe(['required_permissions', 'high_severity_findings', 'risk_exceptions'])
|
|
->and(count($actions))->toBe(3)
|
|
->and(array_column($actions, 'icon'))->toBe([
|
|
'heroicon-m-shield-exclamation',
|
|
'heroicon-m-shield-exclamation',
|
|
'heroicon-o-exclamation-triangle',
|
|
])
|
|
->and($actions[0]['actionUrl'])->toBe(RequiredPermissionsLinks::requiredPermissions($tenant))
|
|
->and($actions[1]['actionUrl'])->toBe(FindingResource::getUrl('index', [
|
|
'tab' => 'needs_action',
|
|
'high_severity' => 1,
|
|
], panel: 'tenant', tenant: $tenant))
|
|
->and($actions[2]['actionUrl'])->toBe(FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant));
|
|
|
|
$this->actingAs($user);
|
|
setTenantPanelContext($tenant);
|
|
|
|
$content = $this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
|
->assertSuccessful()
|
|
->getContent();
|
|
|
|
$recommendedButtonClasses = tenantDashboardButtonClassesForXPath(
|
|
$content,
|
|
"//*[@data-testid='tenant-dashboard-recommended-action']//*[self::a or self::button][contains(@class, 'fi-btn')]",
|
|
);
|
|
$asideButtonClasses = tenantDashboardButtonClassesForXPath(
|
|
$content,
|
|
"//*[@data-testid='tenant-dashboard-readiness-card']//*[self::a or self::button][contains(@class, 'fi-btn')]",
|
|
);
|
|
$priorityMarkerClasses = tenantDashboardButtonClassesForXPath(
|
|
$content,
|
|
"//*[@data-testid='tenant-dashboard-recommended-action-priority']",
|
|
);
|
|
|
|
expect(substr_count($content, 'data-testid="tenant-dashboard-recommended-action"'))->toBe(3)
|
|
->and(substr_count($content, 'data-testid="tenant-dashboard-recommended-action-icon"'))->toBe(3)
|
|
->and($content)->toContain('data-icon="heroicon-m-shield-exclamation"')
|
|
->and($content)->toContain('data-icon="heroicon-o-exclamation-triangle"')
|
|
->and($recommendedButtonClasses)->not->toBeEmpty()
|
|
->and($asideButtonClasses)->not->toBeEmpty()
|
|
->and(collect([...$recommendedButtonClasses, ...$asideButtonClasses])->contains(static fn (string $classes): bool => str_contains($classes, 'fi-outlined')))->toBeFalse()
|
|
->and(collect($priorityMarkerClasses)->every(static fn (string $classes): bool => str_contains($classes, 'border-gray-200')
|
|
&& str_contains($classes, 'bg-gray-50')
|
|
&& str_contains($classes, 'text-gray-700')))->toBeTrue();
|
|
});
|
|
|
|
it('assigns semantically distinct icons to overdue-findings and recovery-posture follow-ups', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
mockTenantDashboardActionPermissions();
|
|
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
|
|
seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage());
|
|
|
|
$backupSet = workspaceOverviewSeedHealthyBackup($tenant);
|
|
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'follow_up');
|
|
|
|
Finding::factory()
|
|
->for($tenant)
|
|
->overdueByHours()
|
|
->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'severity' => Finding::SEVERITY_LOW,
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
|
|
$actions = app(TenantDashboardSummaryBuilder::class)
|
|
->build($tenant, $user)
|
|
->toArray()['recommendedActions'];
|
|
|
|
expect(collect($actions)->firstWhere('key', 'overdue_findings')['icon'] ?? null)
|
|
->toBe('heroicon-o-clock')
|
|
->and(collect($actions)->firstWhere('key', 'recovery_posture')['icon'] ?? null)
|
|
->toBe('heroicon-o-arrow-path-rounded-square');
|
|
});
|
|
|
|
it('keeps continue-review follow-up unavailable for readonly members who can only inspect review state', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
|
|
|
composeTenantReviewForTest($tenant, $user);
|
|
|
|
$summary = app(TenantDashboardSummaryBuilder::class)
|
|
->build($tenant, $user)
|
|
->toArray();
|
|
|
|
$continueReview = collect($summary['recommendedActions'])->firstWhere('key', 'continue_review');
|
|
|
|
expect($continueReview)
|
|
->not->toBeNull()
|
|
->and($continueReview['actionDisabled'])->toBeTrue()
|
|
->and($continueReview['actionUrl'])->toBeNull()
|
|
->and($continueReview['helperText'])->toContain('continue the review');
|
|
});
|