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\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
@ -133,7 +135,11 @@ public function mount(): void
|
|||||||
|
|
||||||
public function refreshStats(): 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->state = $stats->state;
|
||||||
$this->message = $stats->message;
|
$this->message = $stats->message;
|
||||||
@ -169,7 +175,7 @@ public function refreshStats(): void
|
|||||||
: null;
|
: null;
|
||||||
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||||
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
$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);
|
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\Pages\BaselineCompareLanding;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
@ -38,19 +39,18 @@ protected function getViewData(): array
|
|||||||
return $empty;
|
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;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
$runUrl = $stats->operationRunId !== null
|
$runUrl = $aggregate->stats->operationRunId !== null
|
||||||
? OperationRunLinks::view($stats->operationRunId, $tenant)
|
? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant)
|
||||||
: null;
|
: null;
|
||||||
$findingsUrl = FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
$findingsUrl = FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
$summaryAssessment = $stats->summaryAssessment();
|
$nextActionUrl = match ($aggregate->nextActionTarget) {
|
||||||
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
|
||||||
'run' => $runUrl,
|
'run' => $runUrl,
|
||||||
'findings' => $findingsUrl,
|
'findings' => $findingsUrl,
|
||||||
'landing' => $tenantLandingUrl,
|
'landing' => $tenantLandingUrl,
|
||||||
@ -59,13 +59,24 @@ protected function getViewData(): array
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'hasAssignment' => true,
|
'hasAssignment' => true,
|
||||||
'profileName' => $stats->profileName,
|
'profileName' => $aggregate->profileName,
|
||||||
'lastComparedAt' => $stats->lastComparedHuman,
|
'lastComparedAt' => $aggregate->lastComparedLabel,
|
||||||
'landingUrl' => $tenantLandingUrl,
|
'landingUrl' => $tenantLandingUrl,
|
||||||
'runUrl' => $runUrl,
|
'runUrl' => $runUrl,
|
||||||
'findingsUrl' => $findingsUrl,
|
'findingsUrl' => $findingsUrl,
|
||||||
'nextActionUrl' => $nextActionUrl,
|
'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;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
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 App\Support\OpsUx\ActiveRuns;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
@ -31,51 +31,15 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
$compareStats = BaselineCompareStats::forTenant($tenant);
|
$aggregate = $this->governanceAggregate($tenant);
|
||||||
$compareAssessment = $compareStats->summaryAssessment();
|
$compareAssessment = $aggregate->summaryAssessment;
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|
||||||
$overdueOpenCount = (int) Finding::query()
|
$overdueOpenCount = $aggregate->overdueOpenFindingsCount;
|
||||||
->where('tenant_id', $tenantId)
|
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
$expiringGovernanceCount = $aggregate->expiringGovernanceCount;
|
||||||
->whereNotNull('due_at')
|
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
|
||||||
->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();
|
|
||||||
|
|
||||||
if ($lapsedGovernanceCount > 0) {
|
if ($lapsedGovernanceCount > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
@ -120,7 +84,7 @@ protected function getViewData(): array
|
|||||||
'supportingMessage' => $compareAssessment->supportingMessage,
|
'supportingMessage' => $compareAssessment->supportingMessage,
|
||||||
'badge' => 'Baseline',
|
'badge' => 'Baseline',
|
||||||
'badgeColor' => $compareAssessment->tone,
|
'badgeColor' => $compareAssessment->tone,
|
||||||
'nextStep' => $compareAssessment->nextActionLabel(),
|
'nextStep' => $aggregate->nextActionLabel,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,7 +109,7 @@ protected function getViewData(): array
|
|||||||
$healthyChecks = [
|
$healthyChecks = [
|
||||||
[
|
[
|
||||||
'title' => 'Baseline compare looks trustworthy',
|
'title' => 'Baseline compare looks trustworthy',
|
||||||
'body' => $compareAssessment->headline,
|
'body' => $aggregate->headline,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'No overdue findings',
|
'title' => 'No overdue findings',
|
||||||
@ -172,4 +136,15 @@ protected function getViewData(): array
|
|||||||
'healthyChecks' => $healthyChecks,
|
'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\Filament\Pages\BaselineCompareLanding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
@ -30,31 +31,39 @@ protected function getViewData(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forTenant($tenant);
|
$aggregate = $this->governanceAggregate($tenant);
|
||||||
$summaryAssessment = $stats->summaryAssessment();
|
$runUrl = $aggregate->stats->operationRunId !== null
|
||||||
$runUrl = null;
|
? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant)
|
||||||
|
: null;
|
||||||
if ($stats->operationRunId !== null) {
|
|
||||||
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
$landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
$landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
$nextActionUrl = match ($aggregate->nextActionTarget) {
|
||||||
'run' => $runUrl,
|
'run' => $runUrl,
|
||||||
'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||||
'landing' => $landingUrl,
|
'landing' => $landingUrl,
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
$shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
$shouldShow = in_array($aggregate->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
||||||
|| $summaryAssessment->stateFamily === 'action_required';
|
|| $aggregate->stateFamily === 'action_required';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'shouldShow' => $shouldShow,
|
'shouldShow' => $shouldShow,
|
||||||
'landingUrl' => $landingUrl,
|
'landingUrl' => $landingUrl,
|
||||||
'runUrl' => $runUrl,
|
'runUrl' => $runUrl,
|
||||||
'nextActionUrl' => $nextActionUrl,
|
'nextActionUrl' => $nextActionUrl,
|
||||||
'summaryAssessment' => $summaryAssessment->toArray(),
|
'summaryAssessment' => $aggregate->summaryAssessment->toArray(),
|
||||||
'state' => $stats->state,
|
'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);
|
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{
|
* @return array<int, array{
|
||||||
* label: string,
|
* 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 RelatedNavigationPrimary = 'related_navigation_primary';
|
||||||
case RelatedNavigationDetail = 'related_navigation_detail';
|
case RelatedNavigationDetail = 'related_navigation_detail';
|
||||||
case RelatedNavigationHeader = 'related_navigation_header';
|
case RelatedNavigationHeader = 'related_navigation_header';
|
||||||
|
case TenantGovernanceAggregate = 'tenant_governance_aggregate';
|
||||||
|
|
||||||
public function allowsNegativeResultCache(): bool
|
public function allowsNegativeResultCache(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return match ($this) {
|
||||||
|
self::TenantGovernanceAggregate => false,
|
||||||
|
default => true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public function defaultFreshnessPolicy(): string
|
public function defaultFreshnessPolicy(): string
|
||||||
|
|||||||
@ -296,6 +296,86 @@ x-derived-state-consumers:
|
|||||||
max: 1
|
max: 1
|
||||||
- needle: '->operationLinksFresh($this->run, $this->relatedLinksTenant())'
|
- needle: '->operationLinksFresh($this->run, $this->relatedLinksTenant())'
|
||||||
max: 1
|
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:
|
paths:
|
||||||
/contracts/derived-state/resolve:
|
/contracts/derived-state/resolve:
|
||||||
post:
|
post:
|
||||||
@ -514,6 +594,7 @@ components:
|
|||||||
- row_safe
|
- row_safe
|
||||||
- page_safe
|
- page_safe
|
||||||
- direct_once
|
- direct_once
|
||||||
|
- widget_safe
|
||||||
scopeInputs:
|
scopeInputs:
|
||||||
type: array
|
type: array
|
||||||
description: Scope or capability inputs that affect the result for this consumer.
|
description: Scope or capability inputs that affect the result for this consumer.
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\FindingException;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
|
|
||||||
@ -335,3 +336,119 @@
|
|||||||
expect($stats->findingsCount)->toBe(1)
|
expect($stats->findingsCount)->toBe(1)
|
||||||
->and($stats->severityCounts['high'])->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\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\FindingException;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
@ -246,3 +247,84 @@ function createAssignedBaselineTenant(): array
|
|||||||
->and($assessment->headline)->toContain('Accepted-risk governance has lapsed')
|
->and($assessment->headline)->toContain('Accepted-risk governance has lapsed')
|
||||||
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS);
|
->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\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -128,3 +129,43 @@ function createCoverageBannerTenant(): array
|
|||||||
->assertDontSee('No confirmed drift in the latest baseline compare.')
|
->assertDontSee('No confirmed drift in the latest baseline compare.')
|
||||||
->assertDontSee('Review compare detail');
|
->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\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -16,6 +17,30 @@
|
|||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Livewire\Livewire;
|
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 {
|
it('keeps widget, landing, and banner equally cautious for the same limited-confidence compare result', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -77,3 +102,87 @@
|
|||||||
->assertSee('The last compare finished, but normal result output was suppressed.')
|
->assertSee('The last compare finished, but normal result output was suppressed.')
|
||||||
->assertSee('Review compare detail');
|
->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\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\FindingException;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -193,3 +194,64 @@ function createNeedsAttentionTenant(): array
|
|||||||
->assertSee('Lapsed accepted-risk governance')
|
->assertSee('Lapsed accepted-risk governance')
|
||||||
->assertDontSee('Current dashboard signals look trustworthy.');
|
->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_primary',
|
||||||
'related_navigation_detail',
|
'related_navigation_detail',
|
||||||
'related_navigation_header',
|
'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'];
|
$allowedFreshnessPolicies = ['request_stable', 'invalidate_after_mutation', 'no_reuse'];
|
||||||
$cachePattern = '/static\s+array\s+\$[A-Za-z0-9_]*cache\b/i';
|
$cachePattern = '/static\s+array\s+\$[A-Za-z0-9_]*cache\b/i';
|
||||||
$violations = [];
|
$violations = [];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user