feat(spec-118): resumable baseline evidence + snapshot UX
This commit is contained in:
parent
559bba09a0
commit
e3a062c1a2
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PruneBaselineEvidencePolicyVersionsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'tenantpilot:baseline-evidence:prune {--days= : Number of days to retain baseline evidence policy versions}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Soft-delete baseline-capture/baseline-compare policy versions older than the configured retention window';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?: config('tenantpilot.baselines.full_content_capture.retention_days', 90));
|
||||
|
||||
if ($days < 1) {
|
||||
$this->error('Retention days must be at least 1.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$deleted = PolicyVersion::query()
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('capture_purpose', [
|
||||
PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
])
|
||||
->where('captured_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->info("Pruned {$deleted} baseline evidence policy version(s) older than {$days} days.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -44,12 +44,18 @@ class BaselineCompareLanding extends Page
|
||||
|
||||
public ?string $message = null;
|
||||
|
||||
public ?string $reasonCode = null;
|
||||
|
||||
public ?string $reasonMessage = null;
|
||||
|
||||
public ?string $profileName = null;
|
||||
|
||||
public ?int $profileId = null;
|
||||
|
||||
public ?int $snapshotId = null;
|
||||
|
||||
public ?int $duplicateNamePoliciesCount = null;
|
||||
|
||||
public ?int $operationRunId = null;
|
||||
|
||||
public ?int $findingsCount = null;
|
||||
@ -110,12 +116,15 @@ public function refreshStats(): void
|
||||
$this->profileName = $stats->profileName;
|
||||
$this->profileId = $stats->profileId;
|
||||
$this->snapshotId = $stats->snapshotId;
|
||||
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
||||
$this->operationRunId = $stats->operationRunId;
|
||||
$this->findingsCount = $stats->findingsCount;
|
||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||
$this->lastComparedAt = $stats->lastComparedHuman;
|
||||
$this->lastComparedIso = $stats->lastComparedIso;
|
||||
$this->failureReason = $stats->failureReason;
|
||||
$this->reasonCode = $stats->reasonCode;
|
||||
$this->reasonMessage = $stats->reasonMessage;
|
||||
|
||||
$this->coverageStatus = $stats->coverageStatus;
|
||||
$this->uncoveredTypesCount = $stats->uncoveredTypesCount;
|
||||
@ -126,6 +135,101 @@ public function refreshStats(): void
|
||||
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed view data exposed to the Blade template.
|
||||
*
|
||||
* Moves presentational logic out of Blade `@php` blocks so the
|
||||
* template only receives ready-to-render values.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
||||
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||
|
||||
$evidenceGapsSummary = null;
|
||||
$evidenceGapsTooltip = null;
|
||||
|
||||
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
||||
$parts = [];
|
||||
|
||||
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
||||
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = $reason.' ('.((int) $count).')';
|
||||
}
|
||||
|
||||
if ($parts !== []) {
|
||||
$evidenceGapsSummary = implode(', ', $parts);
|
||||
$evidenceGapsTooltip = __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]);
|
||||
}
|
||||
}
|
||||
|
||||
// Derive the colour class for the findings-count stat card.
|
||||
// Only show danger-red when high-severity findings exist;
|
||||
// use warning-orange for low/medium-only, and success-green for zero.
|
||||
$findingsColorClass = $this->resolveFindingsColorClass($hasWarnings);
|
||||
|
||||
// "Why no findings" explanation when count is zero.
|
||||
$whyNoFindingsMessage = filled($this->reasonMessage) ? (string) $this->reasonMessage : null;
|
||||
$whyNoFindingsFallback = ! $hasWarnings
|
||||
? __('baseline-compare.no_findings_all_clear')
|
||||
: ($hasCoverageWarnings
|
||||
? __('baseline-compare.no_findings_coverage_warnings')
|
||||
: ($hasEvidenceGaps
|
||||
? __('baseline-compare.no_findings_evidence_gaps')
|
||||
: __('baseline-compare.no_findings_default')));
|
||||
$whyNoFindingsColor = $hasWarnings
|
||||
? 'text-warning-600 dark:text-warning-400'
|
||||
: 'text-success-600 dark:text-success-400';
|
||||
|
||||
if ($this->reasonCode === 'no_subjects_in_scope') {
|
||||
$whyNoFindingsColor = 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
|
||||
return [
|
||||
'hasCoverageWarnings' => $hasCoverageWarnings,
|
||||
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||
'hasWarnings' => $hasWarnings,
|
||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||
'findingsColorClass' => $findingsColorClass,
|
||||
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
||||
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
||||
'whyNoFindingsColor' => $whyNoFindingsColor,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Tailwind colour class for the Total Findings stat.
|
||||
*
|
||||
* - Red (danger) only when high-severity findings exist
|
||||
* - Orange (warning) for medium/low-only findings or when warnings present
|
||||
* - Green (success) when fully clear
|
||||
*/
|
||||
private function resolveFindingsColorClass(bool $hasWarnings): string
|
||||
{
|
||||
$count = (int) ($this->findingsCount ?? 0);
|
||||
|
||||
if ($count === 0) {
|
||||
return $hasWarnings
|
||||
? 'text-warning-600 dark:text-warning-400'
|
||||
: 'text-success-600 dark:text-success-400';
|
||||
}
|
||||
|
||||
$hasHigh = ($this->severityCounts['high'] ?? 0) > 0;
|
||||
|
||||
return $hasHigh
|
||||
? 'text-danger-600 dark:text-danger-400'
|
||||
: 'text-warning-600 dark:text-warning-400';
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
|
||||
@ -9,10 +9,16 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\EmbeddedSchema;
|
||||
use Filament\Schemas\Schema;
|
||||
@ -105,6 +111,8 @@ protected function getHeaderActions(): array
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
$actions[] = $this->resumeCaptureAction();
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
@ -139,4 +147,120 @@ public function content(Schema $schema): Schema
|
||||
EmbeddedSchema::make('infolist'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resumeCaptureAction(): Action
|
||||
{
|
||||
return Action::make('resumeCapture')
|
||||
->label('Resume capture')
|
||||
->icon('heroicon-o-forward')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Resume capture')
|
||||
->modalDescription('This will start a follow-up operation to capture remaining baseline evidence for this scope.')
|
||||
->visible(fn (): bool => $this->canResumeCapture())
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! isset($this->run)) {
|
||||
Notification::make()
|
||||
->title('Run not loaded')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(BaselineEvidenceCaptureResumeService::class);
|
||||
$result = $service->resume($this->run, $user);
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
$reason = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot resume capture')
|
||||
->body('Reason: '.str_replace('.', ' ', $reason))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = $result['run'] ?? null;
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
Notification::make()
|
||||
->title('Cannot resume capture')
|
||||
->body('Reason: missing operation run')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$viewAction = Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::tenantlessView($run));
|
||||
|
||||
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
private function canResumeCapture(): bool
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((string) $this->run->status !== 'completed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||
$tokenKey = (string) $this->run->type === 'baseline_capture'
|
||||
? 'baseline_capture.resume_token'
|
||||
: 'baseline_compare.resume_token';
|
||||
$token = data_get($context, $tokenKey);
|
||||
|
||||
if (! is_string($token) || trim($token) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = $this->run->workspace;
|
||||
|
||||
if (! $workspace instanceof \App\Models\Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||
}
|
||||
}
|
||||
|
||||
275
app/Filament/Resources/BaselineSnapshotResource.php
Normal file
275
app/Filament/Resources/BaselineSnapshotResource.php
Normal file
@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource\Pages;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
class BaselineSnapshotResource extends Resource
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = BaselineSnapshot::class;
|
||||
|
||||
protected static ?string $slug = 'baseline-snapshots';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-camera';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Baseline Snapshots';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = self::resolveWorkspace();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
return self::canViewAny();
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; no row actions besides view.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Snapshots are immutable; no bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; snapshots appear after baseline captures.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with('baselineProfile')
|
||||
->when(
|
||||
$workspaceId !== null,
|
||||
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||
)
|
||||
->when(
|
||||
$workspaceId === null,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('captured_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('Snapshot')
|
||||
->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->sortable(),
|
||||
TextColumn::make('baselineProfile.name')
|
||||
->label('Baseline')
|
||||
->wrap()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('captured_at')
|
||||
->label('Captured')
|
||||
->since()
|
||||
->sortable(),
|
||||
TextColumn::make('fidelity_summary')
|
||||
->label('Fidelity')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('snapshot_state')
|
||||
->label('State')
|
||||
->badge()
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
||||
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
|
||||
])
|
||||
->actions([
|
||||
ViewAction::make()->label('View'),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Snapshot')
|
||||
->schema([
|
||||
TextEntry::make('id')
|
||||
->label('Snapshot')
|
||||
->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—'),
|
||||
TextEntry::make('baselineProfile.name')
|
||||
->label('Baseline'),
|
||||
TextEntry::make('captured_at')
|
||||
->label('Captured')
|
||||
->dateTime(),
|
||||
TextEntry::make('snapshot_state')
|
||||
->label('State')
|
||||
->badge()
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
||||
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
|
||||
TextEntry::make('fidelity_summary')
|
||||
->label('Fidelity')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)),
|
||||
TextEntry::make('evidence_gaps')
|
||||
->label('Evidence gaps')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): int => self::gapsCount($record)),
|
||||
TextEntry::make('snapshot_identity_hash')
|
||||
->label('Identity hash')
|
||||
->copyable()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Summary')
|
||||
->schema([
|
||||
ViewEntry::make('summary_jsonb')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(static fn (BaselineSnapshot $record): array => is_array($record->summary_jsonb) ? $record->summary_jsonb : [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListBaselineSnapshots::route('/'),
|
||||
'view' => Pages\ViewBaselineSnapshot::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function resolveWorkspace(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Workspace::query()->whereKey($workspaceId)->first();
|
||||
}
|
||||
|
||||
private static function summary(BaselineSnapshot $snapshot): array
|
||||
{
|
||||
return is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
||||
}
|
||||
|
||||
private static function fidelityCounts(BaselineSnapshot $snapshot): array
|
||||
{
|
||||
$summary = self::summary($snapshot);
|
||||
$counts = $summary['fidelity_counts'] ?? null;
|
||||
$counts = is_array($counts) ? $counts : [];
|
||||
|
||||
$content = $counts['content'] ?? 0;
|
||||
$meta = $counts['meta'] ?? 0;
|
||||
|
||||
return [
|
||||
'content' => is_numeric($content) ? (int) $content : 0,
|
||||
'meta' => is_numeric($meta) ? (int) $meta : 0,
|
||||
];
|
||||
}
|
||||
|
||||
private static function fidelitySummary(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
$counts = self::fidelityCounts($snapshot);
|
||||
|
||||
return sprintf('Content %d, Meta %d', (int) ($counts['content'] ?? 0), (int) ($counts['meta'] ?? 0));
|
||||
}
|
||||
|
||||
private static function gapsCount(BaselineSnapshot $snapshot): int
|
||||
{
|
||||
$summary = self::summary($snapshot);
|
||||
$gaps = $summary['gaps'] ?? null;
|
||||
$gaps = is_array($gaps) ? $gaps : [];
|
||||
|
||||
$count = $gaps['count'] ?? 0;
|
||||
|
||||
return is_numeric($count) ? (int) $count : 0;
|
||||
}
|
||||
|
||||
private static function hasGaps(BaselineSnapshot $snapshot): bool
|
||||
{
|
||||
return self::gapsCount($snapshot) > 0;
|
||||
}
|
||||
|
||||
private static function stateLabel(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBaselineSnapshots extends ListRecords
|
||||
{
|
||||
protected static string $resource = BaselineSnapshotResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBaselineSnapshot extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BaselineSnapshotResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
@ -218,6 +219,30 @@ public static function infolist(Schema $schema): Schema
|
||||
'warnings', 'unproven' => 'warning',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextEntry::make('baseline_compare_why_no_findings')
|
||||
->label('Why no findings')
|
||||
->getStateUsing(function (OperationRun $record): ?string {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$code = data_get($context, 'baseline_compare.reason_code');
|
||||
$code = is_string($code) ? trim($code) : null;
|
||||
$code = $code !== '' ? $code : null;
|
||||
|
||||
if ($code === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$enum = BaselineCompareReasonCode::tryFrom($code);
|
||||
$message = $enum?->message();
|
||||
|
||||
return ($message !== null ? $message.' (' : '').$code.($message !== null ? ')' : '');
|
||||
})
|
||||
->visible(function (OperationRun $record): bool {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$code = data_get($context, 'baseline_compare.reason_code');
|
||||
|
||||
return is_string($code) && trim($code) !== '';
|
||||
})
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('baseline_compare_uncovered_types')
|
||||
->label('Uncovered types')
|
||||
->getStateUsing(function (OperationRun $record): ?string {
|
||||
|
||||
@ -22,14 +22,13 @@ class ListPolicies extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$this->makeSyncAction()
|
||||
->visible(fn (): bool => $this->getFilteredTableQuery()->exists()),
|
||||
$this->makeSyncAction(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
return [$this->makeSyncAction('syncEmpty')];
|
||||
return [$this->makeSyncAction()];
|
||||
}
|
||||
|
||||
private function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -825,10 +826,29 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenant = Tenant::currentOrFail();
|
||||
$tenantId = $tenant->getKey();
|
||||
$user = auth()->user();
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$canSeeBaselinePurposeEvidence = $user instanceof User
|
||||
&& (
|
||||
$resolver->can($user, $tenant, Capabilities::TENANT_SYNC)
|
||||
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
|
||||
);
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query
|
||||
->whereNull('capture_purpose')
|
||||
->orWhereNotIn('capture_purpose', [
|
||||
PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
]);
|
||||
});
|
||||
})
|
||||
->with('policy');
|
||||
}
|
||||
|
||||
|
||||
@ -284,17 +284,29 @@ private function collectInventorySubjects(
|
||||
|
||||
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, display_name: ?string, category: ?string, platform: ?string}> $inventoryByKey */
|
||||
$inventoryByKey = [];
|
||||
$subjectsTotal = 0;
|
||||
|
||||
/** @var array<string, int> $gaps */
|
||||
$gaps = [];
|
||||
|
||||
/**
|
||||
* Ensure we only include unambiguous subjects when matching by subject_key (derived from display name).
|
||||
*
|
||||
* When multiple inventory items share the same "policy_type|subject_key" we cannot reliably map them
|
||||
* across tenants, so we treat them as an evidence gap and exclude them from the snapshot.
|
||||
*
|
||||
* @var array<string, true> $ambiguousKeys
|
||||
*/
|
||||
$ambiguousKeys = [];
|
||||
|
||||
/**
|
||||
* @var array<string, string> $subjectKeyToInventoryKey
|
||||
*/
|
||||
$subjectKeyToInventoryKey = [];
|
||||
|
||||
$query->orderBy('policy_type')
|
||||
->orderBy('external_id')
|
||||
->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$subjectsTotal, &$gaps): void {
|
||||
->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$gaps, &$ambiguousKeys, &$subjectKeyToInventoryKey): void {
|
||||
foreach ($inventoryItems as $inventoryItem) {
|
||||
$subjectsTotal++;
|
||||
|
||||
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
|
||||
$displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null;
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
@ -305,18 +317,37 @@ private function collectInventorySubjects(
|
||||
continue;
|
||||
}
|
||||
|
||||
$policyType = (string) $inventoryItem->policy_type;
|
||||
$logicalKey = $policyType.'|'.$subjectKey;
|
||||
|
||||
if (array_key_exists($logicalKey, $ambiguousKeys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists($logicalKey, $subjectKeyToInventoryKey)) {
|
||||
$ambiguousKeys[$logicalKey] = true;
|
||||
|
||||
$previousKey = $subjectKeyToInventoryKey[$logicalKey];
|
||||
unset($subjectKeyToInventoryKey[$logicalKey], $inventoryByKey[$previousKey]);
|
||||
|
||||
$gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$workspaceSafeId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $inventoryItem->policy_type,
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
);
|
||||
|
||||
$key = (string) $inventoryItem->policy_type.'|'.(string) $inventoryItem->external_id;
|
||||
$key = $policyType.'|'.(string) $inventoryItem->external_id;
|
||||
$subjectKeyToInventoryKey[$logicalKey] = $key;
|
||||
|
||||
$inventoryByKey[$key] = [
|
||||
'tenant_subject_external_id' => (string) $inventoryItem->external_id,
|
||||
'workspace_subject_external_id' => $workspaceSafeId,
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => (string) $inventoryItem->policy_type,
|
||||
'policy_type' => $policyType,
|
||||
'display_name' => $displayName,
|
||||
'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null,
|
||||
'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null,
|
||||
@ -335,7 +366,7 @@ private function collectInventorySubjects(
|
||||
));
|
||||
|
||||
return [
|
||||
'subjects_total' => $subjectsTotal,
|
||||
'subjects_total' => count($subjects),
|
||||
'subjects' => $subjects,
|
||||
'inventory_by_key' => $inventoryByKey,
|
||||
'gaps' => $gaps,
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
@ -120,7 +121,43 @@ public function handle(
|
||||
: BaselineCaptureMode::Opportunistic;
|
||||
|
||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||
try {
|
||||
$rolloutGate->assertEnabled();
|
||||
} catch (RuntimeException) {
|
||||
$this->auditStarted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: 0,
|
||||
effectiveScope: $effectiveScope,
|
||||
);
|
||||
|
||||
$effectiveTypeCount = count($effectiveTypes);
|
||||
$gapCount = max(1, $effectiveTypeCount);
|
||||
|
||||
$this->completeWithCoverageWarning(
|
||||
operationRunService: $operationRunService,
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
inventorySyncRun: null,
|
||||
coverageProof: false,
|
||||
effectiveTypes: $effectiveTypes,
|
||||
coveredTypes: [],
|
||||
uncoveredTypes: $effectiveTypes,
|
||||
errorsRecorded: $gapCount,
|
||||
captureMode: $captureMode,
|
||||
reasonCode: BaselineCompareReasonCode::RolloutDisabled,
|
||||
evidenceGapsByReason: [
|
||||
BaselineCompareReasonCode::RolloutDisabled->value => $gapCount,
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($effectiveTypes === []) {
|
||||
@ -147,6 +184,8 @@ public function handle(
|
||||
uncoveredTypes: [],
|
||||
errorsRecorded: 1,
|
||||
captureMode: $captureMode,
|
||||
reasonCode: BaselineCompareReasonCode::NoSubjectsInScope,
|
||||
evidenceGapsByReason: [],
|
||||
);
|
||||
|
||||
return;
|
||||
@ -414,6 +453,18 @@ public function handle(
|
||||
? EvidenceProvenance::FidelityMeta
|
||||
: EvidenceProvenance::FidelityContent;
|
||||
|
||||
$reasonCode = null;
|
||||
|
||||
if ($subjectsTotal === 0) {
|
||||
$reasonCode = BaselineCompareReasonCode::NoSubjectsInScope;
|
||||
} elseif (count($driftResults) === 0) {
|
||||
$reasonCode = match (true) {
|
||||
$uncoveredTypes !== [] => BaselineCompareReasonCode::CoverageUnproven,
|
||||
$resumeToken !== null || $gapsCount > 0 => BaselineCompareReasonCode::EvidenceCaptureIncomplete,
|
||||
default => BaselineCompareReasonCode::NoDriftDetected,
|
||||
};
|
||||
}
|
||||
|
||||
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$updatedContext['baseline_compare'] = array_merge(
|
||||
is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [],
|
||||
@ -437,6 +488,7 @@ public function handle(
|
||||
...$baselineCoverage,
|
||||
],
|
||||
'fidelity' => $overallFidelity,
|
||||
'reason_code' => $reasonCode?->value,
|
||||
],
|
||||
);
|
||||
$updatedContext['findings'] = array_merge(
|
||||
@ -568,6 +620,8 @@ private function completeWithCoverageWarning(
|
||||
array $uncoveredTypes,
|
||||
int $errorsRecorded,
|
||||
BaselineCaptureMode $captureMode,
|
||||
BaselineCompareReasonCode $reasonCode = BaselineCompareReasonCode::CoverageUnproven,
|
||||
?array $evidenceGapsByReason = null,
|
||||
): void {
|
||||
$summaryCounts = [
|
||||
'total' => 0,
|
||||
@ -599,8 +653,8 @@ private function completeWithCoverageWarning(
|
||||
'throttled' => 0,
|
||||
];
|
||||
|
||||
$evidenceGapsByReason = [
|
||||
'coverage_unproven' => max(1, $errorsRecorded),
|
||||
$evidenceGapsByReason ??= [
|
||||
BaselineCompareReasonCode::CoverageUnproven->value => max(1, $errorsRecorded),
|
||||
];
|
||||
|
||||
$updatedContext['baseline_compare'] = array_merge(
|
||||
@ -615,6 +669,7 @@ private function completeWithCoverageWarning(
|
||||
...$evidenceGapsByReason,
|
||||
],
|
||||
'resume_token' => null,
|
||||
'reason_code' => $reasonCode->value,
|
||||
'coverage' => [
|
||||
'effective_types' => array_values($effectiveTypes),
|
||||
'covered_types' => array_values($coveredTypes),
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
@ -179,6 +180,7 @@ public function panel(Panel $panel): Panel
|
||||
AlertDeliveryResource::class,
|
||||
WorkspaceResource::class,
|
||||
BaselineProfileResource::class,
|
||||
BaselineSnapshotResource::class,
|
||||
])
|
||||
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Throwable;
|
||||
|
||||
final class BaselineContentCapturePhase
|
||||
{
|
||||
@ -37,7 +38,11 @@ public function capture(
|
||||
?int $baselineProfileId = null,
|
||||
?string $createdBy = null,
|
||||
): array {
|
||||
$subjects = array_values($subjects);
|
||||
|
||||
$maxItemsPerRun = max(0, (int) ($budgets['max_items_per_run'] ?? 0));
|
||||
$maxConcurrency = max(1, (int) ($budgets['max_concurrency'] ?? 1));
|
||||
$maxRetries = max(0, (int) ($budgets['max_retries'] ?? 0));
|
||||
|
||||
$offset = 0;
|
||||
|
||||
@ -46,6 +51,10 @@ public function capture(
|
||||
$offset = is_numeric($state['offset'] ?? null) ? max(0, (int) $state['offset']) : 0;
|
||||
}
|
||||
|
||||
if ($offset >= count($subjects)) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$remaining = array_slice($subjects, $offset);
|
||||
$batch = $maxItemsPerRun > 0 ? array_slice($remaining, 0, $maxItemsPerRun) : [];
|
||||
|
||||
@ -60,7 +69,13 @@ public function capture(
|
||||
/** @var array<string, int> $gaps */
|
||||
$gaps = [];
|
||||
|
||||
foreach ($batch as $subject) {
|
||||
/**
|
||||
* @var array<string, true> $seen
|
||||
*/
|
||||
$seen = [];
|
||||
|
||||
foreach (array_chunk($batch, $maxConcurrency) as $chunk) {
|
||||
foreach ($chunk as $subject) {
|
||||
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
||||
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
|
||||
|
||||
@ -71,6 +86,17 @@ public function capture(
|
||||
continue;
|
||||
}
|
||||
|
||||
$subjectKey = $policyType.'|'.$externalId;
|
||||
|
||||
if (isset($seen[$subjectKey])) {
|
||||
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
||||
$stats['skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$subjectKey] = true;
|
||||
|
||||
$policy = Policy::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('policy_type', $policyType)
|
||||
@ -84,6 +110,11 @@ public function capture(
|
||||
continue;
|
||||
}
|
||||
|
||||
$attempt = 0;
|
||||
$result = null;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$result = $this->captureOrchestrator->capture(
|
||||
policy: $policy,
|
||||
tenant: $tenant,
|
||||
@ -97,15 +128,45 @@ public function capture(
|
||||
operationRunId: $operationRunId,
|
||||
baselineProfileId: $baselineProfileId,
|
||||
);
|
||||
} catch (Throwable $throwable) {
|
||||
$result = [
|
||||
'failure' => [
|
||||
'reason' => $throwable->getMessage(),
|
||||
'status' => is_numeric($throwable->getCode()) ? (int) $throwable->getCode() : null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if (is_array($result) && array_key_exists('failure', $result)) {
|
||||
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
||||
$stats['failed']++;
|
||||
if (! (is_array($result) && array_key_exists('failure', $result))) {
|
||||
$stats['succeeded']++;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$failure = is_array($result['failure'] ?? null) ? $result['failure'] : [];
|
||||
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
|
||||
|
||||
$isThrottled = in_array($status, [429, 503], true);
|
||||
|
||||
if ($isThrottled && $attempt < $maxRetries) {
|
||||
$delayMs = $this->retryDelayMs($attempt);
|
||||
usleep($delayMs * 1000);
|
||||
$attempt++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$stats['succeeded']++;
|
||||
if ($isThrottled) {
|
||||
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
||||
$stats['throttled']++;
|
||||
} else {
|
||||
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
||||
$stats['failed']++;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$processed = $offset + count($batch);
|
||||
@ -114,7 +175,13 @@ public function capture(
|
||||
if ($processed < count($subjects)) {
|
||||
$resumeTokenOut = BaselineEvidenceResumeToken::encode([
|
||||
'offset' => $processed,
|
||||
'total' => count($subjects),
|
||||
]);
|
||||
|
||||
$remainingCount = max(0, count($subjects) - $processed);
|
||||
if ($remainingCount > 0) {
|
||||
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($gaps);
|
||||
@ -125,5 +192,17 @@ public function capture(
|
||||
'resume_token' => $resumeTokenOut,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function retryDelayMs(int $attempt): int
|
||||
{
|
||||
$attempt = max(0, $attempt);
|
||||
|
||||
$baseDelayMs = 500;
|
||||
$maxDelayMs = 30_000;
|
||||
|
||||
$delayMs = (int) min($maxDelayMs, $baseDelayMs * (2 ** $attempt));
|
||||
$jitterMs = random_int(0, 250);
|
||||
|
||||
return $delayMs + $jitterMs;
|
||||
}
|
||||
}
|
||||
|
||||
148
app/Services/Baselines/BaselineEvidenceCaptureResumeService.php
Normal file
148
app/Services/Baselines/BaselineEvidenceCaptureResumeService.php
Normal file
@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Baselines;
|
||||
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
final class BaselineEvidenceCaptureResumeService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationRunService $runs,
|
||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilities,
|
||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start a follow-up baseline capture / compare run from an existing run + resume token.
|
||||
*
|
||||
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
|
||||
*/
|
||||
public function resume(OperationRun $priorRun, User $initiator): array
|
||||
{
|
||||
$runType = trim((string) $priorRun->type);
|
||||
|
||||
if (! in_array($runType, [OperationRunType::BaselineCapture->value, OperationRunType::BaselineCompare->value], true)) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.unsupported_run_type'];
|
||||
}
|
||||
|
||||
if ($priorRun->status !== OperationRunStatus::Completed->value) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.run_not_completed'];
|
||||
}
|
||||
|
||||
$tenantId = (int) ($priorRun->tenant_id ?? 0);
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.missing_tenant'];
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.tenant_not_found'];
|
||||
}
|
||||
|
||||
$workspaceId = (int) ($tenant->workspace_id ?? 0);
|
||||
|
||||
if ($workspaceId <= 0) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.missing_workspace'];
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.workspace_not_found'];
|
||||
}
|
||||
|
||||
if (! $this->workspaceCapabilities->isMember($initiator, $workspace)) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.not_workspace_member'];
|
||||
}
|
||||
|
||||
if (! $this->workspaceCapabilities->can($initiator, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.forbidden'];
|
||||
}
|
||||
|
||||
$this->rolloutGate->assertEnabled();
|
||||
|
||||
$context = is_array($priorRun->context) ? $priorRun->context : [];
|
||||
$profileId = (int) ($context['baseline_profile_id'] ?? 0);
|
||||
|
||||
if ($profileId <= 0) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.missing_profile'];
|
||||
}
|
||||
|
||||
$resumeSection = $runType === OperationRunType::BaselineCapture->value ? 'baseline_capture' : 'baseline_compare';
|
||||
$resumeToken = data_get($context, "{$resumeSection}.resume_token");
|
||||
|
||||
if (! is_string($resumeToken) || trim($resumeToken) === '') {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.missing_resume_token'];
|
||||
}
|
||||
|
||||
$newContext = [];
|
||||
|
||||
foreach (['target_scope', 'baseline_profile_id', 'baseline_snapshot_id', 'source_tenant_id', 'effective_scope', 'capture_mode'] as $key) {
|
||||
if (array_key_exists($key, $context)) {
|
||||
$newContext[$key] = $context[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$newContext['resume_from_operation_run_id'] = (int) $priorRun->getKey();
|
||||
|
||||
$newContext[$resumeSection] = [
|
||||
'resume_token' => $resumeToken,
|
||||
'resume_from_operation_run_id' => (int) $priorRun->getKey(),
|
||||
];
|
||||
|
||||
$run = $this->runs->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: $runType,
|
||||
identityInputs: [
|
||||
'baseline_profile_id' => $profileId,
|
||||
],
|
||||
context: $newContext,
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
if ($run->wasRecentlyCreated) {
|
||||
match ($runType) {
|
||||
OperationRunType::BaselineCapture->value => CaptureBaselineSnapshotJob::dispatch($run),
|
||||
OperationRunType::BaselineCompare->value => CompareBaselineToTenantJob::dispatch($run),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'baseline.evidence.resume.started',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'prior_operation_run_id' => (int) $priorRun->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'baseline_profile_id' => $profileId,
|
||||
'run_type' => $runType,
|
||||
],
|
||||
],
|
||||
actorId: (int) $initiator->getKey(),
|
||||
actorEmail: (string) $initiator->email,
|
||||
actorName: (string) $initiator->name,
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $priorRun->getKey(),
|
||||
);
|
||||
|
||||
return ['ok' => true, 'run' => $run];
|
||||
}
|
||||
}
|
||||
@ -16,11 +16,10 @@ public function message(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::NoSubjectsInScope => 'No subjects were in scope for this comparison.',
|
||||
self::CoverageUnproven => 'Coverage proof was not available, so missing-policy outcomes were suppressed.',
|
||||
self::CoverageUnproven => 'Coverage proof was missing or incomplete, so some findings were suppressed for safety.',
|
||||
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
|
||||
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
|
||||
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,8 +7,10 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class BaselineCompareStats
|
||||
{
|
||||
@ -23,12 +25,15 @@ private function __construct(
|
||||
public readonly ?string $profileName,
|
||||
public readonly ?int $profileId,
|
||||
public readonly ?int $snapshotId,
|
||||
public readonly ?int $duplicateNamePoliciesCount,
|
||||
public readonly ?int $operationRunId,
|
||||
public readonly ?int $findingsCount,
|
||||
public readonly array $severityCounts,
|
||||
public readonly ?string $lastComparedHuman,
|
||||
public readonly ?string $lastComparedIso,
|
||||
public readonly ?string $failureReason,
|
||||
public readonly ?string $reasonCode = null,
|
||||
public readonly ?string $reasonMessage = null,
|
||||
public readonly ?string $coverageStatus = null,
|
||||
public readonly ?int $uncoveredTypesCount = null,
|
||||
public readonly array $uncoveredTypes = [],
|
||||
@ -67,12 +72,23 @@ public static function forTenant(?Tenant $tenant): self
|
||||
$profileId = (int) $profile->getKey();
|
||||
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
||||
|
||||
$profileScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||
? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null)
|
||||
: null;
|
||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||
|
||||
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
||||
|
||||
if ($snapshotId === null) {
|
||||
return self::empty(
|
||||
'no_snapshot',
|
||||
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
);
|
||||
}
|
||||
|
||||
@ -84,6 +100,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
|
||||
[$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun);
|
||||
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
||||
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
||||
|
||||
// Active run (queued/running)
|
||||
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||
@ -93,12 +110,15 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
lastComparedHuman: null,
|
||||
lastComparedIso: null,
|
||||
failureReason: null,
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $reasonMessage,
|
||||
coverageStatus: $coverageStatus,
|
||||
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
@ -121,12 +141,15 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
lastComparedHuman: $latestRun->finished_at?->diffForHumans(),
|
||||
lastComparedIso: $latestRun->finished_at?->toIso8601String(),
|
||||
failureReason: (string) $failureReason,
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $reasonMessage,
|
||||
coverageStatus: $coverageStatus,
|
||||
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
@ -171,12 +194,15 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||
findingsCount: $totalFindings,
|
||||
severityCounts: $severityCounts,
|
||||
lastComparedHuman: $lastComparedHuman,
|
||||
lastComparedIso: $lastComparedIso,
|
||||
failureReason: null,
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $reasonMessage,
|
||||
coverageStatus: $coverageStatus,
|
||||
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
@ -195,12 +221,15 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: 0,
|
||||
severityCounts: $severityCounts,
|
||||
lastComparedHuman: $lastComparedHuman,
|
||||
lastComparedIso: $lastComparedIso,
|
||||
failureReason: null,
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $reasonMessage,
|
||||
coverageStatus: $coverageStatus,
|
||||
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
@ -216,12 +245,15 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: $severityCounts,
|
||||
lastComparedHuman: $lastComparedHuman,
|
||||
lastComparedIso: $lastComparedIso,
|
||||
failureReason: null,
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $reasonMessage,
|
||||
coverageStatus: $coverageStatus,
|
||||
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
@ -278,6 +310,7 @@ public static function forWidget(?Tenant $tenant): self
|
||||
profileName: (string) $profile->name,
|
||||
profileId: (int) $profile->getKey(),
|
||||
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
|
||||
duplicateNamePoliciesCount: null,
|
||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||
findingsCount: $totalFindings,
|
||||
severityCounts: [
|
||||
@ -291,6 +324,64 @@ public static function forWidget(?Tenant $tenant): self
|
||||
);
|
||||
}
|
||||
|
||||
private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int
|
||||
{
|
||||
$policyTypes = $effectiveScope->allTypes();
|
||||
|
||||
if ($policyTypes === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$compute = static function () use ($tenant, $policyTypes): int {
|
||||
/**
|
||||
* @var array<string, int> $countsByKey
|
||||
*/
|
||||
$countsByKey = [];
|
||||
|
||||
InventoryItem::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('policy_type', $policyTypes)
|
||||
->whereNotNull('display_name')
|
||||
->select(['id', 'policy_type', 'display_name'])
|
||||
->orderBy('id')
|
||||
->chunkById(1_000, function ($inventoryItems) use (&$countsByKey): void {
|
||||
foreach ($inventoryItems as $inventoryItem) {
|
||||
$displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null;
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
|
||||
if ($subjectKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$logicalKey = (string) $inventoryItem->policy_type.'|'.$subjectKey;
|
||||
$countsByKey[$logicalKey] = ($countsByKey[$logicalKey] ?? 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
$duplicatePolicies = 0;
|
||||
|
||||
foreach ($countsByKey as $count) {
|
||||
if ($count > 1) {
|
||||
$duplicatePolicies += $count;
|
||||
}
|
||||
}
|
||||
|
||||
return $duplicatePolicies;
|
||||
};
|
||||
|
||||
if (app()->environment('testing')) {
|
||||
return $compute();
|
||||
}
|
||||
|
||||
$cacheKey = sprintf(
|
||||
'baseline_compare:tenant:%d:duplicate_names:%s',
|
||||
(int) $tenant->getKey(),
|
||||
hash('sha256', implode('|', $policyTypes)),
|
||||
);
|
||||
|
||||
return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?string, 1: list<string>, 2: ?string}
|
||||
*/
|
||||
@ -335,6 +426,31 @@ private static function coverageInfoForRun(?OperationRun $run): array
|
||||
return [$coverageStatus, $uncoveredTypes, $fidelity];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?string, 1: ?string}
|
||||
*/
|
||||
private static function reasonInfoForRun(?OperationRun $run): array
|
||||
{
|
||||
if (! $run instanceof OperationRun) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||
|
||||
if (! is_array($baselineCompare)) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
$reasonCode = $baselineCompare['reason_code'] ?? null;
|
||||
$reasonCode = is_string($reasonCode) ? trim($reasonCode) : null;
|
||||
$reasonCode = $reasonCode !== '' ? $reasonCode : null;
|
||||
|
||||
$enum = $reasonCode !== null ? BaselineCompareReasonCode::tryFrom($reasonCode) : null;
|
||||
|
||||
return [$reasonCode, $enum?->message()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?int, 1: array<string, int>}
|
||||
*/
|
||||
@ -393,6 +509,7 @@ private static function empty(
|
||||
?string $message,
|
||||
?string $profileName = null,
|
||||
?int $profileId = null,
|
||||
?int $duplicateNamePoliciesCount = null,
|
||||
): self {
|
||||
return new self(
|
||||
state: $state,
|
||||
@ -400,6 +517,7 @@ private static function empty(
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: null,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
|
||||
81
lang/en/baseline-compare.php
Normal file
81
lang/en/baseline-compare.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Baseline Compare Landing Page
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// Duplicate-name warning banner
|
||||
'duplicate_warning_title' => 'Warning',
|
||||
'duplicate_warning_body_plural' => ':count policies in this tenant share the same display name. :app cannot match them to the baseline. Please rename the duplicates in the Microsoft Intune portal.',
|
||||
'duplicate_warning_body_singular' => ':count policy in this tenant shares the same display name. :app cannot match it to the baseline. Please rename the duplicate in the Microsoft Intune portal.',
|
||||
|
||||
// Stats card labels
|
||||
'stat_assigned_baseline' => 'Assigned Baseline',
|
||||
'stat_total_findings' => 'Total Findings',
|
||||
'stat_last_compared' => 'Last Compared',
|
||||
'stat_last_compared_never' => 'Never',
|
||||
'stat_error' => 'Error',
|
||||
|
||||
// Badges
|
||||
'badge_snapshot' => 'Snapshot #:id',
|
||||
'badge_coverage_ok' => 'Coverage: OK',
|
||||
'badge_coverage_warnings' => 'Coverage: Warnings',
|
||||
'badge_fidelity' => 'Fidelity: :level',
|
||||
'badge_evidence_gaps' => 'Evidence gaps: :count',
|
||||
'evidence_gaps_tooltip' => 'Top gaps: :summary',
|
||||
|
||||
// Comparing state
|
||||
'comparing_indicator' => 'Comparing…',
|
||||
|
||||
// Why-no-findings explanations
|
||||
'no_findings_all_clear' => 'All clear',
|
||||
'no_findings_coverage_warnings' => 'Coverage warnings',
|
||||
'no_findings_evidence_gaps' => 'Evidence gaps',
|
||||
'no_findings_default' => 'No findings',
|
||||
|
||||
// Coverage warning banner
|
||||
'coverage_warning_title' => 'Comparison completed with warnings',
|
||||
'coverage_unproven_body' => 'Coverage proof was missing or unreadable for the last comparison run, so findings were suppressed for safety.',
|
||||
'coverage_incomplete_body' => 'Findings were skipped for :count policy :types due to incomplete coverage.',
|
||||
'coverage_uncovered_label' => 'Uncovered: :list',
|
||||
|
||||
// Failed banner
|
||||
'failed_title' => 'Comparison Failed',
|
||||
'failed_body_default' => 'The last baseline comparison failed. Review the run details or retry.',
|
||||
|
||||
// Critical drift banner
|
||||
'critical_drift_title' => 'Critical Drift Detected',
|
||||
'critical_drift_body' => 'The current tenant state deviates from baseline :profile. :count high-severity :findings require immediate attention.',
|
||||
|
||||
// Empty states
|
||||
'empty_no_tenant' => 'No Tenant Selected',
|
||||
'empty_no_assignment' => 'No Baseline Assigned',
|
||||
'empty_no_snapshot' => 'No Snapshot Available',
|
||||
|
||||
// Findings section
|
||||
'findings_description' => 'The tenant configuration drifted from the baseline profile.',
|
||||
|
||||
// No drift
|
||||
'no_drift_title' => 'No Drift Detected',
|
||||
'no_drift_body' => 'The tenant configuration matches the baseline profile. Everything looks good.',
|
||||
|
||||
// Coverage warnings (no findings)
|
||||
'coverage_warnings_title' => 'Coverage Warnings',
|
||||
'coverage_warnings_body' => 'The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results.',
|
||||
|
||||
// Idle
|
||||
'idle_title' => 'Ready to Compare',
|
||||
|
||||
// Buttons
|
||||
'button_view_run' => 'View run',
|
||||
'button_view_failed_run' => 'View failed run',
|
||||
'button_view_findings' => 'View all findings',
|
||||
'button_review_last_run' => 'Review last run',
|
||||
|
||||
];
|
||||
@ -5,44 +5,40 @@
|
||||
@endif
|
||||
|
||||
@php
|
||||
$hasCoverageWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true);
|
||||
$evidenceGapsCountValue = (int) ($evidenceGapsCount ?? 0);
|
||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||
|
||||
$evidenceGapsSummary = null;
|
||||
$evidenceGapsTooltip = null;
|
||||
|
||||
if ($hasEvidenceGaps && is_array($evidenceGapsTopReasons ?? null) && $evidenceGapsTopReasons !== []) {
|
||||
$parts = [];
|
||||
|
||||
foreach (array_slice($evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
||||
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = $reason.' ('.((int) $count).')';
|
||||
}
|
||||
|
||||
if ($parts !== []) {
|
||||
$evidenceGapsSummary = implode(', ', $parts);
|
||||
$evidenceGapsTooltip = 'Top gaps: '.$evidenceGapsSummary;
|
||||
}
|
||||
}
|
||||
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
|
||||
@endphp
|
||||
|
||||
@if ($duplicateNamePoliciesCountValue > 0)
|
||||
<div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
|
||||
<div class="flex items-start gap-3">
|
||||
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-warning-600 dark:text-warning-400" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-base font-semibold text-warning-900 dark:text-warning-200">
|
||||
{{ __('baseline-compare.duplicate_warning_title') }}
|
||||
</div>
|
||||
<div class="text-sm text-warning-800 dark:text-warning-300">
|
||||
{{ __($duplicateNamePoliciesCountValue === 1 ? 'baseline-compare.duplicate_warning_body_singular' : 'baseline-compare.duplicate_warning_body_plural', [
|
||||
'count' => $duplicateNamePoliciesCountValue,
|
||||
'app' => config('app.name', 'TenantPilot'),
|
||||
]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Row 1: Stats Overview --}}
|
||||
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{{-- Stat: Assigned Baseline --}}
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Assigned Baseline</div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_assigned_baseline') }}</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ $profileName ?? '—' }}</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($snapshotId)
|
||||
<x-filament::badge color="success" size="sm" class="w-fit">
|
||||
Snapshot #{{ $snapshotId }}
|
||||
{{ __('baseline-compare.badge_snapshot', ['id' => $snapshotId]) }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@ -52,26 +48,26 @@
|
||||
size="sm"
|
||||
class="w-fit"
|
||||
>
|
||||
Coverage: {{ $coverageStatus === 'ok' ? 'OK' : 'Warnings' }}
|
||||
{{ $coverageStatus === 'ok' ? __('baseline-compare.badge_coverage_ok') : __('baseline-compare.badge_coverage_warnings') }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if (filled($fidelity))
|
||||
<x-filament::badge color="gray" size="sm" class="w-fit">
|
||||
Fidelity: {{ Str::title($fidelity) }}
|
||||
{{ __('baseline-compare.badge_fidelity', ['level' => Str::title($fidelity)]) }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($hasEvidenceGaps)
|
||||
<x-filament::badge color="warning" size="sm" class="w-fit" :title="$evidenceGapsTooltip">
|
||||
Evidence gaps: {{ $evidenceGapsCountValue }}
|
||||
{{ __('baseline-compare.badge_evidence_gaps', ['count' => $evidenceGapsCountValue]) }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($hasEvidenceGaps && filled($evidenceGapsSummary))
|
||||
<div class="mt-1 text-xs text-warning-700 dark:text-warning-300">
|
||||
Top gaps: {{ $evidenceGapsSummary }}
|
||||
{{ __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -80,25 +76,21 @@ class="w-fit"
|
||||
{{-- Stat: Total Findings --}}
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Findings</div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_total_findings') }}</div>
|
||||
@if ($state === 'failed')
|
||||
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">Error</div>
|
||||
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">{{ __('baseline-compare.stat_error') }}</div>
|
||||
@else
|
||||
<div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : ($hasWarnings ? 'text-warning-600 dark:text-warning-400' : 'text-success-600 dark:text-success-400') }}">
|
||||
<div class="text-3xl font-bold {{ $findingsColorClass }}">
|
||||
{{ $findingsCount ?? 0 }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($state === 'comparing')
|
||||
<div class="flex items-center gap-1 text-sm text-info-600 dark:text-info-400">
|
||||
<x-filament::loading-indicator class="h-3 w-3" />
|
||||
Comparing…
|
||||
{{ __('baseline-compare.comparing_indicator') }}
|
||||
</div>
|
||||
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasWarnings)
|
||||
<span class="text-sm text-success-600 dark:text-success-400">All clear</span>
|
||||
@elseif ($state === 'ready' && $hasCoverageWarnings)
|
||||
<span class="text-sm text-warning-600 dark:text-warning-400">Coverage warnings</span>
|
||||
@elseif ($state === 'ready' && $hasEvidenceGaps)
|
||||
<span class="text-sm text-warning-600 dark:text-warning-400">Evidence gaps</span>
|
||||
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
|
||||
<span class="text-sm {{ $whyNoFindingsColor }}">{{ $whyNoFindingsMessage ?? $whyNoFindingsFallback }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@ -106,13 +98,13 @@ class="w-fit"
|
||||
{{-- Stat: Last Compared --}}
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Compared</div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_last_compared') }}</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white" @if ($lastComparedIso) title="{{ $lastComparedIso }}" @endif>
|
||||
{{ $lastComparedAt ?? 'Never' }}
|
||||
{{ $lastComparedAt ?? __('baseline-compare.stat_last_compared_never') }}
|
||||
</div>
|
||||
@if ($this->getRunUrl())
|
||||
<x-filament::link :href="$this->getRunUrl()" size="sm">
|
||||
View run
|
||||
{{ __('baseline-compare.button_view_run') }}
|
||||
</x-filament::link>
|
||||
@endif
|
||||
</div>
|
||||
@ -122,23 +114,28 @@ class="w-fit"
|
||||
|
||||
{{-- Coverage warnings banner --}}
|
||||
@if ($state === 'ready' && $hasCoverageWarnings)
|
||||
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
|
||||
<div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
|
||||
<div class="flex items-start gap-3">
|
||||
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-warning-600 dark:text-warning-400" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-base font-semibold text-warning-900 dark:text-warning-200">
|
||||
Comparison completed with warnings
|
||||
{{ __('baseline-compare.coverage_warning_title') }}
|
||||
</div>
|
||||
<div class="text-sm text-warning-800 dark:text-warning-300">
|
||||
@if (($coverageStatus ?? null) === 'unproven')
|
||||
Coverage proof was missing or unreadable for the last comparison run, so findings were suppressed for safety.
|
||||
{{ __('baseline-compare.coverage_unproven_body') }}
|
||||
@else
|
||||
Findings were skipped for {{ (int) ($uncoveredTypesCount ?? 0) }} policy {{ Str::plural('type', (int) ($uncoveredTypesCount ?? 0)) }} due to incomplete coverage.
|
||||
{{ __('baseline-compare.coverage_incomplete_body', [
|
||||
'count' => (int) ($uncoveredTypesCount ?? 0),
|
||||
'types' => Str::plural('type', (int) ($uncoveredTypesCount ?? 0)),
|
||||
]) }}
|
||||
@endif
|
||||
|
||||
@if (! empty($uncoveredTypes))
|
||||
<div class="mt-2 text-xs text-warning-800 dark:text-warning-300">
|
||||
Uncovered: {{ implode(', ', array_slice($uncoveredTypes, 0, 6)) }}@if (count($uncoveredTypes) > 6)…@endif
|
||||
{{ __('baseline-compare.coverage_uncovered_label', [
|
||||
'list' => implode(', ', array_slice($uncoveredTypes, 0, 6)) . (count($uncoveredTypes) > 6 ? '…' : ''),
|
||||
]) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -152,7 +149,7 @@ class="w-fit"
|
||||
icon="heroicon-o-queue-list"
|
||||
size="sm"
|
||||
>
|
||||
View run
|
||||
{{ __('baseline-compare.button_view_run') }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
@ -163,15 +160,15 @@ class="w-fit"
|
||||
|
||||
{{-- Failed run banner --}}
|
||||
@if ($state === 'failed')
|
||||
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
|
||||
<div role="alert" class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
|
||||
<div class="flex items-start gap-3">
|
||||
<x-heroicon-s-x-circle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-base font-semibold text-danger-800 dark:text-danger-200">
|
||||
Comparison Failed
|
||||
{{ __('baseline-compare.failed_title') }}
|
||||
</div>
|
||||
<div class="text-sm text-danger-700 dark:text-danger-300">
|
||||
{{ $failureReason ?? 'The last baseline comparison failed. Review the run details or retry.' }}
|
||||
{{ $failureReason ?? __('baseline-compare.failed_body_default') }}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
@if ($this->getRunUrl())
|
||||
@ -183,7 +180,7 @@ class="w-fit"
|
||||
icon="heroicon-o-queue-list"
|
||||
size="sm"
|
||||
>
|
||||
View failed run
|
||||
{{ __('baseline-compare.button_view_failed_run') }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@ -194,16 +191,19 @@ class="w-fit"
|
||||
|
||||
{{-- Critical drift banner --}}
|
||||
@if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0)
|
||||
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
|
||||
<div role="alert" class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
|
||||
<div class="flex items-start gap-3">
|
||||
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-base font-semibold text-danger-800 dark:text-danger-200">
|
||||
Critical Drift Detected
|
||||
{{ __('baseline-compare.critical_drift_title') }}
|
||||
</div>
|
||||
<div class="text-sm text-danger-700 dark:text-danger-300">
|
||||
The current tenant state deviates from baseline <strong>{{ $profileName }}</strong>.
|
||||
{{ $severityCounts['high'] }} high-severity {{ Str::plural('finding', $severityCounts['high']) }} require immediate attention.
|
||||
{{ __('baseline-compare.critical_drift_body', [
|
||||
'profile' => $profileName,
|
||||
'count' => $severityCounts['high'],
|
||||
'findings' => Str::plural('finding', $severityCounts['high']),
|
||||
]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -216,13 +216,13 @@ class="w-fit"
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-8 text-center">
|
||||
@if ($state === 'no_tenant')
|
||||
<x-heroicon-o-building-office class="h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Tenant Selected</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_tenant') }}</div>
|
||||
@elseif ($state === 'no_assignment')
|
||||
<x-heroicon-o-link-slash class="h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Baseline Assigned</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_assignment') }}</div>
|
||||
@elseif ($state === 'no_snapshot')
|
||||
<x-heroicon-o-camera class="h-12 w-12 text-warning-400 dark:text-warning-500" />
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Snapshot Available</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_snapshot') }}</div>
|
||||
@endif
|
||||
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
|
||||
</div>
|
||||
@ -236,7 +236,7 @@ class="w-fit"
|
||||
{{ $findingsCount }} {{ Str::plural('Finding', $findingsCount) }}
|
||||
</x-slot>
|
||||
<x-slot name="description">
|
||||
The tenant configuration drifted from the baseline profile.
|
||||
{{ __('baseline-compare.findings_description') }}
|
||||
</x-slot>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
@ -269,7 +269,7 @@ class="w-fit"
|
||||
icon="heroicon-o-eye"
|
||||
size="sm"
|
||||
>
|
||||
View all findings
|
||||
{{ __('baseline-compare.button_view_findings') }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
@ -282,7 +282,7 @@ class="w-fit"
|
||||
icon="heroicon-o-queue-list"
|
||||
size="sm"
|
||||
>
|
||||
Review last run
|
||||
{{ __('baseline-compare.button_review_last_run') }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@ -295,9 +295,9 @@ class="w-fit"
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
|
||||
<x-heroicon-o-check-circle class="h-12 w-12 text-success-500" />
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Drift Detected</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.no_drift_title') }}</div>
|
||||
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
|
||||
The tenant configuration matches the baseline profile. Everything looks good.
|
||||
{{ __('baseline-compare.no_drift_body') }}
|
||||
</div>
|
||||
@if ($this->getRunUrl())
|
||||
<x-filament::button
|
||||
@ -308,7 +308,7 @@ class="w-fit"
|
||||
icon="heroicon-o-queue-list"
|
||||
size="sm"
|
||||
>
|
||||
Review last run
|
||||
{{ __('baseline-compare.button_review_last_run') }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@ -320,9 +320,9 @@ class="w-fit"
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
|
||||
<x-heroicon-o-exclamation-triangle class="h-12 w-12 text-warning-500" />
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">Coverage Warnings</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.coverage_warnings_title') }}</div>
|
||||
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
|
||||
The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results.
|
||||
{{ __('baseline-compare.coverage_warnings_body') }}
|
||||
</div>
|
||||
@if ($this->getRunUrl())
|
||||
<x-filament::button
|
||||
@ -333,7 +333,7 @@ class="w-fit"
|
||||
icon="heroicon-o-queue-list"
|
||||
size="sm"
|
||||
>
|
||||
Review last run
|
||||
{{ __('baseline-compare.button_review_last_run') }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@ -345,7 +345,7 @@ class="w-fit"
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
|
||||
<x-heroicon-o-play class="h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">Ready to Compare</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.idle_title') }}</div>
|
||||
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $message }}
|
||||
</div>
|
||||
|
||||
@ -39,6 +39,11 @@
|
||||
->name('tenantpilot:review-pack:prune')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::command('tenantpilot:baseline-evidence:prune')
|
||||
->daily()
|
||||
->name('tenantpilot:baseline-evidence:prune')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::call(function (): void {
|
||||
$tenants = Tenant::query()
|
||||
->whereHas('providerConnections', function ($q): void {
|
||||
|
||||
@ -146,18 +146,18 @@ ## Phase 5: User Story 3 — Throttling-safe, resumable evidence capture (Priori
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [ ] T058 [P] [US3] Add “budget exhaustion produces resume token” test in `tests/Feature/Baselines/BaselineCompareResumeTokenTest.php`
|
||||
- [ ] T059 [P] [US3] Add “resume is idempotent” test in `tests/Feature/Baselines/BaselineCompareResumeIdempotencyTest.php`
|
||||
- [ ] T060 [P] [US3] Add resume token contract test in `tests/Feature/Baselines/BaselineEvidenceResumeTokenContractTest.php` (token is opaque; decode yields deterministic resume state)
|
||||
- [ ] T061 [P] [US3] Add run-detail resume action test in `tests/Feature/Filament/OperationRunResumeCaptureActionTest.php`
|
||||
- [ ] T062 [P] [US3] Add audit event coverage for resume capture in `tests/Feature/Baselines/BaselineResumeCaptureAuditEventsTest.php`
|
||||
- [X] T058 [P] [US3] Add “budget exhaustion produces resume token” test in `tests/Feature/Baselines/BaselineCompareResumeTokenTest.php`
|
||||
- [X] T059 [P] [US3] Add “resume is idempotent” test in `tests/Feature/Baselines/BaselineCompareResumeIdempotencyTest.php`
|
||||
- [X] T060 [P] [US3] Add resume token contract test in `tests/Feature/Baselines/BaselineEvidenceResumeTokenContractTest.php` (token is opaque; decode yields deterministic resume state)
|
||||
- [X] T061 [P] [US3] Add run-detail resume action test in `tests/Feature/Filament/OperationRunResumeCaptureActionTest.php`
|
||||
- [X] T062 [P] [US3] Add audit event coverage for resume capture in `tests/Feature/Baselines/BaselineResumeCaptureAuditEventsTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [ ] T063 [US3] Implement budgets (items-per-run + concurrency + retries) + retry/backoff/jitter + throttling gap reasons + resume cursor handling in `app/Services/Baselines/BaselineContentCapturePhase.php` (use `BaselineEvidenceResumeToken` encode/decode)
|
||||
- [ ] T064 [US3] Add resume starter service in `app/Services/Baselines/BaselineEvidenceCaptureResumeService.php` (start follow-up `baseline_capture`/`baseline_compare` runs from a prior run + resume token; enforce RBAC; write audit events)
|
||||
- [ ] T065 [US3] Add “Resume capture” header action for eligible runs in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` (requires confirmation; uses Ops-UX queued toast + canonical view-run link)
|
||||
- [ ] T066 [US3] Wire resume token consumption + re-emission into `app/Jobs/CaptureBaselineSnapshotJob.php` (baseline capture) and `app/Jobs/CompareBaselineToTenantJob.php` (baseline compare)
|
||||
- [X] T063 [US3] Implement budgets (items-per-run + concurrency + retries) + retry/backoff/jitter + throttling gap reasons + resume cursor handling in `app/Services/Baselines/BaselineContentCapturePhase.php` (use `BaselineEvidenceResumeToken` encode/decode)
|
||||
- [X] T064 [US3] Add resume starter service in `app/Services/Baselines/BaselineEvidenceCaptureResumeService.php` (start follow-up `baseline_capture`/`baseline_compare` runs from a prior run + resume token; enforce RBAC; write audit events)
|
||||
- [X] T065 [US3] Add “Resume capture” header action for eligible runs in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` (requires confirmation; uses Ops-UX queued toast + canonical view-run link)
|
||||
- [X] T066 [US3] Wire resume token consumption + re-emission into `app/Jobs/CaptureBaselineSnapshotJob.php` (baseline capture) and `app/Jobs/CompareBaselineToTenantJob.php` (baseline compare)
|
||||
|
||||
**Parallel execution example (US3)**:
|
||||
|
||||
@ -176,15 +176,15 @@ ## Phase 6: User Story 4 — “Why no findings?” is always clear (Priority: P
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [ ] T067 [P] [US4] Add reason-code coverage test for zero-subject / zero-findings / suppressed-by-coverage outcomes in `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`
|
||||
- [ ] T068 [P] [US4] Add UI assertion test for “why no findings” messaging in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
|
||||
- [X] T067 [P] [US4] Add reason-code coverage test for zero-subject / zero-findings / suppressed-by-coverage outcomes in `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`
|
||||
- [X] T068 [P] [US4] Add UI assertion test for “why no findings” messaging in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [ ] T069 [US4] Populate `context.baseline_compare.reason_code` for all 0-subject / 0-findings outcomes in `app/Jobs/CompareBaselineToTenantJob.php` (use `BaselineCompareReasonCode`, including `coverage_unproven`/`rollout_disabled` where applicable)
|
||||
- [ ] T070 [US4] Render reason-code explanation + evidence context in Monitoring run detail in `app/Filament/Resources/OperationRunResource.php`
|
||||
- [ ] T071 [US4] Replace “All clear” copy with reason-aware messaging on baseline compare landing in `resources/views/filament/pages/baseline-compare-landing.blade.php` (source reason code from `BaselineCompareStats`)
|
||||
- [ ] T072 [US4] Propagate reason code + human message from run context in `app/Support/Baselines/BaselineCompareStats.php`
|
||||
- [X] T069 [US4] Populate `context.baseline_compare.reason_code` for all 0-subject / 0-findings outcomes in `app/Jobs/CompareBaselineToTenantJob.php` (use `BaselineCompareReasonCode`, including `coverage_unproven`/`rollout_disabled` where applicable)
|
||||
- [X] T070 [US4] Render reason-code explanation + evidence context in Monitoring run detail in `app/Filament/Resources/OperationRunResource.php`
|
||||
- [X] T071 [US4] Replace “All clear” copy with reason-aware messaging on baseline compare landing in `resources/views/filament/pages/baseline-compare-landing.blade.php` (source reason code from `BaselineCompareStats`)
|
||||
- [X] T072 [US4] Propagate reason code + human message from run context in `app/Support/Baselines/BaselineCompareStats.php`
|
||||
|
||||
**Parallel execution example (US4)**:
|
||||
|
||||
@ -199,13 +199,13 @@ ## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Guardrails, visibility, and validation across all stories.
|
||||
|
||||
- [ ] T073 [P] Add Spec 118 no-legacy regression guard(s) in `tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php` (assert capture/compare do not implement hashing outside the provider/hasher pipeline and do not reference deprecated helpers)
|
||||
- [ ] T074 Update PolicyVersion listing to hide baseline-purpose evidence by default (unless the actor has `tenant.sync` or `tenant_findings.view`) in `app/Filament/Resources/PolicyVersionResource.php`
|
||||
- [ ] T075 [P] Add visibility/authorization coverage for baseline-purpose PolicyVersions in `tests/Feature/Filament/PolicyVersionBaselineEvidenceVisibilityTest.php` (assert baseline-purpose rows are hidden for `tenant.view`-only actors)
|
||||
- [ ] T076 Implement baseline-purpose PolicyVersion retention enforcement in `app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php` and schedule it in `routes/console.php` (prune `baseline_capture`/`baseline_compare` older than configured retention; do not prune `backup`) + tests in `tests/Feature/Retention/PruneBaselineEvidencePolicyVersionsTest.php` and `tests/Feature/Scheduling/PruneBaselineEvidencePolicyVersionsScheduleTest.php`
|
||||
- [ ] T077 Add Baseline Snapshot list/detail surfaces with fidelity visibility in `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php`, and `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` (badge + counts by fidelity; “captured with gaps” state) + tests in `tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php`
|
||||
- [ ] T078 Run formatting on changed files using `vendor/bin/sail bin pint --dirty --format agent` (touchpoints include `app/Jobs/CaptureBaselineSnapshotJob.php`, `app/Jobs/CompareBaselineToTenantJob.php`, `app/Services/Baselines/BaselineContentCapturePhase.php`)
|
||||
- [ ] T079 Run targeted test suite from `specs/118-baseline-drift-engine/quickstart.md` and update it if any step is inaccurate in `specs/118-baseline-drift-engine/quickstart.md`
|
||||
- [X] T073 [P] Add Spec 118 no-legacy regression guard(s) in `tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php` (assert capture/compare do not implement hashing outside the provider/hasher pipeline and do not reference deprecated helpers)
|
||||
- [X] T074 Update PolicyVersion listing to hide baseline-purpose evidence by default (unless the actor has `tenant.sync` or `tenant_findings.view`) in `app/Filament/Resources/PolicyVersionResource.php`
|
||||
- [X] T075 [P] Add visibility/authorization coverage for baseline-purpose PolicyVersions in `tests/Feature/Filament/PolicyVersionBaselineEvidenceVisibilityTest.php` (assert baseline-purpose rows are hidden for `tenant.view`-only actors)
|
||||
- [X] T076 Implement baseline-purpose PolicyVersion retention enforcement in `app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php` and schedule it in `routes/console.php` (prune `baseline_capture`/`baseline_compare` older than configured retention; do not prune `backup`) + tests in `tests/Feature/Retention/PruneBaselineEvidencePolicyVersionsTest.php` and `tests/Feature/Scheduling/PruneBaselineEvidencePolicyVersionsScheduleTest.php`
|
||||
- [X] T077 Add Baseline Snapshot list/detail surfaces with fidelity visibility in `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php`, and `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` (badge + counts by fidelity; “captured with gaps” state) + tests in `tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php`
|
||||
- [X] T078 Run formatting on changed files using `vendor/bin/sail bin pint --dirty --format agent` (touchpoints include `app/Jobs/CaptureBaselineSnapshotJob.php`, `app/Jobs/CompareBaselineToTenantJob.php`, `app/Services/Baselines/BaselineContentCapturePhase.php`)
|
||||
- [X] T079 Run targeted test suite from `specs/118-baseline-drift-engine/quickstart.md` and update it if any step is inaccurate in `specs/118-baseline-drift-engine/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
|
||||
104
tests/Feature/Baselines/BaselineCaptureAmbiguousMatchGapTest.php
Normal file
104
tests/Feature/Baselines/BaselineCaptureAmbiguousMatchGapTest.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('treats duplicate subject_key matches as an evidence gap and captures remaining subjects', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$displayName = 'Duplicate Policy';
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'dup-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => ['etag' => 'E1'],
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'dup-2',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => ['etag' => 'E2'],
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'unique-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Unique Policy',
|
||||
'meta_jsonb' => ['etag' => 'E_UNIQUE'],
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCapture->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CaptureBaselineSnapshotJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
|
||||
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
expect((int) ($counts['total'] ?? 0))->toBe(1);
|
||||
expect((int) ($counts['succeeded'] ?? 0))->toBe(1);
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
expect(data_get($context, 'baseline_capture.gaps.by_reason.ambiguous_match'))->toBe(1);
|
||||
|
||||
$snapshot = BaselineSnapshot::query()
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->sole();
|
||||
|
||||
expect(
|
||||
BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->count(),
|
||||
)->toBe(1);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName('Unique Policy');
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectKey: (string) $subjectKey,
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->where('subject_external_id', $workspaceSafeExternalId)
|
||||
->sole();
|
||||
});
|
||||
199
tests/Feature/Baselines/BaselineCompareResumeIdempotencyTest.php
Normal file
199
tests/Feature/Baselines/BaselineCompareResumeIdempotencyTest.php
Normal file
@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('resumes full-content compare deterministically without re-capturing already-captured subjects', function (): void {
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 1);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$displayNames = ['Resume Idempotent A', 'Resume Idempotent B'];
|
||||
|
||||
foreach ($displayNames as $displayName) {
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline'),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$policies = [];
|
||||
|
||||
foreach ($displayNames as $i => $displayName) {
|
||||
$policies[] = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'external_id' => $i === 0 ? 'resume-idem-a' : 'resume-idem-b',
|
||||
'platform' => 'windows',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
}
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
foreach ($policies as $policy) {
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => fake()->uuid()],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$fakeOrchestrator = new class extends PolicyCaptureOrchestrator
|
||||
{
|
||||
public array $capturedExternalIds = [];
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function capture(
|
||||
Policy $policy,
|
||||
\App\Models\Tenant $tenant,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
?string $createdBy = null,
|
||||
array $metadata = [],
|
||||
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
): array {
|
||||
$this->capturedExternalIds[] = (string) $policy->external_id;
|
||||
|
||||
$version = \App\Models\PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'platform' => (string) $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'snapshot' => ['settings' => [['key' => 'k', 'value' => 1]]],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
'capture_purpose' => $capturePurpose,
|
||||
'operation_run_id' => $operationRunId,
|
||||
'baseline_profile_id' => $baselineProfileId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'captured' => [
|
||||
'payload' => $version->snapshot,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
$contentCapturePhase = new BaselineContentCapturePhase($fakeOrchestrator);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$firstRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($firstRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
contentCapturePhase: $contentCapturePhase,
|
||||
);
|
||||
|
||||
$firstRun->refresh();
|
||||
$firstContext = is_array($firstRun->context) ? $firstRun->context : [];
|
||||
$resumeToken = $firstContext['baseline_compare']['resume_token'] ?? null;
|
||||
expect($resumeToken)->toBeString();
|
||||
|
||||
expect($fakeOrchestrator->capturedExternalIds)->toHaveCount(1);
|
||||
expect($fakeOrchestrator->capturedExternalIds[0])->toBe('resume-idem-a');
|
||||
|
||||
$secondRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'baseline_compare' => [
|
||||
'resume_token' => (string) $resumeToken,
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($secondRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
contentCapturePhase: $contentCapturePhase,
|
||||
);
|
||||
|
||||
$secondRun->refresh();
|
||||
$secondContext = is_array($secondRun->context) ? $secondRun->context : [];
|
||||
expect($secondContext['baseline_compare']['resume_token'] ?? null)->toBeNull();
|
||||
|
||||
expect($fakeOrchestrator->capturedExternalIds)->toBe(['resume-idem-a', 'resume-idem-b']);
|
||||
});
|
||||
172
tests/Feature/Baselines/BaselineCompareResumeTokenTest.php
Normal file
172
tests/Feature/Baselines/BaselineCompareResumeTokenTest.php
Normal file
@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('records a resume token when full-content compare cannot capture all subjects within budget', function (): void {
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 1);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$displayNames = ['Resume Token A', 'Resume Token B'];
|
||||
|
||||
foreach ($displayNames as $displayName) {
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline'),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$policies = [];
|
||||
|
||||
foreach ($displayNames as $i => $displayName) {
|
||||
$policies[] = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'external_id' => $i === 0 ? 'resume-token-a' : 'resume-token-b',
|
||||
'platform' => 'windows',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
}
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
foreach ($policies as $policy) {
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => fake()->uuid()],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$fakeOrchestrator = new class extends PolicyCaptureOrchestrator
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
public function capture(
|
||||
Policy $policy,
|
||||
\App\Models\Tenant $tenant,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
?string $createdBy = null,
|
||||
array $metadata = [],
|
||||
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
): array {
|
||||
$version = \App\Models\PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'platform' => (string) $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'snapshot' => ['settings' => [['key' => 'k', 'value' => 1]]],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
'capture_purpose' => $capturePurpose,
|
||||
'operation_run_id' => $operationRunId,
|
||||
'baseline_profile_id' => $baselineProfileId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'captured' => [
|
||||
'payload' => $version->snapshot,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
$contentCapturePhase = new BaselineContentCapturePhase($fakeOrchestrator);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
contentCapturePhase: $contentCapturePhase,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$token = $context['baseline_compare']['resume_token'] ?? null;
|
||||
expect($token)->toBeString();
|
||||
|
||||
$state = BaselineEvidenceResumeToken::decode((string) $token);
|
||||
expect($state)->toBeArray();
|
||||
expect($state)->toHaveKey('offset');
|
||||
expect($state['offset'])->toBe(1);
|
||||
});
|
||||
@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('records no_subjects_in_scope when the resolved subject list is empty', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = \App\Models\OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::InventorySync->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'inventory' => [
|
||||
'coverage' => [
|
||||
'policy_types' => [
|
||||
'deviceConfiguration' => ['status' => 'succeeded'],
|
||||
],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$compareRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(0);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoSubjectsInScope->value);
|
||||
});
|
||||
|
||||
it('records no_drift_detected when subjects are processed but no drift findings are produced', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$externalId = 'policy-uuid';
|
||||
$displayName = 'Stable Policy';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectKey: (string) $subjectKey,
|
||||
);
|
||||
|
||||
$metaJsonb = [
|
||||
'odata_type' => '#microsoft.graph.deviceConfiguration',
|
||||
'etag' => 'E_STABLE',
|
||||
];
|
||||
|
||||
$baselineHash = app(BaselineSnapshotIdentity::class)->hashItemContent(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectExternalId: $externalId,
|
||||
metaJsonb: $metaJsonb,
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'meta',
|
||||
'source' => 'inventory',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = \App\Models\OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::InventorySync->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'inventory' => [
|
||||
'coverage' => [
|
||||
'policy_types' => [
|
||||
'deviceConfiguration' => ['status' => 'succeeded'],
|
||||
],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => $metaJsonb,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$compareRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
|
||||
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(1);
|
||||
expect(data_get($compareRun->context, 'result.findings_total'))->toBe(0);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoDriftDetected->value);
|
||||
});
|
||||
|
||||
it('records coverage_unproven when findings are suppressed due to missing coverage proof', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$compareRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::CoverageUnproven->value);
|
||||
});
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
|
||||
it('encodes and decodes resume token state deterministically', function (): void {
|
||||
$state = [
|
||||
'offset' => 12,
|
||||
'note' => 'opaque to callers',
|
||||
];
|
||||
|
||||
$token = BaselineEvidenceResumeToken::encode($state);
|
||||
|
||||
expect($token)->toBeString();
|
||||
expect($token)->not->toContain('+');
|
||||
expect($token)->not->toContain('/');
|
||||
expect($token)->not->toContain('=');
|
||||
|
||||
$decoded = BaselineEvidenceResumeToken::decode($token);
|
||||
|
||||
expect($decoded)->toBe($state);
|
||||
});
|
||||
|
||||
it('returns null for invalid resume tokens', function (): void {
|
||||
expect(BaselineEvidenceResumeToken::decode(''))->toBeNull();
|
||||
expect(BaselineEvidenceResumeToken::decode('not-base64url'))->toBeNull();
|
||||
|
||||
$payload = json_encode(['v' => 999, 'state' => ['offset' => 1]], JSON_THROW_ON_ERROR);
|
||||
$token = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
|
||||
|
||||
expect(BaselineEvidenceResumeToken::decode($token))->toBeNull();
|
||||
});
|
||||
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
it('writes an audit event when resuming evidence capture', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'baseline_compare' => [
|
||||
'resume_token' => BaselineEvidenceResumeToken::encode(['offset' => 1]),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$service = app(BaselineEvidenceCaptureResumeService::class);
|
||||
$result = $service->resume($run, $user);
|
||||
|
||||
expect($result['ok'] ?? false)->toBeTrue();
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'baseline.evidence.resume.started')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
});
|
||||
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\InventoryItem;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('shows a warning banner when duplicate policy names make baseline matching ambiguous', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
|
||||
$displayName = 'Duplicate Policy';
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'dup-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'dup-2',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSee(__('baseline-compare.duplicate_warning_title'))
|
||||
->assertSee('share the same display name')
|
||||
->assertSee('cannot match them to the baseline');
|
||||
});
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('shows a clear why-no-findings explanation when the last run had zero drift', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$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' => 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,
|
||||
],
|
||||
'fidelity' => 'meta',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSee(BaselineCompareReasonCode::NoDriftDetected->message());
|
||||
});
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('shows snapshot fidelity counts and gap state on list and view pages', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$complete = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subHour(),
|
||||
'summary_jsonb' => [
|
||||
'total_items' => 5,
|
||||
'fidelity_counts' => ['content' => 5, 'meta' => 0],
|
||||
'gaps' => ['count' => 0, 'by_reason' => []],
|
||||
],
|
||||
]);
|
||||
|
||||
$withGaps = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinutes(10),
|
||||
'summary_jsonb' => [
|
||||
'total_items' => 5,
|
||||
'fidelity_counts' => ['content' => 3, 'meta' => 2],
|
||||
'gaps' => ['count' => 2, 'by_reason' => ['meta_fallback' => 2]],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Complete')
|
||||
->assertSee('Captured with gaps')
|
||||
->assertSee('Content 5, Meta 0')
|
||||
->assertSee('Content 3, Meta 2');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $withGaps], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Captured with gaps')
|
||||
->assertSee('Content 3, Meta 2')
|
||||
->assertSee('Evidence gaps')
|
||||
->assertSee('2');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $complete], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Complete')
|
||||
->assertSee('Content 5, Meta 0')
|
||||
->assertSee('Evidence gaps')
|
||||
->assertSee('0');
|
||||
});
|
||||
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('offers a Resume capture action on eligible baseline compare runs with a resume token', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$token = BaselineEvidenceResumeToken::encode(['offset' => 1]);
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'baseline_compare' => [
|
||||
'resume_token' => $token,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertActionVisible('resumeCapture')
|
||||
->callAction('resumeCapture')
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
||||
|
||||
$resumed = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', OperationRunType::BaselineCompare->value)
|
||||
->where('status', OperationRunStatus::Queued->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($resumed)->not->toBeNull();
|
||||
$context = is_array($resumed?->context) ? $resumed->context : [];
|
||||
expect($context['baseline_compare']['resume_token'] ?? null)->toBe($token);
|
||||
});
|
||||
@ -26,7 +26,7 @@ function getPolicySyncHeaderAction(Testable $component, string $name): ?Action
|
||||
return null;
|
||||
}
|
||||
|
||||
it('shows sync only in empty state when policies table is empty', function (): void {
|
||||
it('shows sync in header and empty state when policies table is empty', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
@ -34,11 +34,11 @@ function getPolicySyncHeaderAction(Testable $component, string $name): ?Action
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(ListPolicies::class)
|
||||
->assertTableEmptyStateActionsExistInOrder(['syncEmpty']);
|
||||
->assertTableEmptyStateActionsExistInOrder(['sync']);
|
||||
|
||||
$headerSync = getPolicySyncHeaderAction($component, 'sync');
|
||||
expect($headerSync)->not->toBeNull();
|
||||
expect($headerSync?->isVisible())->toBeFalse();
|
||||
expect($headerSync?->isVisible())->toBeTrue();
|
||||
});
|
||||
|
||||
it('shows sync only in header when policies table is not empty', function (): void {
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('hides baseline-purpose policy versions for tenant.view-only actors', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$resolver = \Mockery::mock(CapabilityResolver::class);
|
||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||
$resolver->shouldReceive('primeMemberships')->andReturnNull();
|
||||
$resolver->shouldReceive('can')
|
||||
->andReturnUsing(static fn ($user, $tenant, string $capability): bool => $capability === Capabilities::TENANT_VIEW);
|
||||
|
||||
app()->instance(CapabilityResolver::class, $resolver);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$baselineProfile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$backupVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::Backup->value,
|
||||
]);
|
||||
|
||||
$baselinePurposeVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
'baseline_profile_id' => (int) $baselineProfile->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListPolicyVersions::class)
|
||||
->assertCanSeeTableRecords([$backupVersion])
|
||||
->assertCanNotSeeTableRecords([$baselinePurposeVersion]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $baselinePurposeVersion], tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('shows baseline-purpose policy versions for actors with tenant.sync', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
app()->forgetInstance(CapabilityResolver::class);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$baselineProfile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$backupVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::Backup->value,
|
||||
]);
|
||||
|
||||
$baselinePurposeVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
'baseline_profile_id' => (int) $baselineProfile->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListPolicyVersions::class)
|
||||
->assertCanSeeTableRecords([$backupVersion, $baselinePurposeVersion]);
|
||||
});
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('prevents legacy fingerprinting/compare helpers from re-entering baseline orchestration (Spec 118)', function (): void {
|
||||
$forbiddenTokens = [
|
||||
'PolicyNormalizer',
|
||||
'VersionDiff',
|
||||
'flattenForDiff',
|
||||
'SettingsNormalizer',
|
||||
'ScopeTagsNormalizer',
|
||||
'->hashNormalized(',
|
||||
'::hashNormalized(',
|
||||
];
|
||||
|
||||
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
|
||||
expect($compareJob)->toBeString();
|
||||
expect($compareJob)->toContain('CurrentStateHashResolver');
|
||||
|
||||
foreach ($forbiddenTokens as $token) {
|
||||
expect($compareJob)->not->toContain($token);
|
||||
}
|
||||
|
||||
$captureJob = file_get_contents(base_path('app/Jobs/CaptureBaselineSnapshotJob.php'));
|
||||
expect($captureJob)->toBeString();
|
||||
expect($captureJob)->toContain('CurrentStateHashResolver');
|
||||
|
||||
foreach ($forbiddenTokens as $token) {
|
||||
expect($captureJob)->not->toContain($token);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('prunes baseline-purpose policy versions past retention but keeps backups', function (): void {
|
||||
config()->set('tenantpilot.baselines.full_content_capture.retention_days', 30);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$oldBaselineCompare = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subDays(45),
|
||||
]);
|
||||
|
||||
$oldBaselineCapture = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subDays(45),
|
||||
]);
|
||||
|
||||
$recentBaselineCompare = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 3,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
$oldBackup = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 4,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::Backup->value,
|
||||
'captured_at' => now()->subDays(45),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:baseline-evidence:prune')->assertExitCode(0);
|
||||
|
||||
expect($oldBaselineCompare->refresh()->trashed())->toBeTrue();
|
||||
expect($oldBaselineCapture->refresh()->trashed())->toBeTrue();
|
||||
expect($recentBaselineCompare->refresh()->trashed())->toBeFalse();
|
||||
expect($oldBackup->refresh()->trashed())->toBeFalse();
|
||||
});
|
||||
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
it('schedules baseline evidence policy version pruning daily without overlapping', function (): void {
|
||||
/** @var Schedule $schedule */
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())
|
||||
->first(fn ($event) => ($event->description ?? null) === 'tenantpilot:baseline-evidence:prune');
|
||||
|
||||
expect($event)->not->toBeNull();
|
||||
expect($event->withoutOverlapping)->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user