feat/027-enrollment-config-subtypes #31
@ -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');
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user