feat(007): device config & compliance snapshot/restore improvements #9
@ -365,6 +365,7 @@ ## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
- UI consistency: Prefer Filament components (`<x-filament::section>`, infolist/table entries, etc.) over custom HTML/Tailwind for admin UI; only roll custom markup when Filament cannot express the UI.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
@ -55,8 +55,9 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull()
|
||||
->tabs([
|
||||
Tab::make('Normalized settings')
|
||||
->id('normalized-settings')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('normalized_settings')
|
||||
Infolists\Components\ViewEntry::make('normalized_settings_catalog')
|
||||
->view('filament.infolists.entries.normalized-settings')
|
||||
->state(function (PolicyVersion $record) {
|
||||
$normalized = app(PolicyNormalizer::class)->normalize(
|
||||
@ -69,15 +70,34 @@ public static function infolist(Schema $schema): Schema
|
||||
$normalized['record_id'] = (string) $record->getKey();
|
||||
|
||||
return $normalized;
|
||||
}),
|
||||
})
|
||||
->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') === 'settingsCatalogPolicy'),
|
||||
|
||||
Infolists\Components\ViewEntry::make('normalized_settings_standard')
|
||||
->view('filament.infolists.entries.policy-settings-standard')
|
||||
->state(function (PolicyVersion $record) {
|
||||
$normalized = app(PolicyNormalizer::class)->normalize(
|
||||
is_array($record->snapshot) ? $record->snapshot : [],
|
||||
$record->policy_type ?? '',
|
||||
$record->platform
|
||||
);
|
||||
|
||||
$normalized['context'] = 'version';
|
||||
$normalized['record_id'] = (string) $record->getKey();
|
||||
|
||||
return $normalized;
|
||||
})
|
||||
->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') !== 'settingsCatalogPolicy'),
|
||||
]),
|
||||
Tab::make('Raw JSON')
|
||||
->id('raw-json')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('snapshot_pretty')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (PolicyVersion $record) => $record->snapshot ?? []),
|
||||
]),
|
||||
Tab::make('Diff')
|
||||
->id('diff')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('normalized_diff')
|
||||
->view('filament.infolists.entries.normalized-diff')
|
||||
@ -93,8 +113,9 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
return $diff->compare($from, $to);
|
||||
}),
|
||||
Infolists\Components\TextEntry::make('diff')
|
||||
->label('Diff JSON vs previous')
|
||||
Infolists\Components\ViewEntry::make('diff_json')
|
||||
->label('Raw diff (advanced)')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (PolicyVersion $record) {
|
||||
$previous = $record->previous();
|
||||
|
||||
@ -102,11 +123,38 @@ public static function infolist(Schema $schema): Schema
|
||||
return ['summary' => 'No previous version'];
|
||||
}
|
||||
|
||||
return app(VersionDiff::class)
|
||||
->compare($previous->snapshot ?? [], $record->snapshot ?? []);
|
||||
})
|
||||
->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT))
|
||||
->copyable(),
|
||||
$diff = app(VersionDiff::class)->compare(
|
||||
$previous->snapshot ?? [],
|
||||
$record->snapshot ?? []
|
||||
);
|
||||
|
||||
$filter = static fn (array $items): array => array_filter(
|
||||
$items,
|
||||
static fn (mixed $value, string $key): bool => ! str_contains($key, '@odata.context'),
|
||||
ARRAY_FILTER_USE_BOTH
|
||||
);
|
||||
|
||||
$added = $filter($diff['added'] ?? []);
|
||||
$removed = $filter($diff['removed'] ?? []);
|
||||
$changed = $filter($diff['changed'] ?? []);
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'added' => count($added),
|
||||
'removed' => count($removed),
|
||||
'changed' => count($changed),
|
||||
'message' => sprintf(
|
||||
'%d added, %d removed, %d changed',
|
||||
count($added),
|
||||
count($removed),
|
||||
count($changed)
|
||||
),
|
||||
],
|
||||
'added' => $added,
|
||||
'removed' => $removed,
|
||||
'changed' => $changed,
|
||||
];
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
@ -14,10 +14,114 @@ public function mount(PolicyVersion $version): void
|
||||
$this->version = $version;
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
{
|
||||
return view('livewire.policy-version-assignments-widget', [
|
||||
'version' => $this->version,
|
||||
'compliance' => $this->complianceNotifications(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total:int,templates:array<int,string>,items:array<int,array{rule_name:?string,template_id:string,template_key:string}>}
|
||||
*/
|
||||
private function complianceNotifications(): array
|
||||
{
|
||||
if ($this->version->policy_type !== 'deviceCompliancePolicy') {
|
||||
return [
|
||||
'total' => 0,
|
||||
'templates' => [],
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$snapshot = $this->version->snapshot;
|
||||
|
||||
if (! is_array($snapshot)) {
|
||||
return [
|
||||
'total' => 0,
|
||||
'templates' => [],
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$scheduled = $snapshot['scheduledActionsForRule'] ?? null;
|
||||
|
||||
if (! is_array($scheduled)) {
|
||||
return [
|
||||
'total' => 0,
|
||||
'templates' => [],
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
$templateIds = [];
|
||||
|
||||
foreach ($scheduled as $rule) {
|
||||
if (! is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ruleName = $rule['ruleName'] ?? null;
|
||||
$configs = $rule['scheduledActionConfigurations'] ?? null;
|
||||
|
||||
if (! is_array($configs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($configs as $config) {
|
||||
if (! is_array($config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($config['actionType'] ?? null) !== 'notification') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templateKey = $this->resolveNotificationTemplateKey($config);
|
||||
|
||||
if ($templateKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templateId = $config[$templateKey] ?? null;
|
||||
|
||||
if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'rule_name' => is_string($ruleName) ? $ruleName : null,
|
||||
'template_id' => $templateId,
|
||||
'template_key' => $templateKey,
|
||||
];
|
||||
$templateIds[] = $templateId;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => count($items),
|
||||
'templates' => array_values(array_unique($templateIds)),
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveNotificationTemplateKey(array $config): ?string
|
||||
{
|
||||
if (array_key_exists('notificationTemplateId', $config)) {
|
||||
return 'notificationTemplateId';
|
||||
}
|
||||
|
||||
if (array_key_exists('notificationMessageTemplateId', $config)) {
|
||||
return 'notificationMessageTemplateId';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isEmptyGuid(string $value): bool
|
||||
{
|
||||
return strtolower($value) === '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,22 +25,41 @@ public function sanitizeQuery(string $policyType, array $query): array
|
||||
$allowedExpand = $contract['allowed_expand'] ?? [];
|
||||
$warnings = [];
|
||||
|
||||
if (! empty($query['$select']) && is_array($query['$select'])) {
|
||||
if (! empty($query['$select'])) {
|
||||
$original = $query['$select'];
|
||||
$query['$select'] = array_values(array_intersect($original, $allowedSelect));
|
||||
$select = is_array($original)
|
||||
? $original
|
||||
: array_map('trim', explode(',', (string) $original));
|
||||
$filtered = array_values(array_intersect($select, $allowedSelect));
|
||||
|
||||
if (count($query['$select']) !== count($original)) {
|
||||
if (count($filtered) !== count($select)) {
|
||||
$warnings[] = 'Trimmed unsupported $select fields for capability safety.';
|
||||
}
|
||||
|
||||
if ($filtered === []) {
|
||||
unset($query['$select']);
|
||||
} else {
|
||||
$query['$select'] = implode(',', $filtered);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($query['$expand']) && is_array($query['$expand'])) {
|
||||
if (! empty($query['$expand'])) {
|
||||
$original = $query['$expand'];
|
||||
$query['$expand'] = array_values(array_intersect($original, $allowedExpand));
|
||||
$expand = is_array($original)
|
||||
? $original
|
||||
: [trim((string) $original)];
|
||||
$expand = array_values(array_filter($expand, static fn ($value) => $value !== ''));
|
||||
$filtered = array_values(array_intersect($expand, $allowedExpand));
|
||||
|
||||
if (count($query['$expand']) !== count($original)) {
|
||||
if (count($filtered) !== count($expand)) {
|
||||
$warnings[] = 'Trimmed unsupported $expand fields for capability safety.';
|
||||
}
|
||||
|
||||
if ($filtered === []) {
|
||||
unset($query['$expand']);
|
||||
} else {
|
||||
$query['$expand'] = implode(',', $filtered);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@ -44,7 +44,12 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
|
||||
*/
|
||||
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform);
|
||||
$snapshot = $snapshot ?? [];
|
||||
|
||||
$normalized = $this->normalize($snapshot, $policyType, $platform);
|
||||
$flat = $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
|
||||
|
||||
return array_merge($flat, $this->flattenComplianceNotificationsForDiff($snapshot));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -85,6 +90,85 @@ private function buildComplianceBlocks(array $snapshot): array
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function flattenComplianceNotificationsForDiff(array $snapshot): array
|
||||
{
|
||||
$scheduled = $snapshot['scheduledActionsForRule'] ?? null;
|
||||
|
||||
if (! is_array($scheduled)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$templateIds = [];
|
||||
|
||||
foreach ($scheduled as $rule) {
|
||||
if (! is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$configs = $rule['scheduledActionConfigurations'] ?? null;
|
||||
|
||||
if (! is_array($configs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($configs as $config) {
|
||||
if (! is_array($config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($config['actionType'] ?? null) !== 'notification') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templateKey = $this->resolveNotificationTemplateKey($config);
|
||||
|
||||
if ($templateKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templateId = $config[$templateKey] ?? null;
|
||||
|
||||
if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templateIds[] = $templateId;
|
||||
}
|
||||
}
|
||||
|
||||
$templateIds = array_values(array_unique($templateIds));
|
||||
sort($templateIds);
|
||||
|
||||
if ($templateIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'Compliance notifications > Template IDs' => $templateIds,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveNotificationTemplateKey(array $config): ?string
|
||||
{
|
||||
if (array_key_exists('notificationTemplateId', $config)) {
|
||||
return 'notificationTemplateId';
|
||||
}
|
||||
|
||||
if (array_key_exists('notificationMessageTemplateId', $config)) {
|
||||
return 'notificationMessageTemplateId';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isEmptyGuid(string $value): bool
|
||||
{
|
||||
return strtolower($value) === '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{keys: array<int, string>, labels?: array<string, string>}
|
||||
*/
|
||||
@ -282,6 +366,8 @@ private function ignoredKeys(): array
|
||||
'settingCount',
|
||||
'settingsCount',
|
||||
'templateReference',
|
||||
'scheduledActionsForRule@odata.context',
|
||||
'scheduledActionsForRule',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -106,23 +106,49 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
|
||||
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
$normalized = $this->normalize($snapshot ?? [], $policyType, $platform);
|
||||
|
||||
return $this->flattenNormalizedForDiff($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten an already normalized payload into key/value pairs for diffing.
|
||||
*
|
||||
* @param array{settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>} $normalized
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function flattenNormalizedForDiff(array $normalized): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) {
|
||||
$title = $normalized['settings_table']['title'] ?? 'Settings';
|
||||
$prefix = is_string($title) && $title !== '' ? $title.' > ' : '';
|
||||
|
||||
foreach ($normalized['settings_table']['rows'] as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $row['path'] ?? $row['definition'] ?? 'entry';
|
||||
$key = $prefix.($row['path'] ?? $row['definition'] ?? 'entry');
|
||||
$map[$key] = $row['value'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($normalized['settings'] as $block) {
|
||||
foreach ($normalized['settings'] ?? [] as $block) {
|
||||
if (! is_array($block)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = $block['title'] ?? null;
|
||||
$prefix = is_string($title) && $title !== '' ? $title.' > ' : '';
|
||||
|
||||
if (($block['type'] ?? null) === 'table') {
|
||||
foreach ($block['rows'] ?? [] as $row) {
|
||||
$key = $row['path'] ?? $row['label'] ?? 'entry';
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $prefix.($row['path'] ?? $row['label'] ?? 'entry');
|
||||
$map[$key] = $row['value'] ?? null;
|
||||
}
|
||||
|
||||
@ -130,7 +156,11 @@ public function flattenForDiff(?array $snapshot, string $policyType, ?string $pl
|
||||
}
|
||||
|
||||
foreach ($block['entries'] ?? [] as $entry) {
|
||||
$key = $entry['key'] ?? 'entry';
|
||||
if (! is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $prefix.($entry['key'] ?? 'entry');
|
||||
$map[$key] = $entry['value'] ?? null;
|
||||
}
|
||||
}
|
||||
@ -554,6 +584,8 @@ private function normalizeStandard(array $snapshot): array
|
||||
'omaSettings',
|
||||
'settings',
|
||||
'settingsDelta',
|
||||
'scheduledActionsForRule',
|
||||
'scheduledActionsForRule@odata.context',
|
||||
];
|
||||
|
||||
$filtered = Arr::except($snapshot, $metadataKeys);
|
||||
|
||||
@ -46,7 +46,10 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
|
||||
*/
|
||||
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform);
|
||||
$snapshot = $snapshot ?? [];
|
||||
$normalized = $this->normalize($snapshot, $policyType, $platform);
|
||||
|
||||
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -39,12 +39,18 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
$this->graphLogger->logRequest('get_policy', $context);
|
||||
|
||||
try {
|
||||
$response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, [
|
||||
$options = [
|
||||
'tenant' => $tenantIdentifier,
|
||||
'client_id' => $tenant->app_client_id,
|
||||
'client_secret' => $tenant->app_client_secret,
|
||||
'platform' => $policy->platform,
|
||||
]);
|
||||
];
|
||||
|
||||
if ($policy->policy_type === 'deviceCompliancePolicy') {
|
||||
$options['expand'] = 'scheduledActionsForRule($expand=scheduledActionConfigurations)';
|
||||
}
|
||||
|
||||
$response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, $options);
|
||||
} catch (Throwable $throwable) {
|
||||
$mapped = GraphErrorMapper::fromThrowable($throwable, $context);
|
||||
|
||||
@ -73,6 +79,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
);
|
||||
}
|
||||
|
||||
if ($policy->policy_type === 'deviceCompliancePolicy') {
|
||||
[$payload, $metadata] = $this->hydrateComplianceActions(
|
||||
tenantIdentifier: $tenantIdentifier,
|
||||
tenant: $tenant,
|
||||
policyId: $policy->external_id,
|
||||
payload: is_array($payload) ? $payload : [],
|
||||
metadata: $metadata
|
||||
);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$reason = $response->warnings[0] ?? 'Graph request failed';
|
||||
$failure = [
|
||||
@ -174,6 +190,68 @@ private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant
|
||||
return [$payload, $metadata];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate compliance policies with scheduled actions (notification templates).
|
||||
*
|
||||
* @return array{0:array,1:array}
|
||||
*/
|
||||
private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
||||
{
|
||||
$existingActions = $payload['scheduledActionsForRule'] ?? null;
|
||||
|
||||
if (is_array($existingActions) && $existingActions !== []) {
|
||||
$metadata['compliance_actions_hydration'] = 'embedded';
|
||||
|
||||
return [$payload, $metadata];
|
||||
}
|
||||
|
||||
$path = sprintf('deviceManagement/deviceCompliancePolicies/%s/scheduledActionsForRule', urlencode($policyId));
|
||||
$options = [
|
||||
'tenant' => $tenantIdentifier,
|
||||
'client_id' => $tenant->app_client_id,
|
||||
'client_secret' => $tenant->app_client_secret,
|
||||
];
|
||||
|
||||
$actions = [];
|
||||
$nextPath = $path;
|
||||
$hydrationStatus = 'complete';
|
||||
|
||||
while ($nextPath) {
|
||||
$response = $this->graphClient->request('GET', $nextPath, $options);
|
||||
|
||||
if ($response->failed()) {
|
||||
$hydrationStatus = 'failed';
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$data = $response->data;
|
||||
$pageItems = $data['value'] ?? (is_array($data) ? $data : []);
|
||||
|
||||
foreach ($pageItems as $item) {
|
||||
if (is_array($item)) {
|
||||
$actions[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
$nextLink = $data['@odata.nextLink'] ?? null;
|
||||
|
||||
if (! $nextLink) {
|
||||
break;
|
||||
}
|
||||
|
||||
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
|
||||
}
|
||||
|
||||
if (! empty($actions)) {
|
||||
$payload['scheduledActionsForRule'] = $actions;
|
||||
}
|
||||
|
||||
$metadata['compliance_actions_hydration'] = $hydrationStatus;
|
||||
|
||||
return [$payload, $metadata];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all settingDefinitionId from settings array, including nested children.
|
||||
*/
|
||||
|
||||
@ -242,6 +242,7 @@ public function execute(
|
||||
'client_secret' => $tenant->app_client_secret,
|
||||
'platform' => $item->platform,
|
||||
];
|
||||
$updateMethod = $this->resolveUpdateMethod($item->policy_type);
|
||||
|
||||
$settingsApply = null;
|
||||
$itemStatus = 'applied';
|
||||
@ -249,6 +250,7 @@ public function execute(
|
||||
$resultReason = null;
|
||||
$createdPolicyId = null;
|
||||
$createdPolicyMode = null;
|
||||
$settingsApplyEligible = false;
|
||||
|
||||
if ($item->policy_type === 'settingsCatalogPolicy') {
|
||||
$settings = $this->extractSettingsCatalogSettings($originalPayload);
|
||||
@ -258,10 +260,55 @@ public function execute(
|
||||
$item->policy_type,
|
||||
$item->policy_identifier,
|
||||
$policyPayload,
|
||||
$graphOptions
|
||||
$graphOptions + ['method' => $updateMethod]
|
||||
);
|
||||
|
||||
if ($response->successful() && $settings !== []) {
|
||||
$settingsApplyEligible = $response->successful();
|
||||
|
||||
if ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) {
|
||||
$createOutcome = $this->createSettingsCatalogPolicy(
|
||||
originalPayload: $originalPayload,
|
||||
settings: $settings,
|
||||
graphOptions: $graphOptions,
|
||||
context: $context,
|
||||
fallbackName: $item->resolvedDisplayName(),
|
||||
);
|
||||
|
||||
$response = $createOutcome['response'] ?? $response;
|
||||
|
||||
if ($createOutcome['success']) {
|
||||
$createdPolicyId = $createOutcome['policy_id'];
|
||||
$createdPolicyMode = $createOutcome['mode'] ?? null;
|
||||
$mode = $createOutcome['mode'] ?? 'settings';
|
||||
|
||||
$itemStatus = $mode === 'settings' ? 'applied' : 'partial';
|
||||
$resultReason = $mode === 'metadata_only'
|
||||
? 'Policy missing; created metadata-only policy. Manual settings apply required.'
|
||||
: 'Policy missing; created new policy with settings.';
|
||||
|
||||
if ($settings !== []) {
|
||||
$settingsApply = $mode === 'metadata_only'
|
||||
? [
|
||||
'total' => count($settings),
|
||||
'applied' => 0,
|
||||
'failed' => 0,
|
||||
'manual_required' => count($settings),
|
||||
'issues' => [],
|
||||
]
|
||||
: [
|
||||
'total' => count($settings),
|
||||
'applied' => count($settings),
|
||||
'failed' => 0,
|
||||
'manual_required' => 0,
|
||||
'issues' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$settingsApplyEligible = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($settingsApplyEligible && $settings !== []) {
|
||||
[$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings(
|
||||
policyId: $item->policy_identifier,
|
||||
settings: $settings,
|
||||
@ -314,7 +361,7 @@ public function execute(
|
||||
];
|
||||
}
|
||||
}
|
||||
} elseif ($settings !== []) {
|
||||
} elseif ($settingsApplyEligible && $settings !== []) {
|
||||
$settingsApply = [
|
||||
'total' => count($settings),
|
||||
'applied' => 0,
|
||||
@ -328,7 +375,7 @@ public function execute(
|
||||
$item->policy_type,
|
||||
$item->policy_identifier,
|
||||
$payload,
|
||||
$graphOptions
|
||||
$graphOptions + ['method' => $updateMethod]
|
||||
);
|
||||
|
||||
if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) {
|
||||
@ -349,6 +396,26 @@ public function execute(
|
||||
$resultReason = 'Policy missing; created new Autopilot profile.';
|
||||
}
|
||||
}
|
||||
} elseif ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) {
|
||||
$createOutcome = $this->createPolicyFromSnapshot(
|
||||
policyType: $item->policy_type,
|
||||
payload: $payload,
|
||||
originalPayload: $originalPayload,
|
||||
graphOptions: $graphOptions,
|
||||
context: $context,
|
||||
fallbackName: $item->resolvedDisplayName(),
|
||||
);
|
||||
|
||||
if ($createOutcome['attempted']) {
|
||||
$response = $createOutcome['response'] ?? $response;
|
||||
|
||||
if ($createOutcome['success']) {
|
||||
$createdPolicyId = $createOutcome['policy_id'];
|
||||
$createdPolicyMode = 'created';
|
||||
$itemStatus = 'applied';
|
||||
$resultReason = 'Policy missing; created new policy.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
@ -602,6 +669,52 @@ private function resolveRestoreMode(string $policyType): string
|
||||
return $restore;
|
||||
}
|
||||
|
||||
private function resolveUpdateMethod(string $policyType): string
|
||||
{
|
||||
$contract = $this->contracts->get($policyType);
|
||||
$method = strtoupper((string) ($contract['update_method'] ?? 'PATCH'));
|
||||
|
||||
return $method !== '' ? $method : 'PATCH';
|
||||
}
|
||||
|
||||
private function resolveCreateMethod(string $policyType): ?string
|
||||
{
|
||||
$contract = $this->contracts->get($policyType);
|
||||
$method = strtoupper((string) ($contract['create_method'] ?? 'POST'));
|
||||
|
||||
return $method !== '' ? $method : null;
|
||||
}
|
||||
|
||||
private function shouldAttemptPolicyCreate(string $policyType, object $response): bool
|
||||
{
|
||||
if (! $this->isNotFoundResponse($response)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resource = $this->contracts->resourcePath($policyType);
|
||||
$method = $this->resolveCreateMethod($policyType);
|
||||
|
||||
return is_string($resource) && $resource !== '' && $method !== null;
|
||||
}
|
||||
|
||||
private function isNotFoundResponse(object $response): bool
|
||||
{
|
||||
if (($response->status ?? null) === 404) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
|
||||
$message = strtolower((string) ($response->meta['error_message'] ?? ''));
|
||||
|
||||
if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $message !== '' && (str_contains($message, 'not found')
|
||||
|| str_contains($message, 'resource not found')
|
||||
|| str_contains($message, 'does not exist'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $entries
|
||||
* @return array<string, array<string, string>>
|
||||
@ -1359,6 +1472,70 @@ private function createSettingsCatalogPolicy(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{attempted:bool,success:bool,policy_id:?string,response:?object}
|
||||
*/
|
||||
private function createPolicyFromSnapshot(
|
||||
string $policyType,
|
||||
array $payload,
|
||||
array $originalPayload,
|
||||
array $graphOptions,
|
||||
array $context,
|
||||
string $fallbackName,
|
||||
): array {
|
||||
$resource = $this->contracts->resourcePath($policyType);
|
||||
$method = $this->resolveCreateMethod($policyType);
|
||||
|
||||
if (! is_string($resource) || $resource === '' || $method === null) {
|
||||
return [
|
||||
'attempted' => false,
|
||||
'success' => false,
|
||||
'policy_id' => null,
|
||||
'response' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$createPayload = Arr::except($payload, ['assignments']);
|
||||
$createPayload = $this->applyOdataTypeForCreate($policyType, $createPayload, $originalPayload);
|
||||
$createPayload = $this->applyRestoredNameToPayload($createPayload, $originalPayload, $fallbackName);
|
||||
|
||||
if ($createPayload === []) {
|
||||
return [
|
||||
'attempted' => true,
|
||||
'success' => false,
|
||||
'policy_id' => null,
|
||||
'response' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$this->graphLogger->logRequest('create_policy', $context + [
|
||||
'endpoint' => $resource,
|
||||
'method' => $method,
|
||||
'policy_type' => $policyType,
|
||||
]);
|
||||
|
||||
$response = $this->graphClient->request(
|
||||
$method,
|
||||
$resource,
|
||||
['json' => $createPayload] + Arr::except($graphOptions, ['platform'])
|
||||
);
|
||||
|
||||
$this->graphLogger->logResponse('create_policy', $response, $context + [
|
||||
'endpoint' => $resource,
|
||||
'method' => $method,
|
||||
'policy_type' => $policyType,
|
||||
]);
|
||||
|
||||
$policyId = $this->extractCreatedPolicyId($response);
|
||||
|
||||
return [
|
||||
'attempted' => true,
|
||||
'success' => $response->successful(),
|
||||
'policy_id' => $policyId,
|
||||
'response' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{attempted:bool,success:bool,policy_id:?string,response:?object}
|
||||
*/
|
||||
@ -1543,6 +1720,63 @@ private function prefixRestoredName(?string $name, string $fallback): string
|
||||
return $prefix.$base;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $originalPayload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function applyRestoredNameToPayload(array $payload, array $originalPayload, string $fallbackName): array
|
||||
{
|
||||
$displayName = $this->resolvePayloadString($payload, ['displayName']);
|
||||
$name = $this->resolvePayloadString($payload, ['name']);
|
||||
$originalDisplayName = $this->resolvePayloadString($originalPayload, ['displayName']);
|
||||
$originalName = $this->resolvePayloadString($originalPayload, ['name']);
|
||||
$baseName = $displayName ?? $originalDisplayName ?? $name ?? $originalName ?? $fallbackName;
|
||||
$restoredName = $this->prefixRestoredName($baseName, $fallbackName);
|
||||
|
||||
if (array_key_exists('displayName', $payload) || $originalDisplayName !== null || $displayName !== null) {
|
||||
$payload['displayName'] = $restoredName;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
if (array_key_exists('name', $payload) || $originalName !== null || $name !== null) {
|
||||
$payload['name'] = $restoredName;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$payload['displayName'] = $restoredName;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $originalPayload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function applyOdataTypeForCreate(string $policyType, array $payload, array $originalPayload): array
|
||||
{
|
||||
if (array_key_exists('@odata.type', $payload)) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']);
|
||||
|
||||
if ($odataType === null) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
if (! $this->contracts->matchesTypeFamily($policyType, $odataType)) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$payload['@odata.type'] = $odataType;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<int, string> $keys
|
||||
|
||||
@ -136,7 +136,10 @@
|
||||
'deviceCompliancePolicy' => [
|
||||
'resource' => 'deviceManagement/deviceCompliancePolicies',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
|
||||
'allowed_expand' => [],
|
||||
'allowed_expand' => [
|
||||
'scheduledActionsForRule',
|
||||
'scheduledActionsForRule($expand=scheduledActionConfigurations)',
|
||||
],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.deviceCompliancePolicy',
|
||||
'#microsoft.graph.windows10CompliancePolicy',
|
||||
@ -328,7 +331,7 @@
|
||||
],
|
||||
'assignmentFilter' => [
|
||||
'resource' => 'deviceManagement/assignmentFilters',
|
||||
'allowed_select' => ['id', 'displayName', 'description', 'platform', 'rule', '@odata.type', 'roleScopeTagIds'],
|
||||
'allowed_select' => ['id', 'displayName', 'description', 'platform', 'rule'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.deviceAndAppManagementAssignmentFilter',
|
||||
@ -345,7 +348,7 @@
|
||||
],
|
||||
'roleScopeTag' => [
|
||||
'resource' => 'deviceManagement/roleScopeTags',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'isBuiltIn'],
|
||||
'allowed_select' => ['id', 'displayName', 'description', 'isBuiltIn'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.roleScopeTag',
|
||||
@ -362,7 +365,7 @@
|
||||
],
|
||||
'notificationMessageTemplate' => [
|
||||
'resource' => 'deviceManagement/notificationMessageTemplates',
|
||||
'allowed_select' => ['id', 'displayName', 'description', 'brandingOptions', '@odata.type', 'lastModifiedDateTime'],
|
||||
'allowed_select' => ['id', 'displayName', 'description', 'brandingOptions', 'lastModifiedDateTime'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.notificationMessageTemplate',
|
||||
|
||||
@ -1,35 +1,169 @@
|
||||
@php
|
||||
$diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []];
|
||||
$summary = $diff['summary'] ?? [];
|
||||
|
||||
$groupByBlock = static function (array $items): array {
|
||||
$groups = [];
|
||||
|
||||
foreach ($items as $path => $value) {
|
||||
if (! is_string($path) || $path === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(' > ', $path, 2);
|
||||
|
||||
if (count($parts) === 2) {
|
||||
[$group, $label] = $parts;
|
||||
} else {
|
||||
$group = 'Other';
|
||||
$label = $path;
|
||||
}
|
||||
|
||||
$groups[$group][$label] = $value;
|
||||
}
|
||||
|
||||
ksort($groups);
|
||||
|
||||
return $groups;
|
||||
};
|
||||
|
||||
$stringify = static function (mixed $value): string {
|
||||
if ($value === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
|
||||
};
|
||||
|
||||
$isExpandable = static function (mixed $value): bool {
|
||||
if (is_array($value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_string($value) && strlen($value) > 160;
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-semibold text-gray-800">Normalized diff</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
{{ $summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0) }}
|
||||
<div class="space-y-4">
|
||||
<x-filament::section
|
||||
heading="Normalized diff"
|
||||
:description="$summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0)"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($summary['added'] ?? 0) }} added
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($summary['removed'] ?? 0) }} removed
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($summary['changed'] ?? 0) }} changed
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@foreach (['added' => 'Added', 'removed' => 'Removed', 'changed' => 'Changed'] as $key => $label)
|
||||
@foreach (['changed' => ['label' => 'Changed', 'collapsed' => false], 'added' => ['label' => 'Added', 'collapsed' => true], 'removed' => ['label' => 'Removed', 'collapsed' => true]] as $key => $meta)
|
||||
@php
|
||||
$items = $diff[$key] ?? [];
|
||||
$groups = $groupByBlock(is_array($items) ? $items : []);
|
||||
@endphp
|
||||
|
||||
@if (! empty($items))
|
||||
@if ($groups !== [])
|
||||
<x-filament::section
|
||||
:heading="$meta['label']"
|
||||
collapsible
|
||||
:collapsed="$meta['collapsed']"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
@foreach ($groups as $group => $groupItems)
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">{{ $label }}</div>
|
||||
<ul class="mt-1 space-y-1">
|
||||
@foreach ($items as $name => $value)
|
||||
<li class="rounded border border-gray-100 bg-gray-50 p-2 text-sm text-gray-800">
|
||||
<span class="font-medium">{{ $name }}</span>:
|
||||
@if (is_array($value))
|
||||
<pre class="mt-1 overflow-x-auto text-xs">{{ json_encode($value, JSON_PRETTY_PRINT) }}</pre>
|
||||
@else
|
||||
<span>{{ is_bool($value) ? ($value ? 'true' : 'false') : (string) $value }}</span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $group }}
|
||||
</div>
|
||||
<x-filament::badge size="sm" color="gray">
|
||||
{{ count($groupItems) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
||||
@foreach ($groupItems as $name => $value)
|
||||
<div class="px-4 py-3">
|
||||
@if ($key === 'changed' && is_array($value) && array_key_exists('from', $value) && array_key_exists('to', $value))
|
||||
@php
|
||||
$from = $value['from'];
|
||||
$to = $value['to'];
|
||||
$fromText = $stringify($from);
|
||||
$toText = $stringify($to);
|
||||
@endphp
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ (string) $name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</span>
|
||||
@if ($isExpandable($from))
|
||||
<details class="mt-1">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $fromText }}</pre>
|
||||
</details>
|
||||
@else
|
||||
<div class="mt-1">{{ $fromText }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">To</span>
|
||||
@if ($isExpandable($to))
|
||||
<details class="mt-1">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $toText }}</pre>
|
||||
</details>
|
||||
@else
|
||||
<div class="mt-1">{{ $toText }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$text = $stringify($value);
|
||||
@endphp
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ (string) $name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200 sm:max-w-[70%]">
|
||||
@if ($isExpandable($value))
|
||||
<details>
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $text }}</pre>
|
||||
</details>
|
||||
@else
|
||||
<div class="break-words">{{ $text }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@ -88,6 +88,31 @@
|
||||
{{ $item['compliance_action_warning'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary']))
|
||||
@php
|
||||
$summary = $item['compliance_action_summary'];
|
||||
$missingTemplates = $item['compliance_action_missing_templates'] ?? [];
|
||||
$total = (int) ($summary['total'] ?? 0);
|
||||
$missing = (int) ($summary['missing'] ?? 0);
|
||||
@endphp
|
||||
|
||||
<div class="mt-2 text-xs text-gray-600">
|
||||
Compliance notifications: {{ $total }} total • {{ $missing }} missing
|
||||
</div>
|
||||
|
||||
@if (! empty($missingTemplates) && is_array($missingTemplates))
|
||||
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||
<summary class="cursor-pointer font-semibold">Missing notification templates</summary>
|
||||
<div class="mt-2 space-y-1">
|
||||
@foreach ($missingTemplates as $templateId)
|
||||
<div class="rounded border border-amber-200 bg-white px-2 py-1 text-[11px] text-gray-800">
|
||||
{{ $templateId }}
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@ -192,10 +192,10 @@
|
||||
@if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary']))
|
||||
@php
|
||||
$summary = $item['compliance_action_summary'];
|
||||
$complianceOutcomes = $item['compliance_action_outcomes'] ?? [];
|
||||
$complianceIssues = collect($complianceOutcomes)
|
||||
->filter(fn ($outcome) => ($outcome['status'] ?? null) === 'skipped')
|
||||
->values();
|
||||
$complianceOutcomes = is_array($item['compliance_action_outcomes'] ?? null)
|
||||
? $item['compliance_action_outcomes']
|
||||
: [];
|
||||
$complianceEntries = collect($complianceOutcomes)->values();
|
||||
@endphp
|
||||
|
||||
<div class="mt-2 text-xs text-gray-700">
|
||||
@ -203,18 +203,26 @@
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} skipped
|
||||
</div>
|
||||
|
||||
@if ($complianceIssues->isNotEmpty())
|
||||
@if ($complianceEntries->isNotEmpty())
|
||||
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||
<summary class="cursor-pointer font-semibold">Compliance notification details</summary>
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach ($complianceIssues as $outcome)
|
||||
@foreach ($complianceEntries as $outcome)
|
||||
@php
|
||||
$outcomeStatus = $outcome['status'] ?? 'unknown';
|
||||
$outcomeColor = match ($outcomeStatus) {
|
||||
'mapped' => 'text-green-700 bg-green-100 border-green-200',
|
||||
'skipped' => 'text-amber-900 bg-amber-100 border-amber-200',
|
||||
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
||||
};
|
||||
@endphp
|
||||
<div class="rounded border border-amber-200 bg-white p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-gray-900">
|
||||
Template {{ $outcome['template_id'] ?? 'unknown' }}
|
||||
</div>
|
||||
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-900 bg-amber-100 border-amber-200">
|
||||
skipped
|
||||
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $outcomeColor }}">
|
||||
{{ $outcomeStatus }}
|
||||
</span>
|
||||
</div>
|
||||
@if (! empty($outcome['rule_name']))
|
||||
@ -222,6 +230,11 @@
|
||||
Rule: {{ $outcome['rule_name'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (! empty($outcome['mapped_template_id']))
|
||||
<div class="mt-1 text-[11px] text-gray-700">
|
||||
Mapped to: {{ $outcome['mapped_template_id'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (! empty($outcome['reason']))
|
||||
<div class="mt-1 text-[11px] text-gray-800">
|
||||
{{ $outcome['reason'] }}
|
||||
|
||||
@ -1,22 +1,11 @@
|
||||
<div class="fi-section">
|
||||
<div class="space-y-4">
|
||||
@if($version->assignments && count($version->assignments) > 0)
|
||||
<div class="rounded-lg bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<x-filament::section
|
||||
heading="Assignments"
|
||||
:description="'Captured with this version on ' . $version->captured_at->format('M d, Y H:i')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
|
||||
Assignments
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Captured with this version on {{ $version->captured_at->format('M d, Y H:i') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 px-6 py-4 dark:border-white/10">
|
||||
<!-- Summary -->
|
||||
<div class="mb-4">
|
||||
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Summary</h4>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ count($version->assignments) }} assignment(s)
|
||||
@ -29,12 +18,11 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scope Tags -->
|
||||
@php
|
||||
$scopeTags = $version->scope_tags['names'] ?? [];
|
||||
@endphp
|
||||
@if(!empty($scopeTags))
|
||||
<div class="mb-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Scope Tags</h4>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@foreach($scopeTags as $tag)
|
||||
@ -46,7 +34,6 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Assignment Details -->
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Assignment Details</h4>
|
||||
<div class="mt-2 space-y-2">
|
||||
@ -111,20 +98,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@else
|
||||
<div class="rounded-lg bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
|
||||
<div class="px-6 py-4">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
|
||||
Assignments
|
||||
</h3>
|
||||
<x-filament::section heading="Assignments">
|
||||
@php
|
||||
$assignmentsFetched = $version->metadata['assignments_fetched'] ?? false;
|
||||
$assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false;
|
||||
$assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null;
|
||||
@endphp
|
||||
|
||||
@if($assignmentsFetchFailed)
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Assignments could not be fetched from Microsoft Graph.
|
||||
</p>
|
||||
@if($assignmentsFetchError)
|
||||
@ -133,14 +117,15 @@
|
||||
</p>
|
||||
@endif
|
||||
@elseif($assignmentsFetched)
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
No assignments found for this version.
|
||||
</p>
|
||||
@else
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Assignments were not captured for this version.
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$hasBackupItem = $version->policy->backupItems()
|
||||
->whereNotNull('assignments')
|
||||
@ -152,7 +137,37 @@
|
||||
💡 Assignment data may be available in related backup items.
|
||||
</p>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$complianceTotal = $compliance['total'] ?? 0;
|
||||
$complianceTemplates = $compliance['templates'] ?? [];
|
||||
@endphp
|
||||
@if($complianceTotal > 0)
|
||||
<x-filament::section
|
||||
heading="Compliance notifications"
|
||||
:description="$complianceTotal . ' action(s) • ' . count($complianceTemplates) . ' template(s)'"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
@foreach($compliance['items'] ?? [] as $item)
|
||||
@php
|
||||
$ruleName = $item['rule_name'] ?? null;
|
||||
$templateId = $item['template_id'] ?? null;
|
||||
@endphp
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">•</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ $ruleName ?: 'Default rule' }}
|
||||
</span>
|
||||
@if($templateId)
|
||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Template: {{ $templateId }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -41,7 +41,7 @@ ## Phase 3: Restore Logic and Mapping
|
||||
|
||||
**Purpose**: Restore new policy types safely using assignment and foundation mappings.
|
||||
|
||||
- [ ] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts.
|
||||
- [x] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts.
|
||||
- [x] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types.
|
||||
- [x] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`.
|
||||
- [x] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`.
|
||||
@ -54,8 +54,8 @@ ## Phase 4: Admin UX
|
||||
|
||||
**Purpose**: Surface restore and compliance details clearly in the UI.
|
||||
|
||||
- [ ] T013 Update `resources/views/filament/infolists/entries/restore-preview.blade.php` to surface compliance action/template warnings.
|
||||
- [ ] T014 Update `resources/views/filament/infolists/entries/restore-results.blade.php` to show compliance action mapping outcomes and skip reasons.
|
||||
- [x] T013 Update `resources/views/filament/infolists/entries/restore-preview.blade.php` to surface compliance action/template warnings.
|
||||
- [x] T014 Update `resources/views/filament/infolists/entries/restore-results.blade.php` to show compliance action mapping outcomes and skip reasons.
|
||||
|
||||
**Checkpoint**: Admins can see compliance related mapping results in preview and results.
|
||||
|
||||
|
||||
@ -392,3 +392,105 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
expect($run->results[0]['status'])->toBe('applied');
|
||||
expect($run->results[0]['created_policy_id'])->toBe('autopilot-created');
|
||||
});
|
||||
|
||||
test('restore execution creates missing policy using contracts', function () {
|
||||
$graphClient = new class implements GraphClientInterface
|
||||
{
|
||||
public int $applyCalls = 0;
|
||||
|
||||
public int $createCalls = 0;
|
||||
|
||||
public array $createPayloads = [];
|
||||
|
||||
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(false, [], 404, [], [], [
|
||||
'error_code' => 'ResourceNotFound',
|
||||
'error_message' => 'Resource not found.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
if ($method === 'POST' && $path === 'deviceManagement/deviceCompliancePolicies') {
|
||||
$this->createCalls++;
|
||||
$this->createPayloads[] = $options['json'] ?? [];
|
||||
|
||||
return new GraphResponse(true, ['id' => 'compliance-created']);
|
||||
}
|
||||
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
};
|
||||
|
||||
app()->instance(GraphClientInterface::class, $graphClient);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-4',
|
||||
'name' => 'Tenant Four',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->state([
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'compliance-1',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'platform' => 'windows',
|
||||
'payload' => [
|
||||
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'displayName' => 'Compliance Policy',
|
||||
'description' => 'Test policy',
|
||||
],
|
||||
])
|
||||
->create();
|
||||
|
||||
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
);
|
||||
|
||||
expect($graphClient->applyCalls)->toBe(1);
|
||||
expect($graphClient->createCalls)->toBe(1);
|
||||
expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Compliance Policy');
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->results[0]['status'])->toBe('applied');
|
||||
expect($run->results[0]['created_policy_id'])->toBe('compliance-created');
|
||||
});
|
||||
|
||||
@ -188,4 +188,5 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
expect($policyPreview['compliance_action_warning'] ?? null)->not->toBeNull();
|
||||
expect(($policyPreview['compliance_action_summary']['missing'] ?? 0))->toBe(1);
|
||||
expect($policyPreview['compliance_action_missing_templates'] ?? [])->toContain('template-1');
|
||||
});
|
||||
|
||||
@ -112,3 +112,90 @@
|
||||
$response->assertOk();
|
||||
$response->assertSee('No assignments found for this version');
|
||||
});
|
||||
|
||||
it('shows compliance notifications when present', function () {
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'policy_id' => $this->policy->id,
|
||||
'version_number' => 1,
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'assignments' => null,
|
||||
'snapshot' => [
|
||||
'scheduledActionsForRule' => [
|
||||
[
|
||||
'ruleName' => 'Test rule',
|
||||
'scheduledActionConfigurations' => [
|
||||
[
|
||||
'actionType' => 'notification',
|
||||
'notificationTemplateId' => 'template-123',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->get("/admin/policy-versions/{$version->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Compliance notifications');
|
||||
$response->assertSee('Test rule');
|
||||
$response->assertSee('template-123');
|
||||
});
|
||||
|
||||
it('uses a default label when compliance rule name is missing', function () {
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'policy_id' => $this->policy->id,
|
||||
'version_number' => 1,
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'assignments' => null,
|
||||
'snapshot' => [
|
||||
'scheduledActionsForRule' => [
|
||||
[
|
||||
'ruleName' => null,
|
||||
'scheduledActionConfigurations' => [
|
||||
[
|
||||
'actionType' => 'notification',
|
||||
'notificationTemplateId' => 'template-456',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->get("/admin/policy-versions/{$version->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Compliance notifications');
|
||||
$response->assertSee('Default rule');
|
||||
$response->assertSee('template-456');
|
||||
});
|
||||
|
||||
it('renders structured normalized settings for compliance policy versions', function () {
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'policy_id' => $this->policy->id,
|
||||
'version_number' => 1,
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'platform' => 'all',
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'passwordRequired' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->get("/admin/policy-versions/{$version->id}?tab=normalized-settings");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Password & Access');
|
||||
$response->assertSee('Password required');
|
||||
$response->assertSee('Enabled');
|
||||
});
|
||||
|
||||
@ -15,6 +15,15 @@
|
||||
'bitLockerEnabled' => false,
|
||||
'osMinimumVersion' => '10.0.19045',
|
||||
'activeFirewallRequired' => true,
|
||||
'scheduledActionsForRule' => [
|
||||
[
|
||||
'ruleName' => 'Default rule',
|
||||
'scheduledActionConfigurations' => [
|
||||
['actionType' => 'notification'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'scheduledActionsForRule@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/deviceCompliancePolicies',
|
||||
'customSetting' => 'Custom value',
|
||||
];
|
||||
|
||||
@ -31,6 +40,50 @@
|
||||
expect($additionalBlock)->not->toBeNull();
|
||||
expect(collect($additionalBlock['rows'])->pluck('label')->all())
|
||||
->toContain('Custom Setting');
|
||||
expect(collect($additionalBlock['rows'])->pluck('label')->all())
|
||||
->not->toContain('Scheduled Actions For Rule');
|
||||
expect(collect($additionalBlock['rows'])->pluck('label')->all())
|
||||
->not->toContain('Scheduled Actions For Rule@Odata.context');
|
||||
|
||||
expect($settings->pluck('title')->all())->not->toContain('General');
|
||||
});
|
||||
|
||||
it('flattens compliance notifications into a compact diff key', function () {
|
||||
$normalizer = app(CompliancePolicyNormalizer::class);
|
||||
|
||||
$snapshot = [
|
||||
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'passwordRequired' => true,
|
||||
'scheduledActionsForRule' => [
|
||||
[
|
||||
'ruleName' => null,
|
||||
'scheduledActionConfigurations' => [
|
||||
[
|
||||
'actionType' => 'notification',
|
||||
'notificationTemplateId' => 'template-123',
|
||||
],
|
||||
[
|
||||
'actionType' => 'notification',
|
||||
'notificationTemplateId' => '00000000-0000-0000-0000-000000000000',
|
||||
],
|
||||
[
|
||||
'actionType' => 'block',
|
||||
'notificationTemplateId' => 'template-ignored',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'scheduledActionsForRule@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/deviceCompliancePolicies',
|
||||
];
|
||||
|
||||
$flat = $normalizer->flattenForDiff($snapshot, 'deviceCompliancePolicy', 'windows');
|
||||
|
||||
expect($flat)->toHaveKey('Password & Access > Password required');
|
||||
expect($flat['Password & Access > Password required'])->toBeTrue();
|
||||
|
||||
expect($flat)->toHaveKey('Compliance notifications > Template IDs');
|
||||
expect($flat['Compliance notifications > Template IDs'])->toBe(['template-123']);
|
||||
|
||||
expect(array_keys($flat))->not->toContain('scheduledActionsForRule');
|
||||
expect(array_keys($flat))->not->toContain('scheduledActionsForRule@odata.context');
|
||||
});
|
||||
|
||||
21
tests/Unit/DefaultPolicyNormalizerDiffTest.php
Normal file
21
tests/Unit/DefaultPolicyNormalizerDiffTest.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Intune\DefaultPolicyNormalizer;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('flattens normalized settings with section prefixes for diffs', function () {
|
||||
$normalizer = app(DefaultPolicyNormalizer::class);
|
||||
|
||||
$snapshot = [
|
||||
'@odata.type' => '#microsoft.graph.somePolicy',
|
||||
'displayName' => 'Example Policy',
|
||||
'customSetting' => true,
|
||||
];
|
||||
|
||||
$flat = $normalizer->flattenForDiff($snapshot, 'somePolicyType', 'all');
|
||||
|
||||
expect($flat)->toHaveKey('General > Display Name', 'Example Policy');
|
||||
expect($flat)->toHaveKey('General > Custom Setting');
|
||||
expect($flat['General > Custom Setting'])->toBeTrue();
|
||||
});
|
||||
@ -93,3 +93,19 @@
|
||||
expect($sanitized)->not->toHaveKey('hardwareHashExtractionEnabled');
|
||||
expect($sanitized)->not->toHaveKey('locale');
|
||||
});
|
||||
|
||||
it('exposes compliance policy expand for scheduled actions', function () {
|
||||
$contract = $this->registry->get('deviceCompliancePolicy');
|
||||
|
||||
expect($contract)->not->toBeEmpty();
|
||||
expect($contract['allowed_expand'] ?? [])
|
||||
->toContain('scheduledActionsForRule($expand=scheduledActionConfigurations)');
|
||||
});
|
||||
|
||||
it('omits role scope tags from assignment filter selects', function () {
|
||||
$contract = $this->registry->get('assignmentFilter');
|
||||
|
||||
expect($contract)->not->toBeEmpty();
|
||||
expect($contract['allowed_select'] ?? [])
|
||||
->not->toContain('roleScopeTagIds');
|
||||
});
|
||||
|
||||
@ -42,8 +42,8 @@
|
||||
$query = $result['query'];
|
||||
$warnings = $result['warnings'];
|
||||
|
||||
expect($query['$select'])->toBe(['id', 'displayName']);
|
||||
expect($query['$expand'])->toBe(['assignments']);
|
||||
expect($query['$select'])->toBe('id,displayName');
|
||||
expect($query['$expand'])->toBe('assignments');
|
||||
expect($warnings)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
|
||||
109
tests/Unit/PolicySnapshotServiceTest.php
Normal file
109
tests/Unit/PolicySnapshotServiceTest.php
Normal file
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\PolicySnapshotService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class PolicySnapshotGraphClient 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];
|
||||
|
||||
return new GraphResponse(success: true, data: [
|
||||
'payload' => [
|
||||
'id' => $policyId,
|
||||
'displayName' => 'Compliance Alpha',
|
||||
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
if (str_contains($path, 'scheduledActionsForRule')) {
|
||||
return new GraphResponse(success: true, data: [
|
||||
'value' => [
|
||||
[
|
||||
'ruleName' => 'Default rule',
|
||||
'scheduledActionConfigurations' => [
|
||||
[
|
||||
'actionType' => 'notification',
|
||||
'notificationTemplateId' => 'template-123',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
}
|
||||
|
||||
it('hydrates compliance policy scheduled actions into snapshots', function () {
|
||||
$client = new PolicySnapshotGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-compliance',
|
||||
'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' => 'compliance-123',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Compliance Alpha',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$service = app(PolicySnapshotService::class);
|
||||
$result = $service->fetch($tenant, $policy);
|
||||
|
||||
expect($result)->toHaveKey('payload');
|
||||
expect($result['payload'])->toHaveKey('scheduledActionsForRule');
|
||||
expect($result['payload']['scheduledActionsForRule'])->toHaveCount(1);
|
||||
expect($result['payload']['scheduledActionsForRule'][0]['scheduledActionConfigurations'][0]['notificationTemplateId'])
|
||||
->toBe('template-123');
|
||||
expect($result['metadata']['compliance_actions_hydration'])->toBe('complete');
|
||||
expect($client->requests[0][0])->toBe('getPolicy');
|
||||
expect($client->requests[0][1])->toBe('deviceCompliancePolicy');
|
||||
expect($client->requests[0][2])->toBe('compliance-123');
|
||||
expect($client->requests[0][3]['expand'] ?? null)
|
||||
->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user