diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 517a762..2474ddc 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,7 @@ use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; +use App\Services\Intune\WindowsUpdateRingNormalizer; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -40,6 +41,7 @@ public function register(): void DeviceConfigurationPolicyNormalizer::class, GroupPolicyConfigurationNormalizer::class, SettingsCatalogPolicyNormalizer::class, + WindowsUpdateRingNormalizer::class, ], 'policy-type-normalizers' ); diff --git a/app/Services/Intune/WindowsUpdateRingNormalizer.php b/app/Services/Intune/WindowsUpdateRingNormalizer.php new file mode 100644 index 0000000..66ec7a0 --- /dev/null +++ b/app/Services/Intune/WindowsUpdateRingNormalizer.php @@ -0,0 +1,137 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $normalized['settings'] = array_values(array_filter( + $normalized['settings'], + fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general' + )); + + $normalized['settings'][] = $this->buildUpdateSettingsBlock($snapshot); + $normalized['settings'][] = $this->buildUserExperienceBlock($snapshot); + $normalized['settings'][] = $this->buildAdvancedOptionsBlock($snapshot); + + $normalized['settings'] = array_values(array_filter($normalized['settings'])); + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function buildUpdateSettingsBlock(array $snapshot): ?array + { + $keys = [ + 'allowWindows11Upgrade', + 'automaticUpdateMode', + 'featureUpdatesDeferralPeriodInDays', + 'featureUpdatesPaused', + 'featureUpdatesPauseExpiryDateTime', + 'qualityUpdatesDeferralPeriodInDays', + 'qualityUpdatesPaused', + 'qualityUpdatesPauseExpiryDateTime', + 'updateWindowsDeviceDriverExclusion', + ]; + + return $this->buildBlock('Update Settings', $snapshot, $keys); + } + + private function buildUserExperienceBlock(array $snapshot): ?array + { + $keys = [ + 'deadlineForFeatureUpdatesInDays', + 'deadlineForQualityUpdatesInDays', + 'deadlineGracePeriodInDays', + 'gracePeriodInDays', + 'restartActiveHoursStart', + 'restartActiveHoursEnd', + 'setActiveHours', + 'userPauseAccess', + 'userCheckAccess', + ]; + + return $this->buildBlock('User Experience', $snapshot, $keys); + } + + private function buildAdvancedOptionsBlock(array $snapshot): ?array + { + $keys = [ + 'deliveryOptimizationMode', + 'prereleaseFeatures', + 'servicingChannel', + 'microsoftUpdateServiceAllowed', + ]; + + return $this->buildBlock('Advanced Options', $snapshot, $keys); + } + + private function buildBlock(string $title, array $snapshot, array $keys): ?array + { + $entries = []; + + foreach ($keys as $key) { + if (array_key_exists($key, $snapshot)) { + $entries[] = [ + 'key' => Str::headline($key), + 'value' => $this->formatValue($snapshot[$key]), + ]; + } + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => $title, + 'entries' => $entries, + ]; + } + + private function formatValue(mixed $value): mixed + { + if (is_bool($value)) { + return $value ? 'Yes' : 'No'; + } + + if (is_array($value)) { + return json_encode($value, JSON_PRETTY_PRINT); + } + + return $value; + } +} diff --git a/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php b/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php new file mode 100644 index 0000000..4082a1a --- /dev/null +++ b/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php @@ -0,0 +1,78 @@ + env('INTUNE_TENANT_ID', 'local-tenant'), + 'name' => 'Tenant One', + 'metadata' => [], + 'is_current' => true, + ]); + + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-wuring', + 'policy_type' => 'windowsUpdateRing', + 'display_name' => 'Windows Update Ring A', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'automaticUpdateMode' => 'autoInstallAtMaintenanceTime', + 'featureUpdatesDeferralPeriodInDays' => 14, + 'deadlineForFeatureUpdatesInDays' => 7, + 'deliveryOptimizationMode' => 'httpWithPeeringNat', + 'qualityUpdatesPaused' => false, + 'userPauseAccess' => 'allow', + ], + ]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->get(PolicyResource::getUrl('view', ['record' => $policy])); + + $response->assertOk(); + + // Check for correct titles and settings from the normalizer + $response->assertSee('Update Settings'); + $response->assertSee('Automatic Update Mode'); + $response->assertSee('autoInstallAtMaintenanceTime'); + $response->assertSee('Feature Updates Deferral Period In Days'); + $response->assertSee('14'); + $response->assertSee('Quality Updates Paused'); + $response->assertSee('No'); + + $response->assertSee('User Experience'); + $response->assertSee('Deadline For Feature Updates In Days'); + $response->assertSee('7'); + $response->assertSee('User Pause Access'); + $response->assertSee('allow'); + + $response->assertSee('Advanced Options'); + $response->assertSee('Delivery Optimization Mode'); + $response->assertSee('httpWithPeeringNat'); + + // $response->assertDontSee('@odata.type'); +}); diff --git a/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php new file mode 100644 index 0000000..78d80f1 --- /dev/null +++ b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php @@ -0,0 +1,126 @@ + []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applied[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-wuring', + 'policy_type' => 'windowsUpdateRing', + 'display_name' => 'Windows Update Ring A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + '@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'automaticUpdateMode' => 'autoInstallAtMaintenanceTime', + 'featureUpdatesDeferralPeriodInDays' => 14, + 'roleScopeTagIds' => ['0'], + ]; + + $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' => $backupPayload, + ]); + + $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($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'restore.executed', + 'resource_id' => (string) $run->id, + ]); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + expect($client->applied)->toHaveCount(1); + expect($client->applied[0]['policyType'])->toBe('windowsUpdateRing'); + expect($client->applied[0]['policyId'])->toBe('policy-wuring'); + expect($client->applied[0]['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime'); +});