feat: add tenant governance aggregate contract and action surface follow-ups #199
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
111
app/Support/Baselines/TenantGovernanceAggregate.php
Normal file
111
app/Support/Baselines/TenantGovernanceAggregate.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
71
app/Support/Baselines/TenantGovernanceAggregateResolver.php
Normal file
71
app/Support/Baselines/TenantGovernanceAggregateResolver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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.');
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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=');
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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 = [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user