diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php index efb3e6d..c4e8989 100644 --- a/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php +++ b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php @@ -5,11 +5,17 @@ namespace App\Filament\Resources\BaselineProfileResource\Pages; use App\Filament\Resources\BaselineProfileResource; +use App\Models\BaselineProfile; +use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; +use App\Services\Baselines\BaselineCaptureService; use App\Support\Auth\Capabilities; use App\Support\Workspaces\WorkspaceContext; +use Filament\Actions\Action; use Filament\Actions\EditAction; +use Filament\Forms\Components\Select; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; class ViewBaselineProfile extends ViewRecord @@ -19,11 +25,95 @@ class ViewBaselineProfile extends ViewRecord protected function getHeaderActions(): array { return [ + $this->captureAction(), EditAction::make() ->visible(fn (): bool => $this->hasManageCapability()), ]; } + private function captureAction(): Action + { + return 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.') + ->form([ + Select::make('source_tenant_id') + ->label('Source Tenant') + ->options(fn (): array => $this->getWorkspaceTenantOptions()) + ->required() + ->searchable(), + ]) + ->action(function (array $data): void { + $user = auth()->user(); + + if (! $user instanceof User || ! $this->hasManageCapability()) { + Notification::make() + ->title('Permission denied') + ->danger() + ->send(); + + return; + } + + /** @var BaselineProfile $profile */ + $profile = $this->getRecord(); + $sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']); + + if (! $sourceTenant instanceof Tenant) { + Notification::make() + ->title('Source tenant not found') + ->danger() + ->send(); + + return; + } + + $service = app(BaselineCaptureService::class); + $result = $service->startCapture($profile, $sourceTenant, $user); + + if (! $result['ok']) { + Notification::make() + ->title('Cannot start capture') + ->body('Reason: ' . str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown'))) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title('Capture enqueued') + ->body('Baseline snapshot capture has been started.') + ->success() + ->send(); + }); + } + + /** + * @return array + */ + private function getWorkspaceTenantOptions(): array + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + return []; + } + + return Tenant::query() + ->where('workspace_id', $workspaceId) + ->orderBy('display_name') + ->pluck('display_name', 'id') + ->all(); + } + private function hasManageCapability(): bool { $user = auth()->user(); diff --git a/app/Jobs/CaptureBaselineSnapshotJob.php b/app/Jobs/CaptureBaselineSnapshotJob.php new file mode 100644 index 0000000..7909911 --- /dev/null +++ b/app/Jobs/CaptureBaselineSnapshotJob.php @@ -0,0 +1,285 @@ +operationRun = $run; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } + + public function handle( + BaselineSnapshotIdentity $identity, + AuditLogger $auditLogger, + OperationRunService $operationRunService, + ): void { + if (! $this->operationRun instanceof OperationRun) { + $this->fail(new RuntimeException('OperationRun context is required for CaptureBaselineSnapshotJob.')); + + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $profileId = (int) ($context['baseline_profile_id'] ?? 0); + $sourceTenantId = (int) ($context['source_tenant_id'] ?? 0); + + $profile = BaselineProfile::query()->find($profileId); + + if (! $profile instanceof BaselineProfile) { + throw new RuntimeException("BaselineProfile #{$profileId} not found."); + } + + $sourceTenant = Tenant::query()->find($sourceTenantId); + + if (! $sourceTenant instanceof Tenant) { + throw new RuntimeException("Source Tenant #{$sourceTenantId} not found."); + } + + $initiator = $this->operationRun->user_id + ? User::query()->find($this->operationRun->user_id) + : null; + + $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null); + + $this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator); + + $snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity); + + $identityHash = $identity->computeIdentity($snapshotItems); + + $snapshot = $this->findOrCreateSnapshot( + $profile, + $identityHash, + $snapshotItems, + ); + + $wasNewSnapshot = $snapshot->wasRecentlyCreated; + + if ($profile->status === BaselineProfile::STATUS_ACTIVE) { + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + } + + $summaryCounts = [ + 'total' => count($snapshotItems), + 'processed' => count($snapshotItems), + 'succeeded' => count($snapshotItems), + 'failed' => 0, + ]; + + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: $summaryCounts, + ); + + $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $updatedContext['result'] = [ + 'snapshot_id' => (int) $snapshot->getKey(), + 'snapshot_identity_hash' => $identityHash, + 'was_new_snapshot' => $wasNewSnapshot, + 'items_captured' => count($snapshotItems), + ]; + $this->operationRun->update(['context' => $updatedContext]); + + $this->auditCompleted($auditLogger, $sourceTenant, $profile, $snapshot, $initiator, $snapshotItems); + } + + /** + * @return array}> + */ + private function collectSnapshotItems( + Tenant $sourceTenant, + BaselineScope $scope, + BaselineSnapshotIdentity $identity, + ): array { + $query = InventoryItem::query() + ->where('tenant_id', $sourceTenant->getKey()); + + if (! $scope->isEmpty()) { + $query->whereIn('policy_type', $scope->policyTypes); + } + + $items = []; + + $query->orderBy('policy_type') + ->orderBy('external_id') + ->chunk(500, function ($inventoryItems) use (&$items, $identity): void { + foreach ($inventoryItems as $inventoryItem) { + $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; + $baselineHash = $identity->hashItemContent($metaJsonb); + + $items[] = [ + 'subject_type' => 'policy', + 'subject_external_id' => (string) $inventoryItem->external_id, + 'policy_type' => (string) $inventoryItem->policy_type, + 'baseline_hash' => $baselineHash, + 'meta_jsonb' => [ + 'display_name' => $inventoryItem->display_name, + 'category' => $inventoryItem->category, + 'platform' => $inventoryItem->platform, + ], + ]; + } + }); + + return $items; + } + + /** + * @param array}> $snapshotItems + */ + private function findOrCreateSnapshot( + BaselineProfile $profile, + string $identityHash, + array $snapshotItems, + ): BaselineSnapshot { + $existing = BaselineSnapshot::query() + ->where('workspace_id', $profile->workspace_id) + ->where('baseline_profile_id', $profile->getKey()) + ->where('snapshot_identity_hash', $identityHash) + ->first(); + + if ($existing instanceof BaselineSnapshot) { + return $existing; + } + + $snapshot = BaselineSnapshot::create([ + 'workspace_id' => (int) $profile->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'snapshot_identity_hash' => $identityHash, + 'captured_at' => now(), + 'summary_jsonb' => [ + 'total_items' => count($snapshotItems), + 'policy_type_counts' => $this->countByPolicyType($snapshotItems), + ], + ]); + + foreach (array_chunk($snapshotItems, 100) as $chunk) { + $rows = array_map( + fn (array $item): array => [ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => $item['subject_type'], + 'subject_external_id' => $item['subject_external_id'], + 'policy_type' => $item['policy_type'], + 'baseline_hash' => $item['baseline_hash'], + 'meta_jsonb' => json_encode($item['meta_jsonb']), + 'created_at' => now(), + 'updated_at' => now(), + ], + $chunk, + ); + + BaselineSnapshotItem::insert($rows); + } + + return $snapshot; + } + + /** + * @param array $items + * @return array + */ + private function countByPolicyType(array $items): array + { + $counts = []; + + foreach ($items as $item) { + $type = (string) $item['policy_type']; + $counts[$type] = ($counts[$type] ?? 0) + 1; + } + + ksort($counts); + + return $counts; + } + + private function auditStarted( + AuditLogger $auditLogger, + Tenant $tenant, + BaselineProfile $profile, + ?User $initiator, + ): void { + $auditLogger->log( + tenant: $tenant, + action: 'baseline.capture.started', + context: [ + 'metadata' => [ + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_profile_name' => (string) $profile->name, + ], + ], + actorId: $initiator?->id, + actorEmail: $initiator?->email, + actorName: $initiator?->name, + resourceType: 'baseline_profile', + resourceId: (string) $profile->getKey(), + ); + } + + private function auditCompleted( + AuditLogger $auditLogger, + Tenant $tenant, + BaselineProfile $profile, + BaselineSnapshot $snapshot, + ?User $initiator, + array $snapshotItems, + ): void { + $auditLogger->log( + tenant: $tenant, + action: 'baseline.capture.completed', + context: [ + 'metadata' => [ + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_profile_name' => (string) $profile->name, + 'snapshot_id' => (int) $snapshot->getKey(), + 'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash, + 'items_captured' => count($snapshotItems), + 'was_new_snapshot' => $snapshot->wasRecentlyCreated, + ], + ], + actorId: $initiator?->id, + actorEmail: $initiator?->email, + actorName: $initiator?->name, + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + ); + } +} diff --git a/app/Services/Baselines/BaselineCaptureService.php b/app/Services/Baselines/BaselineCaptureService.php new file mode 100644 index 0000000..f596176 --- /dev/null +++ b/app/Services/Baselines/BaselineCaptureService.php @@ -0,0 +1,79 @@ +validatePreconditions($profile, $sourceTenant); + + if ($precondition !== null) { + return ['ok' => false, 'reason_code' => $precondition]; + } + + $effectiveScope = BaselineScope::fromJsonb( + is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, + ); + + $context = [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $sourceTenant->getKey(), + 'effective_scope' => $effectiveScope->toJsonb(), + ]; + + $run = $this->runs->ensureRunWithIdentity( + tenant: $sourceTenant, + type: 'baseline_capture', + identityInputs: [ + 'baseline_profile_id' => (int) $profile->getKey(), + ], + context: $context, + initiator: $initiator, + ); + + if ($run->wasRecentlyCreated) { + CaptureBaselineSnapshotJob::dispatch($run); + } + + return ['ok' => true, 'run' => $run]; + } + + private function validatePreconditions(BaselineProfile $profile, Tenant $sourceTenant): ?string + { + if ($profile->status !== BaselineProfile::STATUS_ACTIVE) { + return BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE; + } + + if ($sourceTenant->workspace_id === null) { + return BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT; + } + + if ((int) $sourceTenant->workspace_id !== (int) $profile->workspace_id) { + return BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT; + } + + return null; + } +} diff --git a/app/Services/Baselines/BaselineSnapshotIdentity.php b/app/Services/Baselines/BaselineSnapshotIdentity.php new file mode 100644 index 0000000..c761f92 --- /dev/null +++ b/app/Services/Baselines/BaselineSnapshotIdentity.php @@ -0,0 +1,59 @@ + $items + */ + public function computeIdentity(array $items): string + { + if ($items === []) { + return hash('sha256', '[]'); + } + + $normalized = array_map( + fn (array $item): string => implode('|', [ + trim((string) ($item['subject_type'] ?? '')), + trim((string) ($item['subject_external_id'] ?? '')), + trim((string) ($item['policy_type'] ?? '')), + trim((string) ($item['baseline_hash'] ?? '')), + ]), + $items, + ); + + sort($normalized, SORT_STRING); + + return hash('sha256', implode("\n", $normalized)); + } + + /** + * Compute a stable content hash for a single inventory item's metadata. + * + * Strips volatile OData keys and normalizes for stable comparison. + */ + public function hashItemContent(mixed $metaJsonb): string + { + return $this->hasher->hashNormalized($metaJsonb); + } +} diff --git a/tests/Feature/Baselines/BaselineCaptureTest.php b/tests/Feature/Baselines/BaselineCaptureTest.php new file mode 100644 index 0000000..b9f69fc --- /dev/null +++ b/tests/Feature/Baselines/BaselineCaptureTest.php @@ -0,0 +1,349 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + /** @var BaselineCaptureService $service */ + $service = app(BaselineCaptureService::class); + $result = $service->startCapture($profile, $tenant, $user); + + expect($result['ok'])->toBeTrue(); + expect($result)->toHaveKey('run'); + + /** @var OperationRun $run */ + $run = $result['run']; + expect($run->type)->toBe('baseline_capture'); + expect($run->status)->toBe('queued'); + expect($run->tenant_id)->toBe((int) $tenant->getKey()); + + $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()); + + Queue::assertPushed(CaptureBaselineSnapshotJob::class); +}); + +it('rejects capture for a draft profile with reason code', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'status' => BaselineProfile::STATUS_DRAFT, + ]); + + $service = app(BaselineCaptureService::class); + $result = $service->startCapture($profile, $tenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe('baseline.capture.profile_not_active'); + + Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); + expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); +}); + +it('rejects capture for an archived profile with reason code', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->archived()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + + $service = app(BaselineCaptureService::class); + $result = $service->startCapture($profile, $tenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe('baseline.capture.profile_not_active'); + + Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); + expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); +}); + +it('rejects capture for a tenant from a different workspace', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + [$otherUser, $otherTenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + + $service = app(BaselineCaptureService::class); + $result = $service->startCapture($profile, $otherTenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe('baseline.capture.missing_source_tenant'); + + Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); +}); + +// --- T032: Concurrent capture reuses active run [EC-004] --- + +it('reuses an existing active run for the same profile/tenant instead of creating a new one', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $service = app(BaselineCaptureService::class); + + $result1 = $service->startCapture($profile, $tenant, $user); + $result2 = $service->startCapture($profile, $tenant, $user); + + expect($result1['ok'])->toBeTrue(); + expect($result2['ok'])->toBeTrue(); + + expect($result1['run']->getKey())->toBe($result2['run']->getKey()); + expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(1); +}); + +// --- Snapshot dedupe + capture job execution --- + +it('creates a snapshot with items when the capture job executes', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + InventoryItem::factory()->count(3)->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration'], + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_capture', + 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']], + ], + initiator: $user, + ); + + $job = new CaptureBaselineSnapshotJob($run); + $job->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + expect((int) ($counts['total'] ?? 0))->toBe(3); + expect((int) ($counts['succeeded'] ?? 0))->toBe(3); + + $snapshot = BaselineSnapshot::query() + ->where('baseline_profile_id', $profile->getKey()) + ->first(); + + expect($snapshot)->not->toBeNull(); + expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(3); + + $profile->refresh(); + expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey()); +}); + +it('dedupes snapshots when content is unchanged', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + InventoryItem::factory()->count(2)->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'stable_field' => 'value'], + ]); + + $opService = app(OperationRunService::class); + $idService = app(BaselineSnapshotIdentity::class); + $auditLogger = app(AuditLogger::class); + + $run1 = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_capture', + 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']], + ], + initiator: $user, + ); + + $job1 = new CaptureBaselineSnapshotJob($run1); + $job1->handle($idService, $auditLogger, $opService); + + $snapshotCountAfterFirst = BaselineSnapshot::query() + ->where('baseline_profile_id', $profile->getKey()) + ->count(); + + expect($snapshotCountAfterFirst)->toBe(1); + + $run2 = OperationRun::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'baseline_capture', + 'status' => 'queued', + 'outcome' => 'pending', + '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']], + ], + ]); + + $job2 = new CaptureBaselineSnapshotJob($run2); + $job2->handle($idService, $auditLogger, $opService); + + $snapshotCountAfterSecond = BaselineSnapshot::query() + ->where('baseline_profile_id', $profile->getKey()) + ->count(); + + expect($snapshotCountAfterSecond)->toBe(1); +}); + +// --- EC-005: Empty scope produces empty snapshot without errors --- + +it('captures an empty snapshot when no inventory items match the scope', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType']], + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_capture', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $tenant->getKey(), + 'effective_scope' => ['policy_types' => ['nonExistentPolicyType']], + ], + initiator: $user, + ); + + $job = new CaptureBaselineSnapshotJob($run); + $job->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + expect((int) ($counts['total'] ?? 0))->toBe(0); + expect((int) ($counts['failed'] ?? 0))->toBe(0); + + $snapshot = BaselineSnapshot::query() + ->where('baseline_profile_id', $profile->getKey()) + ->first(); + + expect($snapshot)->not->toBeNull(); + expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(0); +}); + +it('captures all inventory items when scope has empty policy_types (all types)', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => []], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'policy_type' => 'deviceConfiguration', + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'policy_type' => 'compliancePolicy', + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_capture', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $tenant->getKey(), + 'effective_scope' => ['policy_types' => []], + ], + initiator: $user, + ); + + $job = new CaptureBaselineSnapshotJob($run); + $job->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + expect((int) ($counts['total'] ?? 0))->toBe(2); + + $snapshot = BaselineSnapshot::query() + ->where('baseline_profile_id', $profile->getKey()) + ->first(); + + expect($snapshot)->not->toBeNull(); + expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(2); +});