Compare commits

...

2 Commits

Author SHA1 Message Date
Ahmed Darrazi
b0c0ebe5ec merge: agent session work 2025-12-29 17:24:05 +01:00
Ahmed Darrazi
e46db605af feat: admin templates hydration + restore 2025-12-29 17:23:41 +01:00
11 changed files with 1027 additions and 0 deletions

View File

@ -8,6 +8,7 @@
use App\Services\Intune\AppProtectionPolicyNormalizer;
use App\Services\Intune\CompliancePolicyNormalizer;
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
use App\Services\Intune\GroupPolicyConfigurationNormalizer;
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
use Illuminate\Support\ServiceProvider;
@ -37,6 +38,7 @@ public function register(): void
AppProtectionPolicyNormalizer::class,
CompliancePolicyNormalizer::class,
DeviceConfigurationPolicyNormalizer::class,
GroupPolicyConfigurationNormalizer::class,
SettingsCatalogPolicyNormalizer::class,
],
'policy-type-normalizers'

View File

@ -108,6 +108,27 @@ public function subresourceSettingsPath(string $policyType, string $policyId): ?
return str_replace('{id}', urlencode($policyId), $path);
}
public function subresourcePath(string $policyType, string $subresourceKey, array $replacements = []): ?string
{
$subresources = config("graph_contracts.types.$policyType.subresources", []);
$subresource = $subresources[$subresourceKey] ?? null;
$path = is_array($subresource) ? ($subresource['path'] ?? null) : null;
if (! is_string($path) || $path === '') {
return null;
}
foreach ($replacements as $key => $value) {
if (! is_string($key) || $key === '') {
continue;
}
$path = str_replace($key, urlencode((string) $value), $path);
}
return $path;
}
public function settingsWriteMethod(string $policyType): ?string
{
$contract = $this->get($policyType);

View File

@ -0,0 +1,162 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class GroupPolicyConfigurationNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'groupPolicyConfiguration';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$definitionValues = $snapshot['definitionValues'] ?? null;
$snapshot = Arr::except($snapshot, ['definitionValues']);
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
if (! is_array($definitionValues) || $definitionValues === []) {
$normalized['warnings'] = array_values(array_unique(array_merge(
$normalized['warnings'] ?? [],
['Administrative Template settings not hydrated for this policy.']
)));
return $normalized;
}
$rows = [];
foreach ($definitionValues as $index => $definitionValue) {
if (! is_array($definitionValue)) {
continue;
}
$definition = $definitionValue['#Definition_displayName'] ?? null;
$definitionId = $definitionValue['#Definition_Id'] ?? null;
$category = $definitionValue['#Definition_categoryPath'] ?? '-';
$enabled = (bool) ($definitionValue['enabled'] ?? false);
$path = $definitionValue['definition@odata.bind'] ?? (is_string($definitionId) ? $definitionId : "definitionValues[{$index}]");
$value = $this->formatGroupPolicyValue($definitionValue, $enabled);
$dataType = $this->inferGroupPolicyDataType($definitionValue);
$rows[] = [
'definition' => is_string($definition) && $definition !== '' ? $definition : 'Definition',
'definition_id' => is_string($definitionId) ? $definitionId : null,
'category' => is_string($category) && $category !== '' ? $category : '-',
'data_type' => $dataType,
'value' => $value,
'description' => '-',
'path' => is_string($path) ? Str::limit($path, 200) : "definitionValues[{$index}]",
'raw' => $definitionValue,
];
}
if ($rows !== []) {
$normalized['settings_table'] = [
'title' => 'Administrative Template settings',
'rows' => $rows,
];
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$normalized = $this->normalize($snapshot ?? [], $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
private function inferGroupPolicyDataType(array $definitionValue): string
{
$presentationValues = $definitionValue['presentationValues'] ?? null;
if (! is_array($presentationValues) || $presentationValues === []) {
return 'Boolean';
}
foreach ($presentationValues as $presentationValue) {
if (! is_array($presentationValue)) {
continue;
}
if (array_key_exists('values', $presentationValue)) {
return 'Choice';
}
if (array_key_exists('value', $presentationValue)) {
$value = $presentationValue['value'];
if (is_bool($value)) {
return 'Boolean';
}
if (is_int($value) || is_float($value) || is_numeric($value)) {
return 'Number';
}
return 'Text';
}
}
return 'Text';
}
private function formatGroupPolicyValue(array $definitionValue, bool $enabled): string
{
$presentationValues = $definitionValue['presentationValues'] ?? null;
if (! is_array($presentationValues) || $presentationValues === []) {
return $enabled ? 'Enabled' : 'Disabled';
}
$parts = [];
foreach ($presentationValues as $presentationValue) {
if (! is_array($presentationValue)) {
continue;
}
$label = $presentationValue['#Presentation_Label'] ?? null;
$value = $presentationValue['value'] ?? null;
$values = $presentationValue['values'] ?? null;
$valueString = match (true) {
is_array($values) => json_encode($values),
is_bool($value) => $value ? 'true' : 'false',
is_scalar($value) => (string) $value,
default => null,
};
if ($valueString === null) {
$clean = Arr::except($presentationValue, ['presentation@odata.bind', '#Presentation_Label', '#Presentation_Id']);
$valueString = $clean !== [] ? json_encode($clean) : null;
}
if (is_string($label) && $label !== '') {
$parts[] = $label.': '.($valueString ?? '-');
} else {
$parts[] = $valueString ?? '-';
}
}
return implode(' | ', array_values(array_filter($parts, static fn ($part) => $part !== '')));
}
}

View File

@ -87,6 +87,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
);
}
if ($policy->policy_type === 'groupPolicyConfiguration') {
[$payload, $metadata] = $this->hydrateGroupPolicyConfiguration(
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
payload: is_array($payload) ? $payload : [],
metadata: $metadata
);
}
if ($policy->policy_type === 'deviceCompliancePolicy') {
[$payload, $metadata] = $this->hydrateComplianceActions(
tenantIdentifier: $tenantIdentifier,
@ -251,6 +261,164 @@ private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant
return [$payload, $metadata];
}
/**
* Hydrate Administrative Templates (Group Policy Configurations) with definitionValues and presentationValues.
*
* @return array{0:array,1:array}
*/
private function hydrateGroupPolicyConfiguration(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$strategy = $this->contracts->memberHydrationStrategy('groupPolicyConfiguration');
$definitionValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'definitionValues', [
'{id}' => $policyId,
]);
if ($strategy !== 'subresource_definition_values' || ! $definitionValuesPath) {
return [$payload, $metadata];
}
$graphBase = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/')
.'/'.trim((string) config('graph.version', 'beta'), '/');
$definitionValues = [];
$nextPath = $definitionValuesPath;
$hydrationStatus = 'complete';
while ($nextPath) {
$response = $this->graphClient->request('GET', $nextPath, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($response->failed()) {
$hydrationStatus = 'failed';
break;
}
$definitionValues = array_merge($definitionValues, $response->data['value'] ?? []);
$nextPath = $response->data['@odata.nextLink'] ?? null;
}
if ($hydrationStatus === 'failed') {
$metadata['warnings'] = array_values(array_unique(array_merge(
$metadata['warnings'] ?? [],
['Hydration failed: could not load Administrative Templates definition values.']
)));
return [$payload, $metadata];
}
$settings = [];
foreach ($definitionValues as $definitionValue) {
if (! is_array($definitionValue)) {
continue;
}
$definition = $definitionValue['definition'] ?? null;
$definitionId = is_array($definition) ? ($definition['id'] ?? null) : null;
$definitionValueId = $definitionValue['id'] ?? null;
if (! is_string($definitionValueId) || $definitionValueId === '') {
continue;
}
if (! is_string($definitionId) || $definitionId === '') {
continue;
}
$presentationValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'presentationValues', [
'{id}' => $policyId,
'{definitionValueId}' => $definitionValueId,
]);
$setting = [
'enabled' => (bool) ($definitionValue['enabled'] ?? false),
'definition@odata.bind' => "{$graphBase}/deviceManagement/groupPolicyDefinitions('{$definitionId}')",
'#Definition_Id' => $definitionId,
'#Definition_displayName' => is_array($definition) ? ($definition['displayName'] ?? null) : null,
'#Definition_classType' => is_array($definition) ? ($definition['classType'] ?? null) : null,
'#Definition_categoryPath' => is_array($definition) ? ($definition['categoryPath'] ?? null) : null,
];
$setting = array_filter($setting, static fn ($value) => $value !== null);
if (! $presentationValuesPath) {
$settings[] = $setting;
continue;
}
$presentationValues = [];
$presentationNext = $presentationValuesPath;
while ($presentationNext) {
$pvResponse = $this->graphClient->request('GET', $presentationNext, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($pvResponse->failed()) {
$metadata['warnings'] = array_values(array_unique(array_merge(
$metadata['warnings'] ?? [],
['Hydration warning: could not load some Administrative Templates presentation values.']
)));
break;
}
$presentationValues = array_merge($presentationValues, $pvResponse->data['value'] ?? []);
$presentationNext = $pvResponse->data['@odata.nextLink'] ?? null;
}
if ($presentationValues !== []) {
$setting['presentationValues'] = [];
foreach ($presentationValues as $presentationValue) {
if (! is_array($presentationValue)) {
continue;
}
$presentation = $presentationValue['presentation'] ?? null;
$presentationId = is_array($presentation) ? ($presentation['id'] ?? null) : null;
if (! is_string($presentationId) || $presentationId === '') {
continue;
}
$cleanPresentationValue = Arr::except($presentationValue, [
'presentation',
'id',
'lastModifiedDateTime',
'createdDateTime',
]);
$cleanPresentationValue['presentation@odata.bind'] = "{$graphBase}/deviceManagement/groupPolicyDefinitions('{$definitionId}')/presentations('{$presentationId}')";
$label = is_array($presentation) ? ($presentation['label'] ?? null) : null;
if (is_string($label) && $label !== '') {
$cleanPresentationValue['#Presentation_Label'] = $label;
}
$cleanPresentationValue['#Presentation_Id'] = $presentationId;
$setting['presentationValues'][] = $cleanPresentationValue;
}
if ($setting['presentationValues'] === []) {
unset($setting['presentationValues']);
}
}
$settings[] = $setting;
}
$payload['definitionValues'] = $settings;
return [$payload, $metadata];
}
/**
* Hydrate compliance policies with scheduled actions (notification templates).
*

View File

@ -473,6 +473,31 @@ public function execute(
$assignmentOutcomes = null;
$assignmentSummary = null;
$restoredAssignments = null;
$definitionValueApply = null;
if (
! $dryRun
&& $item->policy_type === 'groupPolicyConfiguration'
&& is_array($originalPayload)
&& is_array($originalPayload['definitionValues'] ?? null)
&& $originalPayload['definitionValues'] !== []
) {
$definitionValueApply = $this->applyGroupPolicyDefinitionValues(
tenant: $tenant,
tenantIdentifier: $tenantIdentifier,
policyId: $createdPolicyId ?? $item->policy_identifier,
definitionValues: $originalPayload['definitionValues'],
graphOptions: $graphOptions,
context: $context,
);
$definitionSummary = $definitionValueApply['summary'] ?? null;
if (is_array($definitionSummary) && ($definitionSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') {
$itemStatus = 'partial';
$resultReason = 'Administrative Template settings restored with failures';
}
}
if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) {
$assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier;
@ -561,6 +586,11 @@ public function execute(
$result['assignment_summary'] = $assignmentSummary;
}
if (is_array($definitionValueApply)) {
$result['definition_value_outcomes'] = $definitionValueApply['outcomes'] ?? [];
$result['definition_value_summary'] = $definitionValueApply['summary'] ?? null;
}
if ($complianceActionSummary !== null) {
$result['compliance_action_summary'] = $complianceActionSummary;
}
@ -1960,6 +1990,208 @@ private function stripOdataAndReadOnly(array $payload): array
return $clean;
}
/**
* Administrative Templates (groupPolicyConfiguration) restore: wipe existing definitionValues and recreate from snapshot.
*
* @param array<int, mixed> $definitionValues
* @param array<string, mixed> $graphOptions
* @param array<string, mixed> $context
* @return array{outcomes: array<int, array<string, mixed>>, summary: array{success:int,failed:int,skipped:int}}
*/
private function applyGroupPolicyDefinitionValues(
Tenant $tenant,
string $tenantIdentifier,
string $policyId,
array $definitionValues,
array $graphOptions,
array $context,
): array {
$outcomes = [];
$summary = ['success' => 0, 'failed' => 0, 'skipped' => 0];
$listPath = "/deviceManagement/groupPolicyConfigurations/{$policyId}/definitionValues";
$createPath = "/deviceManagement/groupPolicyConfigurations/{$policyId}/definitionValues";
$this->graphLogger->logRequest('restore_group_policy_definition_values_list', $context + [
'method' => 'GET',
'endpoint' => $listPath,
]);
$existingResponse = $this->graphClient->request('GET', $listPath, $graphOptions);
$this->graphLogger->logResponse('restore_group_policy_definition_values_list', $existingResponse, $context + [
'method' => 'GET',
'endpoint' => $listPath,
]);
$existing = $existingResponse->data['value'] ?? [];
foreach ($existing as $existingValue) {
$existingId = is_array($existingValue) ? ($existingValue['id'] ?? null) : null;
if (! is_string($existingId) || $existingId === '') {
continue;
}
$deletePath = "/deviceManagement/groupPolicyConfigurations/{$policyId}/definitionValues/{$existingId}";
$this->graphLogger->logRequest('restore_group_policy_definition_values_delete', $context + [
'method' => 'DELETE',
'endpoint' => $deletePath,
'definition_value_id' => $existingId,
]);
$deleteResponse = $this->graphClient->request('DELETE', $deletePath, $graphOptions);
$this->graphLogger->logResponse('restore_group_policy_definition_values_delete', $deleteResponse, $context + [
'method' => 'DELETE',
'endpoint' => $deletePath,
'definition_value_id' => $existingId,
]);
}
foreach ($definitionValues as $definitionValue) {
if (! is_array($definitionValue)) {
continue;
}
$displayName = $definitionValue['#Definition_displayName'] ?? null;
$definitionId = $definitionValue['#Definition_Id'] ?? null;
$sanitized = $this->sanitizeGroupPolicyDefinitionValue($definitionValue);
if (! isset($sanitized['definition@odata.bind'])) {
$outcomes[] = [
'status' => 'skipped',
'definition_id' => $definitionId,
'definition' => $displayName,
'reason' => 'Missing definition@odata.bind',
];
$summary['skipped']++;
continue;
}
$this->graphLogger->logRequest('restore_group_policy_definition_values_create', $context + [
'method' => 'POST',
'endpoint' => $createPath,
'definition_id' => $definitionId,
'definition' => $displayName,
]);
$createResponse = $this->graphClient->request('POST', $createPath, [
'json' => $sanitized,
] + $graphOptions);
$this->graphLogger->logResponse('restore_group_policy_definition_values_create', $createResponse, $context + [
'method' => 'POST',
'endpoint' => $createPath,
'definition_id' => $definitionId,
'definition' => $displayName,
]);
if ($createResponse->successful()) {
$outcomes[] = [
'status' => 'success',
'definition_id' => $definitionId,
'definition' => $displayName,
];
$summary['success']++;
} else {
$outcomes[] = array_filter([
'status' => 'failed',
'definition_id' => $definitionId,
'definition' => $displayName,
'reason' => $createResponse->meta['error_message'] ?? 'Graph create failed',
'graph_error_message' => $createResponse->meta['error_message'] ?? null,
'graph_error_code' => $createResponse->meta['error_code'] ?? null,
'graph_request_id' => $createResponse->meta['request_id'] ?? null,
'graph_client_request_id' => $createResponse->meta['client_request_id'] ?? null,
], static fn ($value) => $value !== null);
$summary['failed']++;
}
usleep(100000);
}
$this->auditLogger->log(
tenant: $tenant,
action: 'restore.group_policy_definition_values.applied',
context: [
'metadata' => [
'tenant' => $tenantIdentifier,
'policy_id' => $policyId,
'summary' => $summary,
],
],
status: ($summary['failed'] ?? 0) > 0 ? 'warning' : 'success',
resourceType: 'policy',
resourceId: $policyId
);
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
/**
* @param array<string, mixed> $definitionValue
* @return array<string, mixed>
*/
private function sanitizeGroupPolicyDefinitionValue(array $definitionValue): array
{
$clean = [];
foreach ($definitionValue as $key => $value) {
if (is_string($key) && str_starts_with($key, '#')) {
continue;
}
if ($key === 'id') {
continue;
}
if ($key === 'presentationValues' && is_array($value)) {
$cleanPresentationValues = [];
foreach ($value as $presentationValue) {
if (! is_array($presentationValue)) {
continue;
}
$presentationClean = [];
foreach ($presentationValue as $pKey => $pValue) {
if (is_string($pKey) && str_starts_with($pKey, '#')) {
continue;
}
if (in_array($pKey, ['id', 'createdDateTime', 'lastModifiedDateTime', 'presentation'], true)) {
continue;
}
$presentationClean[$pKey] = $pValue;
}
if ($presentationClean !== []) {
$cleanPresentationValues[] = $presentationClean;
}
}
if ($cleanPresentationValues !== []) {
$clean['presentationValues'] = $cleanPresentationValues;
}
continue;
}
$clean[$key] = $value;
}
return $clean;
}
private function assertActiveContext(Tenant $tenant, BackupSet $backupSet): void
{
if (! $tenant->isActive()) {

View File

@ -48,6 +48,26 @@
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'update_strip_keys' => [
'definitionValues',
],
'member_hydration_strategy' => 'subresource_definition_values',
'subresources' => [
'definitionValues' => [
'path' => 'deviceManagement/groupPolicyConfigurations/{id}/definitionValues?$expand=definition',
'collection' => true,
'paging' => true,
'allowed_select' => [],
'allowed_expand' => [],
],
'presentationValues' => [
'path' => 'deviceManagement/groupPolicyConfigurations/{id}/definitionValues/{definitionValueId}/presentationValues?$expand=presentation',
'collection' => true,
'paging' => true,
'allowed_select' => [],
'allowed_expand' => [],
],
],
'assignments_list_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assign',
'assignments_create_method' => 'POST',

View File

@ -0,0 +1,22 @@
# Implementation Plan: Administrative Templates (010)
**Branch**: `feat/010-admin-templates`
**Date**: 2025-12-29
**Spec Source**: [spec.md](./spec.md)
## Summary
Make `groupPolicyConfiguration` snapshots/restores accurate by hydrating and applying `definitionValues` and their `presentationValues`, and present a readable normalized view in Filament.
## Execution Steps
1. Graph contract updates
- Add subresource/hydration metadata for `definitionValues` + `presentationValues`.
2. Snapshot capture hydration
- Extend `PolicySnapshotService` to hydrate Admin Template settings into the payload.
3. Restore
- Extend `RestoreService` to “wipe and replace” definitionValues/presentationValues from snapshot.
4. UI normalization
- Add a normalizer that renders configured settings as readable rows.
5. Tests + formatting
- Add targeted Pest tests for snapshot hydration, normalized display, and restore.
- Run `./vendor/bin/pint --dirty` and the affected tests.

View File

@ -0,0 +1,52 @@
# Feature Specification: Administrative Templates (Group Policy Configurations) (010)
**Feature Branch**: `feat/010-admin-templates`
**Created**: 2025-12-29
**Status**: Draft
**Input**: `.specify/spec.md` (groupPolicyConfiguration scope), `references/IntuneManagement-master` (definitionValues/presentationValues pattern)
## Overview
Add reliable coverage for **Administrative Templates** (`groupPolicyConfiguration`) in the existing inventory/backup/version/restore flows.
Administrative Templates are not fully represented by the base entity alone; the effective policy settings live in:
- `definitionValues` (with expanded `definition`)
- `presentationValues` per definitionValue (with expanded `presentation`)
## In Scope
- Policy type: `groupPolicyConfiguration` (`deviceManagement/groupPolicyConfigurations`)
- Snapshot capture hydrates:
- `definitionValues?$expand=definition`
- `presentationValues?$expand=presentation` for each definitionValue
- Restore supports “snapshot as source of truth” for Admin Templates settings:
- delete existing definitionValues
- recreate definitionValues + presentationValues from snapshot
- UI shows a readable “Normalized settings” view for Admin Templates (definitions + values).
## Out of Scope (v1)
- Translating every ADMX value into Intune-portal-identical wording for every template
- Advanced partial-restore / per-setting selection
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Inventory + readable view (P1)
As an admin, I can open an Administrative Template policy and see its effective configured settings (not only metadata).
**Acceptance**
1. Policy detail shows a structured list/table of configured settings (definition + value).
2. Policy Versions store the hydrated settings and render them in “Normalized settings”.
### User Story 2 — Backup/Version capture includes definition values (P1)
As an admin, a backup/version of an Administrative Template includes the `definitionValues` + `presentationValues`.
**Acceptance**
1. Backup payload contains `definitionValues` array.
2. Each definitionValue includes expanded `definition` and a `presentationValues` collection (when present).
### User Story 3 — Restore settings (P1)
As an admin, restoring an Administrative Template brings the target tenants definition values back to the snapshot state.
**Acceptance**
1. Restore deletes existing definitionValues before recreate.
2. Restore recreates definitionValues and their presentationValues.
3. Clear per-item audit outcomes on failures.

View File

@ -0,0 +1,21 @@
# Tasks: Administrative Templates (Group Policy Configurations) (010)
**Branch**: `feat/010-admin-templates` | **Date**: 2025-12-29
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
## Phase 1: Contracts + Snapshot Hydration
- [ ] T001 Extend `config/graph_contracts.php` for `groupPolicyConfiguration` (hydration/subresources metadata).
- [ ] T002 Hydrate `definitionValues` (+ `presentationValues`) in `app/Services/Intune/PolicySnapshotService.php`.
## Phase 2: Restore (Definition Values)
- [ ] T003 Implement restore apply for `definitionValues` and `presentationValues` in `app/Services/Intune/RestoreService.php`.
## Phase 3: UI Normalization
- [ ] T004 Add `GroupPolicyConfigurationNormalizer` and register it (Policy “Normalized settings” is readable).
## Phase 4: Tests + Verification
- [ ] T005 Add tests for hydration + UI display.
- [ ] T006 Add tests for restore definitionValues apply.
- [ ] T007 Run tests (targeted).
- [ ] T008 Run Pint (`./vendor/bin/pint --dirty`).

View File

@ -0,0 +1,144 @@
<?php
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\BackupService;
use App\Services\Intune\VersionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class GroupPolicyHydrationGraphClient implements GraphClientInterface
{
public array $requests = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
$this->requests[] = ['getPolicy', $policyType, $policyId];
return new GraphResponse(true, ['payload' => [
'id' => $policyId,
'displayName' => 'Admin Templates Alpha',
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
]]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$this->requests[] = [strtoupper($method), $path];
if (str_contains($path, '/definitionValues') && str_contains($path, '$expand=definition')) {
return new GraphResponse(true, [
'value' => [
[
'id' => 'dv-1',
'enabled' => true,
'definition' => [
'id' => 'def-1',
'displayName' => 'Block legacy auth',
'classType' => 'user',
'categoryPath' => 'Windows Components\\Security Options',
],
],
],
]);
}
if (str_contains($path, '/presentationValues') && str_contains($path, '$expand=presentation')) {
return new GraphResponse(true, [
'value' => [
[
'id' => 'pv-1',
'value' => 'enabled',
'presentation' => [
'id' => 'pres-1',
'label' => 'State',
],
],
],
]);
}
return new GraphResponse(true, []);
}
}
test('group policy configuration snapshot hydrates definition values and renders in policy detail', function () {
$client = new GroupPolicyHydrationGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
'tenant_id' => 'tenant-gpo-hydration',
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id;
$_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id;
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'gpo-hydrate',
'policy_type' => 'groupPolicyConfiguration',
'display_name' => 'Admin Templates Alpha',
'platform' => 'windows',
]);
/** @var BackupService $backupService */
$backupService = app(BackupService::class);
$backupSet = $backupService->createBackupSet($tenant, [$policy->id], actorEmail: 'tester@example.com');
$item = $backupSet->items()->first();
expect($item->payload)->toHaveKey('definitionValues');
expect($item->payload['definitionValues'])->toBeArray();
expect($item->payload['definitionValues'][0])->toHaveKey('definition@odata.bind');
expect($item->payload['definitionValues'][0])->toHaveKey('presentationValues');
expect($item->payload['definitionValues'][0]['presentationValues'][0])->toHaveKey('presentation@odata.bind');
expect($item->payload['definitionValues'][0]['#Definition_displayName'])->toBe('Block legacy auth');
expect($item->payload['definitionValues'][0]['presentationValues'][0]['#Presentation_Label'])->toBe('State');
/** @var VersionService $versions */
$versions = app(VersionService::class);
$versions->captureVersion(
policy: $policy,
payload: $item->payload,
createdBy: 'tester@example.com',
metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id],
);
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get(route('filament.admin.resources.policies.view', ['record' => $policy]));
$response->assertOk();
$response->assertSee('Block legacy auth');
$response->assertSee('State');
});

View File

@ -0,0 +1,183 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class GroupPolicyRestoreGraphClient implements GraphClientInterface
{
/**
* @var array<int, array{policy_type:string,policy_id:string,payload:array}>
*/
public array $applyPolicyCalls = [];
/**
* @var array<int, array{method:string,path:string,payload:array|null}>
*/
public array $requestCalls = [];
/**
* @param array<int, GraphResponse> $requestResponses
*/
public function __construct(
private readonly GraphResponse $applyPolicyResponse,
private array $requestResponses = [],
) {}
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->applyPolicyCalls[] = [
'policy_type' => $policyType,
'policy_id' => $policyId,
'payload' => $payload,
];
return $this->applyPolicyResponse;
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$this->requestCalls[] = [
'method' => strtoupper($method),
'path' => $path,
'payload' => $options['json'] ?? null,
];
$response = array_shift($this->requestResponses);
return $response ?? new GraphResponse(true, []);
}
}
test('restore applies administrative template definition values', function () {
$policyResponse = new GraphResponse(true, [], 200, [], [], ['request_id' => 'req-policy', 'client_request_id' => 'client-policy']);
$listExisting = new GraphResponse(true, [
'value' => [
['id' => 'existing-dv-1'],
],
]);
$deleteExisting = new GraphResponse(true, []);
$createDefinitionValue = new GraphResponse(true, []);
$client = new GroupPolicyRestoreGraphClient($policyResponse, [
$listExisting,
$deleteExisting,
$createDefinitionValue,
]);
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
'tenant_id' => 'tenant-gpo-restore',
'name' => 'Tenant One',
'metadata' => [],
]);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'gpo-1',
'policy_type' => 'groupPolicyConfiguration',
'display_name' => 'Admin Templates Alpha',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$payload = [
'id' => 'gpo-1',
'displayName' => 'Admin Templates Alpha',
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
'definitionValues' => [
[
'enabled' => true,
'definition@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')',
'#Definition_Id' => 'def-1',
'#Definition_displayName' => 'Block legacy auth',
'presentationValues' => [
[
'presentation@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')/presentations(\'pres-1\')',
'#Presentation_Label' => 'State',
'#Presentation_Id' => 'pres-1',
'value' => 'enabled',
],
],
],
],
];
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => $payload,
]);
$user = User::factory()->create();
$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,
)->refresh();
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
expect($run->results[0]['definition_value_summary']['success'])->toBe(1);
expect($client->applyPolicyCalls)->toHaveCount(1);
expect($client->applyPolicyCalls[0]['policy_type'])->toBe('groupPolicyConfiguration');
expect($client->requestCalls)->toHaveCount(3);
expect($client->requestCalls[0]['method'])->toBe('GET');
expect($client->requestCalls[0]['path'])->toContain('/deviceManagement/groupPolicyConfigurations/gpo-1/definitionValues');
expect($client->requestCalls[1]['method'])->toBe('DELETE');
expect($client->requestCalls[1]['path'])->toContain('/deviceManagement/groupPolicyConfigurations/gpo-1/definitionValues/existing-dv-1');
expect($client->requestCalls[2]['method'])->toBe('POST');
expect($client->requestCalls[2]['path'])->toContain('/deviceManagement/groupPolicyConfigurations/gpo-1/definitionValues');
expect($client->requestCalls[2]['payload'])->toBeArray();
expect($client->requestCalls[2]['payload'])->toHaveKey('definition@odata.bind');
expect($client->requestCalls[2]['payload'])->not->toHaveKey('#Definition_displayName');
});