From 4d5f6eb79b38af352b800950763320be301b17cf Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 12:03:04 +0100 Subject: [PATCH] feat: enrollment config subtypes (027) --- .../EnrollmentAutopilotPolicyNormalizer.php | 213 ++++++++++++++++++ app/Services/Intune/PolicySyncService.php | 94 +++++++- .../Concerns/InteractsWithODataTypes.php | 9 + config/graph_contracts.php | 49 +++- config/tenantpilot.php | 31 +++ specs/027-enrollment-config-subtypes/tasks.md | 19 +- ...EnrollmentAutopilotSettingsDisplayTest.php | 10 +- .../EnrollmentRestrictionsPreviewOnlyTest.php | 100 ++++++++ ...rollmentConfigurationTypeCollisionTest.php | 107 ++++++++- .../VersionCaptureWithAssignmentsTest.php | 70 ++++++ tests/Unit/PolicyNormalizerTest.php | 22 +- 11 files changed, 687 insertions(+), 37 deletions(-) diff --git a/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php index 9177392..cc85041 100644 --- a/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php +++ b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php @@ -15,6 +15,9 @@ public function supports(string $policyType): bool 'windowsAutopilotDeploymentProfile', 'windowsEnrollmentStatusPage', 'enrollmentRestriction', + 'deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentNotificationConfiguration', ], true); } @@ -34,6 +37,18 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor $warnings[] = 'Restore is preview-only for Enrollment Restrictions.'; } + if ($policyType === 'deviceEnrollmentLimitConfiguration') { + $warnings[] = 'Restore is preview-only for Enrollment Limits.'; + } + + if ($policyType === 'deviceEnrollmentPlatformRestrictionsConfiguration') { + $warnings[] = 'Restore is preview-only for Platform Restrictions.'; + } + + if ($policyType === 'deviceEnrollmentNotificationConfiguration') { + $warnings[] = 'Restore is preview-only for Enrollment Notifications.'; + } + $generalEntries = [ ['key' => 'Type', 'value' => $policyType], ]; @@ -68,6 +83,9 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor 'windowsAutopilotDeploymentProfile' => $this->buildAutopilotBlock($snapshot), 'windowsEnrollmentStatusPage' => $this->buildEnrollmentStatusPageBlock($snapshot), 'enrollmentRestriction' => $this->buildEnrollmentRestrictionBlock($snapshot), + 'deviceEnrollmentLimitConfiguration' => $this->buildEnrollmentLimitBlock($snapshot), + 'deviceEnrollmentPlatformRestrictionsConfiguration' => $this->buildEnrollmentPlatformRestrictionsBlock($snapshot), + 'deviceEnrollmentNotificationConfiguration' => $this->buildEnrollmentNotificationBlock($snapshot), default => null, }; @@ -319,6 +337,201 @@ private function buildEnrollmentRestrictionBlock(array $snapshot): ?array ]; } + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentLimitBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + 'limit' => 'Enrollment limit', + 'limitType' => 'Limit type', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment limits', + 'entries' => $entries, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentPlatformRestrictionsBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'platformType' => 'Platform type', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } + } + + $platformPayload = Arr::get($snapshot, 'platformRestrictions') ?? Arr::get($snapshot, 'platformRestriction'); + if (is_array($platformPayload) && $platformPayload !== []) { + $prefix = (string) (Arr::get($snapshot, 'platformType') ?: 'Platform'); + $this->appendPlatformRestrictionEntries($entries, $prefix, $platformPayload); + } + + $typedRestrictions = [ + 'androidForWorkRestriction' => 'Android work profile', + 'androidRestriction' => 'Android', + 'iosRestriction' => 'iOS/iPadOS', + 'macRestriction' => 'macOS', + 'windowsRestriction' => 'Windows', + ]; + + foreach ($typedRestrictions as $key => $prefix) { + $restriction = Arr::get($snapshot, $key); + + if (! is_array($restriction) || $restriction === []) { + continue; + } + + $this->appendPlatformRestrictionEntries($entries, $prefix, $restriction); + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Platform restrictions (enrollment)', + 'entries' => $entries, + ]; + } + + /** + * @param array $entries + */ + private function appendPlatformRestrictionEntries(array &$entries, string $prefix, array $payload): void + { + $payload = Arr::except($payload, ['@odata.type']); + + $platformBlocked = Arr::get($payload, 'platformBlocked'); + if (is_bool($platformBlocked)) { + $entries[] = ['key' => "{$prefix}: Platform blocked", 'value' => $platformBlocked ? 'Enabled' : 'Disabled']; + } + + $personalBlocked = Arr::get($payload, 'personalDeviceEnrollmentBlocked'); + if (is_bool($personalBlocked)) { + $entries[] = ['key' => "{$prefix}: Personal device enrollment blocked", 'value' => $personalBlocked ? 'Enabled' : 'Disabled']; + } + + $osMin = Arr::get($payload, 'osMinimumVersion'); + $entries[] = [ + 'key' => "{$prefix}: OS minimum version", + 'value' => (is_string($osMin) && $osMin !== '') ? $osMin : 'None', + ]; + + $osMax = Arr::get($payload, 'osMaximumVersion'); + $entries[] = [ + 'key' => "{$prefix}: OS maximum version", + 'value' => (is_string($osMax) && $osMax !== '') ? $osMax : 'None', + ]; + + $blockedManufacturers = Arr::get($payload, 'blockedManufacturers'); + $entries[] = [ + 'key' => "{$prefix}: Blocked manufacturers", + 'value' => (is_array($blockedManufacturers) && $blockedManufacturers !== []) + ? array_values($blockedManufacturers) + : ['None'], + ]; + + $blockedSkus = Arr::get($payload, 'blockedSkus'); + $entries[] = [ + 'key' => "{$prefix}: Blocked SKUs", + 'value' => (is_array($blockedSkus) && $blockedSkus !== []) + ? array_values($blockedSkus) + : ['None'], + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentNotificationBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + 'defaultLocale' => 'Default locale', + 'notificationMessageTemplateId' => 'Notification message template ID', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $notificationMessages = Arr::get($snapshot, 'notificationMessages'); + if (is_array($notificationMessages) && $notificationMessages !== []) { + $entries[] = ['key' => 'Notification messages', 'value' => sprintf('%d item(s)', count($notificationMessages))]; + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment notifications', + 'entries' => $entries, + ]; + } + /** * @return array */ diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index 1f87f19..ec42c31 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -167,7 +167,15 @@ private function resolveCanonicalPolicyType(string $policyType, array $policyDat return $this->resolveConfigurationPolicyType($policyData); } - if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { + $enrollmentConfigurationTypes = [ + 'enrollmentRestriction', + 'windowsEnrollmentStatusPage', + 'deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentNotificationConfiguration', + ]; + + if (! in_array($policyType, $enrollmentConfigurationTypes, true)) { return $policyType; } @@ -175,6 +183,18 @@ private function resolveCanonicalPolicyType(string $policyType, array $policyDat return 'windowsEnrollmentStatusPage'; } + if ($this->isEnrollmentNotificationItem($policyData)) { + return 'deviceEnrollmentNotificationConfiguration'; + } + + if ($this->isEnrollmentLimitItem($policyData)) { + return 'deviceEnrollmentLimitConfiguration'; + } + + if ($this->isEnrollmentPlatformRestrictionsItem($policyData)) { + return 'deviceEnrollmentPlatformRestrictionsConfiguration'; + } + return 'enrollmentRestriction'; } @@ -255,13 +275,77 @@ private function isEnrollmentStatusPageItem(array $policyData): bool || (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration'); } - private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void + private function isEnrollmentLimitItem(array $policyData): bool { - if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { - return; + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.deviceEnrollmentLimitConfiguration') === 0) + || (is_string($configurationType) && strcasecmp($configurationType, 'deviceEnrollmentLimitConfiguration') === 0); + } + + private function isEnrollmentPlatformRestrictionsItem(array $policyData): bool + { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + if (is_string($odataType) && $odataType !== '') { + $odataTypeKey = strtolower($odataType); + + if (in_array($odataTypeKey, [ + '#microsoft.graph.deviceenrollmentplatformrestrictionconfiguration', + '#microsoft.graph.deviceenrollmentplatformrestrictionsconfiguration', + ], true)) { + return true; + } } - $enrollmentTypes = ['enrollmentRestriction', 'windowsEnrollmentStatusPage']; + if (is_string($configurationType) && $configurationType !== '') { + $configurationTypeKey = strtolower($configurationType); + + if (in_array($configurationTypeKey, [ + 'deviceenrollmentplatformrestrictionconfiguration', + 'deviceenrollmentplatformrestrictionsconfiguration', + ], true)) { + return true; + } + } + + return false; + } + + private function isEnrollmentNotificationItem(array $policyData): bool + { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + if (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.deviceEnrollmentNotificationConfiguration') === 0) { + return true; + } + + if (! is_string($configurationType) || $configurationType === '') { + return false; + } + + return in_array(strtolower($configurationType), [ + 'enrollmentnotificationsconfiguration', + 'deviceenrollmentnotificationconfiguration', + ], true); + } + + private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void + { + $enrollmentTypes = [ + 'enrollmentRestriction', + 'windowsEnrollmentStatusPage', + 'deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentNotificationConfiguration', + ]; + + if (! in_array($policyType, $enrollmentTypes, true)) { + return; + } $existingCorrect = Policy::query() ->where('tenant_id', $tenantId) diff --git a/app/Support/Concerns/InteractsWithODataTypes.php b/app/Support/Concerns/InteractsWithODataTypes.php index 3a2d09b..a819b43 100644 --- a/app/Support/Concerns/InteractsWithODataTypes.php +++ b/app/Support/Concerns/InteractsWithODataTypes.php @@ -69,6 +69,15 @@ protected static function odataTypeMap(): array 'enrollmentRestriction' => [ 'all' => '#microsoft.graph.deviceEnrollmentConfiguration', ], + 'deviceEnrollmentLimitConfiguration' => [ + 'all' => '#microsoft.graph.deviceEnrollmentLimitConfiguration', + ], + 'deviceEnrollmentPlatformRestrictionsConfiguration' => [ + 'all' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', + ], + 'deviceEnrollmentNotificationConfiguration' => [ + 'all' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + ], 'windowsAutopilotDeploymentProfile' => [ 'windows' => '#microsoft.graph.windowsAutopilotDeploymentProfile', ], diff --git a/config/graph_contracts.php b/config/graph_contracts.php index c2b9c5c..315e92b 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -518,14 +518,29 @@ 'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', 'assignments_delete_method' => 'DELETE', ], - 'enrollmentRestriction' => [ + 'deviceEnrollmentLimitConfiguration' => [ + 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceEnrollmentLimitConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', + ], + 'deviceEnrollmentPlatformRestrictionsConfiguration' => [ 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', - '#microsoft.graph.deviceEnrollmentLimitConfiguration', ], 'create_method' => 'POST', 'update_method' => 'PATCH', @@ -536,6 +551,36 @@ 'assignments_create_method' => 'POST', 'assignments_payload_key' => 'enrollmentConfigurationAssignments', ], + 'deviceEnrollmentNotificationConfiguration' => [ + 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', + ], + 'enrollmentRestriction' => [ + 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], + 'allowed_expand' => [], + 'type_family' => [], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', + ], 'windowsAutopilotDeploymentProfile' => [ 'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], diff --git a/config/tenantpilot.php b/config/tenantpilot.php index f0f8062..19f97b4 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -185,6 +185,37 @@ 'restore' => 'enabled', 'risk' => 'medium', ], + [ + 'type' => 'deviceEnrollmentLimitConfiguration', + 'label' => 'Enrollment Limits', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], + [ + 'type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'label' => 'Platform Restrictions (Enrollment)', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], + [ + 'type' => 'deviceEnrollmentNotificationConfiguration', + 'label' => 'Enrollment Notifications', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'filter' => "deviceEnrollmentConfigurationType eq 'EnrollmentNotificationsConfiguration'", + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], [ 'type' => 'enrollmentRestriction', 'label' => 'Enrollment Restrictions', diff --git a/specs/027-enrollment-config-subtypes/tasks.md b/specs/027-enrollment-config-subtypes/tasks.md index 46d669c..f89bd79 100644 --- a/specs/027-enrollment-config-subtypes/tasks.md +++ b/specs/027-enrollment-config-subtypes/tasks.md @@ -12,17 +12,16 @@ ## Phase 2: Research & Design - [ ] T003 Decide restore modes and risk levels. ## Phase 3: Tests (TDD) -- [ ] T004 Add sync tests ensuring each subtype is classified correctly. -- [ ] T005 Add snapshot capture test for at least one subtype. -- [ ] T006 Add restore preview test ensuring preview-only behavior. +- [x] T004 Add sync tests ensuring each subtype is classified correctly. +- [x] T005 Add snapshot capture test for at least one subtype. +- [x] T006 Add restore preview test ensuring preview-only behavior. ## Phase 4: Implementation -- [ ] T007 Add new types to `config/tenantpilot.php`. -- [ ] T008 Add contracts in `config/graph_contracts.php` (resource + type families). -- [ ] T009 Update `PolicySyncService` enrollment classification logic. -- [ ] T010 Add normalizer for readable UI output (key fields per subtype). +- [x] T007 Add new types to `config/tenantpilot.php`. +- [x] T008 Add contracts in `config/graph_contracts.php` (resource + type families). +- [x] T009 Update `PolicySyncService` enrollment classification logic. +- [x] T010 Add normalizer for readable UI output (key fields per subtype). ## Phase 5: Verification -- [ ] T011 Run targeted tests. -- [ ] T012 Run Pint (`./vendor/bin/pint --dirty`). - +- [x] T011 Run targeted tests. +- [x] T012 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php index a35ada5..7ad9258 100644 --- a/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php +++ b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php @@ -109,11 +109,11 @@ $response->assertSee('app-2'); }); -test('policy detail renders normalized settings for enrollment restrictions', function () { +test('policy detail renders normalized settings for platform restrictions (enrollment)', function () { $policy = Policy::create([ 'tenant_id' => $this->tenant->id, 'external_id' => 'enroll-restrict-1', - 'policy_type' => 'enrollmentRestriction', + 'policy_type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', 'display_name' => 'Restriction A', 'platform' => 'all', ]); @@ -143,9 +143,9 @@ $response->assertOk(); $response->assertSee('Settings'); - $response->assertSee('Enrollment restrictions'); - $response->assertSee('Personal device enrollment blocked'); + $response->assertSee('Platform restrictions (enrollment)'); + $response->assertSee('Platform: Personal device enrollment blocked'); $response->assertSee('Enabled'); - $response->assertSee('Blocked SKUs'); + $response->assertSee('Platform: Blocked SKUs'); $response->assertSee('sku-1'); }); diff --git a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php index 051fcb0..4bf6b6c 100644 --- a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php +++ b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php @@ -109,3 +109,103 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->applyCalls)->toBe(0); }); + +test('enrollment limit restores are preview-only and skipped on execution', function () { + $client = new class implements GraphClientInterface + { + public int $applyCalls = 0; + + 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->applyCalls++; + + 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, []); + } + }; + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-enrollment-limit', + 'name' => 'Tenant Enrollment Limit', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enrollment-limit-1', + 'policy_type' => 'deviceEnrollmentLimitConfiguration', + 'display_name' => 'Enrollment Limit', + 'platform' => 'all', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Enrollment Limit Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentLimitConfiguration', + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + 'limit' => 5, + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet, [$backupItem->id]); + + $previewItem = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'deviceEnrollmentLimitConfiguration'); + + expect($previewItem)->not->toBeNull() + ->and($previewItem['restore_mode'] ?? null)->toBe('preview-only'); + + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: 'tester@example.com', + actorName: 'Tester', + ); + + expect($run->results)->toHaveCount(1); + expect($run->results[0]['status'])->toBe('skipped'); + expect($run->results[0]['reason'])->toBe('preview_only'); + + expect($client->applyCalls)->toBe(0); +}); diff --git a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php index d296e33..a21e3b3 100644 --- a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php +++ b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php @@ -106,7 +106,11 @@ $mock->shouldReceive('listPolicies') ->andReturnUsing(function (string $policyType) use ($payload) { - if (in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { + if (in_array($policyType, [ + 'enrollmentRestriction', + 'windowsEnrollmentStatusPage', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + ], true)) { return new GraphResponse(true, $payload); } @@ -122,6 +126,11 @@ 'platform' => 'all', 'filter' => null, ], + [ + 'type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'platform' => 'all', + 'filter' => null, + ], [ 'type' => 'enrollmentRestriction', 'platform' => 'all', @@ -142,6 +151,100 @@ ->pluck('external_id') ->all(); + $platformRestrictionIds = Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'deviceEnrollmentPlatformRestrictionsConfiguration') + ->orderBy('external_id') + ->pluck('external_id') + ->all(); + expect($espIds)->toMatchArray(['esp-1']); - expect($restrictionIds)->toMatchArray(['other-1', 'restriction-1']); + expect($platformRestrictionIds)->toMatchArray(['restriction-1']); + expect($restrictionIds)->toMatchArray(['other-1']); +}); + +test('policy sync classifies enrollment configuration subtypes separately', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-sync-enrollment-subtypes', + 'name' => 'Tenant Sync Enrollment Subtypes', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $this->mock(GraphClientInterface::class, function (MockInterface $mock) { + $limitPayload = [ + 'id' => 'limit-1', + 'displayName' => 'Enrollment Limit', + '@odata.type' => '#microsoft.graph.deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentLimitConfiguration', + 'limit' => 5, + ]; + + $platformRestrictionsPayload = [ + 'id' => 'platform-1', + 'displayName' => 'Platform Restrictions', + '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionsConfiguration', + ]; + + $notificationPayload = [ + 'id' => 'notify-1', + 'displayName' => 'Enrollment Notifications', + '@odata.type' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + 'deviceEnrollmentConfigurationType' => 'EnrollmentNotificationsConfiguration', + ]; + + $unfilteredPayload = [ + $limitPayload, + $platformRestrictionsPayload, + $notificationPayload, + ]; + + $mock->shouldReceive('listPolicies') + ->andReturnUsing(function (string $policyType) use ($notificationPayload, $unfilteredPayload) { + if ($policyType === 'deviceEnrollmentNotificationConfiguration') { + return new GraphResponse(true, [$notificationPayload]); + } + + if (in_array($policyType, [ + 'enrollmentRestriction', + 'deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'windowsEnrollmentStatusPage', + ], true)) { + return new GraphResponse(true, $unfilteredPayload); + } + + return new GraphResponse(true, []); + }); + }); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'deviceEnrollmentLimitConfiguration', 'platform' => 'all', 'filter' => null], + ['type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', 'platform' => 'all', 'filter' => null], + ['type' => 'deviceEnrollmentNotificationConfiguration', 'platform' => 'all', 'filter' => null], + ['type' => 'enrollmentRestriction', 'platform' => 'all', 'filter' => null], + ]); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'deviceEnrollmentLimitConfiguration') + ->pluck('external_id') + ->all())->toMatchArray(['limit-1']); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'deviceEnrollmentPlatformRestrictionsConfiguration') + ->pluck('external_id') + ->all())->toMatchArray(['platform-1']); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'deviceEnrollmentNotificationConfiguration') + ->pluck('external_id') + ->all())->toMatchArray(['notify-1']); }); diff --git a/tests/Feature/VersionCaptureWithAssignmentsTest.php b/tests/Feature/VersionCaptureWithAssignmentsTest.php index 2a1ebfb..2cc8455 100644 --- a/tests/Feature/VersionCaptureWithAssignmentsTest.php +++ b/tests/Feature/VersionCaptureWithAssignmentsTest.php @@ -98,6 +98,76 @@ expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); }); +it('captures enrollment limit configuration version with assignments from graph', function () { + $this->policy->forceFill([ + 'policy_type' => 'deviceEnrollmentLimitConfiguration', + 'platform' => 'all', + ])->save(); + + $this->mock(PolicySnapshotService::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentLimitConfiguration', + 'id' => 'test-policy-id', + 'displayName' => 'Enrollment Limit', + 'limit' => 5, + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->withArgs(function (string $policyType): bool { + return $policyType === 'deviceEnrollmentLimitConfiguration'; + }) + ->andReturn([ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + ], + ], + ]); + }); + + $this->mock(GroupResolver::class, function ($mock) { + $mock->shouldReceive('resolveGroupIds') + ->once() + ->andReturn([ + 'group-123' => [ + 'id' => 'group-123', + 'displayName' => 'Test Group', + 'orphaned' => false, + ], + ]); + }); + + $this->mock(AssignmentFilterResolver::class, function ($mock) { + $mock->shouldReceive('resolve') + ->once() + ->andReturn([]); + }); + + $versionService = app(VersionService::class); + $version = $versionService->captureFromGraph( + $this->tenant, + $this->policy, + 'test@example.com' + ); + + expect($version)->not->toBeNull() + ->and($version->policy_type)->toBe('deviceEnrollmentLimitConfiguration') + ->and($version->snapshot['@odata.type'] ?? null)->toBe('#microsoft.graph.deviceEnrollmentLimitConfiguration') + ->and($version->snapshot['limit'] ?? null)->toBe(5) + ->and($version->assignments)->toHaveCount(1) + ->and($version->metadata['assignments_count'])->toBe(1); +}); + it('hydrates assignment filter names when filter data is stored at root', function () { $this->mock(PolicySnapshotService::class, function ($mock) { $mock->shouldReceive('fetch') diff --git a/tests/Unit/PolicyNormalizerTest.php b/tests/Unit/PolicyNormalizerTest.php index 9a7dccf..a4cba28 100644 --- a/tests/Unit/PolicyNormalizerTest.php +++ b/tests/Unit/PolicyNormalizerTest.php @@ -67,34 +67,30 @@ expect(collect($result['warnings'])->join(' '))->toContain('@odata.type mismatch'); }); -it('normalizes enrollment restrictions platform restriction payload', function () { +it('normalizes enrollment platform restriction payload', function () { $snapshot = [ '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration', 'displayName' => 'DeviceTypeRestriction', 'version' => 2, - // Graph uses this singular shape for platform restriction configs. 'platformRestriction' => [ 'platformBlocked' => false, 'personalDeviceEnrollmentBlocked' => true, ], ]; - $result = $this->normalizer->normalize($snapshot, 'enrollmentRestriction', 'all'); + $result = $this->normalizer->normalize($snapshot, 'deviceEnrollmentPlatformRestrictionsConfiguration', 'all'); - $block = collect($result['settings'])->firstWhere('title', 'Enrollment restrictions'); + $block = collect($result['settings'])->firstWhere('title', 'Platform restrictions (enrollment)'); expect($block)->not->toBeNull(); - $platformEntry = collect($block['entries'] ?? [])->firstWhere('key', 'Platform restrictions'); - expect($platformEntry)->toBeNull(); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Platform blocked')['value'] ?? null)->toBe('Disabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Personal device enrollment blocked')['value'] ?? null)->toBe('Enabled'); - expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform blocked')['value'] ?? null)->toBe('Disabled'); - expect(collect($block['entries'] ?? [])->firstWhere('key', 'Personal device enrollment blocked')['value'] ?? null)->toBe('Enabled'); - - expect(collect($block['entries'] ?? [])->firstWhere('key', 'OS minimum version')['value'] ?? null)->toBe('None'); - expect(collect($block['entries'] ?? [])->firstWhere('key', 'OS maximum version')['value'] ?? null)->toBe('None'); - expect(collect($block['entries'] ?? [])->firstWhere('key', 'Blocked manufacturers')['value'] ?? null)->toBe(['None']); - expect(collect($block['entries'] ?? [])->firstWhere('key', 'Blocked SKUs')['value'] ?? null)->toBe(['None']); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: OS minimum version')['value'] ?? null)->toBe('None'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: OS maximum version')['value'] ?? null)->toBe('None'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Blocked manufacturers')['value'] ?? null)->toBe(['None']); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Blocked SKUs')['value'] ?? null)->toBe(['None']); }); it('normalizes Autopilot deployment profile key fields', function () {