feat/027-enrollment-config-subtypes #31

Merged
ahmido merged 12 commits from feat/027-enrollment-config-subtypes into dev 2026-01-04 13:25:16 +00:00
6 changed files with 448 additions and 3 deletions
Showing only changes of commit b9d789ec8e - Show all commits

View File

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

View File

@ -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.
*/

View File

@ -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',

View File

@ -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.

View File

@ -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');
});

View File

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