feat/012-windows-update-rings #18

Merged
ahmido merged 24 commits from feat/012-windows-update-rings into dev 2026-01-01 10:44:18 +00:00
4 changed files with 343 additions and 0 deletions
Showing only changes of commit c1fbb4620f - Show all commits

View File

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

View File

@ -0,0 +1,137 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Str;
class WindowsUpdateRingNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'windowsUpdateRing';
}
/**
* @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 ?? [];
$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<string, mixed>
*/
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;
}
}

View File

@ -0,0 +1,78 @@
<?php
use App\Filament\Resources\PolicyResource;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('policy detail shows normalized settings for windows update ring', function () {
$tenant = Tenant::create([
'tenant_id' => 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');
});

View File

@ -0,0 +1,126 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
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);
test('restore execution applies windows update ring and records audit log', function () {
$client = new class implements GraphClientInterface
{
public array $applied = [];
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->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');
});