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:
parent
b048131f81
commit
286d3c596b
@ -104,13 +104,17 @@ public function makeCurrent(): void
|
||||
DB::transaction(function () {
|
||||
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
|
||||
{
|
||||
$envTenantId = env('INTUNE_TENANT_ID') ?: null;
|
||||
$envTenantId = getenv('INTUNE_TENANT_ID') ?: null;
|
||||
|
||||
if ($envTenantId) {
|
||||
$tenant = static::activeQuery()
|
||||
|
||||
@ -10,6 +10,9 @@
|
||||
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
|
||||
use App\Services\Intune\GroupPolicyConfigurationNormalizer;
|
||||
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
|
||||
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
||||
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
|
||||
use App\Services\Intune\WindowsUpdateRingNormalizer;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@ -40,6 +43,9 @@ public function register(): void
|
||||
DeviceConfigurationPolicyNormalizer::class,
|
||||
GroupPolicyConfigurationNormalizer::class,
|
||||
SettingsCatalogPolicyNormalizer::class,
|
||||
WindowsFeatureUpdateProfileNormalizer::class,
|
||||
WindowsQualityUpdateProfileNormalizer::class,
|
||||
WindowsUpdateRingNormalizer::class,
|
||||
],
|
||||
'policy-type-normalizers'
|
||||
);
|
||||
|
||||
@ -77,6 +77,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
$metadata = Arr::except($response->data, ['payload']);
|
||||
$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') {
|
||||
[$payload, $metadata] = $this->hydrateSettingsCatalog(
|
||||
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
|
||||
{
|
||||
foreach (config('tenantpilot.supported_policy_types', []) as $type) {
|
||||
|
||||
@ -555,6 +555,23 @@ public function execute(
|
||||
$payload,
|
||||
$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 {
|
||||
$response = $this->graphClient->applyPolicy(
|
||||
$item->policy_type,
|
||||
|
||||
107
app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php
Normal file
107
app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
137
app/Services/Intune/WindowsUpdateRingNormalizer.php
Normal file
137
app/Services/Intune/WindowsUpdateRingNormalizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,14 @@ protected static function odataTypeMap(): array
|
||||
'windows' => '#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' => [
|
||||
'windows' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'ios' => '#microsoft.graph.iosCompliancePolicy',
|
||||
|
||||
@ -143,6 +143,13 @@
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'update_strip_keys' => [
|
||||
'version',
|
||||
'qualityUpdatesPauseStartDate',
|
||||
'featureUpdatesPauseStartDate',
|
||||
'qualityUpdatesWillBeRolledBack',
|
||||
'featureUpdatesWillBeRolledBack',
|
||||
],
|
||||
'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
@ -153,6 +160,52 @@
|
||||
'supports_scope_tags' => true,
|
||||
'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' => [
|
||||
'resource' => 'deviceManagement/deviceCompliancePolicies',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
'category' => 'Configuration',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/deviceConfigurations',
|
||||
'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'",
|
||||
'filter' => "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium',
|
||||
@ -39,11 +39,31 @@
|
||||
'category' => 'Update Management',
|
||||
'platform' => 'windows',
|
||||
'endpoint' => 'deviceManagement/deviceConfigurations',
|
||||
'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'",
|
||||
'filter' => "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'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',
|
||||
'label' => 'Device Compliance',
|
||||
@ -130,7 +150,7 @@
|
||||
'category' => 'Enrollment',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
|
||||
'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'",
|
||||
'filter' => "isof('microsoft.graph.windows10EnrollmentCompletionPageConfiguration')",
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium',
|
||||
|
||||
@ -18,7 +18,9 @@
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<ini name="memory_limit" value="512M"/>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="INTUNE_TENANT_ID" value="" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
|
||||
18
specs/012-windows-update-rings/plan.md
Normal file
18
specs/012-windows-update-rings/plan.md
Normal 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.
|
||||
77
specs/012-windows-update-rings/spec.md
Normal file
77
specs/012-windows-update-rings/spec.md
Normal 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.
|
||||
26
specs/012-windows-update-rings/tasks.md
Normal file
26
specs/012-windows-update-rings/tasks.md
Normal 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.
|
||||
@ -11,6 +11,7 @@
|
||||
|
||||
test('progress widget shows running operations for current tenant and user', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$tenant->makeCurrent();
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Own running op
|
||||
@ -39,9 +40,6 @@
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
// $tenant->makeCurrent();
|
||||
$tenant->forceFill(['is_current' => true])->save();
|
||||
|
||||
auth()->login($user); // Login user explicitly for auth()->id() call in component
|
||||
|
||||
Livewire::actingAs($user)
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
test('policy detail shows app protection settings in readable sections', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
|
||||
@ -73,7 +73,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
});
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
@ -23,6 +23,8 @@
|
||||
'name' => 'Tenant',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set 1',
|
||||
@ -60,6 +62,8 @@
|
||||
'name' => 'Tenant 2',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set with restore',
|
||||
@ -93,6 +97,8 @@
|
||||
'name' => 'Tenant Force',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set force',
|
||||
@ -132,6 +138,8 @@
|
||||
'name' => 'Tenant Restore Backup Set',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set restore',
|
||||
@ -171,6 +179,8 @@
|
||||
'name' => 'Tenant Restore Run',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set RR',
|
||||
@ -207,6 +217,8 @@
|
||||
'name' => 'Tenant Restore Restore Run',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set for restore run restore',
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
test('malformed snapshot renders warning on policy and version detail', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
|
||||
@ -50,7 +50,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
});
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
test('policies are listed for the active tenant', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
@ -12,13 +12,12 @@
|
||||
|
||||
test('policy detail shows normalized settings section', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
|
||||
@ -12,13 +12,12 @@
|
||||
|
||||
test('policy version detail renders tabs and scroll-safe blocks', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
|
||||
@ -12,13 +12,12 @@
|
||||
|
||||
test('policy version view shows scope tags even when assignments are missing', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
|
||||
@ -12,13 +12,12 @@
|
||||
|
||||
test('policy version detail shows raw and normalized settings', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
|
||||
@ -11,11 +11,13 @@
|
||||
|
||||
test('policy versions render with timeline data', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-1',
|
||||
|
||||
@ -13,13 +13,12 @@
|
||||
|
||||
it('shows Settings tab for Settings Catalog policy', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
@ -86,13 +85,12 @@
|
||||
|
||||
it('shows display names instead of definition IDs', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
@ -143,13 +141,12 @@
|
||||
|
||||
it('shows fallback prettified labels when definitions not cached', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
@ -195,13 +192,12 @@
|
||||
|
||||
it('shows tabbed layout for non-Settings Catalog policies', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
@ -242,7 +238,7 @@
|
||||
// T034: Test display names shown (not definition IDs)
|
||||
it('displays setting display names instead of raw definition IDs', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'is_current' => true,
|
||||
]);
|
||||
@ -296,7 +292,7 @@
|
||||
// T035: Test values formatted correctly
|
||||
it('formats setting values correctly based on type', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'is_current' => true,
|
||||
]);
|
||||
@ -370,7 +366,7 @@
|
||||
// T036: Test search/filter functionality
|
||||
it('search filters settings in real-time', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'is_current' => true,
|
||||
]);
|
||||
@ -433,7 +429,7 @@
|
||||
// T037: Test graceful degradation for missing definitions
|
||||
it('shows prettified fallback labels when definitions are not cached', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
@ -13,13 +13,12 @@
|
||||
|
||||
test('settings catalog policies render a normalized settings table', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
|
||||
@ -75,16 +75,11 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
app()->instance(GraphClientInterface::class, new SettingsCatalogFakeGraphClient($responses));
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'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();
|
||||
expect(Tenant::current()->id)->toBe($tenant->id);
|
||||
|
||||
|
||||
@ -13,13 +13,12 @@
|
||||
|
||||
test('settings catalog settings render as a filament table with details action', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
|
||||
213
tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php
Normal file
213
tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php
Normal 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');
|
||||
});
|
||||
77
tests/Feature/Filament/WindowsUpdateRingPolicyTest.php
Normal file
77
tests/Feature/Filament/WindowsUpdateRingPolicyTest.php
Normal 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');
|
||||
});
|
||||
151
tests/Feature/Filament/WindowsUpdateRingRestoreTest.php
Normal file
151
tests/Feature/Filament/WindowsUpdateRingRestoreTest.php
Normal 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');
|
||||
});
|
||||
@ -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 () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'),
|
||||
'tenant_id' => 'test-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
|
||||
@ -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 () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'),
|
||||
'tenant_id' => 'test-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
'metadata' => [],
|
||||
'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 () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant-2'),
|
||||
'tenant_id' => 'test-tenant-2',
|
||||
'name' => 'Test Tenant 2',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
|
||||
77
tests/Feature/PolicySyncServiceTest.php
Normal file
77
tests/Feature/PolicySyncServiceTest.php
Normal 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');
|
||||
});
|
||||
@ -4,4 +4,13 @@
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,7 +115,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($result['items'][1]['source_id'])->toBe('filter-2');
|
||||
|
||||
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]['options']['query'])->toBe([]);
|
||||
});
|
||||
|
||||
@ -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'])->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');
|
||||
});
|
||||
|
||||
@ -108,3 +108,27 @@ function restoreIntuneTenantId(string|false $original): void
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user