feat: add tenant governance aggregate contract and action surface follow-ups #199

Merged
ahmido merged 6 commits from 168-tenant-governance-aggregate-contract into dev 2026-03-29 21:14:18 +00:00
17 changed files with 1241 additions and 73 deletions
Showing only changes of commit f6dc5ed947 - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

@ -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 array<int, array{
* label: string,

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use InvalidArgumentException;
final readonly class TenantGovernanceAggregate
{
public function __construct(
public int $tenantId,
public int $workspaceId,
public ?string $profileName,
public string $compareState,
public string $stateFamily,
public string $tone,
public string $headline,
public ?string $supportingMessage,
public ?string $reasonCode,
public ?string $lastComparedLabel,
public int $visibleDriftFindingsCount,
public int $overdueOpenFindingsCount,
public int $expiringGovernanceCount,
public int $lapsedGovernanceCount,
public int $activeNonNewFindingsCount,
public int $highSeverityActiveFindingsCount,
public string $nextActionLabel,
public string $nextActionTarget,
public bool $positiveClaimAllowed,
public BaselineCompareStats $stats,
public BaselineCompareSummaryAssessment $summaryAssessment,
) {
if ($this->tenantId <= 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,
];
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Models\Tenant;
use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\DerivedStateKey;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
final class TenantGovernanceAggregateResolver
{
public const string VARIANT_TENANT_GOVERNANCE_SUMMARY = 'tenant_governance_summary';
public function __construct(
private readonly RequestScopedDerivedStateStore $derivedStateStore,
) {}
public function forTenant(?Tenant $tenant, bool $fresh = false): ?TenantGovernanceAggregate
{
if (! $tenant instanceof Tenant) {
return null;
}
return $this->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;
}
}

View File

@ -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

View File

@ -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.

View File

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

View File

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

View File

@ -0,0 +1,326 @@
<?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\Models\Tenant;
use App\Models\User;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineCompareSummaryAssessment;
use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
function createTenantGovernanceAggregateTenant(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->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<string, mixed> $attributes
* @param array<string, mixed> $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.');
});

View File

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

View File

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

View File

@ -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=');
});

View File

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use Filament\Facades\Filament;
use Livewire\Livewire;
function createTenantGovernanceMemoizationTenant(): 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(),
]);
\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);
});

View File

@ -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 = [];