From 04d61cbad091475ad4116fe95f6606b4b896ec33 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Mar 2026 23:01:39 +0100 Subject: [PATCH] feat: baseline drift engine v1 - Implement Spec 116 baseline capture/compare + coverage guard\n- Add UI surfaces and widgets for baseline compare\n- Add tests and research report --- .github/agents/copilot-instructions.md | 2 +- app/Filament/Pages/BaselineCompareLanding.php | 40 +- app/Filament/Pages/DriftLanding.php | 31 + .../Resources/BaselineProfileResource.php | 105 ++- .../Pages/CreateBaselineProfile.php | 15 +- .../Pages/EditBaselineProfile.php | 49 +- .../Pages/ViewBaselineProfile.php | 21 +- ...selineTenantAssignmentsRelationManager.php | 5 +- .../FindingResource/Pages/ListFindings.php | 8 + .../Resources/OperationRunResource.php | 75 ++ .../Tenant/BaselineCompareCoverageBanner.php | 55 ++ app/Jobs/CaptureBaselineSnapshotJob.php | 33 +- app/Jobs/CompareBaselineToTenantJob.php | 256 ++++++- app/Jobs/RunInventorySyncJob.php | 49 +- app/Models/BaselineProfile.php | 83 ++- .../Baselines/BaselineCaptureService.php | 5 +- .../Baselines/BaselineCompareService.php | 30 +- .../Baselines/BaselineSnapshotIdentity.php | 15 +- .../Baselines/InventoryMetaContract.php | 69 ++ .../Inventory/InventorySyncService.php | 52 +- .../Domains/BaselineProfileStatusBadge.php | 14 +- .../Baselines/BaselineCompareStats.php | 77 +- .../Baselines/BaselineProfileStatus.php | 79 +++ app/Support/Baselines/BaselineReasonCodes.php | 2 + app/Support/Baselines/BaselineScope.php | 172 ++++- app/Support/Inventory/InventoryCoverage.php | 172 +++++ app/Support/Rbac/UiEnforcement.php | 83 ++- database/factories/BaselineProfileFactory.php | 11 +- ...den-master-baseline-drift-deep-analysis.md | 664 ++++++++++++++++++ .../pages/baseline-compare-landing.blade.php | 106 ++- .../filament/pages/drift-landing.blade.php | 49 ++ ...baseline-compare-coverage-banner.blade.php | 46 ++ specs/116-baseline-drift-engine/plan.md | 9 + specs/116-baseline-drift-engine/quickstart.md | 7 +- specs/116-baseline-drift-engine/tasks.md | 110 +-- .../Feature/Baselines/BaselineCaptureTest.php | 105 ++- .../BaselineCompareCoverageGuardTest.php | 354 ++++++++++ .../Baselines/BaselineCompareFindingsTest.php | 468 ++++++++++-- .../BaselineComparePerformanceGuardTest.php | 102 +++ .../BaselineComparePreconditionsTest.php | 47 +- .../BaselineOperabilityAutoCloseTest.php | 7 +- .../BaselineProfileArchiveActionTest.php | 73 ++ .../DriftLandingShowsComparisonInfoTest.php | 72 ++ ...BaselineCompareLandingStartSurfaceTest.php | 163 +++++ ...BaselineProfileCaptureStartSurfaceTest.php | 94 +++ .../Guards/ActionSurfaceContractTest.php | 51 ++ .../Guards/Spec116OneEngineGuardTest.php | 24 + .../InventorySyncStartSurfaceTest.php | 52 +- tests/Pest.php | 35 + tests/Unit/Baselines/BaselineScopeTest.php | 49 ++ .../Baselines/InventoryMetaContractTest.php | 59 ++ 51 files changed, 4029 insertions(+), 325 deletions(-) create mode 100644 app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php create mode 100644 app/Services/Baselines/InventoryMetaContract.php create mode 100644 app/Support/Baselines/BaselineProfileStatus.php create mode 100644 app/Support/Inventory/InventoryCoverage.php create mode 100644 docs/research/golden-master-baseline-drift-deep-analysis.md create mode 100644 resources/views/filament/widgets/tenant/baseline-compare-coverage-banner.blade.php create mode 100644 tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php create mode 100644 tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php create mode 100644 tests/Feature/Baselines/BaselineProfileArchiveActionTest.php create mode 100644 tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php create mode 100644 tests/Feature/Guards/Spec116OneEngineGuardTest.php create mode 100644 tests/Unit/Baselines/BaselineScopeTest.php create mode 100644 tests/Unit/Baselines/InventoryMetaContractTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index f3a70ab..35089e9 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -59,8 +59,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 116-baseline-drift-engine-session-1772451227: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 - 116-baseline-drift-engine: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 - 110-ops-ux-enforcement: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 -- 109-review-pack-export: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12 diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index eca4b40..f88ec46 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -15,6 +15,7 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; @@ -61,6 +62,15 @@ class BaselineCompareLanding extends Page public ?string $failureReason = null; + public ?string $coverageStatus = null; + + public ?int $uncoveredTypesCount = null; + + /** @var list|null */ + public ?array $uncoveredTypes = null; + + public ?string $fidelity = null; + public static function canAccess(): bool { $user = auth()->user(); @@ -100,6 +110,11 @@ public function refreshStats(): void $this->lastComparedAt = $stats->lastComparedHuman; $this->lastComparedIso = $stats->lastComparedIso; $this->failureReason = $stats->failureReason; + + $this->coverageStatus = $stats->coverageStatus; + $this->uncoveredTypesCount = $stats->uncoveredTypesCount; + $this->uncoveredTypes = $stats->uncoveredTypes !== [] ? $stats->uncoveredTypes : null; + $this->fidelity = $stats->fidelity; } public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration @@ -125,13 +140,12 @@ protected function getHeaderActions(): array private function compareNowAction(): Action { - return Action::make('compareNow') + $action = Action::make('compareNow') ->label('Compare Now') ->icon('heroicon-o-play') ->requiresConfirmation() ->modalHeading('Start baseline comparison') ->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.') - ->visible(fn (): bool => $this->canCompare()) ->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true)) ->action(function (): void { $user = auth()->user(); @@ -181,25 +195,11 @@ private function compareNowAction(): Action ] : []) ->send(); }); - } - private function canCompare(): bool - { - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return false; - } - - $resolver = app(CapabilityResolver::class); - - return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC); + return UiEnforcement::forAction($action) + ->requireCapability(Capabilities::TENANT_SYNC) + ->preserveDisabled() + ->apply(); } public function getFindingsUrl(): ?string diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php index d0af49b..aff82f0 100644 --- a/app/Filament/Pages/DriftLanding.php +++ b/app/Filament/Pages/DriftLanding.php @@ -13,6 +13,7 @@ use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; use App\Support\Auth\Capabilities; +use App\Support\Baselines\BaselineCompareStats; use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; @@ -47,6 +48,17 @@ class DriftLanding extends Page public ?string $currentFinishedAt = null; + public ?int $baselineCompareRunId = null; + + public ?string $baselineCompareCoverageStatus = null; + + public ?int $baselineCompareUncoveredTypesCount = null; + + /** @var list|null */ + public ?array $baselineCompareUncoveredTypes = null; + + public ?string $baselineCompareFidelity = null; + public ?int $operationRunId = null; /** @var array|null */ @@ -66,6 +78,16 @@ public function mount(): void abort(403, 'Not allowed'); } + $baselineCompareStats = BaselineCompareStats::forTenant($tenant); + + if ($baselineCompareStats->operationRunId !== null) { + $this->baselineCompareRunId = (int) $baselineCompareStats->operationRunId; + $this->baselineCompareCoverageStatus = $baselineCompareStats->coverageStatus; + $this->baselineCompareUncoveredTypesCount = $baselineCompareStats->uncoveredTypesCount; + $this->baselineCompareUncoveredTypes = $baselineCompareStats->uncoveredTypes !== [] ? $baselineCompareStats->uncoveredTypes : null; + $this->baselineCompareFidelity = $baselineCompareStats->fidelity; + } + $latestSuccessful = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', 'inventory_sync') @@ -292,4 +314,13 @@ public function getOperationRunUrl(): ?string return OperationRunLinks::view($this->operationRunId, Tenant::current()); } + + public function getBaselineCompareRunUrl(): ?string + { + if (! is_int($this->baselineCompareRunId)) { + return null; + } + + return OperationRunLinks::view($this->baselineCompareRunId, Tenant::current()); + } } diff --git a/app/Filament/Resources/BaselineProfileResource.php b/app/Filament/Resources/BaselineProfileResource.php index f93f7ff..faf4bc0 100644 --- a/app/Filament/Resources/BaselineProfileResource.php +++ b/app/Filament/Resources/BaselineProfileResource.php @@ -13,6 +13,7 @@ use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\Baselines\BaselineProfileStatus; use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; @@ -25,6 +26,7 @@ use Filament\Actions\ActionGroup; use Filament\Actions\BulkActionGroup; use Filament\Facades\Filament; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; @@ -38,6 +40,8 @@ use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\Unique; use UnitEnum; class BaselineProfileResource extends Resource @@ -147,39 +151,53 @@ public static function form(Schema $schema): Schema TextInput::make('name') ->required() ->maxLength(255) + ->rule(fn (?BaselineProfile $record): Unique => Rule::unique('baseline_profiles', 'name') + ->where('workspace_id', $record?->workspace_id ?? app(WorkspaceContext::class)->currentWorkspaceId(request())) + ->ignore($record)) ->helperText('A descriptive name for this baseline profile.'), Textarea::make('description') ->rows(3) ->maxLength(1000) ->helperText('Explain the purpose and scope of this baseline.'), + ]), + Section::make('Controls') + ->schema([ + Select::make('status') + ->required() + ->options(fn (?BaselineProfile $record): array => self::statusOptionsForRecord($record)) + ->default(BaselineProfileStatus::Draft->value) + ->native(false) + ->disabled(fn (?BaselineProfile $record): bool => $record?->status === BaselineProfileStatus::Archived) + ->helperText(fn (?BaselineProfile $record): string => match ($record?->status) { + BaselineProfileStatus::Archived => 'Archived baselines cannot be reactivated.', + BaselineProfileStatus::Active => 'Changing status to Archived is permanent.', + default => 'Only active baselines are enforced during compliance checks.', + }), TextInput::make('version_label') ->label('Version label') ->maxLength(50) ->placeholder('e.g. v2.1 — February rollout') ->helperText('Optional label to identify this version.'), - Select::make('status') - ->required() - ->options([ - BaselineProfile::STATUS_DRAFT => 'Draft', - BaselineProfile::STATUS_ACTIVE => 'Active', - BaselineProfile::STATUS_ARCHIVED => 'Archived', - ]) - ->default(BaselineProfile::STATUS_DRAFT) - ->native(false) - ->helperText('Only active baselines are enforced during compliance checks.'), - ]) - ->columns(2) - ->columnSpanFull(), - Section::make('Scope') - ->schema([ Select::make('scope_jsonb.policy_types') - ->label('Policy type scope') + ->label('Policy types') ->multiple() ->options(self::policyTypeOptions()) - ->helperText('Leave empty to include all policy types.') + ->helperText('Leave empty to include all supported policy types (excluding foundations).') ->native(false), + Select::make('scope_jsonb.foundation_types') + ->label('Foundations') + ->multiple() + ->options(self::foundationTypeOptions()) + ->helperText('Leave empty to exclude foundations. Select foundations to include them.') + ->native(false), + Placeholder::make('metadata') + ->label('Last modified') + ->content(fn (?BaselineProfile $record): string => $record?->updated_at + ? $record->updated_at->diffForHumans() + : '—') + ->visible(fn (?BaselineProfile $record): bool => $record !== null), ]) - ->columnSpanFull(), + ->columns(2), ]); } @@ -207,14 +225,23 @@ public static function infolist(Schema $schema): Schema Section::make('Scope') ->schema([ TextEntry::make('scope_jsonb.policy_types') - ->label('Policy type scope') + ->label('Policy types') ->badge() ->formatStateUsing(function (string $state): string { $options = self::policyTypeOptions(); return $options[$state] ?? $state; }) - ->placeholder('All policy types'), + ->placeholder('All supported policy types (excluding foundations)'), + TextEntry::make('scope_jsonb.foundation_types') + ->label('Foundations') + ->badge() + ->formatStateUsing(function (string $state): string { + $options = self::foundationTypeOptions(); + + return $options[$state] ?? $state; + }) + ->placeholder('None'), ]) ->columnSpanFull(), Section::make('Metadata') @@ -314,7 +341,21 @@ public static function getPages(): array */ public static function policyTypeOptions(): array { - return collect(InventoryPolicyTypeMeta::all()) + return collect(InventoryPolicyTypeMeta::supported()) + ->filter(fn (array $row): bool => filled($row['type'] ?? null)) + ->mapWithKeys(fn (array $row): array => [ + (string) $row['type'] => (string) ($row['label'] ?? $row['type']), + ]) + ->sort() + ->all(); + } + + /** + * @return array + */ + public static function foundationTypeOptions(): array + { + return collect(InventoryPolicyTypeMeta::foundations()) ->filter(fn (array $row): bool => filled($row['type'] ?? null)) ->mapWithKeys(fn (array $row): array => [ (string) $row['type'] => (string) ($row['label'] ?? $row['type']), @@ -346,6 +387,24 @@ public static function audit(BaselineProfile $record, AuditActionId $actionId, a ); } + /** + * Status options scoped to valid transitions from the current record state. + * + * @return array + */ + private static function statusOptionsForRecord(?BaselineProfile $record): array + { + if ($record === null) { + return [BaselineProfileStatus::Draft->value => BaselineProfileStatus::Draft->label()]; + } + + $currentStatus = $record->status instanceof BaselineProfileStatus + ? $record->status + : (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft); + + return $currentStatus->selectOptions(); + } + private static function resolveWorkspace(): ?Workspace { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); @@ -381,13 +440,13 @@ private static function archiveTableAction(?Workspace $workspace): Action ->requiresConfirmation() ->modalHeading('Archive baseline profile') ->modalDescription('Archiving is permanent in v1. This profile can no longer be used for captures or compares.') - ->visible(fn (BaselineProfile $record): bool => $record->status !== BaselineProfile::STATUS_ARCHIVED && self::hasManageCapability()) + ->hidden(fn (BaselineProfile $record): bool => $record->status === BaselineProfileStatus::Archived) ->action(function (BaselineProfile $record): void { if (! self::hasManageCapability()) { throw new AuthorizationException; } - $record->forceFill(['status' => BaselineProfile::STATUS_ARCHIVED])->save(); + $record->forceFill(['status' => BaselineProfileStatus::Archived->value])->save(); self::audit($record, AuditActionId::BaselineProfileArchived, [ 'baseline_profile_id' => (int) $record->getKey(), diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php index 475259b..f297d7f 100644 --- a/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php +++ b/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php @@ -8,6 +8,7 @@ use App\Models\BaselineProfile; use App\Models\User; use App\Support\Audit\AuditActionId; +use App\Support\Baselines\BaselineProfileStatus; use App\Support\Workspaces\WorkspaceContext; use Filament\Notifications\Notification; use Filament\Resources\Pages\CreateRecord; @@ -28,8 +29,14 @@ protected function mutateFormDataBeforeCreate(array $data): array $user = auth()->user(); $data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null; - $policyTypes = $data['scope_jsonb']['policy_types'] ?? []; - $data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []]; + if (isset($data['scope_jsonb'])) { + $policyTypes = $data['scope_jsonb']['policy_types'] ?? []; + $foundationTypes = $data['scope_jsonb']['foundation_types'] ?? []; + $data['scope_jsonb'] = [ + 'policy_types' => is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [], + 'foundation_types' => is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [], + ]; + } return $data; } @@ -45,7 +52,9 @@ protected function afterCreate(): void BaselineProfileResource::audit($record, AuditActionId::BaselineProfileCreated, [ 'baseline_profile_id' => (int) $record->getKey(), 'name' => (string) $record->name, - 'status' => (string) $record->status, + 'status' => $record->status instanceof BaselineProfileStatus + ? $record->status->value + : (string) $record->status, ]); Notification::make() diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php index 49284ec..03c44e6 100644 --- a/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php +++ b/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php @@ -7,6 +7,7 @@ use App\Filament\Resources\BaselineProfileResource; use App\Models\BaselineProfile; use App\Support\Audit\AuditActionId; +use App\Support\Baselines\BaselineProfileStatus; use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; @@ -14,14 +15,49 @@ class EditBaselineProfile extends EditRecord { protected static string $resource = BaselineProfileResource::class; + public function getSubHeading(): string + { + $record = $this->getRecord(); + $status = $record->status instanceof BaselineProfileStatus + ? $record->status + : (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft); + + return $status->label(); + } + + public function getSubHeadingBadgeColor(): string + { + $record = $this->getRecord(); + $status = $record->status instanceof BaselineProfileStatus + ? $record->status + : (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft); + + return $status->color(); + } + /** * @param array $data * @return array */ protected function mutateFormDataBeforeSave(array $data): array { - $policyTypes = $data['scope_jsonb']['policy_types'] ?? []; - $data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []]; + $record = $this->getRecord(); + $currentStatus = $record->status instanceof BaselineProfileStatus + ? $record->status + : (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft); + + if ($currentStatus === BaselineProfileStatus::Archived) { + unset($data['status']); + } + + if (isset($data['scope_jsonb'])) { + $policyTypes = $data['scope_jsonb']['policy_types'] ?? []; + $foundationTypes = $data['scope_jsonb']['foundation_types'] ?? []; + $data['scope_jsonb'] = [ + 'policy_types' => is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [], + 'foundation_types' => is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [], + ]; + } return $data; } @@ -37,7 +73,9 @@ protected function afterSave(): void BaselineProfileResource::audit($record, AuditActionId::BaselineProfileUpdated, [ 'baseline_profile_id' => (int) $record->getKey(), 'name' => (string) $record->name, - 'status' => (string) $record->status, + 'status' => $record->status instanceof BaselineProfileStatus + ? $record->status->value + : (string) $record->status, ]); Notification::make() @@ -45,4 +83,9 @@ protected function afterSave(): void ->success() ->send(); } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('view', ['record' => $this->getRecord()]); + } } diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php index 3ddd62c..10f1f14 100644 --- a/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php +++ b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php @@ -14,6 +14,7 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; use Filament\Actions\EditAction; @@ -36,13 +37,10 @@ protected function getHeaderActions(): array private function captureAction(): Action { - return Action::make('capture') + $action = Action::make('capture') ->label('Capture Snapshot') ->icon('heroicon-o-camera') ->color('primary') - ->visible(fn (): bool => $this->hasManageCapability()) - ->disabled(fn (): bool => ! $this->hasManageCapability()) - ->tooltip(fn (): ?string => ! $this->hasManageCapability() ? 'You need manage permission to capture snapshots.' : null) ->requiresConfirmation() ->modalHeading('Capture Baseline Snapshot') ->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.') @@ -56,13 +54,8 @@ private function captureAction(): Action ->action(function (array $data): void { $user = auth()->user(); - if (! $user instanceof User || ! $this->hasManageCapability()) { - Notification::make() - ->title('Permission denied') - ->danger() - ->send(); - - return; + if (! $user instanceof User) { + abort(403); } /** @var BaselineProfile $profile */ @@ -123,6 +116,12 @@ private function captureAction(): Action ->actions([$viewAction]) ->send(); }); + + return WorkspaceUiEnforcement::forTableAction($action, fn (): ?Workspace => Workspace::query() + ->whereKey((int) $this->getRecord()->workspace_id) + ->first()) + ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) + ->apply(); } /** diff --git a/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php b/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php index adb5e98..2b9d77b 100644 --- a/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php +++ b/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php @@ -55,7 +55,8 @@ public function table(Table $table): Table ->sortable(), ]) ->headerActions([ - $this->assignTenantAction(), + $this->assignTenantAction() + ->hidden(fn (): bool => $this->getOwnerRecord()->tenantAssignments()->doesntExist()), ]) ->actions([ $this->removeAssignmentAction(), @@ -63,7 +64,7 @@ public function table(Table $table): Table ->emptyStateHeading('No tenants assigned') ->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.') ->emptyStateActions([ - $this->assignTenantAction(), + $this->assignTenantAction()->name('assignEmpty'), ]); } diff --git a/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/app/Filament/Resources/FindingResource/Pages/ListFindings.php index 658abe3..70935ee 100644 --- a/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\FindingResource\Pages; use App\Filament\Resources\FindingResource; +use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner; use App\Jobs\BackfillFindingLifecycleJob; use App\Models\Finding; use App\Models\Tenant; @@ -27,6 +28,13 @@ class ListFindings extends ListRecords { protected static string $resource = FindingResource::class; + protected function getHeaderWidgets(): array + { + return [ + BaselineCompareCoverageBanner::class, + ]; + } + protected function getHeaderActions(): array { $actions = []; diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index deedb2f..78d61ea 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -184,6 +184,81 @@ public static function infolist(Schema $schema): Schema ->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary)) ->columnSpanFull(), + Section::make('Baseline compare') + ->schema([ + TextEntry::make('baseline_compare_fidelity') + ->label('Fidelity') + ->badge() + ->getStateUsing(function (OperationRun $record): string { + $context = is_array($record->context) ? $record->context : []; + $fidelity = data_get($context, 'baseline_compare.fidelity'); + + return is_string($fidelity) && $fidelity !== '' ? $fidelity : 'meta'; + }), + TextEntry::make('baseline_compare_coverage_status') + ->label('Coverage') + ->badge() + ->getStateUsing(function (OperationRun $record): string { + $context = is_array($record->context) ? $record->context : []; + $proof = data_get($context, 'baseline_compare.coverage.proof'); + $proof = is_bool($proof) ? $proof : null; + + $uncovered = data_get($context, 'baseline_compare.coverage.uncovered_types'); + $uncovered = is_array($uncovered) ? array_values(array_filter($uncovered, 'is_string')) : []; + + return match (true) { + $proof === false => 'unproven', + $uncovered !== [] => 'warnings', + $proof === true => 'ok', + default => 'unknown', + }; + }) + ->color(fn (?string $state): string => match ((string) $state) { + 'ok' => 'success', + 'warnings', 'unproven' => 'warning', + default => 'gray', + }), + TextEntry::make('baseline_compare_uncovered_types') + ->label('Uncovered types') + ->getStateUsing(function (OperationRun $record): ?string { + $context = is_array($record->context) ? $record->context : []; + $types = data_get($context, 'baseline_compare.coverage.uncovered_types'); + $types = is_array($types) ? array_values(array_filter($types, 'is_string')) : []; + $types = array_values(array_unique(array_filter(array_map('trim', $types), fn (string $type): bool => $type !== ''))); + + if ($types === []) { + return null; + } + + sort($types, SORT_STRING); + + return implode(', ', array_slice($types, 0, 12)).(count($types) > 12 ? '…' : ''); + }) + ->visible(function (OperationRun $record): bool { + $context = is_array($record->context) ? $record->context : []; + $types = data_get($context, 'baseline_compare.coverage.uncovered_types'); + + return is_array($types) && $types !== []; + }) + ->columnSpanFull(), + TextEntry::make('baseline_compare_inventory_sync_run_id') + ->label('Inventory sync run') + ->getStateUsing(function (OperationRun $record): ?string { + $context = is_array($record->context) ? $record->context : []; + $syncRunId = data_get($context, 'baseline_compare.inventory_sync_run_id'); + + return is_numeric($syncRunId) ? '#'.(string) (int) $syncRunId : null; + }) + ->visible(function (OperationRun $record): bool { + $context = is_array($record->context) ? $record->context : []; + + return is_numeric(data_get($context, 'baseline_compare.inventory_sync_run_id')); + }), + ]) + ->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_compare') + ->columns(2) + ->columnSpanFull(), + Section::make('Verification report') ->schema([ ViewEntry::make('verification_report') diff --git a/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php b/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php new file mode 100644 index 0000000..b3f70ce --- /dev/null +++ b/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php @@ -0,0 +1,55 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'shouldShow' => false, + ]; + } + + $stats = BaselineCompareStats::forTenant($tenant); + + $uncoveredTypes = $stats->uncoveredTypes ?? []; + $uncoveredTypes = is_array($uncoveredTypes) ? $uncoveredTypes : []; + + $coverageStatus = $stats->coverageStatus; + $hasWarnings = in_array($coverageStatus, ['warning', 'unproven'], true) && $uncoveredTypes !== []; + + $runUrl = null; + + if ($stats->operationRunId !== null) { + $runUrl = OperationRunLinks::view($stats->operationRunId, $tenant); + } + + return [ + 'shouldShow' => $hasWarnings && $runUrl !== null, + 'runUrl' => $runUrl, + 'coverageStatus' => $coverageStatus, + 'fidelity' => $stats->fidelity, + 'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes), + 'uncoveredTypes' => $uncoveredTypes, + ]; + } +} diff --git a/app/Jobs/CaptureBaselineSnapshotJob.php b/app/Jobs/CaptureBaselineSnapshotJob.php index 7909911..ef75345 100644 --- a/app/Jobs/CaptureBaselineSnapshotJob.php +++ b/app/Jobs/CaptureBaselineSnapshotJob.php @@ -11,8 +11,10 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Baselines\BaselineSnapshotIdentity; +use App\Services\Baselines\InventoryMetaContract; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineScope; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; @@ -45,6 +47,7 @@ public function middleware(): array public function handle( BaselineSnapshotIdentity $identity, + InventoryMetaContract $metaContract, AuditLogger $auditLogger, OperationRunService $operationRunService, ): void { @@ -78,7 +81,7 @@ public function handle( $this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator); - $snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity); + $snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity, $metaContract); $identityHash = $identity->computeIdentity($snapshotItems); @@ -90,7 +93,7 @@ public function handle( $wasNewSnapshot = $snapshot->wasRecentlyCreated; - if ($profile->status === BaselineProfile::STATUS_ACTIVE) { + if ($profile->status === BaselineProfileStatus::Active) { $profile->update(['active_snapshot_id' => $snapshot->getKey()]); } @@ -127,22 +130,31 @@ private function collectSnapshotItems( Tenant $sourceTenant, BaselineScope $scope, BaselineSnapshotIdentity $identity, + InventoryMetaContract $metaContract, ): array { $query = InventoryItem::query() ->where('tenant_id', $sourceTenant->getKey()); - if (! $scope->isEmpty()) { - $query->whereIn('policy_type', $scope->policyTypes); - } + $query->whereIn('policy_type', $scope->allTypes()); $items = []; $query->orderBy('policy_type') ->orderBy('external_id') - ->chunk(500, function ($inventoryItems) use (&$items, $identity): void { + ->chunk(500, function ($inventoryItems) use (&$items, $identity, $metaContract): void { foreach ($inventoryItems as $inventoryItem) { $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; - $baselineHash = $identity->hashItemContent($metaJsonb); + $contract = $metaContract->build( + policyType: (string) $inventoryItem->policy_type, + subjectExternalId: (string) $inventoryItem->external_id, + metaJsonb: $metaJsonb, + ); + + $baselineHash = $identity->hashItemContent( + policyType: (string) $inventoryItem->policy_type, + subjectExternalId: (string) $inventoryItem->external_id, + metaJsonb: $metaJsonb, + ); $items[] = [ 'subject_type' => 'policy', @@ -153,6 +165,13 @@ private function collectSnapshotItems( 'display_name' => $inventoryItem->display_name, 'category' => $inventoryItem->category, 'platform' => $inventoryItem->platform, + 'meta_contract' => $contract, + 'fidelity' => 'meta', + 'source' => 'inventory', + 'observed_at' => $inventoryItem->last_seen_at?->toIso8601String(), + 'observed_operation_run_id' => is_numeric($inventoryItem->last_seen_operation_run_id) + ? (int) $inventoryItem->last_seen_operation_run_id + : null, ], ]; } diff --git a/app/Jobs/CompareBaselineToTenantJob.php b/app/Jobs/CompareBaselineToTenantJob.php index eaa2d2f..da6b35c 100644 --- a/app/Jobs/CompareBaselineToTenantJob.php +++ b/app/Jobs/CompareBaselineToTenantJob.php @@ -15,11 +15,11 @@ use App\Models\Workspace; use App\Services\Baselines\BaselineAutoCloseService; use App\Services\Baselines\BaselineSnapshotIdentity; -use App\Services\Drift\DriftHasher; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Services\Settings\SettingsResolver; use App\Support\Baselines\BaselineScope; +use App\Support\Inventory\InventoryCoverage; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OperationRunType; @@ -52,7 +52,6 @@ public function middleware(): array } public function handle( - DriftHasher $driftHasher, BaselineSnapshotIdentity $snapshotIdentity, AuditLogger $auditLogger, OperationRunService $operationRunService, @@ -95,13 +94,75 @@ public function handle( : null; $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null); + $effectiveTypes = $effectiveScope->allTypes(); $scopeKey = 'baseline_profile:'.$profile->getKey(); $this->auditStarted($auditLogger, $tenant, $profile, $initiator); - $baselineItems = $this->loadBaselineItems($snapshotId, $effectiveScope); - $latestInventorySyncRunId = $this->resolveLatestInventorySyncRunId($tenant); - $currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity, $latestInventorySyncRunId); + if ($effectiveTypes === []) { + $this->completeWithCoverageWarning( + operationRunService: $operationRunService, + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + inventorySyncRun: null, + coverageProof: false, + effectiveTypes: [], + coveredTypes: [], + uncoveredTypes: [], + errorsRecorded: 1, + ); + + return; + } + + $inventorySyncRun = $this->resolveLatestInventorySyncRun($tenant); + $coverage = $inventorySyncRun instanceof OperationRun + ? InventoryCoverage::fromContext($inventorySyncRun->context) + : null; + + if (! $inventorySyncRun instanceof OperationRun || ! $coverage instanceof InventoryCoverage) { + $this->completeWithCoverageWarning( + operationRunService: $operationRunService, + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + inventorySyncRun: $inventorySyncRun, + coverageProof: false, + effectiveTypes: $effectiveTypes, + coveredTypes: [], + uncoveredTypes: $effectiveTypes, + errorsRecorded: count($effectiveTypes), + ); + + return; + } + + $coveredTypes = array_values(array_intersect($effectiveTypes, $coverage->coveredTypes())); + $uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes)); + + if ($coveredTypes === []) { + $this->completeWithCoverageWarning( + operationRunService: $operationRunService, + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + inventorySyncRun: $inventorySyncRun, + coverageProof: true, + effectiveTypes: $effectiveTypes, + coveredTypes: [], + uncoveredTypes: $effectiveTypes, + errorsRecorded: count($effectiveTypes), + ); + + return; + } + + $baselineItems = $this->loadBaselineItems($snapshotId, $coveredTypes); + $currentItems = $this->loadCurrentInventory($tenant, $coveredTypes, $snapshotIdentity, (int) $inventorySyncRun->getKey()); $driftResults = $this->computeDrift( $baselineItems, @@ -110,20 +171,22 @@ public function handle( ); $upsertResult = $this->upsertFindings( - $driftHasher, $tenant, $profile, + $snapshotId, $scopeKey, $driftResults, ); $severityBreakdown = $this->countBySeverity($driftResults); + $countsByChangeType = $this->countByChangeType($driftResults); $summaryCounts = [ 'total' => count($driftResults), 'processed' => count($driftResults), 'succeeded' => (int) $upsertResult['processed_count'], 'failed' => 0, + 'errors_recorded' => count($uncoveredTypes), 'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0, 'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0, 'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0, @@ -135,7 +198,7 @@ public function handle( $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Succeeded->value, + outcome: $uncoveredTypes !== [] ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value, summaryCounts: $summaryCounts, ); @@ -160,6 +223,25 @@ public function handle( } $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $updatedContext['baseline_compare'] = array_merge( + is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [], + [ + 'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(), + 'coverage' => [ + 'effective_types' => $effectiveTypes, + 'covered_types' => $coveredTypes, + 'uncovered_types' => $uncoveredTypes, + 'proof' => true, + ], + 'fidelity' => 'meta', + ], + ); + $updatedContext['findings'] = array_merge( + is_array($updatedContext['findings'] ?? null) ? $updatedContext['findings'] : [], + [ + 'counts_by_change_type' => $countsByChangeType, + ], + ); $updatedContext['result'] = [ 'findings_total' => count($driftResults), 'findings_upserted' => (int) $upsertResult['processed_count'], @@ -171,21 +253,89 @@ public function handle( $this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts); } + private function completeWithCoverageWarning( + OperationRunService $operationRunService, + AuditLogger $auditLogger, + Tenant $tenant, + BaselineProfile $profile, + ?User $initiator, + ?OperationRun $inventorySyncRun, + bool $coverageProof, + array $effectiveTypes, + array $coveredTypes, + array $uncoveredTypes, + int $errorsRecorded, + ): void { + $summaryCounts = [ + 'total' => 0, + 'processed' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'errors_recorded' => max(1, $errorsRecorded), + 'high' => 0, + 'medium' => 0, + 'low' => 0, + 'findings_created' => 0, + 'findings_reopened' => 0, + 'findings_unchanged' => 0, + ]; + + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::PartiallySucceeded->value, + summaryCounts: $summaryCounts, + ); + + $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $updatedContext['baseline_compare'] = array_merge( + is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [], + [ + 'inventory_sync_run_id' => $inventorySyncRun instanceof OperationRun ? (int) $inventorySyncRun->getKey() : null, + 'coverage' => [ + 'effective_types' => array_values($effectiveTypes), + 'covered_types' => array_values($coveredTypes), + 'uncovered_types' => array_values($uncoveredTypes), + 'proof' => $coverageProof, + ], + 'fidelity' => 'meta', + ], + ); + $updatedContext['findings'] = array_merge( + is_array($updatedContext['findings'] ?? null) ? $updatedContext['findings'] : [], + [ + 'counts_by_change_type' => [], + ], + ); + $updatedContext['result'] = [ + 'findings_total' => 0, + 'findings_upserted' => 0, + 'findings_resolved' => 0, + 'severity_breakdown' => [], + ]; + + $this->operationRun->update(['context' => $updatedContext]); + + $this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts); + } + /** * Load baseline snapshot items keyed by "policy_type|subject_external_id". * * @return array}> */ - private function loadBaselineItems(int $snapshotId, BaselineScope $scope): array + private function loadBaselineItems(int $snapshotId, array $policyTypes): array { $items = []; + if ($policyTypes === []) { + return $items; + } + $query = BaselineSnapshotItem::query() ->where('baseline_snapshot_id', $snapshotId); - if (! $scope->isEmpty()) { - $query->whereIn('policy_type', $scope->policyTypes); - } + $query->whereIn('policy_type', $policyTypes); $query ->orderBy('id') @@ -212,7 +362,7 @@ private function loadBaselineItems(int $snapshotId, BaselineScope $scope): array */ private function loadCurrentInventory( Tenant $tenant, - BaselineScope $scope, + array $policyTypes, BaselineSnapshotIdentity $snapshotIdentity, ?int $latestInventorySyncRunId = null, ): array { @@ -223,10 +373,12 @@ private function loadCurrentInventory( $query->where('last_seen_operation_run_id', $latestInventorySyncRunId); } - if (! $scope->isEmpty()) { - $query->whereIn('policy_type', $scope->policyTypes); + if ($policyTypes === []) { + return []; } + $query->whereIn('policy_type', $policyTypes); + $items = []; $query->orderBy('policy_type') @@ -234,7 +386,11 @@ private function loadCurrentInventory( ->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): void { foreach ($inventoryItems as $inventoryItem) { $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; - $currentHash = $snapshotIdentity->hashItemContent($metaJsonb); + $currentHash = $snapshotIdentity->hashItemContent( + policyType: (string) $inventoryItem->policy_type, + subjectExternalId: (string) $inventoryItem->external_id, + metaJsonb: $metaJsonb, + ); $key = $inventoryItem->policy_type.'|'.$inventoryItem->external_id; $items[$key] = [ @@ -253,7 +409,7 @@ private function loadCurrentInventory( return $items; } - private function resolveLatestInventorySyncRunId(Tenant $tenant): ?int + private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun { $run = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) @@ -261,11 +417,9 @@ private function resolveLatestInventorySyncRunId(Tenant $tenant): ?int ->where('status', OperationRunStatus::Completed->value) ->orderByDesc('completed_at') ->orderByDesc('id') - ->first(['id']); + ->first(); - $runId = $run?->getKey(); - - return is_numeric($runId) ? (int) $runId : null; + return $run instanceof OperationRun ? $run : null; } /** @@ -351,9 +505,9 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ * @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array} */ private function upsertFindings( - DriftHasher $driftHasher, Tenant $tenant, BaselineProfile $profile, + int $baselineSnapshotId, string $scopeKey, array $driftResults, ): array { @@ -366,16 +520,16 @@ private function upsertFindings( $seenFingerprints = []; foreach ($driftResults as $driftItem) { - $fingerprint = $driftHasher->fingerprint( + $recurrenceKey = $this->recurrenceKey( tenantId: $tenantId, - scopeKey: $scopeKey, - subjectType: $driftItem['subject_type'], + baselineSnapshotId: $baselineSnapshotId, + policyType: $driftItem['policy_type'], subjectExternalId: $driftItem['subject_external_id'], changeType: $driftItem['change_type'], - baselineHash: $driftItem['baseline_hash'], - currentHash: $driftItem['current_hash'], ); + $fingerprint = $recurrenceKey; + $seenFingerprints[] = $fingerprint; $finding = Finding::query() @@ -404,6 +558,7 @@ private function upsertFindings( 'subject_external_id' => $driftItem['subject_external_id'], 'severity' => $driftItem['severity'], 'fingerprint' => $fingerprint, + 'recurrence_key' => $recurrenceKey, 'evidence_jsonb' => $driftItem['evidence'], 'baseline_operation_run_id' => null, 'current_operation_run_id' => (int) $this->operationRun->getKey(), @@ -471,6 +626,55 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i } } + /** + * Stable identity for baseline-compare findings, scoped to a baseline snapshot. + */ + private function recurrenceKey( + int $tenantId, + int $baselineSnapshotId, + string $policyType, + string $subjectExternalId, + string $changeType, + ): string { + $parts = [ + (string) $tenantId, + (string) $baselineSnapshotId, + $this->normalizeKeyPart($policyType), + $this->normalizeKeyPart($subjectExternalId), + $this->normalizeKeyPart($changeType), + ]; + + return hash('sha256', implode('|', $parts)); + } + + private function normalizeKeyPart(string $value): string + { + return trim(mb_strtolower($value)); + } + + /** + * @param array $driftResults + * @return array + */ + private function countByChangeType(array $driftResults): array + { + $counts = []; + + foreach ($driftResults as $item) { + $changeType = (string) ($item['change_type'] ?? ''); + + if ($changeType === '') { + continue; + } + + $counts[$changeType] = ($counts[$changeType] ?? 0) + 1; + } + + ksort($counts); + + return $counts; + } + /** * @param array $driftResults * @return array diff --git a/app/Jobs/RunInventorySyncJob.php b/app/Jobs/RunInventorySyncJob.php index a84102f..ab42451 100644 --- a/app/Jobs/RunInventorySyncJob.php +++ b/app/Jobs/RunInventorySyncJob.php @@ -9,6 +9,8 @@ use App\Services\Intune\AuditLogger; use App\Services\Inventory\InventorySyncService; use App\Services\OperationRunService; +use App\Support\Inventory\InventoryCoverage; +use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Providers\ProviderReasonCodes; @@ -70,8 +72,20 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $ $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; $policyTypes = $context['policy_types'] ?? []; $policyTypes = is_array($policyTypes) ? array_values(array_filter(array_map('strval', $policyTypes))) : []; + $includeFoundations = (bool) ($context['include_foundations'] ?? false); + + $foundationTypes = collect(InventoryPolicyTypeMeta::foundations()) + ->map(fn (array $row): mixed => $row['type'] ?? null) + ->filter(fn (mixed $type): bool => is_string($type) && $type !== '') + ->values() + ->all(); + + $attemptedTypes = $includeFoundations + ? array_values(array_unique(array_merge($policyTypes, $foundationTypes))) + : array_values(array_diff($policyTypes, $foundationTypes)); $processedPolicyTypes = []; + $coverageStatusByType = []; $successCount = 0; $failedCount = 0; @@ -84,8 +98,11 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $ $this->operationRun, $tenant, $context, - function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$successCount, &$failedCount): void { + function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void { $processedPolicyTypes[] = $policyType; + $coverageStatusByType[$policyType] = $success + ? InventoryCoverage::StatusSucceeded + : InventoryCoverage::StatusFailed; if ($success) { $successCount++; @@ -97,7 +114,37 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe }, ); + $statusByType = []; + + foreach ($attemptedTypes as $type) { + if (! is_string($type) || $type === '') { + continue; + } + + $statusByType[$type] = InventoryCoverage::StatusSkipped; + } + + foreach ($coverageStatusByType as $type => $status) { + if (! is_string($type) || $type === '') { + continue; + } + + $statusByType[$type] = $status; + } + + if ((string) ($result['status'] ?? '') === 'skipped') { + foreach ($statusByType as $type => $status) { + $statusByType[$type] = InventoryCoverage::StatusSkipped; + } + } + $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $updatedContext['inventory'] = array_merge( + is_array($updatedContext['inventory'] ?? null) ? $updatedContext['inventory'] : [], + [ + 'coverage' => InventoryCoverage::buildPayload($statusByType, $foundationTypes), + ], + ); $updatedContext['result'] = [ 'had_errors' => (bool) ($result['had_errors'] ?? true), 'error_codes' => is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [], diff --git a/app/Models/BaselineProfile.php b/app/Models/BaselineProfile.php index 27abca8..4849a0c 100644 --- a/app/Models/BaselineProfile.php +++ b/app/Models/BaselineProfile.php @@ -1,28 +1,103 @@ 'array', + /** @var list */ + protected $fillable = [ + 'workspace_id', + 'name', + 'description', + 'version_label', + 'status', + 'scope_jsonb', + 'active_snapshot_id', + 'created_by_user_id', ]; + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => BaselineProfileStatus::class, + ]; + } + + protected function scopeJsonb(): Attribute + { + return Attribute::make( + get: function (mixed $value): array { + return BaselineScope::fromJsonb( + $this->decodeScopeJsonb($value) + )->toJsonb(); + }, + set: function (mixed $value): string { + $scope = BaselineScope::fromJsonb(is_array($value) ? $value : null)->toJsonb(); + + try { + return json_encode($scope, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return '{"policy_types":[],"foundation_types":[]}'; + } + }, + ); + } + + /** + * Decode raw scope_jsonb value from the database. + * + * @return array|null + */ + private function decodeScopeJsonb(mixed $value): ?array + { + if (is_array($value)) { + return $value; + } + + if (is_string($value) && $value !== '') { + try { + $decoded = json_decode($value, true, flags: JSON_THROW_ON_ERROR); + + return is_array($decoded) ? $decoded : null; + } catch (JsonException) { + return null; + } + } + + return null; + } + public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class); diff --git a/app/Services/Baselines/BaselineCaptureService.php b/app/Services/Baselines/BaselineCaptureService.php index 8877202..2e0b5cd 100644 --- a/app/Services/Baselines/BaselineCaptureService.php +++ b/app/Services/Baselines/BaselineCaptureService.php @@ -10,6 +10,7 @@ use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineScope; use App\Support\OperationRunType; @@ -41,7 +42,7 @@ public function startCapture( $context = [ 'baseline_profile_id' => (int) $profile->getKey(), 'source_tenant_id' => (int) $sourceTenant->getKey(), - 'effective_scope' => $effectiveScope->toJsonb(), + 'effective_scope' => $effectiveScope->toEffectiveScopeContext(), ]; $run = $this->runs->ensureRunWithIdentity( @@ -63,7 +64,7 @@ public function startCapture( private function validatePreconditions(BaselineProfile $profile, Tenant $sourceTenant): ?string { - if ($profile->status !== BaselineProfile::STATUS_ACTIVE) { + if ($profile->status !== BaselineProfileStatus::Active) { return BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE; } diff --git a/app/Services/Baselines/BaselineCompareService.php b/app/Services/Baselines/BaselineCompareService.php index efa951b..a6e5a02 100644 --- a/app/Services/Baselines/BaselineCompareService.php +++ b/app/Services/Baselines/BaselineCompareService.php @@ -6,11 +6,13 @@ use App\Jobs\CompareBaselineToTenantJob; use App\Models\BaselineProfile; +use App\Models\BaselineSnapshot; use App\Models\BaselineTenantAssignment; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineScope; use App\Support\OperationRunType; @@ -27,6 +29,7 @@ public function __construct( public function startCompare( Tenant $tenant, User $initiator, + ?int $baselineSnapshotId = null, ): array { $assignment = BaselineTenantAssignment::query() ->where('workspace_id', $tenant->workspace_id) @@ -43,13 +46,28 @@ public function startCompare( return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE]; } - $precondition = $this->validatePreconditions($profile); + $hasExplicitSnapshotSelection = is_int($baselineSnapshotId) && $baselineSnapshotId > 0; + $precondition = $this->validatePreconditions($profile, hasExplicitSnapshotSelection: $hasExplicitSnapshotSelection); if ($precondition !== null) { return ['ok' => false, 'reason_code' => $precondition]; } - $snapshotId = (int) $profile->active_snapshot_id; + $snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0; + + if ($snapshotId > 0) { + $snapshot = BaselineSnapshot::query() + ->where('workspace_id', (int) $profile->workspace_id) + ->where('baseline_profile_id', (int) $profile->getKey()) + ->whereKey($snapshotId) + ->first(['id']); + + if (! $snapshot instanceof BaselineSnapshot) { + return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT]; + } + } else { + $snapshotId = (int) $profile->active_snapshot_id; + } $profileScope = BaselineScope::fromJsonb( is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, @@ -63,7 +81,7 @@ public function startCompare( $context = [ 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_snapshot_id' => $snapshotId, - 'effective_scope' => $effectiveScope->toJsonb(), + 'effective_scope' => $effectiveScope->toEffectiveScopeContext(), ]; $run = $this->runs->ensureRunWithIdentity( @@ -83,13 +101,13 @@ public function startCompare( return ['ok' => true, 'run' => $run]; } - private function validatePreconditions(BaselineProfile $profile): ?string + private function validatePreconditions(BaselineProfile $profile, bool $hasExplicitSnapshotSelection = false): ?string { - if ($profile->status !== BaselineProfile::STATUS_ACTIVE) { + if ($profile->status !== BaselineProfileStatus::Active) { return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE; } - if ($profile->active_snapshot_id === null) { + if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) { return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT; } diff --git a/app/Services/Baselines/BaselineSnapshotIdentity.php b/app/Services/Baselines/BaselineSnapshotIdentity.php index c761f92..d9162e5 100644 --- a/app/Services/Baselines/BaselineSnapshotIdentity.php +++ b/app/Services/Baselines/BaselineSnapshotIdentity.php @@ -16,6 +16,7 @@ final class BaselineSnapshotIdentity { public function __construct( private readonly DriftHasher $hasher, + private readonly InventoryMetaContract $metaContract, ) {} /** @@ -50,10 +51,18 @@ public function computeIdentity(array $items): string /** * Compute a stable content hash for a single inventory item's metadata. * - * Strips volatile OData keys and normalizes for stable comparison. + * Hashes ONLY the Spec 116 meta contract output (not the full meta_jsonb payload). + * + * @param array $metaJsonb */ - public function hashItemContent(mixed $metaJsonb): string + public function hashItemContent(string $policyType, string $subjectExternalId, array $metaJsonb): string { - return $this->hasher->hashNormalized($metaJsonb); + $contract = $this->metaContract->build( + policyType: $policyType, + subjectExternalId: $subjectExternalId, + metaJsonb: $metaJsonb, + ); + + return $this->hasher->hashNormalized($contract); } } diff --git a/app/Services/Baselines/InventoryMetaContract.php b/app/Services/Baselines/InventoryMetaContract.php new file mode 100644 index 0000000..4a4ce29 --- /dev/null +++ b/app/Services/Baselines/InventoryMetaContract.php @@ -0,0 +1,69 @@ + $metaJsonb + * @return array{ + * version: int, + * policy_type: string, + * subject_external_id: string, + * odata_type: ?string, + * etag: ?string, + * scope_tag_ids: ?list, + * assignment_target_count: ?int + * } + */ + public function build(string $policyType, string $subjectExternalId, array $metaJsonb): array + { + $odataType = $metaJsonb['odata_type'] ?? null; + $odataType = is_string($odataType) ? trim($odataType) : null; + $odataType = $odataType !== '' ? $odataType : null; + + $etag = $metaJsonb['etag'] ?? null; + $etag = is_string($etag) ? trim($etag) : null; + $etag = $etag !== '' ? $etag : null; + + $scopeTagIds = $metaJsonb['scope_tag_ids'] ?? null; + $scopeTagIds = is_array($scopeTagIds) ? $this->normalizeStringList($scopeTagIds) : null; + + $assignmentTargetCount = $metaJsonb['assignment_target_count'] ?? null; + $assignmentTargetCount = is_numeric($assignmentTargetCount) ? (int) $assignmentTargetCount : null; + + return [ + 'version' => self::VERSION, + 'policy_type' => trim($policyType), + 'subject_external_id' => trim($subjectExternalId), + 'odata_type' => $odataType, + 'etag' => $etag, + 'scope_tag_ids' => $scopeTagIds, + 'assignment_target_count' => $assignmentTargetCount, + ]; + } + + /** + * @param array $values + * @return list + */ + private function normalizeStringList(array $values): array + { + $values = array_values(array_filter($values, 'is_string')); + $values = array_map('trim', $values); + $values = array_values(array_filter($values, fn (string $value): bool => $value !== '')); + $values = array_values(array_unique($values)); + + sort($values, SORT_STRING); + + return $values; + } +} diff --git a/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php index bb1d03f..9e378cd 100644 --- a/app/Services/Inventory/InventorySyncService.php +++ b/app/Services/Inventory/InventorySyncService.php @@ -11,6 +11,7 @@ use App\Services\OperationRunService; use App\Services\Providers\ProviderConnectionResolver; use App\Services\Providers\ProviderGateway; +use App\Support\Inventory\InventoryCoverage; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OperationRunType; @@ -62,16 +63,41 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun 'started_at' => now(), ]); - $result = $this->executeSelection($operationRun, $tenant, $normalizedSelection); + $policyTypes = $normalizedSelection['policy_types'] ?? []; + $policyTypes = is_array($policyTypes) ? $policyTypes : []; + $foundationTypes = $this->foundationTypes(); + $includeFoundations = (bool) ($normalizedSelection['include_foundations'] ?? false); + + $attemptedTypes = $includeFoundations + ? array_values(array_unique(array_merge($policyTypes, $foundationTypes))) + : array_values(array_diff($policyTypes, $foundationTypes)); + + $statusByType = []; + + foreach ($attemptedTypes as $type) { + if (! is_string($type) || $type === '') { + continue; + } + + $statusByType[$type] = InventoryCoverage::StatusSkipped; + } + + $result = $this->executeSelection( + $operationRun, + $tenant, + $normalizedSelection, + function (string $policyType, bool $success, ?string $errorCode) use (&$statusByType): void { + $statusByType[$policyType] = $success + ? InventoryCoverage::StatusSucceeded + : InventoryCoverage::StatusFailed; + }, + ); $status = (string) ($result['status'] ?? 'failed'); $hadErrors = (bool) ($result['had_errors'] ?? true); $errorCodes = is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : []; $errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null; - $policyTypes = $normalizedSelection['policy_types'] ?? []; - $policyTypes = is_array($policyTypes) ? $policyTypes : []; - $operationOutcome = match ($status) { 'success' => OperationRunOutcome::Succeeded->value, 'partial' => OperationRunOutcome::PartiallySucceeded->value, @@ -95,6 +121,24 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun } $updatedContext = is_array($operationRun->context) ? $operationRun->context : []; + + $coverageStatusByType = $statusByType; + + if ($status === 'skipped') { + foreach ($coverageStatusByType as $type => $coverageStatus) { + $coverageStatusByType[$type] = InventoryCoverage::StatusSkipped; + } + } + + $updatedContext['inventory'] = array_merge( + is_array($updatedContext['inventory'] ?? null) ? $updatedContext['inventory'] : [], + [ + 'coverage' => InventoryCoverage::buildPayload( + statusByType: $coverageStatusByType, + foundationTypes: $foundationTypes, + ), + ], + ); $updatedContext['result'] = [ 'had_errors' => $hadErrors, 'error_codes' => $errorCodes, diff --git a/app/Support/Badges/Domains/BaselineProfileStatusBadge.php b/app/Support/Badges/Domains/BaselineProfileStatusBadge.php index bdc2945..654b37f 100644 --- a/app/Support/Badges/Domains/BaselineProfileStatusBadge.php +++ b/app/Support/Badges/Domains/BaselineProfileStatusBadge.php @@ -4,22 +4,22 @@ namespace App\Support\Badges\Domains; -use App\Models\BaselineProfile; use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeMapper; use App\Support\Badges\BadgeSpec; +use App\Support\Baselines\BaselineProfileStatus; final class BaselineProfileStatusBadge implements BadgeMapper { public function spec(mixed $value): BadgeSpec { $state = BadgeCatalog::normalizeState($value); + $enum = BaselineProfileStatus::tryFrom($state); - return match ($state) { - BaselineProfile::STATUS_DRAFT => new BadgeSpec('Draft', 'gray', 'heroicon-m-pencil-square'), - BaselineProfile::STATUS_ACTIVE => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'), - BaselineProfile::STATUS_ARCHIVED => new BadgeSpec('Archived', 'warning', 'heroicon-m-archive-box'), - default => BadgeSpec::unknown(), - }; + if ($enum === null) { + return BadgeSpec::unknown(); + } + + return new BadgeSpec($enum->label(), $enum->color(), $enum->icon()); } } diff --git a/app/Support/Baselines/BaselineCompareStats.php b/app/Support/Baselines/BaselineCompareStats.php index c6ceca2..ec9be9b 100644 --- a/app/Support/Baselines/BaselineCompareStats.php +++ b/app/Support/Baselines/BaselineCompareStats.php @@ -14,6 +14,7 @@ final class BaselineCompareStats { /** * @param array $severityCounts + * @param list $uncoveredTypes */ private function __construct( public readonly string $state, @@ -27,6 +28,10 @@ private function __construct( public readonly ?string $lastComparedHuman, public readonly ?string $lastComparedIso, public readonly ?string $failureReason, + public readonly ?string $coverageStatus = null, + public readonly ?int $uncoveredTypesCount = null, + public readonly array $uncoveredTypes = [], + public readonly ?string $fidelity = null, ) {} public static function forTenant(?Tenant $tenant): self @@ -74,6 +79,8 @@ public static function forTenant(?Tenant $tenant): self ->latest('id') ->first(); + [$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun); + // Active run (queued/running) if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) { return new self( @@ -88,6 +95,10 @@ public static function forTenant(?Tenant $tenant): self lastComparedHuman: null, lastComparedIso: null, failureReason: null, + coverageStatus: $coverageStatus, + uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, + uncoveredTypes: $uncoveredTypes, + fidelity: $fidelity, ); } @@ -110,6 +121,10 @@ public static function forTenant(?Tenant $tenant): self lastComparedHuman: $latestRun->finished_at?->diffForHumans(), lastComparedIso: $latestRun->finished_at?->toIso8601String(), failureReason: (string) $failureReason, + coverageStatus: $coverageStatus, + uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, + uncoveredTypes: $uncoveredTypes, + fidelity: $fidelity, ); } @@ -154,13 +169,19 @@ public static function forTenant(?Tenant $tenant): self lastComparedHuman: $lastComparedHuman, lastComparedIso: $lastComparedIso, failureReason: null, + coverageStatus: $coverageStatus, + uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, + uncoveredTypes: $uncoveredTypes, + fidelity: $fidelity, ); } - if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') { + if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && in_array($latestRun->outcome, ['succeeded', 'partially_succeeded'], true)) { return new self( state: 'ready', - message: 'No open drift findings for this baseline comparison. The tenant matches the baseline.', + message: $latestRun->outcome === 'succeeded' + ? 'No open drift findings for this baseline comparison. The tenant matches the baseline.' + : 'Comparison completed with warnings. Findings may be incomplete due to missing coverage.', profileName: $profileName, profileId: $profileId, snapshotId: $snapshotId, @@ -170,6 +191,10 @@ public static function forTenant(?Tenant $tenant): self lastComparedHuman: $lastComparedHuman, lastComparedIso: $lastComparedIso, failureReason: null, + coverageStatus: $coverageStatus, + uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, + uncoveredTypes: $uncoveredTypes, + fidelity: $fidelity, ); } @@ -185,6 +210,10 @@ public static function forTenant(?Tenant $tenant): self lastComparedHuman: $lastComparedHuman, lastComparedIso: $lastComparedIso, failureReason: null, + coverageStatus: $coverageStatus, + uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, + uncoveredTypes: $uncoveredTypes, + fidelity: $fidelity, ); } @@ -248,6 +277,50 @@ public static function forWidget(?Tenant $tenant): self ); } + /** + * @return array{0: ?string, 1: list, 2: ?string} + */ + private static function coverageInfoForRun(?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]; + } + + $coverage = $baselineCompare['coverage'] ?? null; + $coverage = is_array($coverage) ? $coverage : []; + + $proof = $coverage['proof'] ?? null; + $proof = is_bool($proof) ? $proof : null; + + $uncoveredTypes = $coverage['uncovered_types'] ?? null; + $uncoveredTypes = is_array($uncoveredTypes) ? array_values(array_filter($uncoveredTypes, 'is_string')) : []; + $uncoveredTypes = array_values(array_unique(array_filter(array_map('trim', $uncoveredTypes), fn (string $type): bool => $type !== ''))); + sort($uncoveredTypes, SORT_STRING); + + $coverageStatus = null; + + if ($proof === false) { + $coverageStatus = 'unproven'; + } elseif ($uncoveredTypes !== []) { + $coverageStatus = 'warning'; + } elseif ($proof === true) { + $coverageStatus = 'ok'; + } + + $fidelity = $baselineCompare['fidelity'] ?? null; + $fidelity = is_string($fidelity) ? trim($fidelity) : null; + $fidelity = $fidelity !== '' ? $fidelity : null; + + return [$coverageStatus, $uncoveredTypes, $fidelity]; + } + private static function empty( string $state, ?string $message, diff --git a/app/Support/Baselines/BaselineProfileStatus.php b/app/Support/Baselines/BaselineProfileStatus.php new file mode 100644 index 0000000..e12c660 --- /dev/null +++ b/app/Support/Baselines/BaselineProfileStatus.php @@ -0,0 +1,79 @@ + 'Draft', + self::Active => 'Active', + self::Archived => 'Archived', + }; + } + + /** + * Filament badge color for this status. + */ + public function color(): string + { + return match ($this) { + self::Draft => 'gray', + self::Active => 'success', + self::Archived => 'warning', + }; + } + + /** + * Heroicon identifier for this status. + */ + public function icon(): string + { + return match ($this) { + self::Draft => 'heroicon-m-pencil-square', + self::Active => 'heroicon-m-check-circle', + self::Archived => 'heroicon-m-archive-box', + }; + } + + /** + * Whether this status allows editing the profile. + */ + public function isEditable(): bool + { + return $this !== self::Archived; + } + + /** + * Allowed transitions from this status. + * + * @return array + */ + public function allowedTransitions(): array + { + return match ($this) { + self::Draft => [self::Draft, self::Active], + self::Active => [self::Active, self::Archived], + self::Archived => [self::Archived], + }; + } + + /** + * Status options for a Filament Select field, scoped to allowed transitions. + * + * @return array + */ + public function selectOptions(): array + { + return collect($this->allowedTransitions()) + ->mapWithKeys(fn (self $s): array => [$s->value => $s->label()]) + ->all(); + } +} diff --git a/app/Support/Baselines/BaselineReasonCodes.php b/app/Support/Baselines/BaselineReasonCodes.php index 0dabf27..66db016 100644 --- a/app/Support/Baselines/BaselineReasonCodes.php +++ b/app/Support/Baselines/BaselineReasonCodes.php @@ -21,4 +21,6 @@ final class BaselineReasonCodes public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active'; public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot'; + + public const string COMPARE_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot'; } diff --git a/app/Support/Baselines/BaselineScope.php b/app/Support/Baselines/BaselineScope.php index beefd98..fb79670 100644 --- a/app/Support/Baselines/BaselineScope.php +++ b/app/Support/Baselines/BaselineScope.php @@ -8,15 +8,20 @@ * Value object for baseline scope resolution. * * A scope defines which policy types are included in a baseline profile. - * An empty policy_types array means "all types" (no filter). + * + * Spec 116 semantics: + * - Empty policy_types means "all supported policy types" (excluding foundations). + * - Empty foundation_types means "none". */ final class BaselineScope { /** * @param array $policyTypes + * @param array $foundationTypes */ public function __construct( public readonly array $policyTypes = [], + public readonly array $foundationTypes = [], ) {} /** @@ -31,9 +36,11 @@ public static function fromJsonb(?array $scopeJsonb): self } $policyTypes = $scopeJsonb['policy_types'] ?? []; + $foundationTypes = $scopeJsonb['foundation_types'] ?? []; return new self( policyTypes: is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [], + foundationTypes: is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [], ); } @@ -41,64 +48,69 @@ public static function fromJsonb(?array $scopeJsonb): self * Normalize the effective scope by intersecting profile scope with an optional override. * * Override can only narrow the profile scope (subset enforcement). - * If the profile scope is empty (all types), the override becomes the effective scope. - * If the override is empty or null, the profile scope is used as-is. + * Empty override means "no override". */ public static function effective(self $profileScope, ?self $overrideScope): self { + $profileScope = $profileScope->expandDefaults(); + if ($overrideScope === null || $overrideScope->isEmpty()) { return $profileScope; } - if ($profileScope->isEmpty()) { - return $overrideScope; - } + $overridePolicyTypes = self::normalizePolicyTypes($overrideScope->policyTypes); + $overrideFoundationTypes = self::normalizeFoundationTypes($overrideScope->foundationTypes); - $intersected = array_values(array_intersect($profileScope->policyTypes, $overrideScope->policyTypes)); + $effectivePolicyTypes = $overridePolicyTypes !== [] + ? array_values(array_intersect($profileScope->policyTypes, $overridePolicyTypes)) + : $profileScope->policyTypes; - return new self(policyTypes: $intersected); + $effectiveFoundationTypes = $overrideFoundationTypes !== [] + ? array_values(array_intersect($profileScope->foundationTypes, $overrideFoundationTypes)) + : $profileScope->foundationTypes; + + return new self( + policyTypes: self::uniqueSorted($effectivePolicyTypes), + foundationTypes: self::uniqueSorted($effectiveFoundationTypes), + ); } /** - * An empty scope means "all types". + * An empty scope means "no override" (for override_scope semantics). */ public function isEmpty(): bool { - return $this->policyTypes === []; + return $this->policyTypes === [] && $this->foundationTypes === []; } /** - * Check if a policy type is included in this scope. + * Apply Spec 116 defaults and filter to supported types. */ - public function includes(string $policyType): bool + public function expandDefaults(): self { - if ($this->isEmpty()) { - return true; - } + $policyTypes = $this->policyTypes === [] + ? self::supportedPolicyTypes() + : self::normalizePolicyTypes($this->policyTypes); - return in_array($policyType, $this->policyTypes, true); + $foundationTypes = self::normalizeFoundationTypes($this->foundationTypes); + + return new self( + policyTypes: $policyTypes, + foundationTypes: $foundationTypes, + ); } /** - * Validate that override is a subset of the profile scope. + * @return list */ - public static function isValidOverride(self $profileScope, self $overrideScope): bool + public function allTypes(): array { - if ($overrideScope->isEmpty()) { - return true; - } + $expanded = $this->expandDefaults(); - if ($profileScope->isEmpty()) { - return true; - } - - foreach ($overrideScope->policyTypes as $type) { - if (! in_array($type, $profileScope->policyTypes, true)) { - return false; - } - } - - return true; + return self::uniqueSorted(array_merge( + $expanded->policyTypes, + $expanded->foundationTypes, + )); } /** @@ -108,6 +120,102 @@ public function toJsonb(): array { return [ 'policy_types' => $this->policyTypes, + 'foundation_types' => $this->foundationTypes, ]; } + + /** + * Effective scope payload for OperationRun.context. + * + * @return array{policy_types: list, foundation_types: list, all_types: list, foundations_included: bool} + */ + public function toEffectiveScopeContext(): array + { + $expanded = $this->expandDefaults(); + $allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes)); + + return [ + 'policy_types' => $expanded->policyTypes, + 'foundation_types' => $expanded->foundationTypes, + 'all_types' => $allTypes, + 'foundations_included' => $expanded->foundationTypes !== [], + ]; + } + + /** + * @return list + */ + private static function supportedPolicyTypes(): array + { + $supported = config('tenantpilot.supported_policy_types', []); + + if (! is_array($supported)) { + return []; + } + + $types = collect($supported) + ->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null)) + ->map(fn (array $row): string => (string) $row['type']) + ->filter(fn (string $type): bool => $type !== '') + ->values() + ->all(); + + return self::uniqueSorted($types); + } + + /** + * @return list + */ + private static function supportedFoundationTypes(): array + { + $foundations = config('tenantpilot.foundation_types', []); + + if (! is_array($foundations)) { + return []; + } + + $types = collect($foundations) + ->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null)) + ->map(fn (array $row): string => (string) $row['type']) + ->filter(fn (string $type): bool => $type !== '') + ->values() + ->all(); + + return self::uniqueSorted($types); + } + + /** + * @param array $types + * @return list + */ + private static function normalizePolicyTypes(array $types): array + { + $supported = self::supportedPolicyTypes(); + + return self::uniqueSorted(array_values(array_intersect($types, $supported))); + } + + /** + * @param array $types + * @return list + */ + private static function normalizeFoundationTypes(array $types): array + { + $supported = self::supportedFoundationTypes(); + + return self::uniqueSorted(array_values(array_intersect($types, $supported))); + } + + /** + * @param array $types + * @return list + */ + private static function uniqueSorted(array $types): array + { + $types = array_values(array_unique(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== ''))); + + sort($types, SORT_STRING); + + return $types; + } } diff --git a/app/Support/Inventory/InventoryCoverage.php b/app/Support/Inventory/InventoryCoverage.php new file mode 100644 index 0000000..9ec0dfd --- /dev/null +++ b/app/Support/Inventory/InventoryCoverage.php @@ -0,0 +1,172 @@ + $policyTypes + * @param array $foundationTypes + */ + public function __construct( + public array $policyTypes, + public array $foundationTypes, + ) {} + + public static function fromContext(mixed $context): ?self + { + if (! is_array($context)) { + return null; + } + + $inventory = $context['inventory'] ?? null; + if (! is_array($inventory)) { + return null; + } + + $coverage = $inventory['coverage'] ?? null; + if (! is_array($coverage)) { + return null; + } + + $policyTypes = self::normalizeCoverageMap($coverage['policy_types'] ?? null); + $foundationTypes = self::normalizeCoverageMap($coverage['foundation_types'] ?? null); + + if ($policyTypes === [] && $foundationTypes === []) { + return null; + } + + return new self( + policyTypes: $policyTypes, + foundationTypes: $foundationTypes, + ); + } + + /** + * @return list + */ + public function coveredTypes(): array + { + $covered = []; + + foreach (array_merge($this->policyTypes, $this->foundationTypes) as $type => $meta) { + if (($meta['status'] ?? null) === self::StatusSucceeded) { + $covered[] = $type; + } + } + + sort($covered, SORT_STRING); + + return array_values(array_unique($covered)); + } + + /** + * Build the canonical `inventory.coverage.*` payload for OperationRun.context. + * + * @param array $statusByType + * @param list $foundationTypes + * @return array{policy_types: array, foundation_types: array} + */ + public static function buildPayload(array $statusByType, array $foundationTypes): array + { + $foundationTypes = array_values(array_unique(array_filter($foundationTypes, fn (mixed $type): bool => is_string($type) && $type !== ''))); + $foundationLookup = array_fill_keys($foundationTypes, true); + + $policy = []; + $foundations = []; + + foreach ($statusByType as $type => $status) { + if (! is_string($type) || $type === '') { + continue; + } + + $normalizedStatus = self::normalizeStatus($status); + + if ($normalizedStatus === null) { + continue; + } + + $row = ['status' => $normalizedStatus]; + + if (array_key_exists($type, $foundationLookup)) { + $foundations[$type] = $row; + + continue; + } + + $policy[$type] = $row; + } + + ksort($policy); + ksort($foundations); + + return [ + 'policy_types' => $policy, + 'foundation_types' => $foundations, + ]; + } + + private static function normalizeStatus(mixed $status): ?string + { + if (! is_string($status)) { + return null; + } + + return match ($status) { + self::StatusSucceeded, self::StatusFailed, self::StatusSkipped => $status, + default => null, + }; + } + + /** + * @return array + */ + private static function normalizeCoverageMap(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $normalized = []; + + foreach ($value as $type => $meta) { + if (! is_string($type) || $type === '') { + continue; + } + + if (! is_array($meta)) { + continue; + } + + $status = self::normalizeStatus($meta['status'] ?? null); + + if ($status === null) { + continue; + } + + $row = ['status' => $status]; + + if (array_key_exists('item_count', $meta) && is_int($meta['item_count'])) { + $row['item_count'] = $meta['item_count']; + } + + if (array_key_exists('error_code', $meta) && (is_string($meta['error_code']) || $meta['error_code'] === null)) { + $row['error_code'] = $meta['error_code']; + } + + $normalized[$type] = $row; + } + + ksort($normalized); + + return $normalized; + } +} diff --git a/app/Support/Rbac/UiEnforcement.php b/app/Support/Rbac/UiEnforcement.php index 769d456..0b4dda1 100644 --- a/app/Support/Rbac/UiEnforcement.php +++ b/app/Support/Rbac/UiEnforcement.php @@ -50,6 +50,8 @@ final class UiEnforcement private bool $preserveExistingVisibility = false; + private bool $preserveExistingDisabled = false; + private function __construct(Action|BulkAction $action) { $this->action = $action; @@ -167,6 +169,21 @@ public function preserveVisibility(): self return $this; } + /** + * Preserve the action's existing disabled logic. + * + * Use this when the action is disabled for business reasons (e.g. operation + * state) and you still want UiEnforcement to add capability gating on top. + * + * @return $this + */ + public function preserveDisabled(): self + { + $this->preserveExistingDisabled = true; + + return $this; + } + /** * Apply all enforcement rules to the action and return it. * @@ -316,9 +333,17 @@ private function applyDisabledState(): void return; } + $existingDisabled = $this->preserveExistingDisabled + ? $this->getExistingDisabledCondition() + : null; + $tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission(); - $this->action->disabled(function (?Model $record = null) { + $this->action->disabled(function (?Model $record = null) use ($existingDisabled) { + if ($existingDisabled !== null && $this->evaluateDisabledCondition($existingDisabled, $record)) { + return true; + } + if ($this->isBulk && $this->action instanceof BulkAction) { $user = auth()->user(); @@ -371,6 +396,62 @@ private function applyDisabledState(): void }); } + /** + * Attempt to retrieve the existing disabled condition from the action. + * + * Filament stores this as the protected property `$isDisabled` (bool|Closure) + * on actions via the CanBeDisabled concern. + */ + private function getExistingDisabledCondition(): bool|Closure|null + { + try { + $ref = new ReflectionObject($this->action); + if (! $ref->hasProperty('isDisabled')) { + return null; + } + + $property = $ref->getProperty('isDisabled'); + $property->setAccessible(true); + + /** @var bool|Closure $value */ + $value = $property->getValue($this->action); + + return $value; + } catch (Throwable) { + return null; + } + } + + /** + * Evaluate an existing bool|Closure disabled condition. + * + * This is a best-effort evaluator for business disabled closures. + * If the closure cannot be evaluated safely, we fail closed (return true). + */ + private function evaluateDisabledCondition(bool|Closure $condition, ?Model $record): bool + { + if (is_bool($condition)) { + return $condition; + } + + try { + $reflection = new \ReflectionFunction($condition); + $parameters = $reflection->getParameters(); + + if ($parameters === []) { + return (bool) $condition(); + } + + if ($record === null) { + return true; + } + + return (bool) $condition($record); + } catch (Throwable) { + return true; + } + } + /** * Add confirmation modal for destructive actions. */ diff --git a/database/factories/BaselineProfileFactory.php b/database/factories/BaselineProfileFactory.php index a9ae350..77bd188 100644 --- a/database/factories/BaselineProfileFactory.php +++ b/database/factories/BaselineProfileFactory.php @@ -5,6 +5,7 @@ use App\Models\BaselineProfile; use App\Models\User; use App\Models\Workspace; +use App\Support\Baselines\BaselineProfileStatus; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -24,8 +25,8 @@ public function definition(): array 'name' => fake()->unique()->words(3, true), 'description' => fake()->optional()->sentence(), 'version_label' => fake()->optional()->numerify('v#.#'), - 'status' => BaselineProfile::STATUS_DRAFT, - 'scope_jsonb' => ['policy_types' => []], + 'status' => BaselineProfileStatus::Draft->value, + 'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []], 'active_snapshot_id' => null, 'created_by_user_id' => null, ]; @@ -34,21 +35,21 @@ public function definition(): array public function active(): static { return $this->state(fn (): array => [ - 'status' => BaselineProfile::STATUS_ACTIVE, + 'status' => BaselineProfileStatus::Active->value, ]); } public function archived(): static { return $this->state(fn (): array => [ - 'status' => BaselineProfile::STATUS_ARCHIVED, + 'status' => BaselineProfileStatus::Archived->value, ]); } public function withScope(array $policyTypes): static { return $this->state(fn (): array => [ - 'scope_jsonb' => ['policy_types' => $policyTypes], + 'scope_jsonb' => ['policy_types' => $policyTypes, 'foundation_types' => []], ]); } diff --git a/docs/research/golden-master-baseline-drift-deep-analysis.md b/docs/research/golden-master-baseline-drift-deep-analysis.md new file mode 100644 index 0000000..c548e0b --- /dev/null +++ b/docs/research/golden-master-baseline-drift-deep-analysis.md @@ -0,0 +1,664 @@ +# Golden Master / Baseline Drift — Deep Settings-Drift (Content-Fidelity) Analysis + +> Enterprise Research Report for TenantAtlas / TenantPilot +> Date: 2025-07-15 +> Scope: Architecture, code evidence, implementation proposal + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [System Map — Side-by-Side Comparison](#2-system-map) +3. [Architecture Decision Record (ADR-001): Unify vs Separate](#3-adr-001) +4. [Deep-Dive: Why Settings Changes Don't Produce Baseline Drift](#4-deep-dive) +5. [Code Evidence Table](#5-code-evidence) +6. [Type Coverage Matrix](#6-type-coverage-matrix) +7. [Proposal: Deep Drift Implementation Plan](#7-deep-drift-plan) +8. [Test Plan (Enterprise)](#8-test-plan) +9. [Open Questions / Assumptions](#9-open-questions) +10. [Key Questions Answered (KQ-01 through KQ-06)](#10-key-questions) + +--- + +## 1. Executive Summary + +1. **Two parallel drift systems exist**: *Baseline Compare* (meta fidelity, inventory-sourced) and *Backup Drift* (content fidelity, PolicyVersion-sourced). They share `DriftHasher` but are otherwise separate data paths with separate finding generators. + +2. **The core gap**: `CompareBaselineToTenantJob` hashes `InventoryMetaContract` v1 — which contains only `odata_type`, `etag`, `scope_tag_ids`, `assignment_target_count` — never actual policy settings. When an admin changes a Wi-Fi password or a compliance threshold in Intune, _none of these meta signals necessarily change_. + +3. **Inventory sync uses Graph LIST endpoints**, which return metadata and display fields only. Per-item GET (which fetches settings, assignments, scope tags) is only performed during _Backup_ via `PolicyCaptureOrchestrator`. + +4. **`DriftFindingGenerator`** (the backup drift system) _does_ detect settings changes — it normalizes `PolicyVersion.snapshot` via `SettingsNormalizer` → `PolicyNormalizer::flattenForDiff()` → type-specific normalizers, then hashes with `DriftHasher`. + +5. **Spec 116 already designs v2** with a provider precedence chain (`PolicyVersion → Inventory content → Meta fallback`), which is the correct architectural direction. The v1 meta baseline shipped first as a deliberate, safe-to-ship initial milestone. + +6. **Unification is recommended** (provider chain approach) — not merging the two jobs, but enabling `CompareBaselineToTenantJob` to optionally consume `PolicyVersion` snapshots as a content-fidelity provider, falling back to InventoryMetaContract when no PolicyVersion is available. + +7. **28 supported policy types** are registered in `tenantpilot.php`, plus 3 foundation types. Of these, 10+ have complex hydration (settings catalog, group policy, security baselines, compliance actions) and would benefit most from deep-drift detection. + +8. **The `etag` signal** is unreliable as a settings-change proxy: Microsoft Graph etag semantics vary per resource type, and etag may or may not change when settings are modified. It is useful as a _hint_ but not a _guarantee_. + +9. **API cost is the primary constraint**: content-fidelity compare requires per-item GET calls (or a recent Backup that already captured PolicyVersions). The hybrid provider chain avoids this by opportunistically _reusing_ existing PolicyVersions without requiring a full backup before every compare. + +10. **Coverage Guard is critical for v2**: the baseline system must know _which types have fresh PolicyVersions_ and suppress content-fidelity findings for types where no recent version exists (falling back to meta fidelity). + +11. **Risk profile**: Shipping deep-drift for wrong types (without proper per-type normalization) could produce false positives. Type-specific normalizers already exist for the backup drift path; reusing them is safe. + +12. **Recommended phasing**: v1.5 (current sprint) = add `content_hash_source` column to `baseline_snapshot_items` + provider chain in compare job. v2.0 = on-demand per-item GET during baseline capture for types lacking recent PolicyVersions. + +--- + +## 2. System Map + +### Side-by-Side Comparison Table + +| Dimension | System A: Baseline Compare | System B: Backup Drift | +|---|---|---| +| **Entry point** | `CompareBaselineToTenantJob` | `GenerateDriftFindingsJob` → `DriftFindingGenerator` | +| **Data source (current)** | `InventoryItem` (from LIST sync) | `PolicyVersion` (from per-item GET backup) | +| **Data source (baseline)** | `BaselineSnapshotItem` (captured from inventory) | Earlier `PolicyVersion` from prior `OperationRun` | +| **Hash contract** | `InventoryMetaContract` v1 → `DriftHasher::hashNormalized()` | `SettingsNormalizer` → `PolicyNormalizer::flattenForDiff()` → `DriftHasher::hashNormalized()` | +| **Hash inputs** | `version`, `policy_type`, `subject_external_id`, `odata_type`, `etag`, `scope_tag_ids`, `assignment_target_count` | Full `PolicyVersion.snapshot` JSON (with volatile key removal) | +| **Fidelity** | `meta` (persisted as `fidelity='meta'` in snapshot context) | `content` (settings + assignments + scope_tags) | +| **Dimensions detected** | `missing_policy`, `different_version`, `unexpected_policy` | `policy_snapshot` (added/removed/modified), `policy_assignments` (modified), `policy_scope_tags` (modified) | +| **Finding identity** | `recurrence_key = sha256(tenantId\|snapshotId\|policyType\|extId\|changeType)` | `recurrence_key = sha256(drift:tenantId:scopeKey:subjectType:extId:dimension)` | +| **Scope key** | `baseline_profile:{profileId}` | `DriftScopeKey::fromSelectionHash()` | +| **Auto-close** | `BaselineAutoCloseService` (stale finding resolution) | `resolveStaleDriftFindings()` within `DriftFindingGenerator` | +| **Coverage guard** | `InventoryCoverage::fromContext()` → uncovered types → partial outcome | None (trusts backup captured all types) | +| **Graph API calls** | Zero at compare time (reads from DB) | Zero at compare time (reads PolicyVersions from DB) | +| **Graph API calls (capture)** | Zero (inventory sync did LIST) | Per-item GET via `PolicyCaptureOrchestrator` | +| **Normalizer pipeline** | None (meta contract is the normalization) | `SettingsNormalizer` → `PolicyNormalizer` → type normalizers | +| **Shared components** | `DriftHasher`, `Finding` model | `DriftHasher`, `Finding` model | +| **Trigger** | After inventory sync, on schedule/manual | After backup, on schedule/manual | + +### Data Flow Diagrams + +``` +SYSTEM A — Baseline Compare (Meta Fidelity) +============================================ +Graph LIST ──► InventorySyncService ──► InventoryItem (meta_jsonb) + │ + ▼ + CaptureBaselineSnapshotJob + ├─ InventoryMetaContract.build() + ├─ DriftHasher.hashNormalized() + └─► BaselineSnapshotItem (baseline_hash) + │ + ▼ + CompareBaselineToTenantJob + ├─ loadCurrentInventory() → InventoryItem + ├─ BaselineSnapshotIdentity.hashItemContent() + │ └─ InventoryMetaContract.build() + │ └─ DriftHasher.hashNormalized() + ├─ computeDrift() → hash compare + └─ upsertFindings() → Finding records + + +SYSTEM B — Backup Drift (Content Fidelity) +============================================ +Graph GET ──► PolicySnapshotService.fetch() ──► full JSON snapshot + │ + ▼ + PolicyCaptureOrchestrator.capture() + ├─ assignments GET + ├─ scope tags resolve + └─► VersionService.captureVersion() ──► PolicyVersion + │ + ▼ + DriftFindingGenerator.generate() + ├─ versionForRun() → baseline/current PV + ├─ SettingsNormalizer.normalizeForDiff() + │ └─ PolicyNormalizer.flattenForDiff() + ├─ DriftHasher.hashNormalized() × 3 + │ (snapshot, assignments, scope_tags) + └─ upsertDriftFinding() → Finding records +``` + +--- + +## 3. ADR-001: Unify vs Separate + +### Title +ADR-001: Golden Master Baseline Compare — Provider Chain for Content Fidelity + +### Status +PROPOSED + +### Context + +TenantPilot has two drift detection systems that evolved independently: + +- **System A (Baseline Compare)**: Designed for "does the tenant still match the golden master?" Use case. Ships with meta-fidelity (v1) — fast, cheap, zero additional Graph calls at compare time. Detects structural drift (policy added/removed/meta-changed) but is blind to _settings_ changes. + +- **System B (Backup Drift)**: Designed for "what changed between two backup points?" Use case. Content-fidelity — full PolicyVersion snapshots with per-type normalization. Detects settings, assignments, and scope tag changes. + +The two systems cannot be merged into one without fundamentally changing their triggering, scoping, and API cost models. However, System A's accuracy can be dramatically improved by _consuming_ data already produced by System B. + +### Decision + +**Adopt the Provider Chain pattern** as already designed in Spec 116 v2: + +``` +ContentProvider = PolicyVersion → InventoryContent → MetaFallback +``` + +Specifically: +1. `CompareBaselineToTenantJob` gains a `ContentProviderChain` that, for each `(policy_type, external_id)`: + - **First**: Looks for a `PolicyVersion` captured since the last baseline snapshot timestamp. If found, normalizes via `SettingsNormalizer` → `DriftHasher` → returns `content` fidelity hash. + - **Second (future)**: Looks for enriched inventory content if inventory sync is upgraded to capture settings (v2.0+). + - **Fallback**: Builds `InventoryMetaContract` v1 → `DriftHasher` → returns `meta` fidelity hash. + +2. Each baseline snapshot item records its `fidelity` (`meta` | `content`) and `content_hash_source` (`inventory_meta_v1` | `policy_version:{id}` | `inventory_content_v2`). + +3. Compare findings carry `fidelity` in evidence, enabling UI to display confidence level. + +4. Coverage Guard is extended: a type is `content-covered` only if PolicyVersions exist for ≥N% of items. Below that threshold, fallback to meta fidelity (do not suppress). + +### Consequences + +- **Positive**: No new Graph API calls needed (reuses existing PolicyVersions from backups). Zero additional infrastructure. Incremental rollout per policy type. Existing meta-fidelity behavior preserved as fallback. +- **Negative**: Content fidelity depends on backup recency. If a tenant hasn't been backed up, only meta fidelity is available. Could create "mixed fidelity" findings within a single compare run. +- **Rejected Alternative**: Full merge of System A and B into a single system. Rejected because they serve different use cases (golden master comparison vs point-in-time drift), have different scoping models (BaselineProfile vs selection_hash), and different triggering models (post-inventory-sync vs post-backup). +- **Rejected Alternative**: Always-GET during baseline compare. Rejected due to API cost (30+ types × 100s of policies = 1000s of GET calls per tenant per compare run). + +### Compliance Notes +- Livewire v4.0+ / Filament v5: no UI changes in core ADR; provider chain is purely backend. +- Provider registration: n/a (backend services only). +- No destructive actions. +- Asset strategy: no new assets. + +--- + +## 4. Deep-Dive: Why Settings Changes Don't Produce Baseline Drift + +### The Root Cause Chain + +**Step 1: Inventory Sync captures only LIST metadata** + +`InventorySyncService::executeSelectionUnderLock()` (line ~340-450) calls Graph LIST endpoints. For each policy, it extracts: +- `display_name`, `category`, `platform` (display fields) +- `odata_type`, `etag`, `scope_tag_ids`, `assignment_target_count` (meta signals) + +These are stored in `InventoryItem.meta_jsonb`. **No settings values are fetched or stored.** + +**Step 2: Baseline Capture hashes only the Meta Contract** + +`CaptureBaselineSnapshotJob::collectSnapshotItems()` reads from `InventoryItem`, then calls `BaselineSnapshotIdentity::hashItemContent()`: + +```php +// BaselineSnapshotIdentity.php, line 56-67 +public function hashItemContent(string $policyType, string $subjectExternalId, array $metaJsonb): string +{ + $contract = $this->metaContract->build( + policyType: $policyType, + subjectExternalId: $subjectExternalId, + metaJsonb: $metaJsonb, + ); + return $this->hasher->hashNormalized($contract); +} +``` + +The `InventoryMetaContract::build()` output is: +```php +[ + 'version' => 1, + 'policy_type' => 'settingsCatalogPolicy', + 'subject_external_id' => '', + 'odata_type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'etag' => '"abc..."', // ← unreliable change indicator + 'scope_tag_ids' => ['0'], + 'assignment_target_count' => 3, +] +``` + +**This is ALL that gets hashed.** Actual policy settings (the Wi-Fi password, the compliance threshold, the firewall rule) are _nowhere_ in this contract. + +**Step 3: Baseline Compare re-computes the same meta hash** + +`CompareBaselineToTenantJob::loadCurrentInventory()` (line 367-409) reads current `InventoryItem` records and calls the same `BaselineSnapshotIdentity::hashItemContent()` with the same `InventoryMetaContract`, producing the same hash structure. + +`computeDrift()` (line 435-500) then compares `baseline_hash` vs `current_hash`: + +```php +if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) { + $drift[] = ['change_type' => 'different_version', ...]; +} +``` + +**If the admin changed a policy setting but the meta signals (etag, scope_tag_ids, assignment_target_count) stayed the same, `baseline_hash === current_hash` and NO drift is detected.** + +### Why etag is unreliable + +Microsoft Graph etag behavior varies by resource type: +- **Some types** update etag on any property change (including settings) +- **Some types** update etag only on top-level property changes (not nested settings) +- **Settings Catalog policies** may or may not update the parent resource etag when child `settings` are modified (the settings are a separate subresource at `/configurationPolicies/{id}/settings`) +- **Group Policy Configurations** have settings in `definitionValues` → `presentationValues` (multi-level nesting); etag at root level may not reflect these changes + +### The Contrast: How Backup Drift _Does_ Detect Settings Changes + +`DriftFindingGenerator::generate()` (line 32-80) operates on `PolicyVersion.snapshot` — the full JSON captured via per-item GET: + +```php +$baselineSnapshot = $baselineVersion->snapshot; // Full JSON from Graph GET +$currentSnapshot = $currentVersion->snapshot; + +$baselineNormalized = $this->settingsNormalizer->normalizeForDiff($baselineSnapshot, $policyType, $platform); +$currentNormalized = $this->settingsNormalizer->normalizeForDiff($currentSnapshot, $policyType, $platform); + +$baselineSnapshotHash = $this->hasher->hashNormalized($baselineNormalized); +$currentSnapshotHash = $this->hasher->hashNormalized($currentNormalized); + +if ($baselineSnapshotHash !== $currentSnapshotHash) { + // → Drift finding with change_type = 'modified' +} +``` + +This pipeline captures actual settings values, normalizes them per policy type, strips volatile metadata, and hashes the result. If a setting changed, the hash changes, and drift is detected. + +### Summary Visualization + +``` +Admin changes Wi-Fi password in Intune + │ + ▼ +┌─────────────────────────────────┐ +│ Graph LIST (inventory sync) │ +│ returns: displayName, etag, ... │ +│ │ +│ etag MAY change, settings NOT │ +│ returned by LIST endpoint │ +└────────────┬────────────────────┘ + │ + ┌───────┴────────┐ + ▼ ▼ + InventoryItem PolicyVersion + (meta only) (if backup ran) + │ │ + ▼ ▼ + Meta Contract Full Snapshot + hash unchanged hash CHANGED + │ │ + ▼ ▼ + Baseline Backup Drift: + Compare: "modified" ✅ + NO DRIFT ❌ +``` + +--- + +## 5. Code Evidence Table + +| # | Class / File | Lines | Role | Key Finding | +|---|---|---|---|---| +| 1 | `app/Jobs/CompareBaselineToTenantJob.php` | 785 total; L367-409 (loadCurrentInventory), L435-500 (computeDrift) | Core baseline compare job | Reads from `InventoryItem` only; hashes via `InventoryMetaContract` → **blind to settings** | +| 2 | `app/Services/Baselines/InventoryMetaContract.php` | 75 total; L30-57 (build) | Meta hash contract builder | Hashes only: version, policy_type, external_id, odata_type, etag, scope_tag_ids, assignment_target_count — **no settings content** | +| 3 | `app/Services/Baselines/BaselineSnapshotIdentity.php` | 73 total; L56-67 (hashItemContent) | Per-item hash via meta contract | Delegates to `InventoryMetaContract.build()` → `DriftHasher.hashNormalized()` | +| 4 | `app/Jobs/CaptureBaselineSnapshotJob.php` | 305 total | Captures snapshot from inventory | Reads `InventoryItem`, stores `fidelity='meta'` and `source='inventory'` | +| 5 | `app/Services/Drift/DriftFindingGenerator.php` | 484 total; L32-80 (generate), L250-267 (recurrenceKey) | Backup drift finding generator | Uses `PolicyVersion.snapshot` with `SettingsNormalizer` → **detects settings changes** | +| 6 | `app/Services/Drift/DriftHasher.php` | 100 total; L13-24 (hashNormalized) | Shared hasher | `sha256(json_encode(normalized))` with volatile key removal. **SHARED by both systems.** | +| 7 | `app/Services/Drift/Normalizers/SettingsNormalizer.php` | 22 total | Thin wrapper | Delegates to `PolicyNormalizer::flattenForDiff()`. Used by System B only. | +| 8 | `app/Services/Intune/PolicyNormalizer.php` | 67 total | Type-specific normalizer router | Routes to per-type normalizers for diff operations | +| 9 | `app/Services/Inventory/InventorySyncService.php` | 652 total; L340-450 (executeSelectionUnderLock) | LIST-based sync | Fetches from Graph LIST endpoints; extracts meta signals only; upserts `InventoryItem` | +| 10 | `app/Services/Intune/BackupService.php` | 438 total | Backup orchestration | Creates `BackupSet`, uses `PolicyCaptureOrchestrator` for per-item GET → PolicyVersion | +| 11 | `app/Services/Intune/PolicyCaptureOrchestrator.php` | 429 total | Per-item GET + hydration | Fetches full snapshot, assignments, scope tags; creates PolicyVersion with all content | +| 12 | `app/Services/Intune/PolicySnapshotService.php` | 852 total | Per-item Graph GET | Type-specific hydration (hydrateConfigurationPolicySettings, hydrateGroupPolicyConfiguration, etc.) | +| 13 | `app/Services/Intune/VersionService.php` | 312 total; L1-150 (captureVersion) | PolicyVersion persistence | Transactional, locking, consecutive version_number | +| 14 | `app/Models/PolicyVersion.php` | Model | PolicyVersion model | Casts: snapshot(array), assignments(array), scope_tags(array), plus hash columns | +| 15 | `app/Models/InventoryItem.php` | Model | Inventory item model | Casts: meta_jsonb(array) — **no settings content** | +| 16 | `app/Models/BaselineSnapshotItem.php` | Model | Snapshot item model | Has `baseline_hash(64)`, `meta_jsonb` | +| 17 | `app/Support/Inventory/InventoryCoverage.php` | 173 total | Coverage parser | `fromContext()` extracts per-type status from sync run context | +| 18 | `app/Services/Drift/DriftRunSelector.php` | ~60 total | Run pair selector | Selects 2 most recent sync runs with same `selection_hash` (System B only) | +| 19 | `app/Jobs/GenerateDriftFindingsJob.php` | ~200 total | Dispatcher for System B | Dispatches `DriftFindingGenerator` for policy-version-based drift | +| 20 | `config/graph_contracts.php` | 867 total | Policy type registry | Defines endpoints, hydration strategies, subresources, type families per policy type | +| 21 | `config/tenantpilot.php` | 385 total; L18-293 (supported_policy_types) | Application config | 28 supported policy types + 3 foundation types | +| 22 | `specs/116-baseline-drift-engine/spec.md` | 237 total | Feature spec | Defines v1 (meta) and v2 (content fidelity) requirements | +| 23 | `specs/116-baseline-drift-engine/research.md` | 200 total | Phase 0 research | 6 key decisions including v2 architecture strategy | +| 24 | `specs/116-baseline-drift-engine/plan.md` | 259 total | Implementation plan | Steps 1-7 for v1; v2 deferred | + +--- + +## 6. Type Coverage Matrix + +Coverage assessment for deep-drift feasibility: **which types have per-type normalization and hydration support?** + +| # | `policy_type` | Label | Hydration | Subresources | Per-Type Normalizer | Deep-Drift Feasible | Notes | +|---|---|---|---|---|---|---|---| +| 1 | `settingsCatalogPolicy` | Settings Catalog Policy | `configurationPolicies` | `settings` (list) | Yes (via PolicyNormalizer) | **YES** | Most impactful — complex nested settings | +| 2 | `endpointSecurityPolicy` | Endpoint Security Policies | `configurationPolicies` | `settings` (list) | Yes (shared with settings catalog) | **YES** | Same endpoint family as settings catalog | +| 3 | `securityBaselinePolicy` | Security Baselines | `configurationPolicies` | `settings` (list) | Yes (shared) | **YES** | Same pipeline | +| 4 | `groupPolicyConfiguration` | Administrative Templates | `groupPolicyConfigurations` | `definitionValues` → `presentationValues` | Yes (via PolicyNormalizer) | **YES** | Multi-level nesting; hydration required | +| 5 | `deviceConfiguration` | Device Configuration | `deviceConfigurations` | None (properties on root) | Yes (via PolicyNormalizer) | **YES** | Properties directly on resource | +| 6 | `deviceCompliancePolicy` | Device Compliance | `deviceCompliancePolicies` | `scheduledActionsForRule` (expand) | Yes (via PolicyNormalizer) | **YES** | Actions subresource needs expand | +| 7 | `windowsUpdateRing` | Software Update Ring | `deviceConfigurations` (filtered) | None (properties on root) | Yes (shared with deviceConfig) | **YES** | Subset of deviceConfiguration | +| 8 | `appProtectionPolicy` | App Protection (MAM) | `managedAppPolicies` | None (properties) | Partial (via PolicyNormalizer) | **YES** | Mobile-focused | +| 9 | `conditionalAccessPolicy` | Conditional Access | `identity/conditionalAccess/policies` | None (properties) | Yes (via PolicyNormalizer) | **YES** | High-risk, preview-only restore | +| 10 | `deviceManagementScript` | PowerShell Scripts | `deviceManagementScripts` | None (scriptContent base64) | Partial | **PARTIAL** | Script content is base64 in snapshot | +| 11 | `deviceShellScript` | macOS Shell Scripts | `deviceShellScripts` | None (scriptContent base64) | Partial | **PARTIAL** | Same pattern as PS scripts | +| 12 | `deviceHealthScript` | Proactive Remediations | `deviceHealthScripts` | None | Partial | **PARTIAL** | Detection + remediation scripts | +| 13 | `deviceComplianceScript` | Custom Compliance Scripts | `deviceComplianceScripts` | None | Partial | **PARTIAL** | Script content | +| 14 | `windowsFeatureUpdateProfile` | Feature Updates | `windowsFeatureUpdateProfiles` | None | Yes | **YES** | Simple properties | +| 15 | `windowsQualityUpdateProfile` | Quality Updates | `windowsQualityUpdateProfiles` | None | Yes | **YES** | Simple properties | +| 16 | `windowsDriverUpdateProfile` | Driver Updates | `windowsDriverUpdateProfiles` | None | Yes | **YES** | Simple properties | +| 17 | `mamAppConfiguration` | App Config (MAM) | `targetedManagedAppConfigurations` | None | Partial | **YES** | Properties-based | +| 18 | `managedDeviceAppConfiguration` | App Config (Device) | `mobileAppConfigurations` | None | Partial | **YES** | Properties-based | +| 19 | `windowsAutopilotDeploymentProfile` | Autopilot Profiles | `windowsAutopilotDeploymentProfiles` | None | Minimal | **YES** | Properties-based | +| 20 | `windowsEnrollmentStatusPage` | Enrollment Status Page | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Enrollment types have limited settings | +| 21 | `deviceEnrollmentLimitConfiguration` | Enrollment Limits | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Numeric limit only | +| 22 | `deviceEnrollmentPlatformRestrictionsConfiguration` | Platform Restrictions | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Nested restriction config | +| 23 | `deviceEnrollmentNotificationConfiguration` | Enrollment Notifications | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Template snapshots nested | +| 24 | `enrollmentRestriction` | Enrollment Restrictions | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Mixed config type | +| 25 | `termsAndConditions` | Terms & Conditions | `termsAndConditions` | None | Yes | **YES** | bodyText, acceptanceStatement | +| 26 | `endpointSecurityIntent` | Endpoint Security Intents | `intents` | categories/settings (legacy) | Partial | **PARTIAL** | Legacy intent API; migrating to configPolicies | +| 27 | `mobileApp` | Applications | `mobileApps` | None | Minimal | **META-ONLY** | Metadata-only backup per config | +| 28 | `policySet` | Policy Sets | (if supported) | assignments | Minimal | **META-ONLY** | Container for other policies | + +**Foundation Types:** + +| # | `foundation_type` | Label | Deep-Drift | Notes | +|---|---|---|---|---| +| F1 | `assignmentFilter` | Assignment Filter | **YES** | `rule` property is key content | +| F2 | `roleScopeTag` | Scope Tag | **META-ONLY** | displayName + description only | +| F3 | `notificationMessageTemplate` | Notification Template | **PARTIAL** | Localized messages are subresource | + +**Summary:** +- **Full content-fidelity feasible**: 16 types (settingsCatalog, endpointSecurity, securityBaseline, groupPolicy, deviceConfig, compliance, updateRings/profiles, appProtection, conditionalAccess, appConfigs, autopilot, termsAndConditions, assignmentFilter) +- **Partial** (script content / legacy APIs): 5 types +- **Meta-only sufficient**: 7 types (enrollment configs, mobileApp, roleScopeTag) + +--- + +## 7. Proposal: Deep Drift Implementation Plan + +### Phase v1.5 — Provider Chain (Opportunistic Content Fidelity) + +**Goal**: Enable baseline compare to use existing PolicyVersions for content-fidelity hash when available, with meta-fidelity fallback. + +**Estimated effort**: 3-5 days + +#### Step 1: ContentHashProvider Interface +```php +// app/Contracts/Baselines/ContentHashProvider.php +interface ContentHashProvider +{ + /** + * @return array{hash: string, fidelity: string, source: string}|null + */ + public function resolve(string $policyType, string $externalId, int $tenantId, CarbonImmutable $since): ?array; +} +``` + +#### Step 2: PolicyVersionContentProvider +```php +// app/Services/Baselines/PolicyVersionContentProvider.php +// Looks up the latest PolicyVersion for (tenant_id, external_id, policy_type) +// captured_at >= $since (baseline snapshot timestamp) +// Returns SettingsNormalizer → DriftHasher hash with fidelity='content' +``` + +#### Step 3: MetaFallbackProvider (existing logic) +```php +// Wraps InventoryMetaContract → DriftHasher → fidelity='meta' +``` + +#### Step 4: ContentProviderChain +```php +// Iterates [PolicyVersionContentProvider, MetaFallbackProvider] +// Returns first non-null result +``` + +#### Step 5: Integration in CompareBaselineToTenantJob +- `loadCurrentInventory()` accepts optional `ContentProviderChain` +- For each item: try chain, record fidelity + source +- `computeDrift()` unchanged (still hash vs hash comparison) +- Finding evidence includes `fidelity` and `content_hash_source` + +#### Step 6: CaptureBaselineSnapshotJob enhancement +- Optional: during capture, also try `PolicyVersionContentProvider` to store content-fidelity baseline_hash +- Store `content_hash_source` in `baseline_snapshot_items.meta_jsonb` +- This means: if a backup was taken before baseline capture, the baseline itself is content-fidelity + +#### Step 7: Coverage extension +- Add `content_coverage` to compare run context: which types had PolicyVersions, which fell back to meta +- Display in operation detail UI + +#### Migration +```sql +-- Optional: add column for source tracking +ALTER TABLE baseline_snapshot_items + ADD COLUMN content_hash_source VARCHAR(255) NULL DEFAULT 'inventory_meta_v1'; +``` + +### Phase v2.0 — On-Demand Content Capture (Future) + +**Goal**: For types without recent PolicyVersions, perform targeted per-item GET during baseline capture/compare. + +**Estimated effort**: 5-8 days + +- Introduce `BaselineContentCaptureJob` that, for a given baseline profile's scope, identifies items lacking recent PolicyVersions and performs targeted GET + PolicyVersion creation. +- Reuses existing `PolicyCaptureOrchestrator` with a new "baseline-triggered" context. +- Adds `capture_mode` to baseline profile: `meta_only` (v1), `opportunistic` (v1.5), `full_content` (v2.0). +- Rate limiting: per-tenant throttle to avoid Graph API quota issues. +- Budget guard: max N items per capture run, with continuation support. + +### Phase v2.5 — Inventory Content Enrichment (Future, Optional) + +**Goal**: Optionally have inventory sync capture settings content inline during LIST (where type supports `$expand`). + +- Some types support `$expand=settings` on LIST (settings catalog, endpoint security). +- This would give "free" content fidelity without per-item GET. +- High complexity: varies per type, may increase LIST payload size significantly. +- Evaluate ROI after v2.0 ships. + +--- + +## 8. Test Plan (Enterprise) + +### Unit Tests + +| # | Test File | Scope | Key Assertions | +|---|---|---|---| +| U1 | `tests/Unit/Baselines/ContentProviderChainTest.php` | Provider chain resolution | First provider wins; null fallback; fidelity recorded correctly | +| U2 | `tests/Unit/Baselines/PolicyVersionContentProviderTest.php` | PolicyVersion lookup + normalization | Correct hash for known snapshot; returns null when no PV; respects `$since` cutoff | +| U3 | `tests/Unit/Baselines/MetaFallbackProviderTest.php` | Meta contract fallback | Produces `fidelity='meta'`; matches existing `InventoryMetaContract` behavior exactly | +| U4 | `tests/Unit/Baselines/InventoryMetaContractTest.php` | (Existing) contract stability | Null handling, ordering, versioning — extend for edge cases | + +### Feature Tests + +| # | Test File | Scope | Key Assertions | +|---|---|---|---| +| F1 | `tests/Feature/Baselines/BaselineCompareContentFidelityTest.php` | End-to-end compare with PolicyVersions available | Settings change → `different_version` finding with `fidelity='content'` | +| F2 | `tests/Feature/Baselines/BaselineCompareMixedFidelityTest.php` | Some types have PV, some don't | Mixed `fidelity` values in findings; coverage context records both | +| F3 | `tests/Feature/Baselines/BaselineCompareFallbackTest.php` | No PolicyVersions available | Falls back to meta fidelity; identical behavior to v1 | +| F4 | `tests/Feature/Baselines/BaselineCaptureFidelityTest.php` | Capture with PolicyVersions present | `baseline_hash` uses content fidelity; `content_hash_source` recorded | +| F5 | `tests/Feature/Baselines/BaselineCompareStaleVersionTest.php` | PolicyVersion older than snapshot | Falls back to meta (stale PV not used) | +| F6 | `tests/Feature/Baselines/BaselineCompareCoverageGuardContentTest.php` | Coverage reporting for content types | `content_coverage` in run context shows which types are content-covered | + +### Existing Tests to Preserve + +| # | Test File | Impact | +|---|---|---| +| E1 | `tests/Feature/Baselines/BaselineCompareFindingsTest.php` | Must still pass — meta fidelity is default when no PV exists | +| E2 | `tests/Feature/Baselines/BaselineComparePreconditionsTest.php` | No change expected | +| E3 | `tests/Feature/Baselines/BaselineCompareStatsTest.php` | Stats remain grouped by scope_key; may need fidelity breakdown | +| E4 | `tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php` | Auto-close unaffected by fidelity source | + +### Integration / Regression + +| # | Test | Scope | +|---|---|---| +| I1 | Content hash stability across serialization | JSON encode/decode round-trip does not change hash | +| I2 | PolicyVersion normalizer alignment | Same snapshot → `SettingsNormalizer` produces same hash in both System A (via provider) and System B (via DriftFindingGenerator) | +| I3 | Hash collision protection | Different settings → different hashes (property-based test with sample data) | +| I4 | Empty snapshot edge case | PolicyVersion with empty/null snapshot → provider returns null → fallback works | + +### Performance Tests + +| # | Test | Acceptance Criteria | +|---|---|---| +| P1 | Compare job with 500 items, 50% with PolicyVersions | Completes in < 30s (DB-only, no Graph calls) | +| P2 | Provider chain query efficiency | PolicyVersion lookup uses batch query, not N+1 | + +--- + +## 9. Open Questions / Assumptions + +### Open Questions + +| # | Question | Impact | Proposed Resolution | +|---|---|---|---| +| OQ-1 | **Staleness threshold for PolicyVersions**: How old can a PolicyVersion be before we reject it as a content source? | Determines false-negative risk | Default: PolicyVersion must be captured after the baseline snapshot's `captured_at`. Configurable per workspace. | +| OQ-2 | **Mixed fidelity UX**: How should the UI display findings with different fidelity levels? | User trust and understanding | Badge/icon on finding cards: "High confidence (content)" vs "Structural only (meta)". Filterable in findings table. | +| OQ-3 | **Should baseline capture _force_ a backup** if no recent PolicyVersions exist? | API cost vs accuracy trade-off | No for v1.5 (opportunistic only). Yes for v2.0 as opt-in `capture_mode: full_content`. | +| OQ-4 | **etag as change hint**: Should we use etag changes as a _trigger_ for on-demand PolicyVersion capture? | Could reduce unnecessary GETs | Worth investigating in v2.0. If etag changes during inventory sync, schedule targeted per-item GET for that policy only. | +| OQ-5 | **Settings Catalog `$expand=settings`** on LIST: Does Microsoft Graph support this? | Could give "free" content fidelity for settings catalog types | Needs validation against Graph API. If supported, would eliminate per-item GET for the most impactful type. | +| OQ-6 | **Retention / pruning interaction**: If old PolicyVersions are pruned, does that affect baseline compare? | Could lose content fidelity for old baselines | Baseline compare only needs versions captured _after_ baseline snapshot. Pruning policy should respect active baseline snapshots. | + +### Assumptions + +| # | Assumption | Risk if Wrong | +|---|---|---| +| A-1 | `DriftHasher::hashNormalized()` is deterministic across PHP serialization boundaries | Hash mismatch → false drift findings. **Validated**: uses `json_encode` with stable flags + `ksort`. | +| A-2 | `SettingsNormalizer` / `PolicyNormalizer` produce the same output for the same input regardless of call context (System A vs System B) | Hash inconsistency between systems. **Low risk**: same code path. | +| A-3 | PolicyVersions from backups contain complete settings (not partial hydration) | Incomplete content → false negatives or incorrect hashes. **Validated**: `PolicySnapshotService` performs full hydration per type. | +| A-4 | The `Finding` model's `fingerprint`/`recurrence_key` identity allows mixed fidelity sources | Identity collision if fidelity changes source. **Safe**: recurrence_key includes snapshot_id, not hash value. | +| A-5 | Graph LIST endpoints do NOT return settings values for any supported policy type | If wrong, inventory sync could capture settings "for free". **Validated**: LIST returns only `$select` fields per `graph_contracts.php`. | +| A-6 | Per-type normalizers in backup drift path handle all 28 supported policy types | If not, some types would produce unstable hashes. **Partially validated**: `PolicyNormalizer` has a fallback for unknown types. | + +--- + +## 10. Key Questions Answered + +### KQ-01: Are Baseline Compare and Backup Drift truly separate systems? + +**Yes.** They share `DriftHasher` and the `Finding` model, but differ in: +- Data source: `InventoryItem` vs `PolicyVersion` +- Hash contract: `InventoryMetaContract` (7 fields, meta only) vs `SettingsNormalizer → PolicyNormalizer` (full snapshot) +- Finding generator: `CompareBaselineToTenantJob::computeDrift()` vs `DriftFindingGenerator::generate()` +- Finding identity: different recurrence key structures +- Scope model: `BaselineProfile`-scoped vs `selection_hash`-scoped +- Trigger: post-inventory-sync vs post-backup +- Coverage: `InventoryCoverage` guard vs none (trusts backup completeness) + +### KQ-02: Should they be unified or remain separate? + +**Hybrid approach (Provider Chain)** — as designed in Spec 116 v2. Keep separate triggering and scoping, but let System A _consume_ data produced by System B (PolicyVersions) via a provider chain. This avoids: +- Merging two fundamentally different scoping models +- Introducing new Graph API costs +- Disrupting existing backup drift workflows + +### KQ-03: What is the minimal viable "v1.5" to bridge the gap? + +Add a `PolicyVersionContentProvider` that checks for recent PolicyVersions as part of baseline compare's hash computation. For types where a PolicyVersion exists (i.e., a backup was taken), the compare immediately gains content-fidelity. For types without, meta-fidelity continues as before. **Net code change: ~200-300 lines** (interface + 2 providers + chain + integration). + +### KQ-04: Which types benefit most from content-fidelity drift? + +**Top priority** (complex settings, high change frequency): +1. `settingsCatalogPolicy` — most common, deeply nested settings +2. `groupPolicyConfiguration` — multi-level nesting (definitionValues → presentationValues) +3. `deviceCompliancePolicy` — compliance rules + scheduled actions +4. `deviceConfiguration` — broad category, many OData sub-types +5. `endpointSecurityPolicy` — critical security settings +6. `securityBaselinePolicy` — security-critical baselines +7. `conditionalAccessPolicy` — identity security gate + +**Medium priority** (simpler settings but still valuable): +8. `appProtectionPolicy`, `windowsUpdateRing`, `windowsFeatureUpdateProfile`, `windowsQualityUpdateProfile` + +### KQ-05: How does coverage work and how should it extend for content fidelity? + +Currently: `InventoryCoverage::fromContext(latestSyncRun->context)` → `coveredTypes()` returns types with `status=succeeded`. Uncovered types → findings suppressed, outcome = `partially_succeeded`. + +For v1.5: Add `content_coverage` alongside `meta_coverage`: +- `content_covered_types`: types where PolicyVersion exists post-baseline +- `meta_only_types`: types where only meta is available +- `uncovered_types`: types with no coverage at all (findings suppressed) + +Finding evidence should include: +```json +{ + "fidelity": "content", + "content_hash_source": "policy_version:42", + "note": "Hash computed from PolicyVersion #42 captured 2025-07-14T10:30:00Z" +} +``` + +### KQ-06: What is the long-term unified architecture? + +**Provider precedence chain** with configurable capture modes: + +``` +BaselineProfile.capture_mode: + 'meta_only' → InventoryMetaContract only (v1) + 'opportunistic' → PolicyVersion if available → meta fallback (v1.5) + 'full_content' → On-demand GET for missing types → PolicyVersion → meta (v2.0) + +ContentProviderChain: + 1. PolicyVersionContentProvider (checks existing PolicyVersions) + 2. InventoryContentProvider (future: if inventory sync enriched) + 3. MetaFallbackProvider (InventoryMetaContract v1) +``` + +The long-term vision is that baseline capture + compare use the **same normalizer pipeline** as backup drift, producing identical hashes for identical content regardless of which system produced the PolicyVersion. This is achievable because `DriftHasher` and `SettingsNormalizer` are already shared code. + +--- + +## Appendix: Database Schema Reference + +### `baseline_snapshot_items` (current) +``` +id BIGINT PK +baseline_snapshot_id BIGINT FK → baseline_snapshots +subject_type VARCHAR(255) -- 'policy' +subject_external_id VARCHAR(255) -- Graph resource GUID +policy_type VARCHAR(255) -- e.g. 'settingsCatalogPolicy' +baseline_hash VARCHAR(64) -- sha256 of InventoryMetaContract +meta_jsonb JSONB -- {display_name, category, platform, meta_contract: {...}, fidelity, source} +created_at TIMESTAMP +updated_at TIMESTAMP +``` + +### `inventory_items` (current) +``` +id BIGINT PK +tenant_id BIGINT FK → tenants +policy_type VARCHAR(255) +external_id VARCHAR(255) +display_name VARCHAR(255) +category VARCHAR(255) NULL +platform VARCHAR(255) NULL +meta_jsonb JSONB -- {odata_type, etag, scope_tag_ids, assignment_target_count} +last_seen_at TIMESTAMP NULL +last_seen_operation_run_id BIGINT NULL +created_at TIMESTAMP +updated_at TIMESTAMP +``` + +### `policy_versions` (current) +``` +id BIGINT PK +tenant_id BIGINT FK → tenants +policy_id BIGINT FK → policies +version_number INTEGER +policy_type VARCHAR(255) +platform VARCHAR(255) NULL +created_by VARCHAR(255) NULL +captured_at TIMESTAMP +snapshot JSON -- FULL Graph GET response (hydrated) +metadata JSON -- additional metadata +assignments JSON NULL -- full assignments array +scope_tags JSON NULL -- scope tag IDs +assignments_hash VARCHAR(64) NULL +scope_tags_hash VARCHAR(64) NULL +created_at TIMESTAMP +updated_at TIMESTAMP +deleted_at TIMESTAMP NULL -- soft delete +``` + +### Proposed v1.5 Addition +```sql +ALTER TABLE baseline_snapshot_items + ADD COLUMN content_hash_source VARCHAR(255) NULL DEFAULT 'inventory_meta_v1'; +-- Values: 'inventory_meta_v1', 'policy_version:{id}', 'inventory_content_v2' +``` diff --git a/resources/views/filament/pages/baseline-compare-landing.blade.php b/resources/views/filament/pages/baseline-compare-landing.blade.php index 279c08e..224e689 100644 --- a/resources/views/filament/pages/baseline-compare-landing.blade.php +++ b/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -4,6 +4,10 @@
@endif + @php + $hasCoverageWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true); + @endphp + {{-- Row 1: Stats Overview --}} @if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
@@ -12,11 +16,29 @@
Assigned Baseline
{{ $profileName ?? '—' }}
- @if ($snapshotId) - - Snapshot #{{ $snapshotId }} - - @endif +
+ @if ($snapshotId) + + Snapshot #{{ $snapshotId }} + + @endif + + @if (filled($coverageStatus)) + + Coverage: {{ $coverageStatus === 'ok' ? 'OK' : 'Warnings' }} + + @endif + + @if (filled($fidelity)) + + Fidelity: {{ Str::title($fidelity) }} + + @endif +
@@ -27,7 +49,7 @@ @if ($state === 'failed')
Error
@else -
+
{{ $findingsCount ?? 0 }}
@endif @@ -36,8 +58,10 @@ Comparing…
- @elseif (($findingsCount ?? 0) === 0 && $state === 'ready') + @elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasCoverageWarnings) All clear + @elseif ($state === 'ready' && $hasCoverageWarnings) + Coverage warnings @endif
@@ -59,6 +83,47 @@ @endif + {{-- Coverage warnings banner --}} + @if ($state === 'ready' && $hasCoverageWarnings) +
+
+ +
+
+ Comparison completed with warnings +
+
+ @if (($coverageStatus ?? null) === 'unproven') + Coverage proof was missing or unreadable for the last comparison run, so findings were suppressed for safety. + @else + Findings were skipped for {{ (int) ($uncoveredTypesCount ?? 0) }} policy {{ Str::plural('type', (int) ($uncoveredTypesCount ?? 0)) }} due to incomplete coverage. + @endif + + @if (! empty($uncoveredTypes)) +
+ Uncovered: {{ implode(', ', array_slice($uncoveredTypes, 0, 6)) }}@if (count($uncoveredTypes) > 6)…@endif +
+ @endif +
+ @if ($this->getRunUrl()) +
+ + View run + +
+ @endif +
+
+
+ @endif + {{-- Failed run banner --}} @if ($state === 'failed')
@@ -189,7 +254,7 @@ @endif {{-- Ready: no drift --}} - @if ($state === 'ready' && ($findingsCount ?? 0) === 0) + @if ($state === 'ready' && ($findingsCount ?? 0) === 0 && ! $hasCoverageWarnings)
@@ -213,6 +278,31 @@ @endif + {{-- Ready: warnings, no findings --}} + @if ($state === 'ready' && ($findingsCount ?? 0) === 0 && $hasCoverageWarnings) + +
+ +
Coverage Warnings
+
+ The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results. +
+ @if ($this->getRunUrl()) + + Review last run + + @endif +
+
+ @endif + {{-- Idle state --}} @if ($state === 'idle') diff --git a/resources/views/filament/pages/drift-landing.blade.php b/resources/views/filament/pages/drift-landing.blade.php index ab0f8a3..86211bf 100644 --- a/resources/views/filament/pages/drift-landing.blade.php +++ b/resources/views/filament/pages/drift-landing.blade.php @@ -1,4 +1,8 @@ + @php + $baselineCompareHasWarnings = in_array(($baselineCompareCoverageStatus ?? null), ['warning', 'unproven'], true); + @endphp +
@@ -35,6 +39,51 @@
@endif + @if ($baselineCompareRunId) +
+ Baseline compare + @if ($this->getBaselineCompareRunUrl()) + + #{{ $baselineCompareRunId }} + + @else + #{{ $baselineCompareRunId }} + @endif + + @if (filled($baselineCompareCoverageStatus)) + · Coverage + + {{ $baselineCompareCoverageStatus === 'ok' ? 'OK' : 'Warnings' }} + + @endif + + @if (filled($baselineCompareFidelity)) + · Fidelity {{ Str::title($baselineCompareFidelity) }} + @endif +
+ @endif + + @if ($baselineCompareRunId && $baselineCompareHasWarnings) +
+
+
Baseline compare coverage warnings
+
+ @if (($baselineCompareCoverageStatus ?? null) === 'unproven') + Coverage proof was missing or unreadable for the last baseline comparison, so findings were suppressed for safety. + @else + Some policy types were uncovered in the last baseline comparison, so findings may be incomplete. + @endif +
+ + @if (! empty($baselineCompareUncoveredTypes)) +
+ Uncovered: {{ implode(', ', array_slice($baselineCompareUncoveredTypes, 0, 6)) }}@if (count($baselineCompareUncoveredTypes) > 6)…@endif +
+ @endif +
+
+ @endif + @if ($state === 'blocked') Blocked diff --git a/resources/views/filament/widgets/tenant/baseline-compare-coverage-banner.blade.php b/resources/views/filament/widgets/tenant/baseline-compare-coverage-banner.blade.php new file mode 100644 index 0000000..d50f42b --- /dev/null +++ b/resources/views/filament/widgets/tenant/baseline-compare-coverage-banner.blade.php @@ -0,0 +1,46 @@ +@php + /** @var bool $shouldShow */ + /** @var ?string $runUrl */ + /** @var ?string $coverageStatus */ + /** @var ?string $fidelity */ + /** @var int $uncoveredTypesCount */ + /** @var list $uncoveredTypes */ + + $coverageHasWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true); +@endphp + +
+ @if ($shouldShow && $coverageHasWarnings) +
+
+
Baseline compare coverage warnings
+
+ @if (($coverageStatus ?? null) === 'unproven') + Coverage proof was missing or unreadable for the last baseline comparison, so findings were suppressed for safety. + @else + The last baseline comparison had incomplete coverage for {{ (int) $uncoveredTypesCount }} policy {{ Str::plural('type', (int) $uncoveredTypesCount) }}. Findings may be incomplete. + @endif + + @if (filled($fidelity)) + Fidelity: {{ Str::title($fidelity) }} + @endif +
+ + @if (! empty($uncoveredTypes)) +
+ Uncovered: {{ implode(', ', array_slice($uncoveredTypes, 0, 6)) }}@if (count($uncoveredTypes) > 6)…@endif +
+ @endif + + @if (filled($runUrl)) + + @endif +
+
+ @endif +
+ diff --git a/specs/116-baseline-drift-engine/plan.md b/specs/116-baseline-drift-engine/plan.md index f29211c..be8e6aa 100644 --- a/specs/116-baseline-drift-engine/plan.md +++ b/specs/116-baseline-drift-engine/plan.md @@ -141,6 +141,15 @@ ### Step 1 — Baseline scope schema + UI picker - `policy_types: []` meaning “all supported policy types excluding foundations” - `foundation_types: []` meaning “none” - Update `BaselineProfile` form schema (Filament Resource) to show multi-selects for Policy Types and Foundations. +- Document selector-to-config mapping (source of truth for option lists + defaults): + +| Selector | Form state path | Options source | Default semantics | +|---|---|---|---| +| Policy Types | `scope_jsonb.policy_types` | `config('tenantpilot.supported_policy_types')` via `App\Support\Inventory\InventoryPolicyTypeMeta::supported()` | Empty ⇒ all supported policy types (**excluding foundations**) | +| Foundations | `scope_jsonb.foundation_types` | `config('tenantpilot.foundation_types')` via `App\Support\Inventory\InventoryPolicyTypeMeta::foundations()` | Empty ⇒ none | + +Notes: +- Inventory sync selection uses `App\Services\BackupScheduling\PolicyTypeResolver::supportedPolicyTypes()` for policy types, and `InventorySyncService::foundationTypes()` (derived from `config('tenantpilot.foundation_types')`) when `include_foundations=true`. Tests: - Update/add Pest tests around scope expansion defaults (prefer a focused unit-like test if an expansion helper exists). diff --git a/specs/116-baseline-drift-engine/quickstart.md b/specs/116-baseline-drift-engine/quickstart.md index bfbe099..daa2273 100644 --- a/specs/116-baseline-drift-engine/quickstart.md +++ b/specs/116-baseline-drift-engine/quickstart.md @@ -34,11 +34,15 @@ ### 3) Compare baseline to tenant Expected: - Compare uses latest successful baseline snapshot by default (or explicit snapshot selection if provided). -- Compare uses the latest inventory sync run coverage: +- Compare uses the latest **completed** inventory sync run coverage: - For uncovered policy types, **no findings are emitted**. - OperationRun outcome becomes “completed with warnings” (partial) when uncovered types exist. - `summary_counts.errors_recorded = count(uncovered_types)`. - Edge case: if effective scope expands to zero types, outcome is still partial (warnings) and `summary_counts.errors_recorded = 1`. + - Fail-safe: if there is **no completed inventory sync run** or the coverage payload is missing/unreadable, coverage is treated as **unproven** for all effective types: + - **no findings are emitted** + - outcome is partial (warnings) + - compare run context includes `baseline_compare.coverage.proof = false` - Findings identity: - stable `recurrence_key` uses `baseline_snapshot_id` and does **not** include baseline/current hashes. - `fingerprint == recurrence_key`. @@ -53,5 +57,6 @@ ## Minimal smoke test checklist - Compare with full coverage: produces correct findings; outcome success. - Compare with partial coverage: produces findings only for covered types; outcome partial; uncovered types listed in context. +- Compare with unproven coverage (no completed sync / missing coverage): emits zero findings; outcome partial; warning visible in UI. - Re-run compare with no changes: no new findings; `times_seen` increments. - Re-capture snapshot and compare: findings identity changes (snapshot-scoped). diff --git a/specs/116-baseline-drift-engine/tasks.md b/specs/116-baseline-drift-engine/tasks.md index 0980455..7d83556 100644 --- a/specs/116-baseline-drift-engine/tasks.md +++ b/specs/116-baseline-drift-engine/tasks.md @@ -16,11 +16,11 @@ ## Phase 1: Setup (Shared Infrastructure) **Purpose**: Ensure local dev + feature artifacts are ready. -- [ ] T001 Re-run Speckit prerequisites check via `.specify/scripts/bash/check-prerequisites.sh --json` (references `specs/116-baseline-drift-engine/plan.md`) -- [ ] T002 Run required agent context update via `.specify/scripts/bash/update-agent-context.sh copilot` (required by `specs/116-baseline-drift-engine/plan.md`) -- [ ] T003 Ensure Sail + migrations are up for local validation (references `vendor/bin/sail`, `docker-compose.yml`, and `database/migrations/`) -- [ ] T004 [P] Re-validate Spec 116 UI Action Matrix: confirm “no changes required” OR update the matrix table in `specs/116-baseline-drift-engine/spec.md` -- [ ] T005 [P] Document the mapping of “Policy Types” / “Foundations” selectors to config sources in `specs/116-baseline-drift-engine/plan.md` (and adjust `config/tenantpilot.php` / `app/Support/Inventory/InventoryPolicyTypeMeta.php` if mismatched) +- [X] T001 Re-run Speckit prerequisites check via `.specify/scripts/bash/check-prerequisites.sh --json` (references `specs/116-baseline-drift-engine/plan.md`) +- [X] T002 Run required agent context update via `.specify/scripts/bash/update-agent-context.sh copilot` (required by `specs/116-baseline-drift-engine/plan.md`) +- [X] T003 Ensure Sail + migrations are up for local validation (references `vendor/bin/sail`, `docker-compose.yml`, and `database/migrations/`) +- [X] T004 [P] Re-validate Spec 116 UI Action Matrix: confirm “no changes required” OR update the matrix table in `specs/116-baseline-drift-engine/spec.md` +- [X] T005 [P] Document the mapping of “Policy Types” / “Foundations” selectors to config sources in `specs/116-baseline-drift-engine/plan.md` (and adjust `config/tenantpilot.php` / `app/Support/Inventory/InventoryPolicyTypeMeta.php` if mismatched) --- @@ -30,14 +30,14 @@ ## Phase 2: Foundational (Blocking Prerequisites) **Independent Test**: Baseline Profile can be created with the new scope shape, and scope defaults expand deterministically. -- [ ] T006 Update baseline scope schema + default semantics (policy_types excludes foundations by default; foundation_types defaults to none) in `app/Support/Baselines/BaselineScope.php` -- [ ] T007 [P] Update BaselineProfile default scope shape to include `foundation_types` in `database/factories/BaselineProfileFactory.php` -- [ ] T008 [P] Ensure BaselineProfile scope casting/normalization supports `foundation_types` safely in `app/Models/BaselineProfile.php` -- [ ] T009 [P] Create focused tests for scope expansion defaults (empty policy_types => supported excluding foundations; empty foundation_types => none) in `tests/Unit/Baselines/BaselineScopeTest.php` -- [ ] T010 Update BaselineProfile Create/Edit form (UX-001 Main/Aside), scope picker (Policy Types + Foundations), and infolist display semantics in `app/Filament/Resources/BaselineProfileResource.php` -- [ ] T011 [P] Update create-page scope normalization to persist both `policy_types` and `foundation_types` in `app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php` -- [ ] T012 [P] Update edit-page scope normalization to persist both `policy_types` and `foundation_types` in `app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php` -- [ ] T013 Run focused verification for foundational scope/UI changes with `vendor/bin/sail artisan test --compact tests/Unit/Baselines/BaselineScopeTest.php` (references `tests/Unit/Baselines/BaselineScopeTest.php`) +- [X] T006 Update baseline scope schema + default semantics (policy_types excludes foundations by default; foundation_types defaults to none) in `app/Support/Baselines/BaselineScope.php` +- [X] T007 [P] Update BaselineProfile default scope shape to include `foundation_types` in `database/factories/BaselineProfileFactory.php` +- [X] T008 [P] Ensure BaselineProfile scope casting/normalization supports `foundation_types` safely in `app/Models/BaselineProfile.php` +- [X] T009 [P] Create focused tests for scope expansion defaults (empty policy_types => supported excluding foundations; empty foundation_types => none) in `tests/Unit/Baselines/BaselineScopeTest.php` +- [X] T010 Update BaselineProfile Create/Edit form (UX-001 Main/Aside), scope picker (Policy Types + Foundations), and infolist display semantics in `app/Filament/Resources/BaselineProfileResource.php` +- [X] T011 [P] Update create-page scope normalization to persist both `policy_types` and `foundation_types` in `app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php` +- [X] T012 [P] Update edit-page scope normalization to persist both `policy_types` and `foundation_types` in `app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php` +- [X] T013 Run focused verification for foundational scope/UI changes with `vendor/bin/sail artisan test --compact tests/Unit/Baselines/BaselineScopeTest.php` (references `tests/Unit/Baselines/BaselineScopeTest.php`) **Checkpoint**: Scope semantics + scope UI are correct and test-covered. @@ -51,25 +51,25 @@ ## Phase 3: User Story 1 — Capture and compare a baseline with stable findings ### Tests for User Story 1 (write first) -- [ ] T014 [P] [US1] Update capture tests for effective_scope recording + contract-based hashing in `tests/Feature/Baselines/BaselineCaptureTest.php` -- [ ] T015 [P] [US1] Update compare findings tests to assert recurrence-key-based identity (no hashes in fingerprint) + lifecycle idempotency per run + `run.context.findings.counts_by_change_type` is present and accurate in `tests/Feature/Baselines/BaselineCompareFindingsTest.php` -- [ ] T016 [P] [US1] Add unit test for InventoryMetaContract normalization stability (ordering, missing fields, nullability) in `tests/Unit/Baselines/InventoryMetaContractTest.php` -- [ ] T017 [P] [US1] Add/adjust preconditions tests for default snapshot selection via `baseline_profiles.active_snapshot_id` (“latest successful”) + explicit snapshot override (per `specs/116-baseline-drift-engine/contracts/openapi.yaml`) in `tests/Feature/Baselines/BaselineComparePreconditionsTest.php` -- [ ] T018 [P] [US1] Add test that re-capturing (new snapshot id) produces new finding identities (snapshot-scoped) in `tests/Feature/Baselines/BaselineCompareFindingsTest.php` -- [ ] T019 [P] [US1] Extend compare-start auth tests to cover: unauthenticated→302, authenticated non-member/not entitled tenant access→404, authenticated member missing `tenant.sync`→403, and success path in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` -- [ ] T020 [P] [US1] Add capture-start auth tests for Baseline Profile “Capture Snapshot” action to cover: unauthenticated→302, authenticated non-member/not entitled workspace access→404, authenticated member missing `workspace_baselines.manage`→403, and success path in `tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php` -- [ ] T021 [P] [US1] Confirm baseline profile create/edit mutation surfaces have positive + negative authorization coverage (302/404/403 semantics) and update for new scope shape if needed in `tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` +- [X] T014 [P] [US1] Update capture tests for effective_scope recording + contract-based hashing in `tests/Feature/Baselines/BaselineCaptureTest.php` +- [X] T015 [P] [US1] Update compare findings tests to assert recurrence-key-based identity (no hashes in fingerprint) + lifecycle idempotency per run + `run.context.findings.counts_by_change_type` is present and accurate in `tests/Feature/Baselines/BaselineCompareFindingsTest.php` +- [X] T016 [P] [US1] Add unit test for InventoryMetaContract normalization stability (ordering, missing fields, nullability) in `tests/Unit/Baselines/InventoryMetaContractTest.php` +- [X] T017 [P] [US1] Add/adjust preconditions tests for default snapshot selection via `baseline_profiles.active_snapshot_id` (“latest successful”) + explicit snapshot override (per `specs/116-baseline-drift-engine/contracts/openapi.yaml`) in `tests/Feature/Baselines/BaselineComparePreconditionsTest.php` +- [X] T018 [P] [US1] Add test that re-capturing (new snapshot id) produces new finding identities (snapshot-scoped) in `tests/Feature/Baselines/BaselineCompareFindingsTest.php` +- [X] T019 [P] [US1] Extend compare-start auth tests to cover: unauthenticated→302, authenticated non-member/not entitled tenant access→404, authenticated member missing `tenant.sync`→403, and success path in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` +- [X] T020 [P] [US1] Add capture-start auth tests for Baseline Profile “Capture Snapshot” action to cover: unauthenticated→302, authenticated non-member/not entitled workspace access→404, authenticated member missing `workspace_baselines.manage`→403, and success path in `tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php` +- [X] T021 [P] [US1] Confirm baseline profile create/edit mutation surfaces have positive + negative authorization coverage (302/404/403 semantics) and update for new scope shape if needed in `tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` ### Implementation for User Story 1 -- [ ] T022 [US1] Create + implement Inventory Meta Contract builder (normalized whitelist inputs; deterministic ordering) in `app/Services/Baselines/InventoryMetaContract.php` -- [ ] T023 [US1] Update snapshot hashing to hash ONLY the meta contract output (not entire meta_jsonb) in `app/Services/Baselines/BaselineSnapshotIdentity.php` -- [ ] T024 [US1] Update baseline capture job to compute/store `baseline_hash` via InventoryMetaContract and persist `meta_jsonb` evidence (`meta_contract`, `fidelity`, `source`, `observed_at`, `observed_operation_run_id`) in `app/Jobs/CaptureBaselineSnapshotJob.php` -- [ ] T025 [US1] Ensure capture OperationRun context records `effective_scope.*` (policy_types, foundation_types, all_types, foundations_included) in `app/Services/Baselines/BaselineCaptureService.php` -- [ ] T026 [US1] Update baseline compare job to compute `current_hash` via InventoryMetaContract consistently with capture in `app/Jobs/CompareBaselineToTenantJob.php` -- [ ] T027 [US1] Switch baseline-compare finding identity to recurrence key derived from (tenant, baseline_snapshot_id, policy_type, external_id, change_type) and set `fingerprint == recurrence_key` in `app/Jobs/CompareBaselineToTenantJob.php` -- [ ] T028 [US1] Enforce per-run idempotency by using `findings.current_operation_run_id` (and/or evidence) so `times_seen` increments at most once per run identity in `app/Jobs/CompareBaselineToTenantJob.php` -- [ ] T029 [US1] Write compare audit context fields (baseline ids + `findings.counts_by_change_type`) onto the compare OperationRun context in `app/Jobs/CompareBaselineToTenantJob.php` +- [X] T022 [US1] Create + implement Inventory Meta Contract builder (normalized whitelist inputs; deterministic ordering) in `app/Services/Baselines/InventoryMetaContract.php` +- [X] T023 [US1] Update snapshot hashing to hash ONLY the meta contract output (not entire meta_jsonb) in `app/Services/Baselines/BaselineSnapshotIdentity.php` +- [X] T024 [US1] Update baseline capture job to compute/store `baseline_hash` via InventoryMetaContract and persist `meta_jsonb` evidence (`meta_contract`, `fidelity`, `source`, `observed_at`, `observed_operation_run_id`) in `app/Jobs/CaptureBaselineSnapshotJob.php` +- [X] T025 [US1] Ensure capture OperationRun context records `effective_scope.*` (policy_types, foundation_types, all_types, foundations_included) in `app/Services/Baselines/BaselineCaptureService.php` +- [X] T026 [US1] Update baseline compare job to compute `current_hash` via InventoryMetaContract consistently with capture in `app/Jobs/CompareBaselineToTenantJob.php` +- [X] T027 [US1] Switch baseline-compare finding identity to recurrence key derived from (tenant, baseline_snapshot_id, policy_type, external_id, change_type) and set `fingerprint == recurrence_key` in `app/Jobs/CompareBaselineToTenantJob.php` +- [X] T028 [US1] Enforce per-run idempotency by using `findings.current_operation_run_id` (and/or evidence) so `times_seen` increments at most once per run identity in `app/Jobs/CompareBaselineToTenantJob.php` +- [X] T029 [US1] Write compare audit context fields (baseline ids + `findings.counts_by_change_type`) onto the compare OperationRun context in `app/Jobs/CompareBaselineToTenantJob.php` **Checkpoint**: US1 tests pass: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareFindingsTest.php tests/Feature/Baselines/BaselineComparePreconditionsTest.php`. @@ -83,15 +83,15 @@ ## Phase 4: User Story 2 — Coverage warnings prevent misleading missing-policy ### Tests for User Story 2 (write first) -- [ ] T030 [P] [US2] Extend inventory sync tests to assert per-type coverage payload is written to OperationRun context in `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php` -- [ ] T031 [P] [US2] Create coverage-guard regression test: (a) uncovered types => no findings of any kind; (b) no completed inventory sync run / missing coverage payload => fail-safe zero findings; (c) effective scope expands to zero types => warnings + zero findings; outcome partially_succeeded; covered types still emit findings in `tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php` +- [X] T030 [P] [US2] Extend inventory sync tests to assert per-type coverage payload is written to OperationRun context in `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php` +- [X] T031 [P] [US2] Create coverage-guard regression test: (a) uncovered types => no findings of any kind; (b) no completed inventory sync run / missing coverage payload => fail-safe zero findings; (c) effective scope expands to zero types => warnings + zero findings; outcome partially_succeeded; covered types still emit findings in `tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php` ### Implementation for User Story 2 -- [ ] T032 [US2] Persist inventory sync coverage payload into latest inventory sync run context (`inventory.coverage.policy_types` + `inventory.coverage.foundation_types`) in `app/Services/Inventory/InventorySyncService.php` -- [ ] T033 [P] [US2] Create a small coverage parser/helper to normalize context payload for downstream consumers in `app/Support/Inventory/InventoryCoverage.php` -- [ ] T034 [US2] Update baseline compare to read latest inventory sync run coverage, compute uncovered types, skip emission for uncovered types, and write coverage details into compare run context in `app/Jobs/CompareBaselineToTenantJob.php` -- [ ] T035 [US2] Treat missing coverage proof (no completed inventory sync run, or unreadable/missing coverage payload) as uncovered-for-all-types (fail-safe): emit zero findings and mark outcome partially_succeeded (via OperationRunService), setting numeric summary_counts (including errors_recorded) using canonical keys only in `app/Jobs/CompareBaselineToTenantJob.php` +- [X] T032 [US2] Persist inventory sync coverage payload into latest inventory sync run context (`inventory.coverage.policy_types` + `inventory.coverage.foundation_types`) in `app/Services/Inventory/InventorySyncService.php` +- [X] T033 [P] [US2] Create a small coverage parser/helper to normalize context payload for downstream consumers in `app/Support/Inventory/InventoryCoverage.php` +- [X] T034 [US2] Update baseline compare to read latest inventory sync run coverage, compute uncovered types, skip emission for uncovered types, and write coverage details into compare run context in `app/Jobs/CompareBaselineToTenantJob.php` +- [X] T035 [US2] Treat missing coverage proof (no completed inventory sync run, or unreadable/missing coverage payload) as uncovered-for-all-types (fail-safe): emit zero findings and mark outcome partially_succeeded (via OperationRunService), setting numeric summary_counts (including errors_recorded) using canonical keys only in `app/Jobs/CompareBaselineToTenantJob.php` **Note (canonical warning magnitude)**: For (c) “effective scope expands to zero types”, the compare MUST still surface a warning and therefore MUST set `summary_counts.errors_recorded = 1` (even though uncovered-types count is 0), to keep the warning visible under the numeric-only summary_counts contract. @@ -107,16 +107,16 @@ ## Phase 5: User Story 3 — Operators can understand scope, coverage, and fidel ### Tests for User Story 3 (write first) -- [ ] T036 [P] [US3] Update Baseline Compare landing tests to cover warning/coverage state rendering inputs (stats DTO fields) in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` -- [ ] T037 [P] [US3] Update drift landing comparison-info tests to include coverage/fidelity context when source is baseline compare in `tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php` +- [X] T036 [P] [US3] Update Baseline Compare landing tests to cover warning/coverage state rendering inputs (stats DTO fields) in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` +- [X] T037 [P] [US3] Update drift landing comparison-info tests to include coverage/fidelity context when source is baseline compare in `tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php` ### Implementation for User Story 3 -- [ ] T038 [US3] Extend BaselineCompareStats DTO to include coverage status + uncovered types summary + fidelity indicator sourced from latest compare run context in `app/Support/Baselines/BaselineCompareStats.php` -- [ ] T039 [US3] Wire new stats fields into the BaselineCompareLanding Livewire page state in `app/Filament/Pages/BaselineCompareLanding.php` -- [ ] T040 [US3] Render coverage badge + warning banner + fidelity label on the landing view in `resources/views/filament/pages/baseline-compare-landing.blade.php` -- [ ] T041 [US3] Add a findings-list banner when latest baseline compare run had uncovered types (linking to the run) in `app/Filament/Resources/FindingResource/Pages/ListFindings.php` -- [ ] T042 [US3] Ensure run detail already shows context; if needed, add baseline compare “Coverage” summary entry for readability in `app/Filament/Resources/OperationRunResource.php` +- [X] T038 [US3] Extend BaselineCompareStats DTO to include coverage status + uncovered types summary + fidelity indicator sourced from latest compare run context in `app/Support/Baselines/BaselineCompareStats.php` +- [X] T039 [US3] Wire new stats fields into the BaselineCompareLanding Livewire page state in `app/Filament/Pages/BaselineCompareLanding.php` +- [X] T040 [US3] Render coverage badge + warning banner + fidelity label on the landing view in `resources/views/filament/pages/baseline-compare-landing.blade.php` +- [X] T041 [US3] Add a findings-list banner when latest baseline compare run had uncovered types (linking to the run) in `app/Filament/Resources/FindingResource/Pages/ListFindings.php` +- [X] T042 [US3] Ensure run detail already shows context; if needed, add baseline compare “Coverage” summary entry for readability in `app/Filament/Resources/OperationRunResource.php` **Checkpoint**: US3 tests pass: `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php`. @@ -126,19 +126,19 @@ ## Phase 6: Polish & Cross-Cutting Concerns **Purpose**: Preserve operability semantics (auto-close, stats), Ops-UX compliance, and fast regression feedback. -- [ ] T043 Confirm baseline compare stats remain profile-grouped via `scope_key = baseline_profile:{id}` after identity change in `app/Support/Baselines/BaselineCompareStats.php` -- [ ] T044 Ensure baseline auto-close behavior still works with snapshot-scoped identities (no stale open findings after successful compare) in `app/Services/Baselines/BaselineAutoCloseService.php` -- [ ] T045 [P] Update/verify auto-close regression test remains valid after identity change in `tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php` -- [ ] T046 [P] Add/extend guard test asserting OperationRun summary_counts are numeric-only and keys are limited to `OperationSummaryKeys::all()` for baseline capture/compare runs in `tests/Feature/Baselines/BaselineCompareFindingsTest.php` -- [ ] T047 [P] Add Spec 116 “One engine” regression guard to prevent legacy compare/hash paths (no `DriftHasher::fingerprint()` use for baseline compare; capture/compare hashing flows through `InventoryMetaContract`) in `tests/Feature/Guards/Spec116OneEngineGuardTest.php` -- [ ] T048 [P] Add Spec 116 performance regression guard (compare is DB-only; no Graph/hydration calls; compare processes snapshot + inventory via chunking) in `tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php` -- [ ] T049 [P] Verify OPS-UX-GUARD-001 remains satisfied for Spec 116 code paths; if guard tests fail, fix code (preferred) or update guard expectations intentionally in `tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php`, `tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php`, and `tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php` -- [ ] T050 [P] Create Baseline Profile archive action tests (confirmation required + RBAC 403/404 semantics + success path) in `tests/Feature/Baselines/BaselineProfileArchiveActionTest.php` -- [ ] T051 [P] Ensure archive action is declared in Action Surface slots and remains “More” row action only (max 2 visible row actions) in `tests/Feature/Guards/ActionSurfaceContractTest.php` -- [ ] T052 Run baseline-focused test pack for Spec 116: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/` (references `tests/Feature/Baselines/`) -- [ ] T053 Run Ops-UX guard test pack: `vendor/bin/sail artisan test --compact --group=ops-ux` (references `tests/Feature/OpsUx/Constitution/`) -- [ ] T054 Run Pint formatter on changed files: `vendor/bin/sail pint --dirty --format agent` (references `app/` and `tests/`) -- [ ] T055 Validate developer quickstart still matches real behavior (update if needed) in `specs/116-baseline-drift-engine/quickstart.md` +- [X] T043 Confirm baseline compare stats remain profile-grouped via `scope_key = baseline_profile:{id}` after identity change in `app/Support/Baselines/BaselineCompareStats.php` +- [X] T044 Ensure baseline auto-close behavior still works with snapshot-scoped identities (no stale open findings after successful compare) in `app/Services/Baselines/BaselineAutoCloseService.php` +- [X] T045 [P] Update/verify auto-close regression test remains valid after identity change in `tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php` +- [X] T046 [P] Add/extend guard test asserting OperationRun summary_counts are numeric-only and keys are limited to `OperationSummaryKeys::all()` for baseline capture/compare runs in `tests/Feature/Baselines/BaselineCompareFindingsTest.php` +- [X] T047 [P] Add Spec 116 “One engine” regression guard to prevent legacy compare/hash paths (no `DriftHasher::fingerprint()` use for baseline compare; capture/compare hashing flows through `InventoryMetaContract`) in `tests/Feature/Guards/Spec116OneEngineGuardTest.php` +- [X] T048 [P] Add Spec 116 performance regression guard (compare is DB-only; no Graph/hydration calls; compare processes snapshot + inventory via chunking) in `tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php` +- [X] T049 [P] Verify OPS-UX-GUARD-001 remains satisfied for Spec 116 code paths; if guard tests fail, fix code (preferred) or update guard expectations intentionally in `tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php`, `tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php`, and `tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php` +- [X] T050 [P] Create Baseline Profile archive action tests (confirmation required + RBAC 403/404 semantics + success path) in `tests/Feature/Baselines/BaselineProfileArchiveActionTest.php` +- [X] T051 [P] Ensure archive action is declared in Action Surface slots and remains “More” row action only (max 2 visible row actions) in `tests/Feature/Guards/ActionSurfaceContractTest.php` +- [X] T052 Run baseline-focused test pack for Spec 116: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/` (references `tests/Feature/Baselines/`) +- [X] T053 Run Ops-UX guard test pack: `vendor/bin/sail artisan test --compact --group=ops-ux` (references `tests/Feature/OpsUx/Constitution/`) +- [X] T054 Run Pint formatter on changed files: `vendor/bin/sail pint --dirty --format agent` (references `app/` and `tests/`) +- [X] T055 Validate developer quickstart still matches real behavior (update if needed) in `specs/116-baseline-drift-engine/quickstart.md` --- diff --git a/tests/Feature/Baselines/BaselineCaptureTest.php b/tests/Feature/Baselines/BaselineCaptureTest.php index b9f69fc..05bf90b 100644 --- a/tests/Feature/Baselines/BaselineCaptureTest.php +++ b/tests/Feature/Baselines/BaselineCaptureTest.php @@ -8,8 +8,11 @@ use App\Models\OperationRun; use App\Services\Baselines\BaselineCaptureService; use App\Services\Baselines\BaselineSnapshotIdentity; +use App\Services\Baselines\InventoryMetaContract; +use App\Services\Drift\DriftHasher; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineProfileStatus; use Illuminate\Support\Facades\Queue; // --- T031: Capture enqueue + precondition tests --- @@ -21,7 +24,7 @@ $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, - 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ]); /** @var BaselineCaptureService $service */ @@ -40,6 +43,13 @@ $context = is_array($run->context) ? $run->context : []; expect($context['baseline_profile_id'])->toBe((int) $profile->getKey()); expect($context['source_tenant_id'])->toBe((int) $tenant->getKey()); + expect($context)->toHaveKey('effective_scope'); + + $effectiveScope = is_array($context['effective_scope'] ?? null) ? $context['effective_scope'] : []; + expect($effectiveScope['policy_types'])->toBe(['deviceConfiguration']); + expect($effectiveScope['foundation_types'])->toBe([]); + expect($effectiveScope['all_types'])->toBe(['deviceConfiguration']); + expect($effectiveScope['foundations_included'])->toBeFalse(); Queue::assertPushed(CaptureBaselineSnapshotJob::class); }); @@ -51,7 +61,7 @@ $profile = BaselineProfile::factory()->create([ 'workspace_id' => $tenant->workspace_id, - 'status' => BaselineProfile::STATUS_DRAFT, + 'status' => BaselineProfileStatus::Draft->value, ]); $service = app(BaselineCaptureService::class); @@ -111,7 +121,7 @@ $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, - 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ]); $service = app(BaselineCaptureService::class); @@ -133,14 +143,29 @@ $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, - 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ]); - InventoryItem::factory()->count(3)->create([ + InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'policy-a', 'policy_type' => 'deviceConfiguration', - 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration'], + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'], + ]); + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'policy-b', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E2'], + ]); + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'policy-c', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E3'], ]); $opService = app(OperationRunService::class); @@ -151,7 +176,7 @@ context: [ 'baseline_profile_id' => (int) $profile->getKey(), 'source_tenant_id' => (int) $tenant->getKey(), - 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ], initiator: $user, ); @@ -159,6 +184,7 @@ $job = new CaptureBaselineSnapshotJob($run); $job->handle( app(BaselineSnapshotIdentity::class), + app(InventoryMetaContract::class), app(AuditLogger::class), $opService, ); @@ -178,6 +204,39 @@ expect($snapshot)->not->toBeNull(); expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(3); + $builder = app(InventoryMetaContract::class); + $hasher = app(DriftHasher::class); + + $items = BaselineSnapshotItem::query() + ->where('baseline_snapshot_id', $snapshot->getKey()) + ->orderBy('subject_external_id') + ->get(); + + expect($items->pluck('subject_external_id')->all())->toBe(['policy-a', 'policy-b', 'policy-c']); + + foreach ($items as $item) { + /** @var BaselineSnapshotItem $item */ + $inventory = InventoryItem::query() + ->where('tenant_id', $tenant->getKey()) + ->where('policy_type', $item->policy_type) + ->where('external_id', $item->subject_external_id) + ->first(); + + expect($inventory)->not->toBeNull(); + + $contract = $builder->build( + policyType: (string) $inventory->policy_type, + subjectExternalId: (string) $inventory->external_id, + metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [], + ); + + expect($item->baseline_hash)->toBe($hasher->hashNormalized($contract)); + + $meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : []; + expect($meta)->toHaveKey('meta_contract'); + expect($meta['meta_contract'])->toBe($contract); + } + $profile->refresh(); expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey()); }); @@ -187,7 +246,7 @@ $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, - 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ]); InventoryItem::factory()->count(2)->create([ @@ -199,6 +258,7 @@ $opService = app(OperationRunService::class); $idService = app(BaselineSnapshotIdentity::class); + $metaContract = app(InventoryMetaContract::class); $auditLogger = app(AuditLogger::class); $run1 = $opService->ensureRunWithIdentity( @@ -208,13 +268,13 @@ context: [ 'baseline_profile_id' => (int) $profile->getKey(), 'source_tenant_id' => (int) $tenant->getKey(), - 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ], initiator: $user, ); $job1 = new CaptureBaselineSnapshotJob($run1); - $job1->handle($idService, $auditLogger, $opService); + $job1->handle($idService, $metaContract, $auditLogger, $opService); $snapshotCountAfterFirst = BaselineSnapshot::query() ->where('baseline_profile_id', $profile->getKey()) @@ -230,16 +290,16 @@ 'type' => 'baseline_capture', 'status' => 'queued', 'outcome' => 'pending', - 'run_identity_hash' => hash('sha256', 'second-run-' . now()->timestamp), + 'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp), 'context' => [ 'baseline_profile_id' => (int) $profile->getKey(), 'source_tenant_id' => (int) $tenant->getKey(), - 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ], ]); $job2 = new CaptureBaselineSnapshotJob($run2); - $job2->handle($idService, $auditLogger, $opService); + $job2->handle($idService, $metaContract, $auditLogger, $opService); $snapshotCountAfterSecond = BaselineSnapshot::query() ->where('baseline_profile_id', $profile->getKey()) @@ -255,7 +315,7 @@ $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, - 'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType']], + 'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []], ]); $opService = app(OperationRunService::class); @@ -266,7 +326,7 @@ context: [ 'baseline_profile_id' => (int) $profile->getKey(), 'source_tenant_id' => (int) $tenant->getKey(), - 'effective_scope' => ['policy_types' => ['nonExistentPolicyType']], + 'effective_scope' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []], ], initiator: $user, ); @@ -274,6 +334,7 @@ $job = new CaptureBaselineSnapshotJob($run); $job->handle( app(BaselineSnapshotIdentity::class), + app(InventoryMetaContract::class), app(AuditLogger::class), $opService, ); @@ -299,7 +360,7 @@ $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, - 'scope_jsonb' => ['policy_types' => []], + 'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []], ]); InventoryItem::factory()->create([ @@ -311,7 +372,14 @@ InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), 'workspace_id' => $tenant->workspace_id, - 'policy_type' => 'compliancePolicy', + 'policy_type' => 'deviceCompliancePolicy', + ]); + + // Foundation types are excluded by default (unless foundation_types is selected). + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'policy_type' => 'assignmentFilter', ]); $opService = app(OperationRunService::class); @@ -322,7 +390,7 @@ context: [ 'baseline_profile_id' => (int) $profile->getKey(), 'source_tenant_id' => (int) $tenant->getKey(), - 'effective_scope' => ['policy_types' => []], + 'effective_scope' => ['policy_types' => [], 'foundation_types' => []], ], initiator: $user, ); @@ -330,6 +398,7 @@ $job = new CaptureBaselineSnapshotJob($run); $job->handle( app(BaselineSnapshotIdentity::class), + app(InventoryMetaContract::class), app(AuditLogger::class), $opService, ); diff --git a/tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php b/tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php new file mode 100644 index 0000000..f109d84 --- /dev/null +++ b/tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php @@ -0,0 +1,354 @@ +active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'scope_jsonb' => [ + 'policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'], + '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()]); + + $builder = app(InventoryMetaContract::class); + $hasher = app(DriftHasher::class); + + $coveredContract = $builder->build( + policyType: 'deviceConfiguration', + subjectExternalId: 'covered-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'], + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'covered-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $hasher->hashNormalized($coveredContract), + 'meta_jsonb' => ['display_name' => 'Covered Policy'], + ]); + + $uncoveredContract = $builder->build( + policyType: 'deviceCompliancePolicy', + subjectExternalId: 'uncovered-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceCompliancePolicy', 'etag' => 'E_BASELINE'], + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'uncovered-uuid', + 'policy_type' => 'deviceCompliancePolicy', + 'baseline_hash' => $hasher->hashNormalized($uncoveredContract), + 'meta_jsonb' => ['display_name' => 'Uncovered Policy'], + ]); + + $inventorySyncRun = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::InventorySync->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'completed_at' => now(), + 'context' => [ + 'inventory' => [ + 'coverage' => [ + 'policy_types' => [ + 'deviceConfiguration' => ['status' => 'succeeded'], + 'deviceCompliancePolicy' => ['status' => 'failed'], + ], + 'foundation_types' => [], + ], + ], + ], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'covered-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'], + 'display_name' => 'Covered Policy Changed', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $operationRuns = app(OperationRunService::class); + $compareRun = $operationRuns->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', 'deviceCompliancePolicy'], + 'foundation_types' => [], + ], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($compareRun))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $operationRuns, + ); + + $compareRun->refresh(); + expect($compareRun->status)->toBe('completed'); + expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value); + + $counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : []; + expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1); + + $findings = Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('source', 'baseline.compare') + ->get(); + + expect($findings)->toHaveCount(1); + expect((string) data_get($findings->first(), 'evidence_jsonb.change_type'))->toBe('different_version'); +}); + +it('emits zero findings when there is no completed inventory sync run (fail-safe)', 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(), + ]); + + $builder = app(InventoryMetaContract::class); + $hasher = app(DriftHasher::class); + + $contract = $builder->build( + policyType: 'deviceConfiguration', + subjectExternalId: 'policy-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'], + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $hasher->hashNormalized($contract), + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'policy-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'], + 'display_name' => 'Policy Changed', + ]); + + $operationRuns = app(OperationRunService::class); + $compareRun = $operationRuns->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), + $operationRuns, + ); + + $compareRun->refresh(); + expect($compareRun->status)->toBe('completed'); + expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value); + + $counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : []; + expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1); + expect((int) ($counts['total'] ?? -1))->toBe(0); + + expect( + Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('source', 'baseline.compare') + ->count() + )->toBe(0); +}); + +it('emits zero findings when coverage payload is missing (fail-safe)', 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(), + ]); + + 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' => [ + 'selection_hash' => 'latest', + ], + ]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'baseline'), + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'policy-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'], + 'display_name' => 'Policy Changed', + ]); + + $operationRuns = app(OperationRunService::class); + $compareRun = $operationRuns->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), + $operationRuns, + ); + + $compareRun->refresh(); + + expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value); + + $counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : []; + expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1); + expect((int) ($counts['total'] ?? -1))->toBe(0); + + expect( + Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('source', 'baseline.compare') + ->count() + )->toBe(0); +}); + +it('emits a warning and zero findings when effective scope expands to zero types', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'scope_jsonb' => [ + 'policy_types' => ['unsupported_type'], + 'foundation_types' => [], + ], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $operationRuns = app(OperationRunService::class); + $compareRun = $operationRuns->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' => ['unsupported_type'], + 'foundation_types' => [], + ], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($compareRun))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $operationRuns, + ); + + $compareRun->refresh(); + + expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value); + + $counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : []; + expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1); + expect((int) ($counts['total'] ?? -1))->toBe(0); + + expect( + Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('source', 'baseline.compare') + ->count() + )->toBe(0); +}); diff --git a/tests/Feature/Baselines/BaselineCompareFindingsTest.php b/tests/Feature/Baselines/BaselineCompareFindingsTest.php index 63b5bd1..6ed5527 100644 --- a/tests/Feature/Baselines/BaselineCompareFindingsTest.php +++ b/tests/Feature/Baselines/BaselineCompareFindingsTest.php @@ -1,5 +1,6 @@ active()->create([ 'workspace_id' => $tenant->workspace_id, - 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ]); $snapshot = BaselineSnapshot::factory()->create([ @@ -36,6 +37,11 @@ $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + // Baseline has policyA and policyB BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), @@ -62,6 +68,8 @@ 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['different_content' => true], 'display_name' => 'Policy A modified', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), ]); InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), @@ -70,6 +78,8 @@ 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['new_policy' => true], 'display_name' => 'Policy C unexpected', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), ]); $opService = app(OperationRunService::class); @@ -80,14 +90,13 @@ context: [ 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_snapshot_id' => (int) $snapshot->getKey(), - 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ], initiator: $user, ); $job = new CompareBaselineToTenantJob($run); $job->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $opService, @@ -97,6 +106,14 @@ expect($run->status)->toBe('completed'); expect($run->outcome)->toBe('succeeded'); + $context = is_array($run->context) ? $run->context : []; + $countsByChangeType = $context['findings']['counts_by_change_type'] ?? null; + expect($countsByChangeType)->toBe([ + 'different_version' => 1, + 'missing_policy' => 1, + 'unexpected_policy' => 1, + ]); + $scopeKey = 'baseline_profile:'.$profile->getKey(); $findings = Finding::query() @@ -107,6 +124,7 @@ // policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings expect($findings->count())->toBe(3); + expect($findings->every(fn (Finding $finding): bool => filled($finding->recurrence_key) && $finding->recurrence_key === $finding->fingerprint))->toBeTrue(); // Lifecycle v2 fields must be initialized for new findings. expect($findings->pluck('first_seen_at')->filter()->count())->toBe($findings->count()); @@ -134,6 +152,11 @@ $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', @@ -158,6 +181,8 @@ 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['different_content' => true], 'display_name' => 'Policy A modified', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), ]); $opService = app(OperationRunService::class); @@ -181,7 +206,6 @@ $baselineAutoCloseService = new \App\Services\Baselines\BaselineAutoCloseService($settingsResolver); (new CompareBaselineToTenantJob($run))->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $opService, @@ -222,6 +246,26 @@ $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + $olderInventoryRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['settingsCatalogPolicy' => 'succeeded'], + attributes: [ + 'selection_hash' => 'older', + 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], + 'finished_at' => now()->subMinutes(5), + ], + ); + + createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['settingsCatalogPolicy' => 'succeeded'], + attributes: [ + 'selection_hash' => 'latest', + 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], + 'finished_at' => now(), + ], + ); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', @@ -231,19 +275,6 @@ 'meta_jsonb' => ['display_name' => 'Settings Catalog A'], ]); - $olderInventoryRun = OperationRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'workspace_id' => $tenant->workspace_id, - 'type' => OperationRunType::InventorySync->value, - 'status' => OperationRunStatus::Completed->value, - 'outcome' => OperationRunOutcome::Succeeded->value, - 'completed_at' => now()->subMinutes(5), - 'context' => [ - 'policy_types' => ['settingsCatalogPolicy'], - 'selection_hash' => 'older', - ], - ]); - // Inventory item exists, but it was NOT observed in the latest sync run. InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), @@ -256,19 +287,6 @@ 'last_seen_at' => now()->subMinutes(5), ]); - OperationRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'workspace_id' => $tenant->workspace_id, - 'type' => OperationRunType::InventorySync->value, - 'status' => OperationRunStatus::Completed->value, - 'outcome' => OperationRunOutcome::Succeeded->value, - 'completed_at' => now(), - 'context' => [ - 'policy_types' => ['settingsCatalogPolicy'], - 'selection_hash' => 'latest', - ], - ]); - $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, @@ -283,7 +301,6 @@ ); (new CompareBaselineToTenantJob($run))->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $opService, @@ -305,12 +322,12 @@ expect($findings->first()?->evidence_jsonb['change_type'] ?? null)->toBe('missing_policy'); }); -it('produces idempotent fingerprints so re-running compare updates existing findings', function () { +it('uses recurrence-key identity (no hashes in fingerprint) and increments times_seen at most once per run', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, - 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ]); $snapshot = BaselineSnapshot::factory()->create([ @@ -320,19 +337,42 @@ $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $builder = app(InventoryMetaContract::class); + $hasher = app(DriftHasher::class); + + $baselineContract = $builder->build( + policyType: 'deviceConfiguration', + subjectExternalId: 'policy-x-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'], + ); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', 'subject_external_id' => 'policy-x-uuid', 'policy_type' => 'deviceConfiguration', - 'baseline_hash' => hash('sha256', 'baseline-content'), + 'baseline_hash' => $hasher->hashNormalized($baselineContract), 'meta_jsonb' => ['display_name' => 'Policy X'], ]); - // Tenant does NOT have policy-x → missing_policy finding + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'policy-x-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'], + 'display_name' => 'Policy X modified', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + $opService = app(OperationRunService::class); - // First run $run1 = $opService->ensureRunWithIdentity( tenant: $tenant, type: OperationRunType::BaselineCompare->value, @@ -340,31 +380,54 @@ context: [ 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_snapshot_id' => (int) $snapshot->getKey(), - 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ], initiator: $user, ); - $job1 = new CompareBaselineToTenantJob($run1); - $job1->handle( - app(DriftHasher::class), + $job = new CompareBaselineToTenantJob($run1); + $job->handle( app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $opService, ); $scopeKey = 'baseline_profile:'.$profile->getKey(); - $countAfterFirst = Finding::query() + + $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('source', 'baseline.compare') ->where('scope_key', $scopeKey) - ->count(); + ->sole(); - expect($countAfterFirst)->toBe(1); + expect($finding->recurrence_key)->not->toBeNull(); + expect($finding->fingerprint)->toBe($finding->recurrence_key); + expect($finding->times_seen)->toBe(1); - // Second run - new OperationRun so we can dispatch again - // Mark first run as completed so ensureRunWithIdentity creates a new one - $run1->update(['status' => 'completed', 'completed_at' => now()]); + $fingerprint = (string) $finding->fingerprint; + $currentHash1 = (string) ($finding->evidence_jsonb['current_hash'] ?? ''); + expect($currentHash1)->not->toBe(''); + + // Retry the same run ID (job retry): times_seen MUST NOT increment twice for the same run. + $job->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $finding->refresh(); + expect($finding->times_seen)->toBe(1); + expect((string) $finding->fingerprint)->toBe($fingerprint); + expect((string) ($finding->evidence_jsonb['current_hash'] ?? ''))->toBe($currentHash1); + + // Change inventory evidence (hash changes) and run compare again with a new OperationRun. + InventoryItem::query() + ->where('tenant_id', $tenant->getKey()) + ->where('policy_type', 'deviceConfiguration') + ->where('external_id', 'policy-x-uuid') + ->update([ + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_2'], + ]); $run2 = $opService->ensureRunWithIdentity( tenant: $tenant, @@ -373,27 +436,139 @@ context: [ 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_snapshot_id' => (int) $snapshot->getKey(), - 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ], initiator: $user, ); - $job2 = new CompareBaselineToTenantJob($run2); - $job2->handle( - app(DriftHasher::class), + (new CompareBaselineToTenantJob($run2))->handle( app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $opService, ); - $countAfterSecond = Finding::query() + $finding->refresh(); + expect((string) $finding->fingerprint)->toBe($fingerprint); + expect($finding->times_seen)->toBe(2); + expect((string) ($finding->evidence_jsonb['current_hash'] ?? ''))->not->toBe($currentHash1); +}); + +it('creates new finding identities when a new snapshot is captured (snapshot-scoped recurrence)', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $builder = app(InventoryMetaContract::class); + $hasher = app(DriftHasher::class); + + $baselineContract = $builder->build( + policyType: 'deviceConfiguration', + subjectExternalId: 'policy-x-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'], + ); + $baselineHash = $hasher->hashNormalized($baselineContract); + + $snapshot1 = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot1->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-x-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $baselineHash, + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'policy-x-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $opService = app(OperationRunService::class); + $run1 = $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) $snapshot1->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run1))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $scopeKey = 'baseline_profile:'.$profile->getKey(); + + $fingerprint1 = (string) Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('source', 'baseline.compare') ->where('scope_key', $scopeKey) - ->count(); + ->orderBy('id') + ->firstOrFail() + ->fingerprint; - // Same fingerprint → same finding updated, not duplicated - expect($countAfterSecond)->toBe(1); + $snapshot2 = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot2->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-x-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $baselineHash, + ]); + + $run2 = $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) $snapshot2->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run2))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $findings = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->orderBy('id') + ->get(); + + expect($findings)->toHaveCount(2); + expect($findings->pluck('fingerprint')->unique()->count())->toBe(2); + expect($findings->pluck('fingerprint')->all())->toContain($fingerprint1); }); it('creates zero findings when baseline matches tenant inventory exactly', function () { @@ -401,7 +576,7 @@ $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, - 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ]); $snapshot = BaselineSnapshot::factory()->create([ @@ -411,10 +586,26 @@ $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + // Baseline item - $metaContent = ['policy_key' => 'value123']; + $metaContent = [ + 'odata_type' => '#microsoft.graph.deviceConfiguration', + 'etag' => 'E1', + 'scope_tag_ids' => ['scope-2', 'scope-1'], + 'assignment_target_count' => 3, + ]; + + $builder = app(InventoryMetaContract::class); $driftHasher = app(DriftHasher::class); - $contentHash = $driftHasher->hashNormalized($metaContent); + $contentHash = $driftHasher->hashNormalized($builder->build( + policyType: 'deviceConfiguration', + subjectExternalId: 'matching-uuid', + metaJsonb: $metaContent, + )); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), @@ -433,6 +624,8 @@ 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => $metaContent, 'display_name' => 'Matching Policy', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), ]); $opService = app(OperationRunService::class); @@ -443,14 +636,13 @@ context: [ 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_snapshot_id' => (int) $snapshot->getKey(), - 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ], initiator: $user, ); $job = new CompareBaselineToTenantJob($run); $job->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $opService, @@ -476,7 +668,7 @@ $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, - 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ]); $snapshot = BaselineSnapshot::factory()->create([ @@ -486,9 +678,25 @@ $profile->update(['active_snapshot_id' => $snapshot->getKey()]); - $metaContent = ['policy_key' => 'value123']; + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $metaContent = [ + 'odata_type' => '#microsoft.graph.deviceConfiguration', + 'etag' => 'E1', + 'scope_tag_ids' => ['scope-2', 'scope-1'], + 'assignment_target_count' => 3, + ]; + + $builder = app(InventoryMetaContract::class); $driftHasher = app(DriftHasher::class); - $contentHash = $driftHasher->hashNormalized($metaContent); + $contentHash = $driftHasher->hashNormalized($builder->build( + policyType: 'deviceConfiguration', + subjectExternalId: 'matching-uuid', + metaJsonb: $metaContent, + )); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), @@ -515,6 +723,8 @@ 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => $metaContent, 'display_name' => 'Matching Policy', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), ]); InventoryItem::factory()->create([ @@ -524,6 +734,8 @@ 'policy_type' => 'notificationMessageTemplate', 'meta_jsonb' => ['some' => 'value'], 'display_name' => 'Foundation Template', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), ]); $opService = app(OperationRunService::class); @@ -534,13 +746,12 @@ context: [ 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_snapshot_id' => (int) $snapshot->getKey(), - 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ], initiator: $user, ); (new CompareBaselineToTenantJob($run))->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $opService, @@ -578,6 +789,11 @@ $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + // 2 baseline items: one will be missing (high), one will be different (medium) BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), @@ -604,6 +820,8 @@ 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['modified_content' => true], 'display_name' => 'Changed Policy', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), ]); InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), @@ -612,6 +830,8 @@ 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['extra_content' => true], 'display_name' => 'Extra Policy', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), ]); $opService = app(OperationRunService::class); @@ -629,7 +849,6 @@ $job = new CompareBaselineToTenantJob($run); $job->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $opService, @@ -659,6 +878,11 @@ $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + // One missing policy BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), @@ -683,7 +907,6 @@ $job = new CompareBaselineToTenantJob($run); $job->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $opService, @@ -715,6 +938,11 @@ $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', @@ -739,7 +967,6 @@ ); (new CompareBaselineToTenantJob($firstRun))->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $operationRuns, @@ -771,7 +998,6 @@ ); (new CompareBaselineToTenantJob($secondRun))->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $operationRuns, @@ -799,6 +1025,11 @@ $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', @@ -823,7 +1054,6 @@ ); (new CompareBaselineToTenantJob($firstRun))->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $operationRuns, @@ -854,7 +1084,6 @@ ); (new CompareBaselineToTenantJob($secondRun))->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $operationRuns, @@ -892,6 +1121,11 @@ $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', @@ -916,6 +1150,8 @@ 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['different_content' => true], 'display_name' => 'Different Policy', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), ]); InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), @@ -924,6 +1160,8 @@ 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['unexpected' => true], 'display_name' => 'Unexpected Policy', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), ]); $operationRuns = app(OperationRunService::class); @@ -940,7 +1178,6 @@ ); (new CompareBaselineToTenantJob($run))->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $operationRuns, @@ -956,3 +1193,88 @@ expect($findings['different_version']->severity)->toBe(Finding::SEVERITY_LOW); expect($findings['unexpected_policy']->severity)->toBe(Finding::SEVERITY_MEDIUM); }); + +it('writes numeric-only summary_counts with whitelisted keys for capture and compare runs', 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' => []], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'policy-a', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'], + ]); + + $operationRuns = app(OperationRunService::class); + + $captureRun = $operationRuns->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($captureRun))->handle( + app(BaselineSnapshotIdentity::class), + app(InventoryMetaContract::class), + app(AuditLogger::class), + $operationRuns, + ); + + $captureRun->refresh(); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + InventoryItem::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->update([ + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $snapshotId = (int) ($profile->fresh()?->active_snapshot_id ?? 0); + expect($snapshotId)->toBeGreaterThan(0); + + $compareRun = $operationRuns->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => $snapshotId, + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($compareRun))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $operationRuns, + ); + + $allowedKeys = OperationSummaryKeys::all(); + + foreach ([$captureRun->fresh(), $compareRun->fresh()] as $run) { + $counts = is_array($run?->summary_counts) ? $run->summary_counts : []; + + foreach ($counts as $key => $value) { + expect($allowedKeys)->toContain((string) $key); + expect(is_array($value))->toBeFalse(); + expect(is_numeric($value))->toBeTrue(); + } + } +}); diff --git a/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php b/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php new file mode 100644 index 0000000..9bc1e63 --- /dev/null +++ b/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php @@ -0,0 +1,102 @@ +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()]); + + $builder = app(InventoryMetaContract::class); + $hasher = app(DriftHasher::class); + + $baselineContract = $builder->build( + policyType: 'deviceConfiguration', + subjectExternalId: 'policy-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'], + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $hasher->hashNormalized($baselineContract), + 'meta_jsonb' => ['display_name' => 'Policy'], + ]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'policy-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'], + 'display_name' => 'Policy Changed', + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $operationRuns = app(OperationRunService::class); + $compareRun = $operationRuns->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, + ); + + assertNoOutboundHttp(function () use ($compareRun, $operationRuns): void { + (new CompareBaselineToTenantJob($compareRun))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $operationRuns, + ); + }); + + $compareRun->refresh(); + expect($compareRun->outcome)->toBe(OperationRunOutcome::Succeeded->value); + + $code = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php')); + expect($code)->toBeString(); + expect($code)->toContain('->chunk('); +}); diff --git a/tests/Feature/Baselines/BaselineComparePreconditionsTest.php b/tests/Feature/Baselines/BaselineComparePreconditionsTest.php index 0ceb0c3..62088d4 100644 --- a/tests/Feature/Baselines/BaselineComparePreconditionsTest.php +++ b/tests/Feature/Baselines/BaselineComparePreconditionsTest.php @@ -6,6 +6,7 @@ use App\Models\BaselineTenantAssignment; use App\Models\OperationRun; use App\Services\Baselines\BaselineCompareService; +use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineReasonCodes; use App\Support\OperationRunType; use Illuminate\Support\Facades\Queue; @@ -34,7 +35,7 @@ $profile = BaselineProfile::factory()->create([ 'workspace_id' => $tenant->workspace_id, - 'status' => BaselineProfile::STATUS_DRAFT, + 'status' => BaselineProfileStatus::Draft->value, ]); BaselineTenantAssignment::create([ @@ -111,7 +112,7 @@ $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, - 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ]); $snapshot = BaselineSnapshot::factory()->create([ @@ -145,6 +146,48 @@ Queue::assertPushed(CompareBaselineToTenantJob::class); }); +it('uses an explicit snapshot override instead of baseline_profiles.active_snapshot_id when provided', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $activeSnapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $overrideSnapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $activeSnapshot->getKey()]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $service = app(BaselineCompareService::class); + $result = $service->startCompare($tenant, $user, baselineSnapshotId: (int) $overrideSnapshot->getKey()); + + expect($result['ok'])->toBeTrue(); + + /** @var OperationRun $run */ + $run = $result['run']; + $context = is_array($run->context) ? $run->context : []; + + expect($context['baseline_snapshot_id'])->toBe((int) $overrideSnapshot->getKey()); + + Queue::assertPushed(CompareBaselineToTenantJob::class); +}); + // --- EC-004: Concurrent compare reuses active run --- it('reuses an existing active run for the same profile/tenant instead of creating a new one [EC-004]', function () { diff --git a/tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php b/tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php index 4e19620..216c4e3 100644 --- a/tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php +++ b/tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php @@ -11,7 +11,6 @@ use App\Models\WorkspaceSetting; use App\Services\Baselines\BaselineAutoCloseService; use App\Services\Baselines\BaselineSnapshotIdentity; -use App\Services\Drift\DriftHasher; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Support\OperationRunOutcome; @@ -46,6 +45,11 @@ function runBaselineCompareForSnapshot( BaselineProfile $profile, BaselineSnapshot $snapshot, ): OperationRun { + createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + $operationRuns = app(OperationRunService::class); $run = $operationRuns->ensureRunWithIdentity( @@ -62,7 +66,6 @@ function runBaselineCompareForSnapshot( $job = new CompareBaselineToTenantJob($run); $job->handle( - app(DriftHasher::class), app(BaselineSnapshotIdentity::class), app(AuditLogger::class), $operationRuns, diff --git a/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php b/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php new file mode 100644 index 0000000..360009c --- /dev/null +++ b/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php @@ -0,0 +1,73 @@ +active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + Livewire::actingAs($user) + ->test(ListBaselineProfiles::class) + ->assertTableActionExists('archive', fn (Action $action): bool => $action->isConfirmationRequired(), $profile); +}); + +it('disables archive for workspace members missing workspace_baselines.manage', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + Livewire::actingAs($user) + ->test(ListBaselineProfiles::class) + ->assertTableActionVisible('archive', $profile) + ->assertTableActionDisabled('archive', $profile) + ->assertTableActionExists('archive', fn (Action $action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $profile) + ->callTableAction('archive', $profile); + + $profile->refresh(); + expect($profile->status)->toBe(BaselineProfileStatus::Active); +}); + +it('archives baseline profiles for authorized workspace members', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + Livewire::actingAs($user) + ->test(ListBaselineProfiles::class) + ->assertTableActionVisible('archive', $profile) + ->assertTableActionEnabled('archive', $profile) + ->callTableAction('archive', $profile) + ->assertHasNoTableActionErrors() + ->assertTableActionHidden('archive', $profile->fresh()); + + $profile->refresh(); + expect($profile->status)->toBe(BaselineProfileStatus::Archived); +}); + +it('does not show workspace-owned baseline profiles from other workspaces in the list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $otherWorkspace = Workspace::factory()->create(); + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + ]); + + Livewire::actingAs($user) + ->test(ListBaselineProfiles::class) + ->assertCanNotSeeTableRecords([$profile]); +}); diff --git a/tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php b/tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php index dd6e6a0..5bad3b4 100644 --- a/tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php +++ b/tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php @@ -1,6 +1,13 @@ assertSet('baselineFinishedAt', $baseline->finished_at->toDateTimeString()) ->assertSet('currentFinishedAt', $current->finished_at->toDateTimeString()); }); + +test('drift landing exposes baseline compare coverage + fidelity context when available', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $scopeKey = hash('sha256', 'scope-landing-comparison-info'); + + createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'status' => 'success', + 'finished_at' => now()->subDays(2), + ]); + + createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'status' => 'success', + 'finished_at' => now()->subDay(), + ]); + + $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(), + ]); + + $compareRun = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'completed_at' => now()->subMinutes(5), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => [], + 'uncovered_types' => ['deviceConfiguration'], + 'proof' => false, + ], + 'fidelity' => 'meta', + ], + ], + ]); + + Livewire::test(DriftLanding::class) + ->assertSet('baselineCompareRunId', (int) $compareRun->getKey()) + ->assertSet('baselineCompareCoverageStatus', 'unproven') + ->assertSet('baselineCompareFidelity', 'meta') + ->assertSet('baselineCompareUncoveredTypesCount', 1); +}); diff --git a/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php b/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php index c28dbb0..6ab94d1 100644 --- a/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php +++ b/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php @@ -7,11 +7,65 @@ use App\Models\BaselineSnapshot; use App\Models\BaselineTenantAssignment; use App\Models\OperationRun; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; +use App\Support\OperationRunType; use App\Support\OpsUx\OpsUxBrowserEvents; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; +it('redirects unauthenticated users (302)', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->get(BaselineCompareLanding::getUrl(tenant: $tenant, panel: 'tenant')) + ->assertStatus(302); +}); + +it('returns 404 for authenticated users not entitled to the tenant', function (): void { + [$member, $tenant] = createUserWithTenant(role: 'owner'); + $nonMember = \App\Models\User::factory()->create(); + + $this->actingAs($nonMember) + ->get(BaselineCompareLanding::getUrl(tenant: $tenant, panel: 'tenant')) + ->assertNotFound(); +}); + +it('does not start baseline compare for members missing tenant.sync', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $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(), + ]); + + Livewire::test(BaselineCompareLanding::class) + ->assertActionVisible('compareNow') + ->assertActionDisabled('compareNow') + ->callAction('compareNow') + ->assertStatus(200); + + Queue::assertNotPushed(CompareBaselineToTenantJob::class); +}); + it('dispatches ops-ux run-enqueued after starting baseline compare', function (): void { Queue::fake(); @@ -65,3 +119,112 @@ ->call('refreshStats') ->assertStatus(200); }); + +it('exposes full coverage + fidelity context in stats', 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(), + ]); + + $compareRun = 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' => [ + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => [], + 'proof' => true, + ], + 'fidelity' => 'meta', + ], + ], + ]); + + Livewire::test(BaselineCompareLanding::class) + ->call('refreshStats') + ->assertSet('operationRunId', (int) $compareRun->getKey()) + ->assertSet('coverageStatus', 'ok') + ->assertSet('uncoveredTypesCount', 0) + ->assertSet('fidelity', 'meta'); +}); + +it('exposes coverage warning context in stats', 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(), + ]); + + $compareRun = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'coverage' => [ + 'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => ['deviceCompliancePolicy'], + 'proof' => true, + ], + 'fidelity' => 'meta', + ], + ], + ]); + + Livewire::test(BaselineCompareLanding::class) + ->call('refreshStats') + ->assertSet('operationRunId', (int) $compareRun->getKey()) + ->assertSet('coverageStatus', 'warning') + ->assertSet('uncoveredTypesCount', 1) + ->assertSet('uncoveredTypes', ['deviceCompliancePolicy']) + ->assertSet('fidelity', 'meta'); +}); diff --git a/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php b/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php new file mode 100644 index 0000000..4852546 --- /dev/null +++ b/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php @@ -0,0 +1,94 @@ +active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $this->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')) + ->assertStatus(302); +}); + +it('returns 404 for authenticated users accessing a baseline profile from another workspace', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + [$otherUser, $otherTenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $otherTenant->workspace_id); + + $this->actingAs($otherUser) + ->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')) + ->assertNotFound(); +}); + +it('does not start capture for workspace members missing workspace_baselines.manage', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(ViewBaselineProfile::class, ['record' => $profile->getKey()]) + ->assertActionVisible('capture') + ->assertActionDisabled('capture') + ->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()]) + ->assertStatus(200); + + Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); +}); + +it('starts capture successfully for authorized workspace members', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(ViewBaselineProfile::class, ['record' => $profile->getKey()]) + ->assertActionVisible('capture') + ->assertActionEnabled('capture') + ->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()]) + ->assertStatus(200); + + Queue::assertPushed(CaptureBaselineSnapshotJob::class); + + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'baseline_capture') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('queued'); +}); diff --git a/tests/Feature/Guards/ActionSurfaceContractTest.php b/tests/Feature/Guards/ActionSurfaceContractTest.php index 8189b42..d8008cd 100644 --- a/tests/Feature/Guards/ActionSurfaceContractTest.php +++ b/tests/Feature/Guards/ActionSurfaceContractTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Filament\Resources\BaselineProfileResource; +use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems; use App\Filament\Resources\OperationRunResource; @@ -10,6 +11,7 @@ use App\Filament\Resources\PolicyResource\Pages\ListPolicies; use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; use App\Jobs\SyncPoliciesJob; +use App\Models\BaselineProfile; use App\Models\InventoryItem; use App\Models\OperationRun; use App\Models\Tenant; @@ -18,6 +20,7 @@ use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition; use App\Support\Ui\ActionSurface\ActionSurfaceValidator; use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Actions\ActionGroup; use Filament\Actions\BulkActionGroup; use Filament\Facades\Filament; @@ -92,6 +95,54 @@ } }); +it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $declaration = BaselineProfileResource::actionSurfaceDeclaration(); + $details = $declaration->slot(ActionSurfaceSlot::ListRowMoreMenu)?->details; + + expect($details)->toBeString(); + expect($details)->toContain('archive'); + + $this->actingAs($user); + + $livewire = Livewire::test(ListBaselineProfiles::class) + ->assertCanSeeTableRecords([$profile]); + + $table = $livewire->instance()->getTable(); + + $rowActions = $table->getActions(); + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + + expect($moreGroup)->toBeInstanceOf(ActionGroup::class); + expect($moreGroup?->getLabel())->toBe('More'); + + $primaryRowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toContain('view'); + expect($primaryRowActionNames)->not->toContain('archive'); + + $primaryRowActionCount = count($primaryRowActionNames); + expect($primaryRowActionCount)->toBeLessThanOrEqual(2); + + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($moreActionNames)->toContain('archive'); +}); + it('ensures representative declarations satisfy required slots', function (): void { $profiles = new ActionSurfaceProfileDefinition; diff --git a/tests/Feature/Guards/Spec116OneEngineGuardTest.php b/tests/Feature/Guards/Spec116OneEngineGuardTest.php new file mode 100644 index 0000000..0473f28 --- /dev/null +++ b/tests/Feature/Guards/Spec116OneEngineGuardTest.php @@ -0,0 +1,24 @@ +toBeString(); + expect($compareJob)->toContain('hashItemContent'); + expect($compareJob)->not->toContain('->fingerprint('); + expect($compareJob)->not->toContain('::fingerprint('); + + $captureJob = file_get_contents(base_path('app/Jobs/CaptureBaselineSnapshotJob.php')); + expect($captureJob)->toBeString(); + expect($captureJob)->toContain('InventoryMetaContract'); + expect($captureJob)->toContain('hashItemContent'); + expect($captureJob)->not->toContain('->fingerprint('); + expect($captureJob)->not->toContain('::fingerprint('); + + $identity = file_get_contents(base_path('app/Services/Baselines/BaselineSnapshotIdentity.php')); + expect($identity)->toBeString(); + expect($identity)->toContain('InventoryMetaContract'); + expect($identity)->toContain('hashNormalized'); + expect($identity)->not->toContain('fingerprint('); +}); diff --git a/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php b/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php index 15117b8..e9a80a5 100644 --- a/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php +++ b/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php @@ -29,10 +29,14 @@ Filament::setTenant($tenant, true); $sync = app(InventorySyncService::class); - $policyTypes = $sync->defaultSelectionPayload()['policy_types'] ?? []; + $policyTypes = array_slice($sync->defaultSelectionPayload()['policy_types'] ?? [], 0, 2); Livewire::test(ListInventoryItems::class) - ->callAction('run_inventory_sync', data: ['policy_types' => $policyTypes]); + ->callAction('run_inventory_sync', data: [ + 'policy_types' => $policyTypes, + 'include_foundations' => false, + 'include_dependencies' => false, + ]); $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) @@ -49,10 +53,52 @@ expect(collect($notifications)->last()['actions'][0]['url'] ?? null) ->toBe(OperationRunLinks::view($opRun, $tenant)); - Queue::assertPushed(RunInventorySyncJob::class, function (RunInventorySyncJob $job) use ($tenant, $user, $opRun): bool { + $capturedJob = null; + + Queue::assertPushed(RunInventorySyncJob::class, function (RunInventorySyncJob $job) use (&$capturedJob, $tenant, $user, $opRun): bool { + $capturedJob = $job; + return $job->tenantId === (int) $tenant->getKey() && $job->userId === (int) $user->getKey() && $job->operationRun instanceof OperationRun && (int) $job->operationRun->getKey() === (int) $opRun?->getKey(); }); + + expect($capturedJob)->toBeInstanceOf(RunInventorySyncJob::class); + + $mockSync = \Mockery::mock(InventorySyncService::class); + $mockSync + ->shouldReceive('executeSelection') + ->once() + ->andReturnUsing(function (OperationRun $operationRun, $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed): array { + $policyTypes = $selectionPayload['policy_types'] ?? []; + $policyTypes = is_array($policyTypes) ? array_values(array_filter(array_map('strval', $policyTypes))) : []; + + foreach ($policyTypes as $policyType) { + $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null); + } + + return [ + 'status' => 'success', + 'had_errors' => false, + 'error_codes' => [], + 'error_context' => [], + 'errors_count' => 0, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'processed_policy_types' => $policyTypes, + 'failed_policy_types' => [], + 'skipped_policy_types' => [], + ]; + }); + + $capturedJob->handle($mockSync, app(\App\Services\Intune\AuditLogger::class), app(\App\Services\OperationRunService::class)); + + $opRun->refresh(); + + $context = is_array($opRun->context) ? $opRun->context : []; + $coverage = $context['inventory']['coverage']['policy_types'] ?? null; + + expect($coverage)->toBeArray(); + expect(array_keys($coverage))->toEqualCanonicalizing($policyTypes); }); diff --git a/tests/Pest.php b/tests/Pest.php index 2c817c5..61339e9 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -200,6 +200,41 @@ function createInventorySyncOperationRun(Tenant $tenant, array $attributes = []) ->create($attributes); } +/** + * Create a completed inventory sync run with coverage proof in context. + * + * @param array $statusByType Example: ['deviceConfiguration' => 'succeeded'] + * @param list $foundationTypes + * @param array $attributes + */ +function createInventorySyncOperationRunWithCoverage( + Tenant $tenant, + array $statusByType, + array $foundationTypes = [], + array $attributes = [], +): \App\Models\OperationRun { + $context = is_array($attributes['context'] ?? null) ? $attributes['context'] : []; + $inventory = is_array($context['inventory'] ?? null) ? $context['inventory'] : []; + + $inventory['coverage'] = \App\Support\Inventory\InventoryCoverage::buildPayload( + statusByType: $statusByType, + foundationTypes: $foundationTypes, + ); + + $context['inventory'] = $inventory; + $attributes['context'] = $context; + + if (! array_key_exists('finished_at', $attributes) && ! array_key_exists('completed_at', $attributes)) { + $attributes['finished_at'] = now(); + } + + $attributes['type'] ??= \App\Support\OperationRunType::InventorySync->value; + $attributes['status'] ??= \App\Support\OperationRunStatus::Completed->value; + $attributes['outcome'] ??= \App\Support\OperationRunOutcome::Succeeded->value; + + return createInventorySyncOperationRun($tenant, $attributes); +} + /** * @return array{0: User, 1: Tenant} */ diff --git a/tests/Unit/Baselines/BaselineScopeTest.php b/tests/Unit/Baselines/BaselineScopeTest.php new file mode 100644 index 0000000..8221e57 --- /dev/null +++ b/tests/Unit/Baselines/BaselineScopeTest.php @@ -0,0 +1,49 @@ +set('tenantpilot.supported_policy_types', [ + ['type' => 'deviceConfiguration', 'label' => 'Device Configuration'], + ['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'], + ]); + + config()->set('tenantpilot.foundation_types', [ + ['type' => 'assignmentFilter', 'label' => 'Assignment Filter'], + ]); + + $scope = BaselineScope::fromJsonb([ + 'policy_types' => [], + 'foundation_types' => [], + ])->expandDefaults(); + + expect($scope->policyTypes)->toBe([ + 'deviceCompliancePolicy', + 'deviceConfiguration', + ]); + + expect($scope->foundationTypes)->toBe([]); + expect($scope->allTypes())->toBe([ + 'deviceCompliancePolicy', + 'deviceConfiguration', + ]); +}); + +it('filters unknown types and does not allow foundations inside policy_types', function () { + config()->set('tenantpilot.supported_policy_types', [ + ['type' => 'deviceConfiguration'], + ]); + + config()->set('tenantpilot.foundation_types', [ + ['type' => 'assignmentFilter'], + ]); + + $scope = BaselineScope::fromJsonb([ + 'policy_types' => ['deviceConfiguration', 'assignmentFilter', 'unknown'], + 'foundation_types' => ['assignmentFilter', 'unknown'], + ])->expandDefaults(); + + expect($scope->policyTypes)->toBe(['deviceConfiguration']); + expect($scope->foundationTypes)->toBe(['assignmentFilter']); + expect($scope->allTypes())->toBe(['assignmentFilter', 'deviceConfiguration']); +}); diff --git a/tests/Unit/Baselines/InventoryMetaContractTest.php b/tests/Unit/Baselines/InventoryMetaContractTest.php new file mode 100644 index 0000000..fbe64a8 --- /dev/null +++ b/tests/Unit/Baselines/InventoryMetaContractTest.php @@ -0,0 +1,59 @@ +build( + policyType: 'deviceConfiguration', + subjectExternalId: 'policy-a', + metaJsonb: [ + 'etag' => 'E1', + 'odata_type' => '#microsoft.graph.deviceConfiguration', + 'scope_tag_ids' => ['2', '1'], + 'assignment_target_count' => 3, + ], + ); + + $b = $builder->build( + policyType: 'deviceConfiguration', + subjectExternalId: 'policy-a', + metaJsonb: [ + 'assignment_target_count' => 3, + 'scope_tag_ids' => ['1', '2'], + 'odata_type' => '#microsoft.graph.deviceConfiguration', + 'etag' => 'E1', + ], + ); + + expect($a)->toBe($b); +}); + +it('represents missing signals as null (not omitted)', function () { + $builder = app(InventoryMetaContract::class); + + $contract = $builder->build( + policyType: 'deviceConfiguration', + subjectExternalId: 'policy-a', + metaJsonb: [], + ); + + expect($contract)->toHaveKeys([ + 'version', + 'policy_type', + 'subject_external_id', + 'odata_type', + 'etag', + 'scope_tag_ids', + 'assignment_target_count', + ]); + + expect($contract['version'])->toBe(1); + expect($contract['policy_type'])->toBe('deviceConfiguration'); + expect($contract['subject_external_id'])->toBe('policy-a'); + expect($contract['odata_type'])->toBeNull(); + expect($contract['etag'])->toBeNull(); + expect($contract['scope_tag_ids'])->toBeNull(); + expect($contract['assignment_target_count'])->toBeNull(); +});