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);