From 4d5f6eb79b38af352b800950763320be301b17cf Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 12:03:04 +0100 Subject: [PATCH 1/5] 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 () { -- 2.45.2 From b9d789ec8ea4476a01dfbbfc7337d3390f227861 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 12:40:39 +0100 Subject: [PATCH 2/5] fix: show enrollment notification settings --- .../EnrollmentAutopilotPolicyNormalizer.php | 71 +++++++- app/Services/Intune/PolicySnapshotService.php | 129 +++++++++++++++ config/graph_contracts.php | 3 + specs/027-enrollment-config-subtypes/tasks.md | 1 + .../Filament/PolicyVersionSettingsTest.php | 92 +++++++++++ tests/Unit/PolicySnapshotServiceTest.php | 155 ++++++++++++++++++ 6 files changed, 448 insertions(+), 3 deletions(-) diff --git a/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php index cc85041..ab73397 100644 --- a/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php +++ b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php @@ -496,7 +496,10 @@ private function buildEnrollmentNotificationBlock(array $snapshot): ?array foreach ([ 'priority' => 'Priority', 'version' => 'Version', + 'platformType' => 'Platform type', 'deviceEnrollmentConfigurationType' => 'Configuration type', + 'brandingOptions' => 'Branding options', + 'templateType' => 'Template type', 'defaultLocale' => 'Default locale', 'notificationMessageTemplateId' => 'Notification message template ID', ] as $key => $label) { @@ -511,9 +514,71 @@ private function buildEnrollmentNotificationBlock(array $snapshot): ?array } } - $notificationMessages = Arr::get($snapshot, 'notificationMessages'); - if (is_array($notificationMessages) && $notificationMessages !== []) { - $entries[] = ['key' => 'Notification messages', 'value' => sprintf('%d item(s)', count($notificationMessages))]; + $notificationTemplates = Arr::get($snapshot, 'notificationTemplates'); + if (is_array($notificationTemplates) && $notificationTemplates !== []) { + $entries[] = ['key' => 'Notification templates', 'value' => array_values($notificationTemplates)]; + } + + $templateSnapshots = Arr::get($snapshot, 'notificationTemplateSnapshots'); + if (is_array($templateSnapshots) && $templateSnapshots !== []) { + foreach ($templateSnapshots as $templateSnapshot) { + if (! is_array($templateSnapshot)) { + continue; + } + + $channel = Arr::get($templateSnapshot, 'channel'); + $channelLabel = is_string($channel) && $channel !== '' ? $channel : 'Template'; + + $templateId = Arr::get($templateSnapshot, 'template_id'); + if (is_string($templateId) && $templateId !== '') { + $entries[] = ['key' => "{$channelLabel} template ID", 'value' => $templateId]; + } + + $template = Arr::get($templateSnapshot, 'template'); + if (is_array($template) && $template !== []) { + $displayName = Arr::get($template, 'displayName'); + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => "{$channelLabel} template name", 'value' => $displayName]; + } + + $brandingOptions = Arr::get($template, 'brandingOptions'); + if (is_string($brandingOptions) && $brandingOptions !== '') { + $entries[] = ['key' => "{$channelLabel} branding options", 'value' => $brandingOptions]; + } + + $defaultLocale = Arr::get($template, 'defaultLocale'); + if (is_string($defaultLocale) && $defaultLocale !== '') { + $entries[] = ['key' => "{$channelLabel} default locale", 'value' => $defaultLocale]; + } + } + + $localizedMessages = Arr::get($templateSnapshot, 'localized_notification_messages'); + if (is_array($localizedMessages) && $localizedMessages !== []) { + foreach ($localizedMessages as $localizedMessage) { + if (! is_array($localizedMessage)) { + continue; + } + + $locale = Arr::get($localizedMessage, 'locale'); + $localeLabel = is_string($locale) && $locale !== '' ? $locale : 'locale'; + + $subject = Arr::get($localizedMessage, 'subject'); + if (is_string($subject) && $subject !== '') { + $entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Subject", 'value' => $subject]; + } + + $messageTemplate = Arr::get($localizedMessage, 'messageTemplate'); + if (is_string($messageTemplate) && $messageTemplate !== '') { + $entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Message", 'value' => $messageTemplate]; + } + + $isDefault = Arr::get($localizedMessage, 'isDefault'); + if (is_bool($isDefault)) { + $entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Default", 'value' => $isDefault ? 'Enabled' : 'Disabled']; + } + } + } + } } $assigned = Arr::get($snapshot, 'assignments'); diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index 33572eb..aa3c57e 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -124,6 +124,15 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ); } + if ($policy->policy_type === 'deviceEnrollmentNotificationConfiguration') { + [$payload, $metadata] = $this->hydrateEnrollmentNotificationTemplates( + tenantIdentifier: $tenantIdentifier, + tenant: $tenant, + payload: is_array($payload) ? $payload : [], + metadata: $metadata + ); + } + if ($response->failed()) { $reason = $this->formatGraphFailureReason($response); @@ -607,6 +616,126 @@ private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tena return [$payload, $metadata]; } + /** + * Hydrate enrollment notifications with message template details. + * + * @return array{0:array,1:array} + */ + private function hydrateEnrollmentNotificationTemplates(string $tenantIdentifier, Tenant $tenant, array $payload, array $metadata): array + { + $existing = $payload['notificationTemplateSnapshots'] ?? null; + + if (is_array($existing) && $existing !== []) { + $metadata['enrollment_notification_templates_hydration'] = 'embedded'; + + return [$payload, $metadata]; + } + + $templateRefs = $payload['notificationTemplates'] ?? null; + + if (! is_array($templateRefs) || $templateRefs === []) { + $metadata['enrollment_notification_templates_hydration'] = 'none'; + + return [$payload, $metadata]; + } + + $options = [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + ]; + + $snapshots = []; + $failures = 0; + + foreach ($templateRefs as $templateRef) { + if (! is_string($templateRef) || $templateRef === '') { + continue; + } + + [$channel, $templateId] = $this->parseEnrollmentNotificationTemplateRef($templateRef); + + if ($templateId === null) { + $failures++; + + continue; + } + + $templatePath = sprintf('deviceManagement/notificationMessageTemplates/%s', urlencode($templateId)); + $templateResponse = $this->graphClient->request('GET', $templatePath, $options); + + if ($templateResponse->failed() || ! is_array($templateResponse->data)) { + $failures++; + + continue; + } + + $template = Arr::except($templateResponse->data, ['@odata.context']); + + $messagesPath = sprintf( + 'deviceManagement/notificationMessageTemplates/%s/localizedNotificationMessages', + urlencode($templateId) + ); + $messagesResponse = $this->graphClient->request('GET', $messagesPath, $options); + + $messages = []; + + if ($messagesResponse->failed()) { + $failures++; + } else { + $pageItems = $messagesResponse->data['value'] ?? []; + + if (is_array($pageItems)) { + foreach ($pageItems as $message) { + if (is_array($message)) { + $messages[] = Arr::except($message, ['@odata.context']); + } + } + } + } + + $snapshots[] = [ + 'channel' => $channel, + 'template_id' => $templateId, + 'template' => $template, + 'localized_notification_messages' => $messages, + ]; + } + + if ($snapshots === []) { + $metadata['enrollment_notification_templates_hydration'] = 'failed'; + + return [$payload, $metadata]; + } + + $payload['notificationTemplateSnapshots'] = $snapshots; + + $metadata['enrollment_notification_templates_hydration'] = $failures > 0 ? 'partial' : 'complete'; + + return [$payload, $metadata]; + } + + /** + * @return array{0:?string,1:?string} + */ + private function parseEnrollmentNotificationTemplateRef(string $templateRef): array + { + if (! str_contains($templateRef, '_')) { + return [null, $templateRef]; + } + + [$channel, $templateId] = explode('_', $templateRef, 2); + + $channel = trim($channel); + $templateId = trim($templateId); + + if ($templateId === '') { + return [$channel !== '' ? $channel : null, null]; + } + + return [$channel !== '' ? $channel : null, $templateId]; + } + /** * Extract all settingDefinitionId from settings array, including nested children. */ diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 315e92b..ecd5238 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -562,6 +562,9 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'update_strip_keys' => [ + 'notificationTemplateSnapshots', + ], 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', 'assignments_create_method' => 'POST', diff --git a/specs/027-enrollment-config-subtypes/tasks.md b/specs/027-enrollment-config-subtypes/tasks.md index f89bd79..6f0d020 100644 --- a/specs/027-enrollment-config-subtypes/tasks.md +++ b/specs/027-enrollment-config-subtypes/tasks.md @@ -21,6 +21,7 @@ ## Phase 4: Implementation - [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). +- [x] T013 Hydrate notification templates for enrollment notifications. ## Phase 5: Verification - [x] T011 Run targeted tests. diff --git a/tests/Feature/Filament/PolicyVersionSettingsTest.php b/tests/Feature/Filament/PolicyVersionSettingsTest.php index 56af173..a7495c7 100644 --- a/tests/Feature/Filament/PolicyVersionSettingsTest.php +++ b/tests/Feature/Filament/PolicyVersionSettingsTest.php @@ -56,3 +56,95 @@ $response->assertSee('Enable feature'); $response->assertSee('Normalized diff'); }); + +test('policy version detail shows enrollment notification template settings', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-enrollment-notify', + 'name' => 'Tenant Enrollment Notify', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enroll-notify-1', + 'policy_type' => 'deviceEnrollmentNotificationConfiguration', + 'display_name' => 'Enrollment Notifications', + 'platform' => 'all', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + 'displayName' => 'Enrollment Notifications', + 'priority' => 1, + 'version' => 1, + 'platformType' => 'windows', + 'notificationTemplates' => ['Email_email-template-1', 'Push_push-template-1'], + 'notificationTemplateSnapshots' => [ + [ + 'channel' => 'Email', + 'template_id' => 'email-template-1', + 'template' => [ + 'id' => 'email-template-1', + 'displayName' => 'Email Template', + 'defaultLocale' => 'en-us', + 'brandingOptions' => 'none', + ], + 'localized_notification_messages' => [ + [ + 'locale' => 'en-us', + 'subject' => 'Email Subject', + 'messageTemplate' => 'Email Body', + 'isDefault' => true, + ], + ], + ], + [ + 'channel' => 'Push', + 'template_id' => 'push-template-1', + 'template' => [ + 'id' => 'push-template-1', + 'displayName' => 'Push Template', + 'defaultLocale' => 'en-us', + 'brandingOptions' => 'none', + ], + 'localized_notification_messages' => [ + [ + 'locale' => 'en-us', + 'subject' => 'Push Subject', + 'messageTemplate' => 'Push Body', + 'isDefault' => true, + ], + ], + ], + ], + ], + ]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->get(PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings'); + + $response->assertOk(); + $response->assertSee('Enrollment notifications'); + $response->assertSee('Notification templates'); + $response->assertSee('Email (en-us) Subject'); + $response->assertSee('Email Subject'); + $response->assertSee('Email (en-us) Message'); + $response->assertSee('Email Body'); + $response->assertSee('Push (en-us) Subject'); + $response->assertSee('Push Subject'); + $response->assertSee('Push (en-us) Message'); + $response->assertSee('Push Body'); +}); diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index 9256adf..e3100ad 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -172,6 +172,120 @@ public function request(string $method, string $path, array $options = []): Grap } } +class EnrollmentNotificationSnapshotGraphClient implements GraphClientInterface +{ + public array $requests = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->requests[] = ['getPolicy', $policyType, $policyId, $options]; + + if ($policyType === 'deviceEnrollmentNotificationConfiguration') { + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => 'Enrollment Notifications', + '@odata.type' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + 'priority' => 1, + 'version' => 1, + 'platformType' => 'windows', + 'brandingOptions' => 'none', + 'templateType' => '0', + 'notificationMessageTemplateId' => '00000000-0000-0000-0000-000000000000', + 'notificationTemplates' => [ + 'Email_email-template-1', + 'Push_push-template-1', + ], + 'deviceEnrollmentConfigurationType' => 'enrollmentNotificationsConfiguration', + ], + ]); + } + + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => 'Policy', + '@odata.type' => '#microsoft.graph.deviceConfiguration', + ], + ]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [$method, $path, $options]; + + if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/email-template-1/localizedNotificationMessages')) { + return new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => 'email-template-1_en-us', + 'locale' => 'en-us', + 'subject' => 'Email Subject', + 'messageTemplate' => 'Email Body', + 'isDefault' => true, + ], + ], + ]); + } + + if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/push-template-1/localizedNotificationMessages')) { + return new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => 'push-template-1_en-us', + 'locale' => 'en-us', + 'subject' => 'Push Subject', + 'messageTemplate' => 'Push Body', + 'isDefault' => true, + ], + ], + ]); + } + + if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/email-template-1')) { + return new GraphResponse(success: true, data: [ + '@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/notificationMessageTemplates/$entity', + 'id' => 'email-template-1', + 'displayName' => 'Email Template', + 'defaultLocale' => 'en-us', + 'brandingOptions' => 'none', + ]); + } + + if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/push-template-1')) { + return new GraphResponse(success: true, data: [ + '@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/notificationMessageTemplates/$entity', + 'id' => 'push-template-1', + 'displayName' => 'Push Template', + 'defaultLocale' => 'en-us', + 'brandingOptions' => 'none', + ]); + } + + return new GraphResponse(success: true, data: []); + } +} + it('hydrates compliance policy scheduled actions into snapshots', function () { $client = new PolicySnapshotGraphClient; app()->instance(GraphClientInterface::class, $client); @@ -247,6 +361,47 @@ public function request(string $method, string $path, array $options = []): Grap 'securityBaselinePolicy', ]); +it('hydrates enrollment notification templates into snapshots', function () { + $client = new EnrollmentNotificationSnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-enrollment-notifications', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enroll-notify-123', + 'policy_type' => 'deviceEnrollmentNotificationConfiguration', + 'display_name' => 'Enrollment Notifications', + 'platform' => 'all', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload'])->toHaveKey('notificationTemplateSnapshots'); + expect($result['payload']['notificationTemplateSnapshots'])->toHaveCount(2); + expect($result['metadata']['enrollment_notification_templates_hydration'] ?? null)->toBe('complete'); + + $email = collect($result['payload']['notificationTemplateSnapshots'])->firstWhere('channel', 'Email'); + expect($email)->not->toBeNull() + ->and($email['template_id'] ?? null)->toBe('email-template-1') + ->and($email['template']['displayName'] ?? null)->toBe('Email Template') + ->and($email['localized_notification_messages'][0]['subject'] ?? null)->toBe('Email Subject'); + + $push = collect($result['payload']['notificationTemplateSnapshots'])->firstWhere('channel', 'Push'); + expect($push)->not->toBeNull() + ->and($push['template_id'] ?? null)->toBe('push-template-1') + ->and($push['template']['displayName'] ?? null)->toBe('Push Template') + ->and($push['localized_notification_messages'][0]['subject'] ?? null)->toBe('Push Subject'); +}); + it('filters mobile app snapshots to metadata-only keys', function () { $client = new PolicySnapshotGraphClient; app()->instance(GraphClientInterface::class, $client); -- 2.45.2 From 5bc596e34dfa4ac1f7d305bfb57bc2e35b5dd00d Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 12:58:15 +0100 Subject: [PATCH 3/5] chore: close 027 research tasks --- specs/027-enrollment-config-subtypes/tasks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/027-enrollment-config-subtypes/tasks.md b/specs/027-enrollment-config-subtypes/tasks.md index 6f0d020..7342257 100644 --- a/specs/027-enrollment-config-subtypes/tasks.md +++ b/specs/027-enrollment-config-subtypes/tasks.md @@ -8,8 +8,8 @@ ## Phase 1: Setup - [x] T001 Create spec/plan/tasks and checklist. ## Phase 2: Research & Design -- [ ] T002 Confirm `@odata.type` for each subtype and whether Graph supports assignments. -- [ ] T003 Decide restore modes and risk levels. +- [x] T002 Confirm `@odata.type` for each subtype and whether Graph supports assignments. +- [x] T003 Decide restore modes and risk levels. ## Phase 3: Tests (TDD) - [x] T004 Add sync tests ensuring each subtype is classified correctly. -- 2.45.2 From adfa650222b79f275b3fa40cb199f31c90ea4d5f Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 13:56:58 +0100 Subject: [PATCH 4/5] ui: group tenant view actions --- .../TenantResource/Pages/ViewTenant.php | 61 ++++++++++--------- tests/Feature/Filament/TenantSetupTest.php | 1 + 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php index cad7dac..ee10dca 100644 --- a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php @@ -18,34 +18,39 @@ class ViewTenant extends ViewRecord protected function getHeaderActions(): array { return [ - Actions\EditAction::make(), - Actions\Action::make('admin_consent') - ->label('Admin consent') - ->icon('heroicon-o-clipboard-document') - ->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record)) - ->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null) - ->openUrlInNewTab(), - Actions\Action::make('open_in_entra') - ->label('Open in Entra') - ->icon('heroicon-o-arrow-top-right-on-square') - ->url(fn (Tenant $record) => TenantResource::entraUrl($record)) - ->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null) - ->openUrlInNewTab(), - Actions\Action::make('verify') - ->label('Verify configuration') - ->icon('heroicon-o-check-badge') - ->color('primary') - ->requiresConfirmation() - ->action(function ( - Tenant $record, - TenantConfigService $configService, - TenantPermissionService $permissionService, - RbacHealthService $rbacHealthService, - AuditLogger $auditLogger - ) { - TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); - }), - TenantResource::rbacAction(), + Actions\ActionGroup::make([ + Actions\EditAction::make(), + Actions\Action::make('admin_consent') + ->label('Admin consent') + ->icon('heroicon-o-clipboard-document') + ->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record)) + ->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null) + ->openUrlInNewTab(), + Actions\Action::make('open_in_entra') + ->label('Open in Entra') + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (Tenant $record) => TenantResource::entraUrl($record)) + ->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null) + ->openUrlInNewTab(), + Actions\Action::make('verify') + ->label('Verify configuration') + ->icon('heroicon-o-check-badge') + ->color('primary') + ->requiresConfirmation() + ->action(function ( + Tenant $record, + TenantConfigService $configService, + TenantPermissionService $permissionService, + RbacHealthService $rbacHealthService, + AuditLogger $auditLogger + ) { + TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); + }), + TenantResource::rbacAction(), + ]) + ->label('Actions') + ->icon('heroicon-o-ellipsis-vertical') + ->color('gray'), ]; } } diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php index 2d0d171..fe463b8 100644 --- a/tests/Feature/Filament/TenantSetupTest.php +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -172,6 +172,7 @@ public function request(string $method, string $path, array $options = []): Grap $response = $this->get(route('filament.admin.resources.tenants.view', $tenant)); $response->assertOk(); + $response->assertSee('Actions'); $response->assertSee($firstKey); $response->assertSee('ok'); $response->assertSee('missing'); -- 2.45.2 From 717837458cea05b435dbefb0c55b0062932b29ca Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 14:10:06 +0100 Subject: [PATCH 5/5] ui: add tenant actions --- app/Filament/Resources/TenantResource.php | 6 ++++ .../TenantResource/Pages/ViewTenant.php | 25 +++++++++++++++ tests/Feature/Filament/TenantSetupTest.php | 31 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index dd61427..92f5156 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -157,6 +157,12 @@ public static function table(Table $table): Table ->url(fn (Tenant $record) => static::adminConsentUrl($record)) ->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null) ->openUrlInNewTab(), + Actions\Action::make('open_in_entra') + ->label('Open in Entra') + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (Tenant $record) => static::entraUrl($record)) + ->visible(fn (Tenant $record) => static::entraUrl($record) !== null) + ->openUrlInNewTab(), Actions\Action::make('verify') ->label('Verify configuration') ->icon('heroicon-o-check-badge') diff --git a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php index ee10dca..d65f9d9 100644 --- a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php @@ -9,6 +9,7 @@ use App\Services\Intune\TenantConfigService; use App\Services\Intune\TenantPermissionService; use Filament\Actions; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; class ViewTenant extends ViewRecord @@ -47,6 +48,30 @@ protected function getHeaderActions(): array TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); }), TenantResource::rbacAction(), + Actions\Action::make('archive') + ->label('Deactivate') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (Tenant $record) => ! $record->trashed()) + ->action(function (Tenant $record, AuditLogger $auditLogger) { + $record->delete(); + + $auditLogger->log( + tenant: $record, + action: 'tenant.archived', + resourceType: 'tenant', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['tenant_id' => $record->tenant_id]] + ); + + Notification::make() + ->title('Tenant deactivated') + ->body('The tenant has been archived and hidden from lists.') + ->success() + ->send(); + }), ]) ->label('Actions') ->icon('heroicon-o-ellipsis-vertical') diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php index fe463b8..10c6ec8 100644 --- a/tests/Feature/Filament/TenantSetupTest.php +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -177,3 +177,34 @@ public function request(string $method, string $path, array $options = []): Grap $response->assertSee('ok'); $response->assertSee('missing'); }); + +test('tenant list shows Open in Entra action', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Tenant::create([ + 'tenant_id' => 'tenant-ui-list', + 'name' => 'UI Tenant List', + 'app_client_id' => 'client-123', + ]); + + $response = $this->get(route('filament.admin.resources.tenants.index')); + + $response->assertOk(); + $response->assertSee('Open in Entra'); +}); + +test('tenant can be deactivated from the tenant detail action menu', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-ui-deactivate', + 'name' => 'UI Tenant Deactivate', + ]); + + Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + ->callAction('archive'); + + $this->assertSoftDeleted('tenants', ['id' => $tenant->id]); +}); -- 2.45.2