## Summary - amend the operator UI constitution and related SpecKit templates for the new UI/UX governance rules - add Spec 168 artifacts plus the tenant governance aggregate implementation used by the tenant dashboard, banner, and baseline compare landing surfaces - normalize Filament action surfaces around clickable-row inspection, grouped secondary actions, and explicit action-surface declarations across enrolled resources and pages - fix post-suite regressions in membership cache priming, finding workflow state refresh, tenant review derived-state invalidation, and tenant-bound backup-set related navigation ## Commit Series - `docs: amend operator UI constitution` - `spec: add tenant governance aggregate contract` - `feat: add tenant governance aggregate contract` - `refactor: normalize filament action surfaces` - `fix: resolve post-suite state regressions` ## Testing - `vendor/bin/sail artisan test --compact` - Result: `3176 passed, 8 skipped (17384 assertions)` ## Notes - Livewire v4 / Filament v5 stack remains unchanged - no provider registration changes; `bootstrap/providers.php` remains the relevant location - no new global-search resources or asset-registration changes in this branch Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #199
455 lines
16 KiB
PHP
455 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\BaselineSnapshot;
|
|
use App\Models\BaselineTenantAssignment;
|
|
use App\Models\Finding;
|
|
use App\Models\FindingException;
|
|
use App\Models\OperationRun;
|
|
use App\Support\Baselines\BaselineCompareStats;
|
|
|
|
it('returns no_tenant state when tenant is null', function (): void {
|
|
$stats = BaselineCompareStats::forTenant(null);
|
|
|
|
expect($stats->state)->toBe('no_tenant')
|
|
->and($stats->message)->toContain('No tenant');
|
|
});
|
|
|
|
it('returns no_assignment state when tenant has no baseline assignment', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$stats = BaselineCompareStats::forTenant($tenant);
|
|
|
|
expect($stats->state)->toBe('no_assignment')
|
|
->and($stats->profileName)->toBeNull();
|
|
});
|
|
|
|
it('returns no_snapshot state when profile has no consumable snapshot', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$profile = BaselineProfile::factory()->active()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'active_snapshot_id' => null,
|
|
]);
|
|
|
|
BaselineTenantAssignment::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
]);
|
|
|
|
$stats = BaselineCompareStats::forTenant($tenant);
|
|
|
|
expect($stats->state)->toBe('no_snapshot')
|
|
->and($stats->profileName)->toBe($profile->name)
|
|
->and($stats->reasonCode)->toBe('baseline.compare.no_consumable_snapshot');
|
|
});
|
|
|
|
it('falls back to the latest complete snapshot when a newer attempt is incomplete', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$profile = BaselineProfile::factory()->active()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'active_snapshot_id' => null,
|
|
]);
|
|
|
|
$completeSnapshot = BaselineSnapshot::factory()->complete()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'baseline_profile_id' => $profile->getKey(),
|
|
'captured_at' => now()->subHour(),
|
|
'completed_at' => now()->subHour(),
|
|
]);
|
|
|
|
BaselineSnapshot::factory()->incomplete()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'baseline_profile_id' => $profile->getKey(),
|
|
'captured_at' => now(),
|
|
]);
|
|
|
|
$profile->update(['active_snapshot_id' => $completeSnapshot->getKey()]);
|
|
|
|
BaselineTenantAssignment::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
]);
|
|
|
|
$stats = BaselineCompareStats::forTenant($tenant);
|
|
|
|
expect($stats->state)->toBe('idle')
|
|
->and($stats->snapshotId)->toBe((int) $completeSnapshot->getKey())
|
|
->and($stats->reasonCode)->toBeNull();
|
|
});
|
|
|
|
it('reports building as the blocking reason when no complete snapshot exists yet', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$profile = BaselineProfile::factory()->active()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'active_snapshot_id' => null,
|
|
]);
|
|
|
|
BaselineSnapshot::factory()->building()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'baseline_profile_id' => $profile->getKey(),
|
|
]);
|
|
|
|
BaselineTenantAssignment::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
]);
|
|
|
|
$stats = BaselineCompareStats::forTenant($tenant);
|
|
|
|
expect($stats->state)->toBe('no_snapshot')
|
|
->and($stats->reasonCode)->toBe('baseline.compare.snapshot_building')
|
|
->and($stats->reasonMessage)->toContain('building');
|
|
});
|
|
|
|
it('returns comparing state when a run is queued', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$profile = BaselineProfile::factory()->active()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
]);
|
|
|
|
$snapshot = BaselineSnapshot::factory()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'baseline_profile_id' => $profile->getKey(),
|
|
]);
|
|
|
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
|
|
|
BaselineTenantAssignment::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
]);
|
|
|
|
OperationRun::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'type' => 'baseline_compare',
|
|
'status' => 'queued',
|
|
'outcome' => 'pending',
|
|
]);
|
|
|
|
$stats = BaselineCompareStats::forTenant($tenant);
|
|
|
|
expect($stats->state)->toBe('comparing')
|
|
->and($stats->operationRunId)->not->toBeNull();
|
|
});
|
|
|
|
it('returns failed state when the latest run has failed outcome', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$profile = BaselineProfile::factory()->active()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
]);
|
|
|
|
$snapshot = BaselineSnapshot::factory()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'baseline_profile_id' => $profile->getKey(),
|
|
]);
|
|
|
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
|
|
|
BaselineTenantAssignment::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
]);
|
|
|
|
OperationRun::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'type' => 'baseline_compare',
|
|
'status' => 'completed',
|
|
'outcome' => 'failed',
|
|
'failure_summary' => ['message' => 'Graph API timeout'],
|
|
'completed_at' => now()->subHour(),
|
|
]);
|
|
|
|
$stats = BaselineCompareStats::forTenant($tenant);
|
|
|
|
expect($stats->state)->toBe('failed')
|
|
->and($stats->failureReason)->toBe('Graph API timeout')
|
|
->and($stats->operationRunId)->not->toBeNull()
|
|
->and($stats->lastComparedHuman)->not->toBeNull()
|
|
->and($stats->lastComparedIso)->not->toBeNull();
|
|
});
|
|
|
|
it('returns ready state with grouped severity counts', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$profile = BaselineProfile::factory()->active()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
]);
|
|
|
|
$snapshot = BaselineSnapshot::factory()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'baseline_profile_id' => $profile->getKey(),
|
|
]);
|
|
|
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
|
|
|
BaselineTenantAssignment::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
]);
|
|
|
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
|
|
|
Finding::factory()->count(2)->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'source' => 'baseline.compare',
|
|
'scope_key' => $scopeKey,
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'source' => 'baseline.compare',
|
|
'scope_key' => $scopeKey,
|
|
'severity' => Finding::SEVERITY_MEDIUM,
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'source' => 'baseline.compare',
|
|
'scope_key' => $scopeKey,
|
|
'severity' => Finding::SEVERITY_LOW,
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
|
|
// Terminal finding should not be counted in "open" drift totals.
|
|
Finding::factory()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'source' => 'baseline.compare',
|
|
'scope_key' => $scopeKey,
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'status' => Finding::STATUS_RESOLVED,
|
|
]);
|
|
|
|
OperationRun::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'type' => 'baseline_compare',
|
|
'status' => 'completed',
|
|
'outcome' => 'succeeded',
|
|
'completed_at' => now()->subHours(2),
|
|
]);
|
|
|
|
$stats = BaselineCompareStats::forTenant($tenant);
|
|
|
|
expect($stats->state)->toBe('ready')
|
|
->and($stats->findingsCount)->toBe(4)
|
|
->and($stats->severityCounts)->toBe([
|
|
'high' => 2,
|
|
'medium' => 1,
|
|
'low' => 1,
|
|
])
|
|
->and($stats->lastComparedHuman)->not->toBeNull()
|
|
->and($stats->lastComparedIso)->toContain('T');
|
|
});
|
|
|
|
it('returns idle state when profile is ready but no run exists yet', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$profile = BaselineProfile::factory()->active()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
]);
|
|
|
|
$snapshot = BaselineSnapshot::factory()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'baseline_profile_id' => $profile->getKey(),
|
|
]);
|
|
|
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
|
|
|
BaselineTenantAssignment::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
]);
|
|
|
|
$stats = BaselineCompareStats::forTenant($tenant);
|
|
|
|
expect($stats->state)->toBe('idle')
|
|
->and($stats->profileName)->toBe($profile->name)
|
|
->and($stats->snapshotId)->toBe((int) $snapshot->getKey());
|
|
});
|
|
|
|
it('forWidget returns grouped severity counts for new findings only', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$profile = BaselineProfile::factory()->active()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
]);
|
|
|
|
BaselineTenantAssignment::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
]);
|
|
|
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
|
|
|
// New finding (should be counted)
|
|
Finding::factory()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'source' => 'baseline.compare',
|
|
'scope_key' => $scopeKey,
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
|
|
// Resolved finding (should NOT be counted)
|
|
Finding::factory()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'source' => 'baseline.compare',
|
|
'scope_key' => $scopeKey,
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'status' => Finding::STATUS_RESOLVED,
|
|
]);
|
|
|
|
$stats = BaselineCompareStats::forWidget($tenant);
|
|
|
|
expect($stats->findingsCount)->toBe(1)
|
|
->and($stats->severityCounts['high'])->toBe(1);
|
|
});
|
|
|
|
it('returns governance attention counts from current findings truth', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$profile = BaselineProfile::factory()->active()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
]);
|
|
|
|
$snapshot = BaselineSnapshot::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
]);
|
|
|
|
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
|
|
|
BaselineTenantAssignment::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
]);
|
|
|
|
OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'type' => 'baseline_compare',
|
|
'status' => 'completed',
|
|
'outcome' => 'succeeded',
|
|
'completed_at' => now(),
|
|
'context' => [
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
|
'baseline_compare' => [
|
|
'coverage' => [
|
|
'effective_types' => ['deviceConfiguration'],
|
|
'covered_types' => ['deviceConfiguration'],
|
|
'uncovered_types' => [],
|
|
'proof' => true,
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
Finding::factory()->triaged()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'due_at' => now()->subDay(),
|
|
]);
|
|
|
|
$expiringFinding = Finding::factory()->riskAccepted()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
]);
|
|
|
|
FindingException::query()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'finding_id' => (int) $expiringFinding->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' => 'Expiring governance coverage',
|
|
'approval_reason' => 'Approved for coverage',
|
|
'requested_at' => now()->subDays(2),
|
|
'approved_at' => now()->subDay(),
|
|
'effective_from' => now()->subDay(),
|
|
'expires_at' => now()->addDays(2),
|
|
'review_due_at' => now()->addDay(),
|
|
'evidence_summary' => ['reference_count' => 0],
|
|
]);
|
|
|
|
$lapsedFinding = Finding::factory()->riskAccepted()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
]);
|
|
|
|
FindingException::query()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'finding_id' => (int) $lapsedFinding->getKey(),
|
|
'requested_by_user_id' => (int) $user->getKey(),
|
|
'owner_user_id' => (int) $user->getKey(),
|
|
'approved_by_user_id' => (int) $user->getKey(),
|
|
'status' => FindingException::STATUS_EXPIRED,
|
|
'current_validity_state' => FindingException::VALIDITY_EXPIRED,
|
|
'request_reason' => 'Expired governance coverage',
|
|
'approval_reason' => 'Approved for coverage',
|
|
'requested_at' => now()->subDays(3),
|
|
'approved_at' => now()->subDays(2),
|
|
'effective_from' => now()->subDays(2),
|
|
'expires_at' => now()->subDay(),
|
|
'review_due_at' => now()->subDay(),
|
|
'evidence_summary' => ['reference_count' => 0],
|
|
]);
|
|
|
|
Finding::factory()->inProgress()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
]);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
|
|
$stats = BaselineCompareStats::forTenant($tenant);
|
|
|
|
expect($stats->overdueOpenFindingsCount)->toBe(1)
|
|
->and($stats->expiringGovernanceCount)->toBe(1)
|
|
->and($stats->lapsedGovernanceCount)->toBe(1)
|
|
->and($stats->activeNonNewFindingsCount)->toBe(2)
|
|
->and($stats->highSeverityActiveFindingsCount)->toBe(1);
|
|
});
|