diff --git a/app/Services/Intune/ConfigurationPolicyTemplateResolver.php b/app/Services/Intune/ConfigurationPolicyTemplateResolver.php new file mode 100644 index 0000000..3432c59 --- /dev/null +++ b/app/Services/Intune/ConfigurationPolicyTemplateResolver.php @@ -0,0 +1,388 @@ +> + */ + private array $templateCache = []; + + /** + * @var array,reason:?string}>> + */ + private array $familyCache = []; + + /** + * @var array,reason:?string}>> + */ + private array $templateDefinitionCache = []; + + public function __construct( + private readonly GraphClientInterface $graphClient, + ) {} + + /** + * @param array $templateReference + * @param array $graphOptions + * @return array{success:bool,template_id:?string,template_reference:?array,reason:?string,warnings:array} + */ + public function resolveTemplateReference(Tenant $tenant, array $templateReference, array $graphOptions = []): array + { + $warnings = []; + + $templateId = $this->extractString($templateReference, ['templateId', 'TemplateId']); + $templateFamily = $this->extractString($templateReference, ['templateFamily', 'TemplateFamily']); + $templateDisplayName = $this->extractString($templateReference, ['templateDisplayName', 'TemplateDisplayName']); + $templateDisplayVersion = $this->extractString($templateReference, ['templateDisplayVersion', 'TemplateDisplayVersion']); + + if ($templateId !== null) { + $templateOutcome = $this->getTemplate($tenant, $templateId, $graphOptions); + + if ($templateOutcome['success']) { + return [ + 'success' => true, + 'template_id' => $templateId, + 'template_reference' => $templateReference, + 'reason' => null, + 'warnings' => $warnings, + ]; + } + + if ($templateFamily === null) { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => $templateOutcome['reason'] ?? "Template '{$templateId}' is not available in the tenant.", + 'warnings' => $warnings, + ]; + } + } + + if ($templateFamily === null) { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => 'Template reference is missing templateFamily and cannot be resolved.', + 'warnings' => $warnings, + ]; + } + + $listOutcome = $this->listTemplatesByFamily($tenant, $templateFamily, $graphOptions); + + if (! $listOutcome['success']) { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => $listOutcome['reason'] ?? "Unable to list templates for family '{$templateFamily}'.", + 'warnings' => $warnings, + ]; + } + + $candidates = $this->chooseTemplateCandidate( + templates: $listOutcome['templates'], + templateDisplayName: $templateDisplayName, + templateDisplayVersion: $templateDisplayVersion, + ); + + if (count($candidates) !== 1) { + $reason = count($candidates) === 0 + ? "No templates found for family '{$templateFamily}'." + : "Multiple templates found for family '{$templateFamily}' (cannot resolve automatically)."; + + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => $reason, + 'warnings' => $warnings, + ]; + } + + $candidate = $candidates[0]; + $resolvedId = is_array($candidate) ? ($candidate['id'] ?? null) : null; + + if (! is_string($resolvedId) || $resolvedId === '') { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => "Template candidate for family '{$templateFamily}' is missing an id.", + 'warnings' => $warnings, + ]; + } + + if ($templateId !== null && $templateId !== $resolvedId) { + $warnings[] = sprintf("TemplateId '%s' not found; mapped to '%s' via templateFamily.", $templateId, $resolvedId); + } + + $templateReference['templateId'] = $resolvedId; + + if (! isset($templateReference['templateDisplayName']) && isset($candidate['displayName'])) { + $templateReference['templateDisplayName'] = $candidate['displayName']; + } + + if (! isset($templateReference['templateDisplayVersion']) && isset($candidate['displayVersion'])) { + $templateReference['templateDisplayVersion'] = $candidate['displayVersion']; + } + + return [ + 'success' => true, + 'template_id' => $resolvedId, + 'template_reference' => $templateReference, + 'reason' => null, + 'warnings' => $warnings, + ]; + } + + /** + * @param array $graphOptions + * @return array{success:bool,template:?array,reason:?string} + */ + public function getTemplate(Tenant $tenant, string $templateId, array $graphOptions = []): array + { + $tenantKey = $this->tenantKey($tenant, $graphOptions); + + if (isset($this->templateCache[$tenantKey][$templateId])) { + return $this->templateCache[$tenantKey][$templateId]; + } + + $context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform'])); + $path = sprintf('/deviceManagement/configurationPolicyTemplates/%s', urlencode($templateId)); + $response = $this->graphClient->request('GET', $path, $context); + + if ($response->failed()) { + return $this->templateCache[$tenantKey][$templateId] = [ + 'success' => false, + 'template' => null, + 'reason' => $response->meta['error_message'] ?? 'Template lookup failed.', + ]; + } + + return $this->templateCache[$tenantKey][$templateId] = [ + 'success' => true, + 'template' => $response->data, + 'reason' => null, + ]; + } + + /** + * @param array $graphOptions + * @return array{success:bool,templates:array,reason:?string} + */ + public function listTemplatesByFamily(Tenant $tenant, string $templateFamily, array $graphOptions = []): array + { + $tenantKey = $this->tenantKey($tenant, $graphOptions); + $cacheKey = strtolower($templateFamily); + + if (isset($this->familyCache[$tenantKey][$cacheKey])) { + return $this->familyCache[$tenantKey][$cacheKey]; + } + + $escapedFamily = str_replace("'", "''", $templateFamily); + + $context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']), [ + 'query' => [ + '$filter' => "templateFamily eq '{$escapedFamily}'", + '$top' => 999, + ], + ]); + + $response = $this->graphClient->request('GET', '/deviceManagement/configurationPolicyTemplates', $context); + + if ($response->failed()) { + return $this->familyCache[$tenantKey][$cacheKey] = [ + 'success' => false, + 'templates' => [], + 'reason' => $response->meta['error_message'] ?? 'Template list failed.', + ]; + } + + $value = $response->data['value'] ?? []; + $templates = is_array($value) ? array_values(array_filter($value, static fn ($item) => is_array($item))) : []; + + return $this->familyCache[$tenantKey][$cacheKey] = [ + 'success' => true, + 'templates' => $templates, + 'reason' => null, + ]; + } + + /** + * @param array $graphOptions + * @return array{success:bool,definition_ids:array,reason:?string} + */ + public function fetchTemplateSettingDefinitionIds(Tenant $tenant, string $templateId, array $graphOptions = []): array + { + $tenantKey = $this->tenantKey($tenant, $graphOptions); + + if (isset($this->templateDefinitionCache[$tenantKey][$templateId])) { + return $this->templateDefinitionCache[$tenantKey][$templateId]; + } + + $context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']), [ + 'query' => [ + '$expand' => 'settingDefinitions', + '$top' => 999, + ], + ]); + + $path = sprintf('/deviceManagement/configurationPolicyTemplates/%s/settingTemplates', urlencode($templateId)); + $response = $this->graphClient->request('GET', $path, $context); + + if ($response->failed()) { + return $this->templateDefinitionCache[$tenantKey][$templateId] = [ + 'success' => false, + 'definition_ids' => [], + 'reason' => $response->meta['error_message'] ?? 'Template definitions lookup failed.', + ]; + } + + $value = $response->data['value'] ?? []; + $templates = is_array($value) ? $value : []; + $definitionIds = []; + + foreach ($templates as $settingTemplate) { + if (! is_array($settingTemplate)) { + continue; + } + + $definitions = $settingTemplate['settingDefinitions'] ?? null; + + if (! is_array($definitions)) { + continue; + } + + foreach ($definitions as $definition) { + if (! is_array($definition)) { + continue; + } + + $id = $definition['id'] ?? null; + + if (is_string($id) && $id !== '') { + $definitionIds[] = $id; + } + } + } + + $definitionIds = array_values(array_unique($definitionIds)); + + return $this->templateDefinitionCache[$tenantKey][$templateId] = [ + 'success' => true, + 'definition_ids' => $definitionIds, + 'reason' => null, + ]; + } + + /** + * @param array $settings + * @return array + */ + public function extractSettingDefinitionIds(array $settings): array + { + $ids = []; + + $walk = function (mixed $node) use (&$walk, &$ids): void { + if (! is_array($node)) { + return; + } + + foreach ($node as $key => $value) { + if (is_string($key) && strtolower($key) === 'settingdefinitionid' && is_string($value) && $value !== '') { + $ids[] = $value; + } + + $walk($value); + } + }; + + $walk($settings); + + return array_values(array_unique($ids)); + } + + /** + * @param array $templates + * @return array + */ + private function chooseTemplateCandidate(array $templates, ?string $templateDisplayName, ?string $templateDisplayVersion): array + { + $candidates = $templates; + + $active = array_values(array_filter($candidates, static function (array $template): bool { + $state = $template['lifecycleState'] ?? null; + + return is_string($state) && strtolower($state) === 'active'; + })); + + if ($active !== []) { + $candidates = $active; + } + + if ($templateDisplayVersion !== null) { + $byVersion = array_values(array_filter($candidates, static function (array $template) use ($templateDisplayVersion): bool { + $version = $template['displayVersion'] ?? null; + + return is_string($version) && $version === $templateDisplayVersion; + })); + + if ($byVersion !== []) { + $candidates = $byVersion; + } + } + + if ($templateDisplayName !== null) { + $byName = array_values(array_filter($candidates, static function (array $template) use ($templateDisplayName): bool { + $name = $template['displayName'] ?? null; + + return is_string($name) && $name === $templateDisplayName; + })); + + if ($byName !== []) { + $candidates = $byName; + } + } + + return $candidates; + } + + /** + * @param array $payload + * @param array $keys + */ + private function extractString(array $payload, array $keys): ?string + { + $normalized = array_map('strtolower', $keys); + + foreach ($payload as $key => $value) { + if (! is_string($key) || ! in_array(strtolower($key), $normalized, true)) { + continue; + } + + if (is_string($value) && trim($value) !== '') { + return $value; + } + } + + return null; + } + + /** + * @param array $graphOptions + */ + private function tenantKey(Tenant $tenant, array $graphOptions): string + { + $tenantId = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + + return (string) $tenantId; + } +} diff --git a/app/Services/Intune/RestoreRiskChecker.php b/app/Services/Intune/RestoreRiskChecker.php index 35383a4..b2e2136 100644 --- a/app/Services/Intune/RestoreRiskChecker.php +++ b/app/Services/Intune/RestoreRiskChecker.php @@ -16,6 +16,7 @@ class RestoreRiskChecker { public function __construct( private readonly GroupResolver $groupResolver, + private readonly ConfigurationPolicyTemplateResolver $templateResolver, ) {} /** @@ -40,6 +41,7 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem $results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping); $results[] = $this->checkMetadataOnlySnapshots($policyItems); $results[] = $this->checkPreviewOnlyPolicies($policyItems); + $results[] = $this->checkEndpointSecurityTemplates($tenant, $policyItems); $results[] = $this->checkMissingPolicies($tenant, $policyItems); $results[] = $this->checkStalePolicies($tenant, $policyItems); $results[] = $this->checkMissingScopeTagsInScope($items, $policyItems, $selectedItemIds !== null); @@ -229,6 +231,91 @@ private function checkPreviewOnlyPolicies(Collection $policyItems): ?array ]; } + /** + * Validate that Endpoint Security policy templates referenced by snapshots exist in the tenant. + * + * @param Collection $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|null + */ + private function checkEndpointSecurityTemplates(Tenant $tenant, Collection $policyItems): ?array + { + $issues = []; + $hasRestoreEnabled = false; + $graphOptions = $tenant->graphOptions(); + + foreach ($policyItems as $item) { + if ($item->policy_type !== 'endpointSecurityPolicy') { + continue; + } + + $restoreMode = $this->resolveRestoreMode($item->policy_type); + + if ($restoreMode !== 'preview-only') { + $hasRestoreEnabled = true; + } + + $payload = is_array($item->payload) ? $item->payload : []; + $templateReference = $payload['templateReference'] ?? null; + + if (is_string($templateReference)) { + $decoded = json_decode($templateReference, true); + $templateReference = is_array($decoded) ? $decoded : null; + } + + if (! is_array($templateReference)) { + $issues[] = [ + 'backup_item_id' => $item->id, + 'policy_identifier' => $item->policy_identifier, + 'label' => $item->resolvedDisplayName(), + 'reason' => 'Missing templateReference in snapshot.', + ]; + + continue; + } + + $outcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions); + + if (! ($outcome['success'] ?? false)) { + $issues[] = [ + 'backup_item_id' => $item->id, + 'policy_identifier' => $item->policy_identifier, + 'label' => $item->resolvedDisplayName(), + 'template_id' => $templateReference['templateId'] ?? null, + 'template_family' => $templateReference['templateFamily'] ?? null, + 'reason' => $outcome['reason'] ?? 'Template could not be resolved in the tenant.', + ]; + } + } + + if ($issues === []) { + return [ + 'code' => 'endpoint_security_templates', + 'severity' => 'safe', + 'title' => 'Endpoint security templates', + 'message' => 'All referenced Endpoint Security templates are available.', + 'meta' => [ + 'count' => 0, + ], + ]; + } + + $severity = $hasRestoreEnabled ? 'blocking' : 'warning'; + $message = $hasRestoreEnabled + ? 'Some Endpoint Security templates are missing or cannot be resolved in the tenant.' + : 'Some Endpoint Security templates are missing or cannot be resolved (execution is preview-only).'; + + return [ + 'code' => 'endpoint_security_templates', + 'severity' => $severity, + 'title' => 'Endpoint security templates', + 'message' => $message, + 'meta' => [ + 'count' => count($issues), + 'items' => $this->truncateList($issues, 10), + ], + ]; + } + /** * Detect snapshots that were captured as metadata-only. * diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index e75040a..3b20271 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -27,6 +27,7 @@ public function __construct( private readonly VersionService $versionService, private readonly SnapshotValidator $snapshotValidator, private readonly GraphContractRegistry $contracts, + private readonly ConfigurationPolicyTemplateResolver $templateResolver, private readonly AssignmentRestoreService $assignmentRestoreService, private readonly FoundationMappingService $foundationMappingService, ) {} @@ -430,12 +431,13 @@ public function execute( $createdPolicyMode = null; $settingsApplyEligible = false; - if ($item->policy_type === 'settingsCatalogPolicy') { + if (in_array($item->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy'], true)) { + $policyType = $item->policy_type; $settings = $this->extractSettingsCatalogSettings($originalPayload); $policyPayload = $this->stripSettingsFromPayload($payload); $response = $this->graphClient->applyPolicy( - $item->policy_type, + $policyType, $item->policy_identifier, $policyPayload, $graphOptions + ['method' => $updateMethod] @@ -443,8 +445,19 @@ public function execute( $settingsApplyEligible = $response->successful(); - if ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) { + if ($response->failed() && $this->shouldAttemptPolicyCreate($policyType, $response)) { + if ($policyType === 'endpointSecurityPolicy') { + $originalPayload = $this->prepareEndpointSecurityPolicyForCreate( + tenant: $tenant, + originalPayload: $originalPayload, + settings: $settings, + graphOptions: $graphOptions, + context: $context, + ); + } + $createOutcome = $this->createSettingsCatalogPolicy( + policyType: $policyType, originalPayload: $originalPayload, settings: $settings, graphOptions: $graphOptions, @@ -488,6 +501,7 @@ public function execute( if ($settingsApplyEligible && $settings !== []) { [$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings( + policyType: $policyType, policyId: $item->policy_identifier, settings: $settings, graphOptions: $graphOptions, @@ -496,7 +510,18 @@ public function execute( if ($itemStatus === 'manual_required' && $settingsApply !== null && $this->shouldAttemptSettingsCatalogCreate($settingsApply)) { + if ($policyType === 'endpointSecurityPolicy') { + $originalPayload = $this->prepareEndpointSecurityPolicyForCreate( + tenant: $tenant, + originalPayload: $originalPayload, + settings: $settings, + graphOptions: $graphOptions, + context: $context, + ); + } + $createOutcome = $this->createSettingsCatalogPolicy( + policyType: $policyType, originalPayload: $originalPayload, settings: $settings, graphOptions: $graphOptions, @@ -539,14 +564,6 @@ public function execute( ]; } } - } elseif ($settingsApplyEligible && $settings !== []) { - $settingsApply = [ - 'total' => count($settings), - 'applied' => 0, - 'failed' => count($settings), - 'manual_required' => 0, - 'issues' => [], - ]; } } else { if ($item->policy_type === 'appProtectionPolicy') { @@ -1508,15 +1525,16 @@ private function resolveSettingsCatalogSettingId(array $setting): ?string * @return array{0: array{total:int,applied:int,failed:int,manual_required:int,issues:array>}, 1: string} */ private function applySettingsCatalogPolicySettings( + string $policyType, string $policyId, array $settings, array $graphOptions, array $context, ): array { - $method = $this->contracts->settingsWriteMethod('settingsCatalogPolicy'); - $path = $this->contracts->settingsWritePath('settingsCatalogPolicy', $policyId); - $bodyShape = strtolower($this->contracts->settingsWriteBodyShape('settingsCatalogPolicy')); - $fallbackShape = $this->contracts->settingsWriteFallbackBodyShape('settingsCatalogPolicy'); + $method = $this->contracts->settingsWriteMethod($policyType); + $path = $this->contracts->settingsWritePath($policyType, $policyId); + $bodyShape = strtolower($this->contracts->settingsWriteBodyShape($policyType)); + $fallbackShape = $this->contracts->settingsWriteFallbackBodyShape($policyType); $buildIssues = function (string $reason) use ($settings): array { $issues = []; @@ -1549,7 +1567,7 @@ private function applySettingsCatalogPolicySettings( ]; } - $sanitized = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings); + $sanitized = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings); if (! is_array($sanitized) || $sanitized === []) { return [ @@ -1683,14 +1701,15 @@ private function shouldAttemptSettingsCatalogCreate(array $settingsApply): bool * @return array{success:bool,policy_id:?string,response:?object,mode:string} */ private function createSettingsCatalogPolicy( + string $policyType, array $originalPayload, array $settings, array $graphOptions, array $context, string $fallbackName, ): array { - $resource = $this->contracts->resourcePath('settingsCatalogPolicy') ?? 'deviceManagement/configurationPolicies'; - $sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings); + $resource = $this->contracts->resourcePath($policyType) ?? 'deviceManagement/configurationPolicies'; + $sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings); if ($sanitizedSettings === []) { return [ @@ -1747,6 +1766,79 @@ private function createSettingsCatalogPolicy( ]; } + /** + * @param array $originalPayload + * @param array $settings + * @param array $graphOptions + * @param array $context + * @return array + */ + private function prepareEndpointSecurityPolicyForCreate( + Tenant $tenant, + array $originalPayload, + array $settings, + array $graphOptions, + array $context, + ): array { + $templateReference = $this->resolvePayloadArray($originalPayload, ['templateReference', 'TemplateReference']); + + if (! is_array($templateReference)) { + throw new \RuntimeException('Endpoint Security policy snapshot is missing templateReference and cannot be restored safely.'); + } + + $templateOutcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions); + + if (! ($templateOutcome['success'] ?? false)) { + $reason = $templateOutcome['reason'] ?? 'Endpoint Security template is not available in the tenant.'; + + throw new \RuntimeException($reason); + } + + $resolvedTemplateId = $templateOutcome['template_id'] ?? null; + $resolvedReference = $templateOutcome['template_reference'] ?? $templateReference; + + if (! is_string($resolvedTemplateId) || $resolvedTemplateId === '') { + throw new \RuntimeException('Endpoint Security template could not be resolved (missing template id).'); + } + + if (is_array($resolvedReference) && $resolvedReference !== []) { + $originalPayload['templateReference'] = $resolvedReference; + } + + if ($settings === []) { + return $originalPayload; + } + + $definitions = $this->templateResolver->fetchTemplateSettingDefinitionIds($tenant, $resolvedTemplateId, $graphOptions); + + if (! ($definitions['success'] ?? false)) { + return $originalPayload; + } + + $templateDefinitionIds = $definitions['definition_ids'] ?? []; + + if (! is_array($templateDefinitionIds) || $templateDefinitionIds === []) { + return $originalPayload; + } + + $policyDefinitionIds = $this->templateResolver->extractSettingDefinitionIds($settings); + $missing = array_values(array_diff($policyDefinitionIds, $templateDefinitionIds)); + + if ($missing === []) { + return $originalPayload; + } + + $sample = implode(', ', array_slice($missing, 0, 5)); + $suffix = count($missing) > 5 ? sprintf(' (and %d more)', count($missing) - 5) : ''; + + throw new \RuntimeException(sprintf( + 'Endpoint Security settings do not match the resolved template (%s). Missing setting definitions: %s%s', + $resolvedTemplateId, + $sample, + $suffix, + )); + } + /** * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} */ diff --git a/config/graph_contracts.php b/config/graph_contracts.php index ed0536f..cd1a970 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -143,6 +143,19 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'update_whitelist' => [ + 'name', + 'description', + ], + 'update_map' => [ + 'displayName' => 'name', + ], + 'update_strip_keys' => [ + 'platforms', + 'technologies', + 'templateReference', + 'assignments', + ], 'member_hydration_strategy' => 'subresource_settings', 'subresources' => [ 'settings' => [ @@ -153,6 +166,13 @@ 'allowed_expand' => [], ], ], + 'settings_write' => [ + 'path_template' => 'deviceManagement/configurationPolicies/{id}/settings', + 'method' => 'POST', + 'bulk' => true, + 'body_shape' => 'collection', + 'fallback_body_shape' => 'wrapped', + ], // Assignments CRUD (standard Graph pattern) 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', diff --git a/config/tenantpilot.php b/config/tenantpilot.php index e3d1ec9..1f6f205 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -192,7 +192,7 @@ 'platform' => 'windows', 'endpoint' => 'deviceManagement/configurationPolicies', 'backup' => 'full', - 'restore' => 'preview-only', + 'restore' => 'enabled', 'risk' => 'high', ], [ diff --git a/tests/Feature/EndpointSecurityPolicyRestore023Test.php b/tests/Feature/EndpointSecurityPolicyRestore023Test.php new file mode 100644 index 0000000..1a4821e --- /dev/null +++ b/tests/Feature/EndpointSecurityPolicyRestore023Test.php @@ -0,0 +1,265 @@ +}> */ + public array $applyPolicyCalls = []; + + /** @var array}> */ + public array $requestCalls = []; + + /** + * @param array $requestMap + */ + public function __construct( + private readonly GraphResponse $applyPolicyResponse, + private readonly array $requestMap = [], + ) {} + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return $this->applyPolicyResponse; + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requestCalls[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'options' => $options, + ]; + + foreach ($this->requestMap as $needle => $response) { + if (is_string($needle) && $needle !== '' && str_contains($path, $needle)) { + return $response; + } + } + + return new GraphResponse(true, []); + } +} + +test('restore executes endpoint security policy settings via settings endpoint', function () { + $client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, [])); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Endpoint Security Policy', + 'platforms' => ['windows10'], + 'technologies' => ['endpointSecurity'], + 'templateReference' => [ + 'templateId' => 'template-1', + 'templateFamily' => 'endpointSecurityFirewall', + 'templateDisplayName' => 'Windows Firewall Rules', + 'templateDisplayVersion' => 'Version 1', + ], + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($run->status)->toBe('completed'); + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policyType'])->toBe('endpointSecurityPolicy'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('settings'); + + $settingsCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/settings')) + ->values(); + + expect($settingsCalls)->toHaveCount(1); + expect($settingsCalls[0]['path'])->toContain('deviceManagement/configurationPolicies/esp-1/settings'); + + $body = $settingsCalls[0]['options']['json'] ?? null; + expect($body)->toBeArray()->not->toBeEmpty(); + expect($body[0]['settingInstance']['settingDefinitionId'] ?? null) + ->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring'); +}); + +test('restore fails when endpoint security template is missing', function () { + $applyNotFound = new GraphResponse(false, ['error' => ['message' => 'Not found']], 404, [], [], [ + 'error_code' => 'NotFound', + 'error_message' => 'Not found', + ]); + + $templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [ + 'error_code' => 'NotFound', + 'error_message' => 'Template missing', + ]); + + $client = new EndpointSecurityRestoreGraphClient($applyNotFound, [ + 'configurationPolicyTemplates' => $templateNotFound, + ]); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-missing', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-missing', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Endpoint Security Policy', + 'platforms' => ['windows10'], + 'technologies' => ['endpointSecurity'], + 'templateReference' => [ + 'templateId' => 'missing-template', + ], + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($run->status)->toBe('failed'); + + $createCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST' && $call['path'] === 'deviceManagement/configurationPolicies') + ->values(); + + expect($createCalls)->toHaveCount(0); +}); + +test('restore risk checks flag missing endpoint security templates as blocking', function () { + $templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [ + 'error_code' => 'NotFound', + 'error_message' => 'Template missing', + ]); + + $client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, []), [ + 'configurationPolicyTemplates' => $templateNotFound, + ]); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-missing', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-missing', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => [ + 'templateId' => 'missing-template', + 'templateFamily' => 'endpointSecurityFirewall', + ], + 'settings' => [], + ], + 'assignments' => null, + ]); + + $checker = app(RestoreRiskChecker::class); + $result = $checker->check( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + groupMapping: [], + ); + + $results = collect($result['results'] ?? []); + $templateCheck = $results->firstWhere('code', 'endpoint_security_templates'); + + expect($templateCheck)->not->toBeNull(); + expect($templateCheck['severity'] ?? null)->toBe('blocking'); +}); diff --git a/tests/Feature/PolicyTypes017Test.php b/tests/Feature/PolicyTypes017Test.php index 3c3813d..2d475d8 100644 --- a/tests/Feature/PolicyTypes017Test.php +++ b/tests/Feature/PolicyTypes017Test.php @@ -262,6 +262,6 @@ public function request(string $method, string $path, array $options = []): Grap $byType = collect($preview)->keyBy('policy_type'); expect($byType['mamAppConfiguration']['restore_mode'])->toBe('enabled'); - expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('preview-only'); + expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('enabled'); expect($byType['securityBaselinePolicy']['restore_mode'])->toBe('preview-only'); });