throwable instanceof Throwable) { throw $this->throwable; } if (in_array($policyType, $this->failedTypes, true)) { return new GraphResponse(false, [], 403, ['error' => ['code' => 'Forbidden', 'message' => 'forbidden']], [], []); } return new GraphResponse(true, $this->policiesByType[$policyType] ?? []); } public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse { return new GraphResponse(true, []); } public function getOrganization(array $options = []): GraphResponse { return new GraphResponse(true, []); } public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse { return new GraphResponse(true, []); } public function getServicePrincipalPermissions(array $options = []): GraphResponse { return new GraphResponse(true, []); } public function request(string $method, string $path, array $options = []): GraphResponse { return new GraphResponse(true, []); } }; } /** * Executes an inventory sync against a canonical OperationRun. * * @param array $selection * @return array{opRun: \App\Models\OperationRun, result: array, selection: array, selection_hash: string} */ function executeInventorySyncNow(Tenant $tenant, array $selection): array { $service = app(InventorySyncService::class); $opService = app(OperationRunService::class); $defaultConnection = ProviderConnection::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('provider', 'microsoft') ->where('is_default', true) ->first(); if (! $defaultConnection instanceof ProviderConnection) { $defaultConnection = ProviderConnection::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'provider' => 'microsoft', 'entra_tenant_id' => $tenant->tenant_id, 'is_default' => true, 'status' => 'ok', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $defaultConnection->getKey(), 'type' => 'client_secret', 'payload' => [ 'client_id' => 'test-client-id', 'client_secret' => 'test-client-secret', ], ]); } $computed = $service->normalizeAndHashSelection($selection); $context = array_merge($computed['selection'], [ 'selection_hash' => $computed['selection_hash'], ]); $opRun = $opService->ensureRunWithIdentity( tenant: $tenant, type: 'inventory_sync', identityInputs: [ 'selection_hash' => $computed['selection_hash'], ], context: $context, initiator: null, ); $result = $service->executeSelection($opRun, $tenant, $context); $status = (string) ($result['status'] ?? 'failed'); $outcome = match ($status) { 'success' => OperationRunOutcome::Succeeded->value, 'partial' => OperationRunOutcome::PartiallySucceeded->value, default => OperationRunOutcome::Failed->value, }; $opService->updateRun( $opRun, status: OperationRunStatus::Completed->value, outcome: $outcome, summaryCounts: [ 'total' => count($computed['selection']['policy_types'] ?? []), ], ); return [ 'opRun' => $opRun->refresh(), 'result' => $result, 'selection' => $computed['selection'], 'selection_hash' => $computed['selection_hash'], ]; } test('inventory sync upserts and updates last_seen fields without duplicates', function () { $tenant = Tenant::factory()->create(); app()->instance(GraphClientInterface::class, fakeGraphClient([ 'deviceConfiguration' => [ ['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'], ], ])); $selection = [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], 'include_foundations' => false, 'include_dependencies' => false, ]; $runA = executeInventorySyncNow($tenant, $selection); expect($runA['result']['status'] ?? null)->toBe('success'); $item = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->first(); expect($item)->not->toBeNull(); expect($item->external_id)->toBe('cfg-1'); expect($item->last_seen_operation_run_id)->toBe($runA['opRun']->id); $runB = executeInventorySyncNow($tenant, $selection); $items = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->get(); expect($items)->toHaveCount(1); $items->first()->refresh(); expect($items->first()->last_seen_operation_run_id)->toBe($runB['opRun']->id); }); test('inventory sync includes foundation types when include_foundations is true', function () { $tenant = Tenant::factory()->create(); app()->instance(GraphClientInterface::class, fakeGraphClient([ 'deviceConfiguration' => [], 'roleScopeTag' => [ ['id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag'], ], 'assignmentFilter' => [ ['id' => 'filter-1', 'displayName' => 'Filter 1', '@odata.type' => '#microsoft.graph.assignmentFilter'], ], 'notificationMessageTemplate' => [ ['id' => 'tmpl-1', 'displayName' => 'Template 1', '@odata.type' => '#microsoft.graph.notificationMessageTemplate'], ], ])); $run = executeInventorySyncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => true, 'include_dependencies' => false, ]); expect($run['result']['status'] ?? null)->toBe('success'); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) ->where('policy_type', 'roleScopeTag') ->where('external_id', 'tag-1') ->where('category', 'Foundations') ->exists())->toBeTrue(); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) ->where('policy_type', 'assignmentFilter') ->where('external_id', 'filter-1') ->where('category', 'Foundations') ->exists())->toBeTrue(); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) ->where('policy_type', 'notificationMessageTemplate') ->where('external_id', 'tmpl-1') ->where('category', 'Foundations') ->exists())->toBeTrue(); }); test('inventory sync does not sync foundation types when include_foundations is false', function () { $tenant = Tenant::factory()->create(); app()->instance(GraphClientInterface::class, fakeGraphClient([ 'roleScopeTag' => [ ['id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag'], ], ])); $run = executeInventorySyncNow($tenant, [ 'policy_types' => ['roleScopeTag'], 'categories' => [], 'include_foundations' => false, 'include_dependencies' => false, ]); expect($run['result']['status'] ?? null)->toBe('success'); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) ->where('policy_type', 'roleScopeTag') ->exists())->toBeFalse(); }); test('foundation inventory items store sanitized meta_jsonb after sync (no payload dump)', function () { $tenant = Tenant::factory()->create(); app()->instance(GraphClientInterface::class, fakeGraphClient([ 'deviceConfiguration' => [], 'roleScopeTag' => [ [ 'id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag', 'veryLargePayload' => str_repeat('x', 10_000), 'client_secret' => 'should-not-end-up-anywhere', ], ], ])); $run = executeInventorySyncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => true, 'include_dependencies' => false, ]); expect($run['result']['status'] ?? null)->toBe('success'); $foundationItem = \App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) ->where('policy_type', 'roleScopeTag') ->where('external_id', 'tag-1') ->first(); expect($foundationItem)->not->toBeNull(); $stored = is_array($foundationItem->meta_jsonb) ? $foundationItem->meta_jsonb : []; $sanitizer = app(InventoryMetaSanitizer::class); expect($stored)->toBe($sanitizer->sanitize($stored)); expect(json_encode($stored))->not->toContain('Bearer '); expect(json_encode($stored))->not->toContain('should-not-end-up-anywhere'); expect(json_encode($stored))->not->toContain(str_repeat('x', 200)); }); test('inventory sync run counts include foundations when enabled and exclude them when disabled (deterministic)', function () { $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); app()->instance(GraphClientInterface::class, fakeGraphClient([ 'deviceConfiguration' => [ [ 'id' => 'pol-1', 'displayName' => 'Policy 1', '@odata.type' => '#microsoft.graph.deviceConfiguration', ], ], 'roleScopeTag' => [ ['id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag'], ], 'assignmentFilter' => [ ['id' => 'filter-1', 'displayName' => 'Filter 1', '@odata.type' => '#microsoft.graph.assignmentFilter'], ], 'notificationMessageTemplate' => [ [ 'id' => 'tmpl-1', 'displayName' => 'Template 1', '@odata.type' => '#microsoft.graph.notificationMessageTemplate', ], ], ])); $runA = executeInventorySyncNow($tenantA, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => true, 'include_dependencies' => false, ]); expect($runA['result']['status'] ?? null)->toBe('success'); expect((int) ($runA['result']['items_observed_count'] ?? 0))->toBe(4); expect((int) ($runA['result']['items_upserted_count'] ?? 0))->toBe(4); $runB = executeInventorySyncNow($tenantB, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => false, 'include_dependencies' => false, ]); expect($runB['result']['status'] ?? null)->toBe('success'); expect((int) ($runB['result']['items_observed_count'] ?? 0))->toBe(1); expect((int) ($runB['result']['items_upserted_count'] ?? 0))->toBe(1); }); test('configuration policy inventory filtering: settings catalog is not stored as security baseline', function () { $tenant = Tenant::factory()->create(); $settingsCatalogLookalike = [ 'id' => 'pol-1', 'name' => 'Windows 11 SettingsCatalog-Test', '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', 'technologies' => ['mdm'], 'templateReference' => [ 'templateDisplayName' => 'Windows Security Baseline (name only)', ], ]; $securityBaseline = [ 'id' => 'pol-2', 'name' => 'Baseline Policy', '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', 'templateReference' => [ 'templateFamily' => 'securityBaseline', ], ]; app()->instance(GraphClientInterface::class, fakeGraphClient([ 'settingsCatalogPolicy' => [$settingsCatalogLookalike, $securityBaseline], 'securityBaselinePolicy' => [$settingsCatalogLookalike, $securityBaseline], ])); $selection = [ 'policy_types' => ['settingsCatalogPolicy', 'securityBaselinePolicy'], 'categories' => ['Configuration', 'Endpoint Security'], 'include_foundations' => false, 'include_dependencies' => false, ]; executeInventorySyncNow($tenant, $selection); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) ->where('policy_type', 'securityBaselinePolicy') ->where('external_id', 'pol-1') ->exists())->toBeFalse(); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) ->where('policy_type', 'settingsCatalogPolicy') ->where('external_id', 'pol-1') ->exists())->toBeTrue(); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) ->where('policy_type', 'securityBaselinePolicy') ->where('external_id', 'pol-2') ->exists())->toBeTrue(); }); test('meta whitelist drops unknown keys without failing', function () { $tenant = Tenant::factory()->create(); $sanitizer = app(InventoryMetaSanitizer::class); $meta = $sanitizer->sanitize([ 'odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'W/\"123\"', 'scope_tag_ids' => ['0', 'tag-1'], 'assignment_target_count' => '5', 'warnings' => ['ok'], 'unknown_key' => 'should_not_persist', ]); $item = \App\Models\InventoryItem::query()->create([ 'tenant_id' => $tenant->id, 'policy_type' => 'deviceConfiguration', 'external_id' => 'cfg-1', 'display_name' => 'Config 1', 'meta_jsonb' => $meta, 'last_seen_at' => now(), 'last_seen_operation_run_id' => null, ]); $item->refresh(); $stored = is_array($item->meta_jsonb) ? $item->meta_jsonb : []; expect($stored)->not->toHaveKey('unknown_key'); expect($stored['assignment_target_count'] ?? null)->toBe(5); }); test('inventory missing is derived from latest completed run and low confidence on partial runs', function () { $tenant = Tenant::factory()->create(); $selection = [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], 'include_foundations' => false, 'include_dependencies' => false, ]; app()->instance(GraphClientInterface::class, fakeGraphClient([ 'deviceConfiguration' => [ ['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'], ], ])); executeInventorySyncNow($tenant, $selection); app()->instance(GraphClientInterface::class, fakeGraphClient([ 'deviceConfiguration' => [], ])); executeInventorySyncNow($tenant, $selection); $missingService = app(InventoryMissingService::class); $result = $missingService->missingForSelection($tenant, $selection); expect($result['missing'])->toHaveCount(1); expect($result['lowConfidence'])->toBeFalse(); app()->instance(GraphClientInterface::class, fakeGraphClient([ 'deviceConfiguration' => [], ], failedTypes: ['deviceConfiguration'])); executeInventorySyncNow($tenant, $selection); $result2 = $missingService->missingForSelection($tenant, $selection); expect($result2['missing'])->toHaveCount(1); expect($result2['lowConfidence'])->toBeTrue(); }); test('selection isolation: run for selection Y does not affect selection X missing', function () { $tenant = Tenant::factory()->create(); $selectionX = [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], 'include_foundations' => false, 'include_dependencies' => false, ]; $selectionY = [ 'policy_types' => ['deviceCompliancePolicy'], 'categories' => ['Compliance'], 'include_foundations' => false, 'include_dependencies' => false, ]; app()->instance(GraphClientInterface::class, fakeGraphClient([ 'deviceConfiguration' => [ ['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'], ], 'deviceCompliancePolicy' => [ ['id' => 'cmp-1', 'displayName' => 'Compliance 1', '@odata.type' => '#microsoft.graph.deviceCompliancePolicy'], ], ])); executeInventorySyncNow($tenant, $selectionX); executeInventorySyncNow($tenant, $selectionY); $missingService = app(InventoryMissingService::class); $resultX = $missingService->missingForSelection($tenant, $selectionX); expect($resultX['missing'])->toHaveCount(0); }); test('lock prevents overlapping runs for same tenant and selection', function () { $tenant = Tenant::factory()->create(); app()->instance(GraphClientInterface::class, fakeGraphClient([ 'deviceConfiguration' => [], ])); $selection = [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], 'include_foundations' => false, 'include_dependencies' => false, ]; $hash = app(\App\Services\Inventory\InventorySelectionHasher::class)->hash($selection); $lock = Cache::lock("inventory_sync:tenant:{$tenant->id}:selection:{$hash}", 900); expect($lock->get())->toBeTrue(); $run = executeInventorySyncNow($tenant, $selection); expect($run['result']['status'] ?? null)->toBe('skipped'); $codes = is_array($run['result']['error_codes'] ?? null) ? $run['result']['error_codes'] : []; expect($codes)->toContain('lock_contended'); $lock->release(); }); test('inventory sync does not create snapshot or backup rows', function () { $tenant = Tenant::factory()->create(); $baseline = [ 'policy_versions' => PolicyVersion::query()->count(), 'backup_sets' => BackupSet::query()->count(), 'backup_items' => BackupItem::query()->count(), 'backup_schedules' => BackupSchedule::query()->count(), ]; app()->instance(GraphClientInterface::class, fakeGraphClient([ 'deviceConfiguration' => [], ])); executeInventorySyncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], 'include_foundations' => false, 'include_dependencies' => false, ]); expect(PolicyVersion::query()->count())->toBe($baseline['policy_versions']); expect(BackupSet::query()->count())->toBe($baseline['backup_sets']); expect(BackupItem::query()->count())->toBe($baseline['backup_items']); expect(BackupSchedule::query()->count())->toBe($baseline['backup_schedules']); }); test('run error persistence is safe and does not include bearer tokens', function () { $tenant = Tenant::factory()->create(); $throwable = new RuntimeException('Graph failed: Bearer abc.def.ghi'); app()->instance(GraphClientInterface::class, fakeGraphClient(throwable: $throwable)); $run = executeInventorySyncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], 'include_foundations' => false, 'include_dependencies' => false, ]); expect($run['result']['status'] ?? null)->toBe('failed'); $context = is_array($run['result']['error_context'] ?? null) ? $run['result']['error_context'] : []; $message = (string) ($context['message'] ?? ''); expect($message)->not->toContain('abc.def.ghi'); expect($message)->toContain('Bearer [REDACTED]'); });