feat/012-windows-update-rings (#18)

Created a safe session branch, committed everything, fast-forward merged back into feat/012-windows-update-rings, then pushed.
Commit: 074a656 feat(rings): update rings + update profiles
Push is done; upstream tracking is se

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #18
This commit is contained in:
ahmido 2026-01-01 10:44:17 +00:00
parent b048131f81
commit 286d3c596b
40 changed files with 1298 additions and 49 deletions

View File

@ -104,13 +104,17 @@ public function makeCurrent(): void
DB::transaction(function () { DB::transaction(function () {
static::activeQuery()->update(['is_current' => false]); static::activeQuery()->update(['is_current' => false]);
$this->forceFill(['is_current' => true])->save(); static::query()
->whereKey($this->getKey())
->update(['is_current' => true]);
}); });
$this->forceFill(['is_current' => true]);
} }
public static function current(): self public static function current(): self
{ {
$envTenantId = env('INTUNE_TENANT_ID') ?: null; $envTenantId = getenv('INTUNE_TENANT_ID') ?: null;
if ($envTenantId) { if ($envTenantId) {
$tenant = static::activeQuery() $tenant = static::activeQuery()

View File

@ -10,6 +10,9 @@
use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
use App\Services\Intune\GroupPolicyConfigurationNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer;
use App\Services\Intune\SettingsCatalogPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer;
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
use App\Services\Intune\WindowsUpdateRingNormalizer;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@ -40,6 +43,9 @@ public function register(): void
DeviceConfigurationPolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class,
GroupPolicyConfigurationNormalizer::class, GroupPolicyConfigurationNormalizer::class,
SettingsCatalogPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class,
WindowsFeatureUpdateProfileNormalizer::class,
WindowsQualityUpdateProfileNormalizer::class,
WindowsUpdateRingNormalizer::class,
], ],
'policy-type-normalizers' 'policy-type-normalizers'
); );

View File

