From f6dc5ed9472363e5cd287518e376449ec99ba6e5 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 29 Mar 2026 23:11:48 +0200 Subject: [PATCH] feat: add tenant governance aggregate contract --- app/Filament/Pages/BaselineCompareLanding.php | 21 +- .../Widgets/Dashboard/BaselineCompareNow.php | 31 +- .../Widgets/Dashboard/NeedsAttention.php | 67 ++-- .../Tenant/BaselineCompareCoverageBanner.php | 35 +- .../Baselines/BaselineCompareStats.php | 29 ++ .../Baselines/TenantGovernanceAggregate.php | 111 ++++++ .../TenantGovernanceAggregateResolver.php | 71 ++++ .../Ui/DerivedState/DerivedStateFamily.php | 6 +- ...-scoped-derived-state.logical.openapi.yaml | 81 +++++ .../Baselines/BaselineCompareStatsTest.php | 117 +++++++ .../BaselineCompareSummaryAssessmentTest.php | 82 +++++ .../TenantGovernanceAggregateResolverTest.php | 326 ++++++++++++++++++ .../BaselineCompareCoverageBannerTest.php | 41 +++ .../BaselineCompareSummaryConsistencyTest.php | 109 ++++++ .../Filament/NeedsAttentionWidgetTest.php | 62 ++++ ...nantGovernanceAggregateMemoizationTest.php | 122 +++++++ .../DerivedStateConsumerAdoptionGuardTest.php | 3 +- 17 files changed, 1241 insertions(+), 73 deletions(-) create mode 100644 app/Support/Baselines/TenantGovernanceAggregate.php create mode 100644 app/Support/Baselines/TenantGovernanceAggregateResolver.php create mode 100644 tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php create mode 100644 tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index 20b10324..1fbd8b28 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -15,6 +15,8 @@ use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCompareEvidenceGapDetails; use App\Support\Baselines\BaselineCompareStats; +use App\Support\Baselines\TenantGovernanceAggregate; +use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -133,7 +135,11 @@ public function mount(): void public function refreshStats(): void { - $stats = BaselineCompareStats::forTenant(static::resolveTenantContextForCurrentPanel()); + $tenant = static::resolveTenantContextForCurrentPanel(); + $stats = BaselineCompareStats::forTenant($tenant); + $aggregate = $tenant instanceof Tenant + ? $this->governanceAggregate($tenant, $stats) + : null; $this->state = $stats->state; $this->message = $stats->message; @@ -169,7 +175,7 @@ public function refreshStats(): void : null; $this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null; $this->operatorExplanation = $stats->operatorExplanation()->toArray(); - $this->summaryAssessment = $stats->summaryAssessment()->toArray(); + $this->summaryAssessment = $aggregate?->summaryAssessment->toArray(); } /** @@ -419,4 +425,15 @@ public function getRunUrl(): ?string return OperationRunLinks::view($this->operationRunId, $tenant); } + + private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate + { + /** @var TenantGovernanceAggregateResolver $resolver */ + $resolver = app(TenantGovernanceAggregateResolver::class); + + /** @var TenantGovernanceAggregate $aggregate */ + $aggregate = $resolver->fromStats($tenant, $stats); + + return $aggregate; + } } diff --git a/app/Filament/Widgets/Dashboard/BaselineCompareNow.php b/app/Filament/Widgets/Dashboard/BaselineCompareNow.php index 1e647744..f1bb999f 100644 --- a/app/Filament/Widgets/Dashboard/BaselineCompareNow.php +++ b/app/Filament/Widgets/Dashboard/BaselineCompareNow.php @@ -7,7 +7,8 @@ use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Resources\FindingResource; use App\Models\Tenant; -use App\Support\Baselines\BaselineCompareStats; +use App\Support\Baselines\TenantGovernanceAggregate; +use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\OperationRunLinks; use Filament\Facades\Filament; use Filament\Widgets\Widget; @@ -38,19 +39,18 @@ protected function getViewData(): array return $empty; } - $stats = BaselineCompareStats::forTenant($tenant); + $aggregate = $this->governanceAggregate($tenant); - if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) { + if ($aggregate->compareState === 'no_assignment') { return $empty; } $tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant); - $runUrl = $stats->operationRunId !== null - ? OperationRunLinks::view($stats->operationRunId, $tenant) + $runUrl = $aggregate->stats->operationRunId !== null + ? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant) : null; $findingsUrl = FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant); - $summaryAssessment = $stats->summaryAssessment(); - $nextActionUrl = match ($summaryAssessment->nextActionTarget()) { + $nextActionUrl = match ($aggregate->nextActionTarget) { 'run' => $runUrl, 'findings' => $findingsUrl, 'landing' => $tenantLandingUrl, @@ -59,13 +59,24 @@ protected function getViewData(): array return [ 'hasAssignment' => true, - 'profileName' => $stats->profileName, - 'lastComparedAt' => $stats->lastComparedHuman, + 'profileName' => $aggregate->profileName, + 'lastComparedAt' => $aggregate->lastComparedLabel, 'landingUrl' => $tenantLandingUrl, 'runUrl' => $runUrl, 'findingsUrl' => $findingsUrl, 'nextActionUrl' => $nextActionUrl, - 'summaryAssessment' => $summaryAssessment->toArray(), + 'summaryAssessment' => $aggregate->summaryAssessment->toArray(), ]; } + + private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate + { + /** @var TenantGovernanceAggregateResolver $resolver */ + $resolver = app(TenantGovernanceAggregateResolver::class); + + /** @var TenantGovernanceAggregate $aggregate */ + $aggregate = $resolver->forTenant($tenant); + + return $aggregate; + } } diff --git a/app/Filament/Widgets/Dashboard/NeedsAttention.php b/app/Filament/Widgets/Dashboard/NeedsAttention.php index aa0950f6..c6df00c2 100644 --- a/app/Filament/Widgets/Dashboard/NeedsAttention.php +++ b/app/Filament/Widgets/Dashboard/NeedsAttention.php @@ -4,9 +4,9 @@ namespace App\Filament\Widgets\Dashboard; -use App\Models\Finding; use App\Models\Tenant; -use App\Support\Baselines\BaselineCompareStats; +use App\Support\Baselines\TenantGovernanceAggregate; +use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\OpsUx\ActiveRuns; use Filament\Facades\Filament; use Filament\Widgets\Widget; @@ -31,51 +31,15 @@ protected function getViewData(): array } $tenantId = (int) $tenant->getKey(); - $compareStats = BaselineCompareStats::forTenant($tenant); - $compareAssessment = $compareStats->summaryAssessment(); + $aggregate = $this->governanceAggregate($tenant); + $compareAssessment = $aggregate->summaryAssessment; $items = []; - $overdueOpenCount = (int) Finding::query() - ->where('tenant_id', $tenantId) - ->whereIn('status', Finding::openStatusesForQuery()) - ->whereNotNull('due_at') - ->where('due_at', '<', now()) - ->count(); - - $lapsedGovernanceCount = (int) Finding::query() - ->where('tenant_id', $tenantId) - ->where('status', Finding::STATUS_RISK_ACCEPTED) - ->where(function ($query): void { - $query - ->whereDoesntHave('findingException') - ->orWhereHas('findingException', function ($exceptionQuery): void { - $exceptionQuery->whereIn('current_validity_state', [ - \App\Models\FindingException::VALIDITY_EXPIRED, - \App\Models\FindingException::VALIDITY_REVOKED, - \App\Models\FindingException::VALIDITY_REJECTED, - \App\Models\FindingException::VALIDITY_MISSING_SUPPORT, - ]); - }); - }) - ->count(); - - $expiringGovernanceCount = (int) Finding::query() - ->where('tenant_id', $tenantId) - ->where('status', Finding::STATUS_RISK_ACCEPTED) - ->whereHas('findingException', function ($query): void { - $query->where('current_validity_state', \App\Models\FindingException::VALIDITY_EXPIRING); - }) - ->count(); - - $highSeverityCount = (int) Finding::query() - ->where('tenant_id', $tenantId) - ->whereIn('status', Finding::openStatusesForQuery()) - ->whereIn('severity', [ - Finding::SEVERITY_HIGH, - Finding::SEVERITY_CRITICAL, - ]) - ->count(); + $overdueOpenCount = $aggregate->overdueOpenFindingsCount; + $lapsedGovernanceCount = $aggregate->lapsedGovernanceCount; + $expiringGovernanceCount = $aggregate->expiringGovernanceCount; + $highSeverityCount = $aggregate->highSeverityActiveFindingsCount; if ($lapsedGovernanceCount > 0) { $items[] = [ @@ -120,7 +84,7 @@ protected function getViewData(): array 'supportingMessage' => $compareAssessment->supportingMessage, 'badge' => 'Baseline', 'badgeColor' => $compareAssessment->tone, - 'nextStep' => $compareAssessment->nextActionLabel(), + 'nextStep' => $aggregate->nextActionLabel, ]; } @@ -145,7 +109,7 @@ protected function getViewData(): array $healthyChecks = [ [ 'title' => 'Baseline compare looks trustworthy', - 'body' => $compareAssessment->headline, + 'body' => $aggregate->headline, ], [ 'title' => 'No overdue findings', @@ -172,4 +136,15 @@ protected function getViewData(): array 'healthyChecks' => $healthyChecks, ]; } + + private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate + { + /** @var TenantGovernanceAggregateResolver $resolver */ + $resolver = app(TenantGovernanceAggregateResolver::class); + + /** @var TenantGovernanceAggregate $aggregate */ + $aggregate = $resolver->forTenant($tenant); + + return $aggregate; + } } diff --git a/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php b/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php index 9460d6f4..4046f85e 100644 --- a/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php +++ b/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php @@ -6,7 +6,8 @@ use App\Filament\Pages\BaselineCompareLanding; use App\Models\Tenant; -use App\Support\Baselines\BaselineCompareStats; +use App\Support\Baselines\TenantGovernanceAggregate; +use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\OperationRunLinks; use Filament\Facades\Filament; use Filament\Widgets\Widget; @@ -30,31 +31,39 @@ protected function getViewData(): array ]; } - $stats = BaselineCompareStats::forTenant($tenant); - $summaryAssessment = $stats->summaryAssessment(); - $runUrl = null; - - if ($stats->operationRunId !== null) { - $runUrl = OperationRunLinks::view($stats->operationRunId, $tenant); - } + $aggregate = $this->governanceAggregate($tenant); + $runUrl = $aggregate->stats->operationRunId !== null + ? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant) + : null; $landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant); - $nextActionUrl = match ($summaryAssessment->nextActionTarget()) { + $nextActionUrl = match ($aggregate->nextActionTarget) { 'run' => $runUrl, 'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant), 'landing' => $landingUrl, default => null, }; - $shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true) - || $summaryAssessment->stateFamily === 'action_required'; + $shouldShow = in_array($aggregate->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true) + || $aggregate->stateFamily === 'action_required'; return [ 'shouldShow' => $shouldShow, 'landingUrl' => $landingUrl, 'runUrl' => $runUrl, 'nextActionUrl' => $nextActionUrl, - 'summaryAssessment' => $summaryAssessment->toArray(), - 'state' => $stats->state, + 'summaryAssessment' => $aggregate->summaryAssessment->toArray(), + 'state' => $aggregate->compareState, ]; } + + private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate + { + /** @var TenantGovernanceAggregateResolver $resolver */ + $resolver = app(TenantGovernanceAggregateResolver::class); + + /** @var TenantGovernanceAggregate $aggregate */ + $aggregate = $resolver->forTenant($tenant); + + return $aggregate; + } } diff --git a/app/Support/Baselines/BaselineCompareStats.php b/app/Support/Baselines/BaselineCompareStats.php index 0b802431..0138c851 100644 --- a/app/Support/Baselines/BaselineCompareStats.php +++ b/app/Support/Baselines/BaselineCompareStats.php @@ -825,6 +825,35 @@ public function summaryAssessment(): BaselineCompareSummaryAssessment return $assessor->assess($this); } + public function toTenantGovernanceAggregate(Tenant $tenant): TenantGovernanceAggregate + { + $summaryAssessment = $this->summaryAssessment(); + + return new TenantGovernanceAggregate( + tenantId: (int) $tenant->getKey(), + workspaceId: (int) $tenant->workspace_id, + profileName: $this->profileName, + compareState: $this->state, + stateFamily: $summaryAssessment->stateFamily, + tone: $summaryAssessment->tone, + headline: $summaryAssessment->headline, + supportingMessage: $summaryAssessment->supportingMessage, + reasonCode: $summaryAssessment->reasonCode, + lastComparedLabel: $summaryAssessment->lastComparedLabel, + visibleDriftFindingsCount: $summaryAssessment->findingsVisibleCount, + overdueOpenFindingsCount: $this->overdueOpenFindingsCount, + expiringGovernanceCount: $this->expiringGovernanceCount, + lapsedGovernanceCount: $this->lapsedGovernanceCount, + activeNonNewFindingsCount: $this->activeNonNewFindingsCount, + highSeverityActiveFindingsCount: $this->highSeverityActiveFindingsCount, + nextActionLabel: $summaryAssessment->nextActionLabel(), + nextActionTarget: $summaryAssessment->nextActionTarget(), + positiveClaimAllowed: $summaryAssessment->positiveClaimAllowed, + stats: $this, + summaryAssessment: $summaryAssessment, + ); + } + /** * @return arraytenantId <= 0) { + throw new InvalidArgumentException('Tenant governance aggregates require a positive tenant id.'); + } + + if ($this->workspaceId <= 0) { + throw new InvalidArgumentException('Tenant governance aggregates require a positive workspace id.'); + } + + if (trim($this->compareState) === '') { + throw new InvalidArgumentException('Tenant governance aggregates require a compare state.'); + } + + if (trim($this->headline) === '') { + throw new InvalidArgumentException('Tenant governance aggregates require a headline.'); + } + + if (trim($this->nextActionLabel) === '') { + throw new InvalidArgumentException('Tenant governance aggregates require a next-action label.'); + } + + if (! in_array($this->nextActionTarget, [ + BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS, + BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING, + BaselineCompareSummaryAssessment::NEXT_TARGET_RUN, + BaselineCompareSummaryAssessment::NEXT_TARGET_NONE, + ], true)) { + throw new InvalidArgumentException('Tenant governance aggregates require a supported next-action target.'); + } + } + + /** + * @return array{ + * tenantId: int, + * workspaceId: int, + * profileName: ?string, + * compareState: string, + * stateFamily: string, + * tone: string, + * headline: string, + * supportingMessage: ?string, + * reasonCode: ?string, + * lastComparedLabel: ?string, + * visibleDriftFindingsCount: int, + * overdueOpenFindingsCount: int, + * expiringGovernanceCount: int, + * lapsedGovernanceCount: int, + * activeNonNewFindingsCount: int, + * highSeverityActiveFindingsCount: int, + * nextActionLabel: string, + * nextActionTarget: string, + * positiveClaimAllowed: bool + * } + */ + public function toArray(): array + { + return [ + 'tenantId' => $this->tenantId, + 'workspaceId' => $this->workspaceId, + 'profileName' => $this->profileName, + 'compareState' => $this->compareState, + 'stateFamily' => $this->stateFamily, + 'tone' => $this->tone, + 'headline' => $this->headline, + 'supportingMessage' => $this->supportingMessage, + 'reasonCode' => $this->reasonCode, + 'lastComparedLabel' => $this->lastComparedLabel, + 'visibleDriftFindingsCount' => $this->visibleDriftFindingsCount, + 'overdueOpenFindingsCount' => $this->overdueOpenFindingsCount, + 'expiringGovernanceCount' => $this->expiringGovernanceCount, + 'lapsedGovernanceCount' => $this->lapsedGovernanceCount, + 'activeNonNewFindingsCount' => $this->activeNonNewFindingsCount, + 'highSeverityActiveFindingsCount' => $this->highSeverityActiveFindingsCount, + 'nextActionLabel' => $this->nextActionLabel, + 'nextActionTarget' => $this->nextActionTarget, + 'positiveClaimAllowed' => $this->positiveClaimAllowed, + ]; + } +} diff --git a/app/Support/Baselines/TenantGovernanceAggregateResolver.php b/app/Support/Baselines/TenantGovernanceAggregateResolver.php new file mode 100644 index 00000000..80265e0d --- /dev/null +++ b/app/Support/Baselines/TenantGovernanceAggregateResolver.php @@ -0,0 +1,71 @@ +resolveAggregate( + tenant: $tenant, + resolver: fn (): TenantGovernanceAggregate => BaselineCompareStats::forTenant($tenant) + ->toTenantGovernanceAggregate($tenant), + fresh: $fresh, + ); + } + + public function fromStats(?Tenant $tenant, BaselineCompareStats $stats, bool $fresh = false): ?TenantGovernanceAggregate + { + if (! $tenant instanceof Tenant) { + return null; + } + + return $this->resolveAggregate( + tenant: $tenant, + resolver: fn (): TenantGovernanceAggregate => $stats->toTenantGovernanceAggregate($tenant), + fresh: $fresh, + ); + } + + private function resolveAggregate(Tenant $tenant, callable $resolver, bool $fresh = false): TenantGovernanceAggregate + { + $key = DerivedStateKey::fromModel( + DerivedStateFamily::TenantGovernanceAggregate, + $tenant, + self::VARIANT_TENANT_GOVERNANCE_SUMMARY, + ); + + $value = $fresh + ? $this->derivedStateStore->resolveFresh( + $key, + $resolver, + DerivedStateFamily::TenantGovernanceAggregate->defaultFreshnessPolicy(), + DerivedStateFamily::TenantGovernanceAggregate->allowsNegativeResultCache(), + ) + : $this->derivedStateStore->resolve( + $key, + $resolver, + DerivedStateFamily::TenantGovernanceAggregate->defaultFreshnessPolicy(), + DerivedStateFamily::TenantGovernanceAggregate->allowsNegativeResultCache(), + ); + + return $value; + } +} diff --git a/app/Support/Ui/DerivedState/DerivedStateFamily.php b/app/Support/Ui/DerivedState/DerivedStateFamily.php index baddc2ba..357853a8 100644 --- a/app/Support/Ui/DerivedState/DerivedStateFamily.php +++ b/app/Support/Ui/DerivedState/DerivedStateFamily.php @@ -12,10 +12,14 @@ enum DerivedStateFamily: string case RelatedNavigationPrimary = 'related_navigation_primary'; case RelatedNavigationDetail = 'related_navigation_detail'; case RelatedNavigationHeader = 'related_navigation_header'; + case TenantGovernanceAggregate = 'tenant_governance_aggregate'; public function allowsNegativeResultCache(): bool { - return true; + return match ($this) { + self::TenantGovernanceAggregate => false, + default => true, + }; } public function defaultFreshnessPolicy(): string diff --git a/specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml b/specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml index a46e61fb..19e27e2c 100644 --- a/specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml +++ b/specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml @@ -296,6 +296,86 @@ x-derived-state-consumers: max: 1 - needle: '->operationLinksFresh($this->run, $this->relatedLinksTenant())' max: 1 + - surface: tenant.dashboard.baseline_governance + family: tenant_governance_aggregate + variant: tenant_governance_summary + accessPattern: widget_safe + scopeInputs: + - record_class + - record_key + - workspace_id + - tenant_id + freshnessPolicy: invalidate_after_mutation + guardScope: + - app/Filament/Widgets/Dashboard/BaselineCompareNow.php + requiredMarkers: + - 'private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate' + - '$this->governanceAggregate($tenant)' + - 'summaryAssessment' + maxOccurrences: + - needle: 'BaselineCompareStats::forTenant(' + max: 0 + - surface: tenant.banner.baseline_compare_coverage + family: tenant_governance_aggregate + variant: tenant_governance_summary + accessPattern: widget_safe + scopeInputs: + - record_class + - record_key + - workspace_id + - tenant_id + freshnessPolicy: invalidate_after_mutation + guardScope: + - app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php + requiredMarkers: + - 'private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate' + - '$this->governanceAggregate($tenant)' + - 'nextActionUrl' + maxOccurrences: + - needle: 'BaselineCompareStats::forTenant(' + max: 0 + - surface: tenant.page.baseline_compare_landing + family: tenant_governance_aggregate + variant: tenant_governance_summary + accessPattern: page_safe + scopeInputs: + - record_class + - record_key + - workspace_id + - tenant_id + freshnessPolicy: invalidate_after_mutation + guardScope: + - app/Filament/Pages/BaselineCompareLanding.php + requiredMarkers: + - 'private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate' + - '$this->governanceAggregate($tenant, $stats)' + - 'Compare now' + maxOccurrences: + - needle: 'BaselineCompareStats::forTenant(' + max: 1 + - needle: '$stats->summaryAssessment()' + max: 0 + - surface: tenant.dashboard.needs_attention + family: tenant_governance_aggregate + variant: tenant_governance_summary + accessPattern: widget_safe + scopeInputs: + - record_class + - record_key + - workspace_id + - tenant_id + freshnessPolicy: invalidate_after_mutation + guardScope: + - app/Filament/Widgets/Dashboard/NeedsAttention.php + requiredMarkers: + - 'private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate' + - '$this->governanceAggregate($tenant)' + - 'Baseline compare posture' + maxOccurrences: + - needle: 'Finding::query()' + max: 0 + - needle: 'BaselineCompareStats::forTenant(' + max: 0 paths: /contracts/derived-state/resolve: post: @@ -514,6 +594,7 @@ components: - row_safe - page_safe - direct_once + - widget_safe scopeInputs: type: array description: Scope or capability inputs that affect the result for this consumer. diff --git a/tests/Feature/Baselines/BaselineCompareStatsTest.php b/tests/Feature/Baselines/BaselineCompareStatsTest.php index 25a59695..52fdb9c4 100644 --- a/tests/Feature/Baselines/BaselineCompareStatsTest.php +++ b/tests/Feature/Baselines/BaselineCompareStatsTest.php @@ -6,6 +6,7 @@ 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; @@ -335,3 +336,119 @@ 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); +}); diff --git a/tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php b/tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php index 4ad1aa06..a86f46b0 100644 --- a/tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php +++ b/tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php @@ -6,6 +6,7 @@ use App\Models\BaselineSnapshot; use App\Models\BaselineTenantAssignment; use App\Models\Finding; +use App\Models\FindingException; use App\Models\OperationRun; use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareStats; @@ -246,3 +247,84 @@ function createAssignedBaselineTenant(): array ->and($assessment->headline)->toContain('Accepted-risk governance has lapsed') ->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS); }); + +it('maps unavailable compare prerequisites to baseline prerequisite guidance', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $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(), + ]); + + $assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment(); + + expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_UNAVAILABLE) + ->and($assessment->headline)->toBe('The current baseline snapshot is not available for compare.') + ->and($assessment->nextActionLabel())->toBe('Review baseline prerequisites') + ->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING); +}); + +it('treats expiring governance as action required even with zero compare findings', function (): void { + [$tenant, $profile, $snapshot] = createAssignedBaselineTenant(); + [$user] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => [], + 'proof' => true, + ], + ], + ], + ]); + + $finding = Finding::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'status' => Finding::STATUS_RISK_ACCEPTED, + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $finding->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], + ]); + + $assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment(); + + expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED) + ->and($assessment->expiringGovernanceCount)->toBe(1) + ->and($assessment->headline)->toContain('Accepted-risk governance is nearing expiry') + ->and($assessment->nextActionLabel())->toBe('Open findings'); +}); diff --git a/tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php b/tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php new file mode 100644 index 00000000..b07afe3b --- /dev/null +++ b/tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php @@ -0,0 +1,326 @@ +active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'name' => 'Aggregate Baseline', + ]); + + $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(), + ]); + + return [$user, $tenant, $profile, $snapshot]; +} + +/** + * @param array $attributes + * @param array $compareContext + */ +function seedTenantGovernanceAggregateRun(Tenant $tenant, BaselineProfile $profile, BaselineSnapshot $snapshot, array $attributes = [], array $compareContext = []): OperationRun +{ + return OperationRun::factory()->create(array_replace_recursive([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => array_replace_recursive([ + 'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => [], + 'proof' => true, + ], + ], $compareContext), + ], + ], $attributes)); +} + +function createTenantGovernanceException(Tenant $tenant, Finding $finding, User $user, string $status, string $validityState, ?\Carbon\CarbonInterface $expiresAt = null): void +{ + FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'approved_by_user_id' => (int) $user->getKey(), + 'status' => $status, + 'current_validity_state' => $validityState, + 'request_reason' => 'Exception created for tenant governance aggregate coverage', + 'approval_reason' => 'Approved for test coverage', + 'requested_at' => now()->subDays(2), + 'approved_at' => now()->subDay(), + 'effective_from' => now()->subDay(), + 'expires_at' => $expiresAt, + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); +} + +it('resolves an unavailable governance aggregate when the assigned baseline has no snapshot', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $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(), + ]); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->compareState)->toBe('no_snapshot') + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_UNAVAILABLE) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING) + ->and($aggregate?->headline)->toBe('The current baseline snapshot is not available for compare.'); +}); + +it('resolves an in-progress governance aggregate for queued compare runs', function (): void { + [, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun( + tenant: $tenant, + profile: $profile, + snapshot: $snapshot, + attributes: [ + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'completed_at' => null, + ], + ); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->compareState)->toBe('comparing') + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_IN_PROGRESS) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN) + ->and($aggregate?->headline)->toBe('Baseline compare is in progress.'); +}); + +it('resolves a failed governance aggregate with run follow-up', function (): void { + [, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun( + tenant: $tenant, + profile: $profile, + snapshot: $snapshot, + attributes: [ + 'outcome' => OperationRunOutcome::Failed->value, + 'failure_summary' => ['message' => 'Graph API timeout'], + ], + ); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN) + ->and($aggregate?->nextActionLabel)->toBe('Review the failed run') + ->and($aggregate?->headline)->toBe('The latest baseline compare failed before it produced a usable result.'); +}); + +it('resolves an action-required aggregate when open drift findings remain', function (): void { + [, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot); + + Finding::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'source' => 'baseline.compare', + 'scope_key' => 'baseline_profile:'.$profile->getKey(), + 'severity' => Finding::SEVERITY_HIGH, + 'status' => Finding::STATUS_NEW, + ]); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED) + ->and($aggregate?->visibleDriftFindingsCount)->toBe(1) + ->and($aggregate?->highSeverityActiveFindingsCount)->toBe(1) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS); +}); + +it('resolves overdue workflow pressure as action required even with zero visible drift', function (): void { + [$user, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot); + + Finding::factory()->triaged()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'due_at' => now()->subDay(), + ]); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED) + ->and($aggregate?->overdueOpenFindingsCount)->toBe(1) + ->and($aggregate?->visibleDriftFindingsCount)->toBe(0) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS) + ->and($aggregate?->headline)->toContain('overdue finding'); +}); + +it('resolves lapsed governance as action required even with zero visible drift', function (): void { + [$user, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot); + + $finding = Finding::factory()->riskAccepted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + ]); + + createTenantGovernanceException( + tenant: $tenant, + finding: $finding, + user: $user, + status: FindingException::STATUS_EXPIRED, + validityState: FindingException::VALIDITY_EXPIRED, + expiresAt: now()->subDay(), + ); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED) + ->and($aggregate?->lapsedGovernanceCount)->toBe(1) + ->and($aggregate?->visibleDriftFindingsCount)->toBe(0) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS) + ->and($aggregate?->headline)->toContain('Accepted-risk governance has lapsed'); +}); + +it('resolves expiring governance into the shared action-required contract', function (): void { + [$user, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot); + + $finding = Finding::factory()->riskAccepted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + ]); + + createTenantGovernanceException( + tenant: $tenant, + finding: $finding, + user: $user, + status: FindingException::STATUS_EXPIRING, + validityState: FindingException::VALIDITY_EXPIRING, + expiresAt: now()->addDays(2), + ); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED) + ->and($aggregate?->expiringGovernanceCount)->toBe(1) + ->and($aggregate?->nextActionLabel)->toBe('Open findings'); +}); + +it('resolves limited-confidence zero findings into a caution aggregate', function (): void { + [, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun( + tenant: $tenant, + profile: $profile, + snapshot: $snapshot, + attributes: [ + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + ], + compareContext: [ + 'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => ['deviceCompliancePolicy'], + 'proof' => false, + ], + ], + ); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_CAUTION) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN) + ->and($aggregate?->headline)->toBe('The last compare finished, but normal result output was suppressed.'); +}); + +it('resolves stale no-drift compare results into a stale aggregate', function (): void { + [, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun( + tenant: $tenant, + profile: $profile, + snapshot: $snapshot, + attributes: [ + 'completed_at' => now()->subDays(10), + ], + ); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_STALE) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING) + ->and($aggregate?->headline)->toBe('The latest baseline compare result is stale.'); +}); + +it('resolves trustworthy no-drift results into a positive all-clear aggregate', function (): void { + [, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_POSITIVE) + ->and($aggregate?->positiveClaimAllowed)->toBeTrue() + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_NONE) + ->and($aggregate?->headline)->toBe('No confirmed drift in the latest baseline compare.'); +}); diff --git a/tests/Feature/Filament/BaselineCompareCoverageBannerTest.php b/tests/Feature/Filament/BaselineCompareCoverageBannerTest.php index af1304d7..5184689c 100644 --- a/tests/Feature/Filament/BaselineCompareCoverageBannerTest.php +++ b/tests/Feature/Filament/BaselineCompareCoverageBannerTest.php @@ -6,6 +6,7 @@ use App\Models\BaselineProfile; use App\Models\BaselineSnapshot; use App\Models\BaselineTenantAssignment; +use App\Models\Finding; use App\Models\OperationRun; use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\OperationRunOutcome; @@ -128,3 +129,43 @@ function createCoverageBannerTenant(): array ->assertDontSee('No confirmed drift in the latest baseline compare.') ->assertDontSee('Review compare detail'); }); + +it('shows an action banner when overdue findings remain without new drift', function (): void { + [$user, $tenant, $profile, $snapshot] = createCoverageBannerTenant(); + $this->actingAs($user); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value, + '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(), + ]); + + Filament::setCurrentPanel(Filament::getPanel('tenant')); + Filament::setTenant($tenant, true); + + Livewire::test(BaselineCompareCoverageBanner::class) + ->assertSee('overdue finding') + ->assertSee('Open findings'); +}); diff --git a/tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php b/tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php index 7fada607..d68739e3 100644 --- a/tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php +++ b/tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php @@ -8,6 +8,7 @@ use App\Models\BaselineProfile; use App\Models\BaselineSnapshot; use App\Models\BaselineTenantAssignment; +use App\Models\Finding; use App\Models\OperationRun; use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\OperationRunOutcome; @@ -16,6 +17,30 @@ use Filament\Facades\Filament; use Livewire\Livewire; +function createBaselineCompareSummaryConsistencyTenant(): array +{ + [$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(), + ]); + + return [$user, $tenant, $profile, $snapshot]; +} + it('keeps widget, landing, and banner equally cautious for the same limited-confidence compare result', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); @@ -77,3 +102,87 @@ ->assertSee('The last compare finished, but normal result output was suppressed.') ->assertSee('Review compare detail'); }); + +it('keeps widget, landing, and banner aligned when overdue workflow remains without new drift', function (): void { + [$user, $tenant, $profile, $snapshot] = createBaselineCompareSummaryConsistencyTenant(); + $this->actingAs($user); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value, + '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(), + ]); + + Filament::setCurrentPanel(Filament::getPanel('tenant')); + Filament::setTenant($tenant, true); + + Livewire::test(BaselineCompareNow::class) + ->assertSee('Action required') + ->assertSee('overdue finding') + ->assertSee('Open findings'); + + Livewire::test(BaselineCompareLanding::class) + ->assertSee('Action required') + ->assertSee('overdue finding') + ->assertSee('Open findings'); + + Livewire::test(BaselineCompareCoverageBanner::class) + ->assertSee('overdue finding') + ->assertSee('Open findings'); +}); + +it('keeps widget, landing, and banner aligned while compare is still running', function (): void { + [$user, $tenant, $profile, $snapshot] = createBaselineCompareSummaryConsistencyTenant(); + $this->actingAs($user); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Running->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + ], + ]); + + Filament::setCurrentPanel(Filament::getPanel('tenant')); + Filament::setTenant($tenant, true); + + Livewire::test(BaselineCompareNow::class) + ->assertSee('In progress') + ->assertSee('Baseline compare is in progress.') + ->assertSee('View run'); + + Livewire::test(BaselineCompareLanding::class) + ->assertSee('In progress') + ->assertSee('Baseline compare is in progress.') + ->assertSee('View run'); + + Livewire::test(BaselineCompareCoverageBanner::class) + ->assertSee('Baseline compare is in progress.') + ->assertSee('View run'); +}); diff --git a/tests/Feature/Filament/NeedsAttentionWidgetTest.php b/tests/Feature/Filament/NeedsAttentionWidgetTest.php index 681532ea..7f625376 100644 --- a/tests/Feature/Filament/NeedsAttentionWidgetTest.php +++ b/tests/Feature/Filament/NeedsAttentionWidgetTest.php @@ -7,6 +7,7 @@ use App\Models\BaselineSnapshot; use App\Models\BaselineTenantAssignment; use App\Models\Finding; +use App\Models\FindingException; use App\Models\OperationRun; use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\OperationRunOutcome; @@ -193,3 +194,64 @@ function createNeedsAttentionTenant(): array ->assertSee('Lapsed accepted-risk governance') ->assertDontSee('Current dashboard signals look trustworthy.'); }); + +it('surfaces expiring governance from the shared aggregate without adding navigation links', function (): void { + [$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant(); + $this->actingAs($user); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => [], + 'proof' => true, + ], + ], + ], + ]); + + $finding = 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) $finding->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], + ]); + + Filament::setCurrentPanel(Filament::getPanel('tenant')); + Filament::setTenant($tenant, true); + + $component = Livewire::test(NeedsAttention::class) + ->assertSee('Expiring accepted-risk governance') + ->assertSee('Open findings') + ->assertDontSee('Current dashboard signals look trustworthy.'); + + expect($component->html())->not->toContain('href='); +}); diff --git a/tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php b/tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php new file mode 100644 index 00000000..61bf6bd8 --- /dev/null +++ b/tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php @@ -0,0 +1,122 @@ +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(), + ]); + + \App\Models\OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => [], + 'proof' => true, + ], + ], + ], + ]); + + return [$user, $tenant]; +} + +it('reuses one tenant-governance aggregate across the tenant dashboard summary widgets', function (): void { + [$user, $tenant] = createTenantGovernanceMemoizationTenant(); + + $this->actingAs($user); + Filament::setCurrentPanel(Filament::getPanel('tenant')); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user)->test(NeedsAttention::class); + Livewire::actingAs($user)->test(BaselineCompareNow::class); + + expect(app(RequestScopedDerivedStateStore::class)->countStored( + DerivedStateFamily::TenantGovernanceAggregate, + Tenant::class, + (string) $tenant->getKey(), + TenantGovernanceAggregateResolver::VARIANT_TENANT_GOVERNANCE_SUMMARY, + ))->toBe(1); +}); + +it('keeps tenant switches from reusing another tenant aggregate in the same request scope', function (): void { + [, $tenantA] = createTenantGovernanceMemoizationTenant(); + [, $tenantB] = createTenantGovernanceMemoizationTenant(); + + $resolver = app(TenantGovernanceAggregateResolver::class); + + $aggregateA = $resolver->forTenant($tenantA); + $aggregateB = $resolver->forTenant($tenantB); + + $store = app(RequestScopedDerivedStateStore::class); + + expect($aggregateA)->not->toBeNull() + ->and($aggregateB)->not->toBeNull() + ->and($aggregateA?->tenantId)->toBe((int) $tenantA->getKey()) + ->and($aggregateB?->tenantId)->toBe((int) $tenantB->getKey()) + ->and($aggregateA?->tenantId)->not->toBe($aggregateB?->tenantId) + ->and($store->countStored( + DerivedStateFamily::TenantGovernanceAggregate, + Tenant::class, + (string) $tenantA->getKey(), + TenantGovernanceAggregateResolver::VARIANT_TENANT_GOVERNANCE_SUMMARY, + ))->toBe(1) + ->and($store->countStored( + DerivedStateFamily::TenantGovernanceAggregate, + Tenant::class, + (string) $tenantB->getKey(), + TenantGovernanceAggregateResolver::VARIANT_TENANT_GOVERNANCE_SUMMARY, + ))->toBe(1); +}); + +it('returns no aggregate and stores nothing when no tenant context exists', function (): void { + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant(null); + + expect($aggregate)->toBeNull() + ->and(app(RequestScopedDerivedStateStore::class)->countStored( + DerivedStateFamily::TenantGovernanceAggregate, + ))->toBe(0); +}); diff --git a/tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php b/tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php index e003e28f..257c7e71 100644 --- a/tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php +++ b/tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php @@ -22,8 +22,9 @@ 'related_navigation_primary', 'related_navigation_detail', 'related_navigation_header', + 'tenant_governance_aggregate', ]; - $allowedAccessPatterns = ['row_safe', 'page_safe', 'direct_once']; + $allowedAccessPatterns = ['row_safe', 'page_safe', 'direct_once', 'widget_safe']; $allowedFreshnessPolicies = ['request_stable', 'invalidate_after_mutation', 'no_reuse']; $cachePattern = '/static\s+array\s+\$[A-Za-z0-9_]*cache\b/i'; $violations = [];