@ -77,6 +77,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
$metadata = Arr::except($response->data, ['payload']); $metadata = Arr::except($response->data, ['payload']);
$metadataWarnings = $metadata['warnings'] ?? []; $metadataWarnings = $metadata['warnings'] ?? [];
if ($policy->policy_type === 'windowsUpdateRing') {
[$payload, $metadata] = $this->hydrateWindowsUpdateRing(
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
payload: is_array($payload) ? $payload : [],
metadata: $metadata,
);
}
if ($policy->policy_type === 'settingsCatalogPolicy') { if ($policy->policy_type === 'settingsCatalogPolicy') {
[$payload, $metadata] = $this->hydrateSettingsCatalog( [$payload, $metadata] = $this->hydrateSettingsCatalog(
tenantIdentifier: $tenantIdentifier, tenantIdentifier: $tenantIdentifier,
@ -152,6 +162,57 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
]; ];
} }
/**
* Hydrate Windows Update Ring payload via derived type cast to capture
* windowsUpdateForBusinessConfiguration-specific properties.
*
* @return array{0:array,1:array}
*/
private function hydrateWindowsUpdateRing(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$odataType = $payload['@odata.type'] ?? null;
$castSegment = $this->deriveTypeCastSegment($odataType);
if ($castSegment === null) {
$metadata['properties_hydration'] = 'skipped';
return [$payload, $metadata];
}
$castPath = sprintf('deviceManagement/deviceConfigurations/%s/%s', urlencode($policyId), $castSegment);
$response = $this->graphClient->request('GET', $castPath, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($response->failed() || ! is_array($response->data)) {
$metadata['properties_hydration'] = 'failed';
return [$payload, $metadata];
}
$metadata['properties_hydration'] = 'complete';
return [array_merge($payload, $response->data), $metadata];
}
private function deriveTypeCastSegment(mixed $odataType): ?string
{
if (! is_string($odataType) || $odataType === '') {
return null;
}
if (! str_starts_with($odataType, '#')) {
return null;
}
$segment = ltrim($odataType, '#');
return $segment !== '' ? $segment : null;
}
private function isMetadataOnlyPolicyType(string $policyType): bool private function isMetadataOnlyPolicyType(string $policyType): bool
{ {
foreach (config('tenantpilot.supported_policy_types', []) as $type) { foreach (config('tenantpilot.supported_policy_types', []) as $type) {

View File

@ -555,6 +555,23 @@ public function execute(
$payload, $payload,
$graphOptions + ['method' => $updateMethod] $graphOptions + ['method' => $updateMethod]
); );
} elseif ($item->policy_type === 'windowsUpdateRing') {
$odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']);
$castSegment = $odataType && str_starts_with($odataType, '#')
? ltrim($odataType, '#')
: 'microsoft.graph.windowsUpdateForBusinessConfiguration';
$updatePath = sprintf(
'deviceManagement/deviceConfigurations/%s/%s',
urlencode($item->policy_identifier),
$castSegment,
);
$response = $this->graphClient->request(
$updateMethod,
$updatePath,
['json' => $payload] + Arr::except($graphOptions, ['platform'])
);
} else { } else {
$response = $this->graphClient->applyPolicy( $response = $this->graphClient->applyPolicy(
$item->policy_type, $item->policy_type,

View File

@ -0,0 +1,107 @@
<?php
namespace App\Services\Intune;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
class WindowsFeatureUpdateProfileNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'windowsFeatureUpdateProfile';
}
/**
* @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'][] = $this->buildFeatureUpdateBlock($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 buildFeatureUpdateBlock(array $snapshot): ?array
{
$entries = [];
$displayName = Arr::get($snapshot, 'displayName');
if (is_string($displayName) && $displayName !== '') {
$entries[] = ['key' => 'Name', 'value' => $displayName];
}
$version = Arr::get($snapshot, 'featureUpdateVersion');
if (is_string($version) && $version !== '') {
$entries[] = ['key' => 'Feature update version', 'value' => $version];
}
$rollout = Arr::get($snapshot, 'rolloutSettings');
if (is_array($rollout)) {
$start = $this->formatDateTime($rollout['offerStartDateTimeInUTC'] ?? null);
$end = $this->formatDateTime($rollout['offerEndDateTimeInUTC'] ?? null);
$interval = $rollout['offerIntervalInDays'] ?? null;
if ($start !== null) {
$entries[] = ['key' => 'Rollout start', 'value' => $start];
}
if ($end !== null) {
$entries[] = ['key' => 'Rollout end', 'value' => $end];
}
if ($interval !== null) {
$entries[] = ['key' => 'Rollout interval (days)', 'value' => $interval];
}
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Feature Update Profile',
'entries' => $entries,
];
}
private function formatDateTime(mixed $value): ?string
{
if (! is_string($value) || $value === '') {
return null;
}
try {
return CarbonImmutable::parse($value)->toDateTimeString();
} catch (\Throwable) {
return $value;
}
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Arr;
class WindowsQualityUpdateProfileNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'windowsQualityUpdateProfile';
}
/**
* @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;
}
$block = $this->buildQualityUpdateBlock($snapshot);
if ($block !== null) {
$normalized['settings'][] = $block;
$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 buildQualityUpdateBlock(array $snapshot): ?array
{
$entries = [];
$displayName = Arr::get($snapshot, 'displayName');
if (is_string($displayName) && $displayName !== '') {
$entries[] = ['key' => 'Name', 'value' => $displayName];
}
$release = Arr::get($snapshot, 'releaseDateDisplayName');
if (is_string($release) && $release !== '') {
$entries[] = ['key' => 'Release', 'value' => $release];
}
$content = Arr::get($snapshot, 'deployableContentDisplayName');
if (is_string($content) && $content !== '') {
$entries[] = ['key' => 'Deployable content', 'value' => $content];
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Quality Update Profile',
'entries' => $entries,
];
}
}

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

@ -29,6 +29,14 @@ protected static function odataTypeMap(): array
'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', 'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', 'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
], ],
'windowsFeatureUpdateProfile' => [
'windows' => '#microsoft.graph.windowsFeatureUpdateProfile',
'all' => '#microsoft.graph.windowsFeatureUpdateProfile',
],
'windowsQualityUpdateProfile' => [
'windows' => '#microsoft.graph.windowsQualityUpdateProfile',
'all' => '#microsoft.graph.windowsQualityUpdateProfile',
],
'deviceCompliancePolicy' => [ 'deviceCompliancePolicy' => [
'windows' => '#microsoft.graph.windows10CompliancePolicy', 'windows' => '#microsoft.graph.windows10CompliancePolicy',
'ios' => '#microsoft.graph.iosCompliancePolicy', 'ios' => '#microsoft.graph.iosCompliancePolicy',

View File

@ -143,6 +143,13 @@
'update_method' => 'PATCH', 'update_method' => 'PATCH',
'id_field' => 'id', 'id_field' => 'id',
'hydration' => 'properties', 'hydration' => 'properties',
'update_strip_keys' => [
'version',
'qualityUpdatesPauseStartDate',
'featureUpdatesPauseStartDate',
'qualityUpdatesWillBeRolledBack',
'featureUpdatesWillBeRolledBack',
],
'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments', 'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign', 'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign',
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
@ -153,6 +160,52 @@
'supports_scope_tags' => true, 'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds', 'scope_tag_field' => 'roleScopeTagIds',
], ],
'windowsFeatureUpdateProfile' => [
'resource' => 'deviceManagement/windowsFeatureUpdateProfiles',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.windowsFeatureUpdateProfile',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'update_strip_keys' => [
'deployableContentDisplayName',
'endOfSupportDate',
],
'assignments_list_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments',
'assignments_create_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'windowsQualityUpdateProfile' => [
'resource' => 'deviceManagement/windowsQualityUpdateProfiles',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.windowsQualityUpdateProfile',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'update_strip_keys' => [
'releaseDateDisplayName',
'deployableContentDisplayName',
],
'assignments_list_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments',
'assignments_create_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'deviceCompliancePolicy' => [ 'deviceCompliancePolicy' => [
'resource' => 'deviceManagement/deviceCompliancePolicies', 'resource' => 'deviceManagement/deviceCompliancePolicies',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],

View File

@ -8,7 +8,7 @@
'category' => 'Configuration', 'category' => 'Configuration',
'platform' => 'all', 'platform' => 'all',
'endpoint' => 'deviceManagement/deviceConfigurations', 'endpoint' => 'deviceManagement/deviceConfigurations',
'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'", 'filter' => "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',
@ -39,11 +39,31 @@
'category' => 'Update Management', 'category' => 'Update Management',
'platform' => 'windows', 'platform' => 'windows',
'endpoint' => 'deviceManagement/deviceConfigurations', 'endpoint' => 'deviceManagement/deviceConfigurations',
'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'", 'filter' => "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium-high', 'risk' => 'medium-high',
], ],
[
'type' => 'windowsFeatureUpdateProfile',
'label' => 'Feature Updates (Windows)',
'category' => 'Update Management',
'platform' => 'windows',
'endpoint' => 'deviceManagement/windowsFeatureUpdateProfiles',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'high',
],
[
'type' => 'windowsQualityUpdateProfile',
'label' => 'Quality Updates (Windows)',
'category' => 'Update Management',
'platform' => 'windows',
'endpoint' => 'deviceManagement/windowsQualityUpdateProfiles',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'high',
],
[ [
'type' => 'deviceCompliancePolicy', 'type' => 'deviceCompliancePolicy',
'label' => 'Device Compliance', 'label' => 'Device Compliance',
@ -130,7 +150,7 @@
'category' => 'Enrollment', 'category' => 'Enrollment',
'platform' => 'all', 'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'", 'filter' => "isof('microsoft.graph.windows10EnrollmentCompletionPageConfiguration')",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',

View File

@ -18,7 +18,9 @@
</include> </include>
</source> </source>
<php> <php>
<ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="INTUNE_TENANT_ID" value="" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>

View File

@ -0,0 +1,18 @@
# Implementation Plan: Windows Update Rings (012)
**Branch**: `feat/012-windows-update-rings`
**Date**: 2025-12-31
**Spec Source**: [spec.md](./spec.md)
## Summary
Make `windowsUpdateRing` snapshots/restores accurate by correctly capturing and applying its settings, and present a readable normalized view in Filament.
Also add coverage for Windows Feature Update Profiles (`windowsFeatureUpdateProfile`) and Windows Quality Update Profiles (`windowsQualityUpdateProfile`) so they can be synced, snapshotted, restored, and displayed in a readable normalized format.
## Execution Steps
1. **Graph contract verification**: Ensure `config/graph_contracts.php` entry for `windowsUpdateRing` is correct and complete.
2. **Snapshot capture hydration**: Extend `PolicySnapshotService` to correctly hydrate `windowsUpdateForBusinessConfiguration` settings into the policy payload.
3. **Restore**: Extend `RestoreService` to apply `windowsUpdateRing` settings from a snapshot to the target policy in Intune.
4. **UI normalization**: Add a dedicated normalizer for `windowsUpdateRing` that renders configured settings as readable rows in the Filament UI.
5. **Feature/Quality Update Profiles**: Add Graph contract + supported types, and normalizers for `windowsFeatureUpdateProfile` and `windowsQualityUpdateProfile`.
6. **Tests + formatting**: Add targeted Pest tests for sync filters/types, snapshot/normalized display (as applicable), and restore payload sanitization. Run `./vendor/bin/pint --dirty` and the affected tests.

View File

@ -0,0 +1,77 @@
# Feature Specification: Windows Update Rings (012)
**Feature Branch**: `feat/012-windows-update-rings`
**Created**: 2025-12-31
**Status**: Draft
**Input**: `config/graph_contracts.php` (windowsUpdateRing scope)
## Overview
Add reliable coverage for **Windows Update Rings** (`windowsUpdateRing`) in the existing inventory/backup/version/restore flows.
This feature also extends coverage to **Windows Feature Update Profiles** ("Feature Updates"), which are managed under the `deviceManagement/windowsFeatureUpdateProfiles` endpoint and have `@odata.type` `#microsoft.graph.windowsFeatureUpdateProfile`.
This feature also extends coverage to **Windows Quality Update Profiles** ("Quality Updates"), which are managed under the `deviceManagement/windowsQualityUpdateProfiles` endpoint and have `@odata.type` `#microsoft.graph.windowsQualityUpdateProfile`.
This policy type is defined in `graph_contracts.php` and uses the `deviceManagement/deviceConfigurations` endpoint, identified by the `@odata.type` `#microsoft.graph.windowsUpdateForBusinessConfiguration`. This feature will focus on implementing the necessary UI normalization and ensuring the sync, backup, versioning, and restore flows function correctly for this policy type.
## In Scope
- Policy type: `windowsUpdateRing`
- Sync: Policies with `@odata.type` of `#microsoft.graph.windowsUpdateForBusinessConfiguration` should be correctly identified and synced as `windowsUpdateRing` policies.
- Snapshot capture: Full snapshot of all settings within a Windows Update Ring policy.
- Restore: Restore a Windows Update Ring policy from a snapshot.
- UI: Display the settings of a Windows Update Ring policy in a readable, normalized format.
- Policy type: `windowsFeatureUpdateProfile`
- Sync: Feature Update Profiles should be listed and synced from `deviceManagement/windowsFeatureUpdateProfiles`.
- Snapshot capture: Full snapshot of the Feature Update Profile payload.
- Restore: Restore a Feature Update Profile from a snapshot.
- UI: Display the key settings of a Feature Update Profile in a readable, normalized format.
- Policy type: `windowsQualityUpdateProfile`
- Sync: Quality Update Profiles should be listed and synced from `deviceManagement/windowsQualityUpdateProfiles`.
- Snapshot capture: Full snapshot of the Quality Update Profile payload.
- Restore: Restore a Quality Update Profile from a snapshot.
- UI: Display the key settings of a Quality Update Profile in a readable, normalized format.
## Out of Scope (v1)
- Advanced analytics or reporting on update compliance.
- Per-setting partial restore.
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Inventory + readable view
As an admin, I can see my Windows Update Ring policies in the policy list and view their configured settings in a clear, understandable format.
**Acceptance**
1. Windows Update Ring policies are listed in the main policy table with the correct type name.
2. The policy detail view shows a structured list/table of configured settings (e.g., "Quality update deferral period", "Automatic update behavior").
3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view.
### User Story 2 — Backup/Version capture
As an admin, when I back up or create a new version of a Windows Update Ring policy, the snapshot contains all its settings.
**Acceptance**
1. The backup/version payload in the `snapshot` column contains all the properties of the `windowsUpdateForBusinessConfiguration` object.
### User Story 3 — Restore settings
As an admin, I can restore a Windows Update Ring policy from a backup or a previous version.
**Acceptance**
1. The restore operation correctly applies the settings from the snapshot to the target policy in Intune.
2. The restore process is audited.
### User Story 4 — Feature Updates inventory + readable view
As an admin, I can see my Windows Feature Update Profiles in the policy list and view their configured rollout/version settings in a clear, understandable format.
**Acceptance**
1. Feature Update Profiles are listed in the main policy table with the correct type name.
2. The policy detail view shows a structured list/table of configured settings (e.g., feature update version, rollout window).
3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view.
### User Story 5 — Quality Updates inventory + readable view
As an admin, I can see my Windows Quality Update Profiles in the policy list and view their configured release/content settings in a clear, understandable format.
**Acceptance**
1. Quality Update Profiles are listed in the main policy table with the correct type name.
2. The policy detail view shows a structured list/table of configured settings (e.g., release, deployable content).
3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view.

View File

@ -0,0 +1,26 @@
# Tasks: Windows Update Rings (012)
**Branch**: `feat/012-windows-update-rings` | **Date**: 2025-12-31
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
## Phase 1: Contracts + Snapshot Hydration
- [X] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.).
- [X] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings.
- [X] T001b Fix Graph filters for update rings (`isof(...)`) and add `windowsFeatureUpdateProfile` support.
## Phase 2: Restore
- [X] T003 Implement restore apply for `windowsUpdateRing` settings in `RestoreService.php`.
## Phase 3: UI Normalization
- [X] T004 Add `WindowsUpdateRingNormalizer` and register it (Policy “Normalized settings” is readable).
- [X] T004b Add `WindowsFeatureUpdateProfileNormalizer` and register it (Policy “Normalized settings” is readable).
- [X] T004c Add `WindowsQualityUpdateProfileNormalizer` and register it (Policy “Normalized settings” is readable).
## Phase 4: Tests + Verification
- [X] T005 Add tests for sync filters + supported types.
- [X] T006 Add tests for restore apply.
- [X] T007 Run tests (targeted).
- [X] T008 Run Pint (`./vendor/bin/pint --dirty`).
## Open TODOs (Follow-up)
- None yet.

View File

@ -11,6 +11,7 @@
test('progress widget shows running operations for current tenant and user', function () { test('progress widget shows running operations for current tenant and user', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create(); $user = User::factory()->create();
// Own running op // Own running op
@ -39,9 +40,6 @@
'status' => 'running', 'status' => 'running',
]); ]);
// $tenant->makeCurrent();
$tenant->forceFill(['is_current' => true])->save();
auth()->login($user); // Login user explicitly for auth()->id() call in component auth()->login($user); // Login user explicitly for auth()->id() call in component
Livewire::actingAs($user) Livewire::actingAs($user)

View File

@ -12,7 +12,7 @@
test('policy detail shows app protection settings in readable sections', function () { test('policy detail shows app protection settings in readable sections', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -73,7 +73,7 @@ public function request(string $method, string $path, array $options = []): Grap
}); });
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
]); ]);

View File

@ -23,6 +23,8 @@
'name' => 'Tenant', 'name' => 'Tenant',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set 1', 'name' => 'Set 1',
@ -60,6 +62,8 @@
'name' => 'Tenant 2', 'name' => 'Tenant 2',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set with restore', 'name' => 'Set with restore',
@ -93,6 +97,8 @@
'name' => 'Tenant Force', 'name' => 'Tenant Force',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set force', 'name' => 'Set force',
@ -132,6 +138,8 @@
'name' => 'Tenant Restore Backup Set', 'name' => 'Tenant Restore Backup Set',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set restore', 'name' => 'Set restore',
@ -171,6 +179,8 @@
'name' => 'Tenant Restore Run', 'name' => 'Tenant Restore Run',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set RR', 'name' => 'Set RR',
@ -207,6 +217,8 @@
'name' => 'Tenant Restore Restore Run', 'name' => 'Tenant Restore Restore Run',
]); ]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => 'Set for restore run restore', 'name' => 'Set for restore run restore',

View File

@ -13,7 +13,7 @@
test('malformed snapshot renders warning on policy and version detail', function () { test('malformed snapshot renders warning on policy and version detail', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -50,7 +50,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
}); });
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -8,7 +8,7 @@
test('policies are listed for the active tenant', function () { test('policies are listed for the active tenant', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
]); ]);

View File

@ -12,13 +12,12 @@
test('policy detail shows normalized settings section', function () { test('policy detail shows normalized settings section', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -12,13 +12,12 @@
test('policy version detail renders tabs and scroll-safe blocks', function () { test('policy version detail renders tabs and scroll-safe blocks', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -12,13 +12,12 @@
test('policy version view shows scope tags even when assignments are missing', function () { test('policy version view shows scope tags even when assignments are missing', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -12,13 +12,12 @@
test('policy version detail shows raw and normalized settings', function () { test('policy version detail shows raw and normalized settings', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -11,11 +11,13 @@
test('policy versions render with timeline data', function () { test('policy versions render with timeline data', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
]); ]);
$tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'external_id' => 'policy-1', 'external_id' => 'policy-1',

View File

@ -13,13 +13,12 @@
it('shows Settings tab for Settings Catalog policy', function () { it('shows Settings tab for Settings Catalog policy', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -86,13 +85,12 @@
it('shows display names instead of definition IDs', function () { it('shows display names instead of definition IDs', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -143,13 +141,12 @@
it('shows fallback prettified labels when definitions not cached', function () { it('shows fallback prettified labels when definitions not cached', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -195,13 +192,12 @@
it('shows tabbed layout for non-Settings Catalog policies', function () { it('shows tabbed layout for non-Settings Catalog policies', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([
@ -242,7 +238,7 @@
// T034: Test display names shown (not definition IDs) // T034: Test display names shown (not definition IDs)
it('displays setting display names instead of raw definition IDs', function () { it('displays setting display names instead of raw definition IDs', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'is_current' => true,
]); ]);
@ -296,7 +292,7 @@
// T035: Test values formatted correctly // T035: Test values formatted correctly
it('formats setting values correctly based on type', function () { it('formats setting values correctly based on type', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'is_current' => true,
]); ]);
@ -370,7 +366,7 @@
// T036: Test search/filter functionality // T036: Test search/filter functionality
it('search filters settings in real-time', function () { it('search filters settings in real-time', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'is_current' => true,
]); ]);
@ -433,7 +429,7 @@
// T037: Test graceful degradation for missing definitions // T037: Test graceful degradation for missing definitions
it('shows prettified fallback labels when definitions are not cached', function () { it('shows prettified fallback labels when definitions are not cached', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'is_current' => true, 'is_current' => true,
]); ]);

View File

@ -13,13 +13,12 @@
test('settings catalog policies render a normalized settings table', function () { test('settings catalog policies render a normalized settings table', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -75,16 +75,11 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, new SettingsCatalogFakeGraphClient($responses)); app()->instance(GraphClientInterface::class, new SettingsCatalogFakeGraphClient($responses));
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, '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(); $tenant->makeCurrent();
expect(Tenant::current()->id)->toBe($tenant->id); expect(Tenant::current()->id)->toBe($tenant->id);

View File

@ -13,13 +13,12 @@
test('settings catalog settings render as a filament table with details action', function () { test('settings catalog settings render as a filament table with details action', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => 'local-tenant',
'name' => 'Tenant One', 'name' => 'Tenant One',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
]); ]);
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent(); $tenant->makeCurrent();
$policy = Policy::create([ $policy = Policy::create([

View File

@ -0,0 +1,213 @@
<?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);
class WindowsUpdateProfilesRestoreGraphClient implements GraphClientInterface
{
/**
* @var array<int, array{policyType:string,policyId:string,payload:array,options:array}>
*/
public array $applyPolicyCalls = [];
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[] = [
'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, []);
}
}
test('restore execution applies windows feature update profile with sanitized payload', function () {
$client = new WindowsUpdateProfilesRestoreGraphClient;
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-feature',
'policy_type' => 'windowsFeatureUpdateProfile',
'display_name' => 'Feature Updates A',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupPayload = [
'id' => 'policy-feature',
'@odata.type' => '#microsoft.graph.windowsFeatureUpdateProfile',
'displayName' => 'Feature Updates A',
'featureUpdateVersion' => 'Windows 11, version 23H2',
'deployableContentDisplayName' => 'Some Content',
'endOfSupportDate' => '2026-01-01T00:00:00Z',
'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');
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
expect($client->applyPolicyCalls)->toHaveCount(1);
expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsFeatureUpdateProfile');
expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-feature');
expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH');
expect($client->applyPolicyCalls[0]['payload']['featureUpdateVersion'])->toBe('Windows 11, version 23H2');
expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']);
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('endOfSupportDate');
});
test('restore execution applies windows quality update profile with sanitized payload', function () {
$client = new WindowsUpdateProfilesRestoreGraphClient;
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-quality',
'policy_type' => 'windowsQualityUpdateProfile',
'display_name' => 'Quality Updates A',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupPayload = [
'id' => 'policy-quality',
'@odata.type' => '#microsoft.graph.windowsQualityUpdateProfile',
'displayName' => 'Quality Updates A',
'qualityUpdateCveIds' => ['CVE-2025-0001'],
'deployableContentDisplayName' => 'Some Content',
'releaseDateDisplayName' => 'January 2026',
'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');
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
expect($client->applyPolicyCalls)->toHaveCount(1);
expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsQualityUpdateProfile');
expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-quality');
expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH');
expect($client->applyPolicyCalls[0]['payload']['qualityUpdateCveIds'])->toBe(['CVE-2025-0001']);
expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']);
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('releaseDateDisplayName');
});

View File

@ -0,0 +1,77 @@
<?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' => 'local-tenant',
'name' => 'Tenant One',
'metadata' => [],
'is_current' => true,
]);
$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,151 @@
<?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 array $requests = [];
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
{
$this->requests[] = [
'method' => strtoupper($method),
'path' => $path,
'payload' => $options['json'] ?? null,
];
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 = [
'id' => 'policy-wuring',
'@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
'automaticUpdateMode' => 'autoInstallAtMaintenanceTime',
'featureUpdatesDeferralPeriodInDays' => 14,
'version' => 7,
'qualityUpdatesPauseStartDate' => '2025-01-01T00:00:00Z',
'featureUpdatesPauseStartDate' => '2025-01-02T00:00:00Z',
'qualityUpdatesWillBeRolledBack' => false,
'featureUpdatesWillBeRolledBack' => false,
'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->requests)->toHaveCount(1);
expect($client->requests[0]['method'])->toBe('PATCH');
expect($client->requests[0]['path'])->toBe('deviceManagement/deviceConfigurations/policy-wuring/microsoft.graph.windowsUpdateForBusinessConfiguration');
expect($client->requests[0]['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime');
expect($client->requests[0]['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14);
expect($client->requests[0]['payload']['roleScopeTagIds'])->toBe(['0']);
expect($client->requests[0]['payload'])->not->toHaveKey('id');
expect($client->requests[0]['payload'])->not->toHaveKey('@odata.type');
expect($client->requests[0]['payload'])->not->toHaveKey('version');
expect($client->requests[0]['payload'])->not->toHaveKey('qualityUpdatesPauseStartDate');
expect($client->requests[0]['payload'])->not->toHaveKey('featureUpdatesPauseStartDate');
expect($client->requests[0]['payload'])->not->toHaveKey('qualityUpdatesWillBeRolledBack');
expect($client->requests[0]['payload'])->not->toHaveKey('featureUpdatesWillBeRolledBack');
});

View File

@ -49,7 +49,7 @@ public function request(string $method, string $path, array $options = []): Grap
test('sync skips managed app configurations from app protection inventory', function () { test('sync skips managed app configurations from app protection inventory', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), 'tenant_id' => 'test-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -49,7 +49,7 @@ public function request(string $method, string $path, array $options = []): Grap
test('sync revives ignored policies when they exist in Intune', function () { test('sync revives ignored policies when they exist in Intune', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), 'tenant_id' => 'test-tenant',
'name' => 'Test Tenant', 'name' => 'Test Tenant',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,
@ -94,7 +94,7 @@ public function request(string $method, string $path, array $options = []): Grap
test('sync creates new policies even if ignored ones exist with same external_id', function () { test('sync creates new policies even if ignored ones exist with same external_id', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant-2'), 'tenant_id' => 'test-tenant-2',
'name' => 'Test Tenant 2', 'name' => 'Test Tenant 2',
'metadata' => [], 'metadata' => [],
'is_current' => true, 'is_current' => true,

View File

@ -0,0 +1,77 @@
<?php
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\PolicySyncService;
use function Pest\Laravel\mock;
it('marks targeted managed app configurations as ignored during sync', function () {
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-1',
'policy_type' => 'appProtectionPolicy',
'ignored_at' => null,
]);
$logger = mock(GraphLogger::class);
$logger->shouldReceive('logRequest')
->zeroOrMoreTimes()
->andReturnNull();
$logger->shouldReceive('logResponse')
->zeroOrMoreTimes()
->andReturnNull();
mock(GraphClientInterface::class)
->shouldReceive('listPolicies')
->once()
->andReturn(new GraphResponse(
success: true,
data: [
[
'id' => 'policy-1',
'displayName' => 'Ignored policy',
'@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration',
],
],
));
$service = app(PolicySyncService::class);
$synced = $service->syncPolicies($tenant, [
['type' => 'appProtectionPolicy'],
]);
$policy->refresh();
expect($policy->ignored_at)->not->toBeNull();
expect($synced)->toBeArray()->toBeEmpty();
});
it('uses isof filters for windows update rings and supports feature/quality update profiles', function () {
$supported = config('tenantpilot.supported_policy_types');
$byType = collect($supported)->keyBy('type');
expect($byType)->toHaveKeys(['deviceConfiguration', 'windowsUpdateRing', 'windowsFeatureUpdateProfile', 'windowsQualityUpdateProfile']);
expect($byType['deviceConfiguration']['filter'] ?? null)
->toBe("not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')");
expect($byType['windowsUpdateRing']['filter'] ?? null)
->toBe("isof('microsoft.graph.windowsUpdateForBusinessConfiguration')");
expect($byType['windowsFeatureUpdateProfile']['endpoint'] ?? null)
->toBe('deviceManagement/windowsFeatureUpdateProfiles');
expect($byType['windowsQualityUpdateProfile']['endpoint'] ?? null)
->toBe('deviceManagement/windowsQualityUpdateProfiles');
});

View File

@ -4,4 +4,13 @@
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase {} abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
}
}

View File

@ -115,7 +115,7 @@ public function request(string $method, string $path, array $options = []): Grap
expect($result['items'][1]['source_id'])->toBe('filter-2'); expect($result['items'][1]['source_id'])->toBe('filter-2');
expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters'); expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters');
expect($client->requests[0]['options']['query']['$select'])->toBe(['id', 'displayName']); expect($client->requests[0]['options']['query']['$select'])->toBe('id,displayName');
expect($client->requests[1]['path'])->toBe('deviceManagement/assignmentFilters?$skiptoken=abc'); expect($client->requests[1]['path'])->toBe('deviceManagement/assignmentFilters?$skiptoken=abc');
expect($client->requests[1]['options']['query'])->toBe([]); expect($client->requests[1]['options']['query'])->toBe([]);
}); });

View File

@ -169,3 +169,85 @@ public function request(string $method, string $path, array $options = []): Grap
expect($client->requests[0][3]['select'])->toContain('roleScopeTagIds'); expect($client->requests[0][3]['select'])->toContain('roleScopeTagIds');
expect($client->requests[0][3]['select'])->not->toContain('@odata.type'); expect($client->requests[0][3]['select'])->not->toContain('@odata.type');
}); });
class WindowsUpdateRingSnapshotGraphClient 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,
'@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
'displayName' => 'Ring A',
],
]);
}
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 ($method === 'GET' && $path === 'deviceManagement/deviceConfigurations/policy-wuring/microsoft.graph.windowsUpdateForBusinessConfiguration') {
return new GraphResponse(success: true, data: [
'automaticUpdateMode' => 'autoInstallAtMaintenanceTime',
'featureUpdatesDeferralPeriodInDays' => 14,
]);
}
return new GraphResponse(success: true, data: []);
}
}
it('hydrates windows update ring snapshots via derived type cast endpoint', function () {
$client = new WindowsUpdateRingSnapshotGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-wuring',
'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' => 'policy-wuring',
'policy_type' => 'windowsUpdateRing',
'display_name' => 'Ring A',
'platform' => 'windows',
]);
$service = app(PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('payload');
expect($result['payload']['@odata.type'])->toBe('#microsoft.graph.windowsUpdateForBusinessConfiguration');
expect($result['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime');
expect($result['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14);
expect($result['metadata']['properties_hydration'] ?? null)->toBe('complete');
});

View File

@ -108,3 +108,27 @@ function restoreIntuneTenantId(string|false $original): void
restoreIntuneTenantId($originalEnv); restoreIntuneTenantId($originalEnv);
}); });
it('makeCurrent keeps tenant current when already current', function () {
$originalEnv = getenv('INTUNE_TENANT_ID');
putenv('INTUNE_TENANT_ID=');
$current = Tenant::create([
'tenant_id' => 'tenant-current',
'name' => 'Already Current',
'is_current' => true,
]);
$other = Tenant::create([
'tenant_id' => 'tenant-other',
'name' => 'Other Tenant',
'is_current' => false,
]);
$current->makeCurrent();
expect($current->fresh()->is_current)->toBeTrue();
expect($other->fresh()->is_current)->toBeFalse();
restoreIntuneTenantId($originalEnv);
});