diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php
index 40b6a56..37afda0 100644
--- a/app/Services/Graph/GraphContractRegistry.php
+++ b/app/Services/Graph/GraphContractRegistry.php
@@ -69,8 +69,7 @@ public function sanitizeUpdatePayload(string $policyType, array $snapshot): arra
$whitelist = $contract['update_whitelist'] ?? null;
$stripKeys = array_merge($this->readOnlyKeys(), $contract['update_strip_keys'] ?? []);
$mapping = $contract['update_map'] ?? [];
-
- $stripOdata = $whitelist !== null || ! empty($contract['update_strip_keys']);
+ $stripOdata = $contract['strip_odata'] ?? ($whitelist !== null || ! empty($contract['update_strip_keys']));
$result = $this->sanitizeArray($snapshot, $whitelist, $stripKeys, $stripOdata, $mapping);
diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php
index 8fc3aab..0cbb466 100644
--- a/app/Services/Intune/RestoreService.php
+++ b/app/Services/Intune/RestoreService.php
@@ -330,6 +330,26 @@ public function execute(
$payload,
$graphOptions
);
+
+ if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) {
+ $createOutcome = $this->createAutopilotDeploymentProfileIfMissing(
+ originalPayload: $originalPayload,
+ graphOptions: $graphOptions,
+ context: $context,
+ policyId: $item->policy_identifier,
+ );
+
+ if ($createOutcome['attempted']) {
+ $response = $createOutcome['response'] ?? $response;
+
+ if ($createOutcome['success']) {
+ $createdPolicyId = $createOutcome['policy_id'];
+ $createdPolicyMode = 'created';
+ $itemStatus = 'applied';
+ $resultReason = 'Policy missing; created new Autopilot profile.';
+ }
+ }
+ }
}
} catch (Throwable $throwable) {
$mapped = GraphErrorMapper::fromThrowable($throwable, $context);
@@ -1324,6 +1344,91 @@ private function createSettingsCatalogPolicy(
];
}
+ /**
+ * @return array{attempted:bool,success:bool,policy_id:?string,response:?object}
+ */
+ private function createAutopilotDeploymentProfileIfMissing(
+ array $originalPayload,
+ array $graphOptions,
+ array $context,
+ string $policyId,
+ ): array {
+ if (! $this->shouldAttemptAutopilotCreate($policyId, $graphOptions)) {
+ return [
+ 'attempted' => false,
+ 'success' => false,
+ 'policy_id' => null,
+ 'response' => null,
+ ];
+ }
+
+ $resource = $this->contracts->resourcePath('windowsAutopilotDeploymentProfile')
+ ?? 'deviceManagement/windowsAutopilotDeploymentProfiles';
+ $payload = $this->contracts->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $originalPayload);
+
+ if ($payload === []) {
+ return [
+ 'attempted' => true,
+ 'success' => false,
+ 'policy_id' => null,
+ 'response' => null,
+ ];
+ }
+
+ $this->graphLogger->logRequest('create_autopilot_profile', $context + [
+ 'endpoint' => $resource,
+ 'method' => 'POST',
+ ]);
+
+ $response = $this->graphClient->request(
+ 'POST',
+ $resource,
+ ['json' => $payload] + Arr::except($graphOptions, ['platform'])
+ );
+
+ $this->graphLogger->logResponse('create_autopilot_profile', $response, $context + [
+ 'endpoint' => $resource,
+ 'method' => 'POST',
+ ]);
+
+ $policyId = $this->extractCreatedPolicyId($response);
+
+ return [
+ 'attempted' => true,
+ 'success' => $response->successful(),
+ 'policy_id' => $policyId,
+ 'response' => $response,
+ ];
+ }
+
+ private function shouldAttemptAutopilotCreate(string $policyId, array $graphOptions): bool
+ {
+ $response = $this->graphClient->getPolicy(
+ 'windowsAutopilotDeploymentProfile',
+ $policyId,
+ $graphOptions
+ );
+
+ if ($response->successful()) {
+ return false;
+ }
+
+ if ($response->status === 404) {
+ return true;
+ }
+
+ $code = strtolower((string) ($response->meta['error_code'] ?? ''));
+ $message = strtolower((string) ($response->meta['error_message'] ?? ''));
+
+ if (str_contains($code, 'notfound') || str_contains($code, 'resource')) {
+ return true;
+ }
+
+ return str_contains($message, 'not found')
+ || str_contains($message, 'resource not found')
+ || str_contains($message, 'does not exist');
+ }
+
private function shouldRetrySettingsCatalogCreateWithoutSettings(object $response): bool
{
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
diff --git a/config/graph_contracts.php b/config/graph_contracts.php
index e760db8..d637985 100644
--- a/config/graph_contracts.php
+++ b/config/graph_contracts.php
@@ -258,11 +258,26 @@
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.windowsAutopilotDeploymentProfile',
+ '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile',
+ '#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
+ 'strip_odata' => false,
+ 'update_strip_keys' => [
+ 'assignments',
+ 'managementServiceAppId',
+ 'outOfBoxExperienceSetting',
+ 'hardwareHashExtractionEnabled',
+ 'locale',
+ ],
+ 'assignments_list_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments',
+ 'assignments_create_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments',
+ 'assignments_create_method' => 'POST',
+ 'assignments_delete_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments/{assignmentId}',
+ 'assignments_delete_method' => 'DELETE',
],
'windowsEnrollmentStatusPage' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php
index 7adccee..6d288b9 100644
--- a/resources/views/filament/infolists/entries/restore-results.blade.php
+++ b/resources/views/filament/infolists/entries/restore-results.blade.php
@@ -237,9 +237,11 @@
@if (! empty($item['created_policy_id']))
@php
$createdMode = $item['created_policy_mode'] ?? null;
- $createdMessage = $createdMode === 'metadata_only'
- ? 'New policy created (metadata only). Apply settings manually.'
- : 'New policy created (manual cleanup required).';
+ $createdMessage = match ($createdMode) {
+ 'metadata_only' => 'New policy created (metadata only). Apply settings manually.',
+ 'created' => 'New policy created.',
+ default => 'New policy created (manual cleanup required).',
+ };
@endphp
{{ $createdMessage }} ID: {{ $item['created_policy_id'] }}
diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md
index 1843d9c..c84d79d 100644
--- a/specs/007-device-config-compliance/tasks.md
+++ b/specs/007-device-config-compliance/tasks.md
@@ -15,10 +15,10 @@ ## Phase 1: Policy Types, Contracts, Permissions
**Purpose**: Add missing device configuration, compliance, scripts, and update ring types with Graph contract coverage.
-- [ ] T001 [P] Expand policy type registry for device configuration, compliance, scripts, and update rings in `config/tenantpilot.php` (labels, categories, restore mode, risk).
-- [ ] T002 [P] Add/update Graph contracts and assignment endpoints for new policy types in `config/graph_contracts.php`.
-- [ ] T003 [P] Verify and extend permissions for the new workloads in `config/intune_permissions.php`.
-- [ ] T004 Update type metadata helpers and filters in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`.
+- [x] T001 [P] Expand policy type registry for device configuration, compliance, scripts, and update rings in `config/tenantpilot.php` (labels, categories, restore mode, risk).
+- [x] T002 [P] Add/update Graph contracts and assignment endpoints for new policy types in `config/graph_contracts.php`.
+- [x] T003 [P] Verify and extend permissions for the new workloads in `config/intune_permissions.php`.
+- [x] T004 Update type metadata helpers and filters in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`.
**Checkpoint**: New policy types are recognized across UI metadata and Graph contract registry.
diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php
index e13c564..b9bf0d0 100644
--- a/tests/Feature/Filament/RestoreExecutionTest.php
+++ b/tests/Feature/Filament/RestoreExecutionTest.php
@@ -282,3 +282,109 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'resource_id' => (string) $run->id,
]);
});
+
+test('restore execution creates an autopilot profile when missing', function () {
+ $graphClient = new class implements GraphClientInterface
+ {
+ public int $applyCalls = 0;
+
+ public int $getCalls = 0;
+
+ public int $createCalls = 0;
+
+ public function listPolicies(string $policyType, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
+ {
+ $this->getCalls++;
+
+ return new GraphResponse(false, [], 404, [], [], [
+ 'error_code' => 'ResourceNotFound',
+ 'error_message' => 'Resource not found.',
+ ]);
+ }
+
+ public function getOrganization(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
+ {
+ $this->applyCalls++;
+
+ return new GraphResponse(false, [], 500, [], [], [
+ 'error_code' => 'InternalServerError',
+ 'error_message' => 'An internal server error has occurred.',
+ ]);
+ }
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ if ($method === 'POST' && str_contains($path, 'windowsAutopilotDeploymentProfiles')) {
+ $this->createCalls++;
+
+ return new GraphResponse(true, ['id' => 'autopilot-created']);
+ }
+
+ return new GraphResponse(true, []);
+ }
+
+ public function getServicePrincipalPermissions(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+ };
+
+ app()->instance(GraphClientInterface::class, $graphClient);
+
+ $tenant = Tenant::factory()->create([
+ 'tenant_id' => 'tenant-1',
+ 'name' => 'Tenant One',
+ 'metadata' => [],
+ ]);
+
+ $backupSet = BackupSet::factory()->for($tenant)->create([
+ 'status' => 'completed',
+ 'item_count' => 1,
+ ]);
+
+ $backupItem = BackupItem::factory()
+ ->for($tenant)
+ ->for($backupSet)
+ ->state([
+ 'policy_id' => null,
+ 'policy_identifier' => 'autopilot-1',
+ 'policy_type' => 'windowsAutopilotDeploymentProfile',
+ 'platform' => 'windows',
+ 'payload' => [
+ '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile',
+ 'displayName' => 'Autopilot Profile',
+ 'language' => 'en-US',
+ ],
+ ])
+ ->create();
+
+ $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($graphClient->applyCalls)->toBe(1);
+ expect($graphClient->getCalls)->toBe(1);
+ expect($graphClient->createCalls)->toBe(1);
+ expect($run->status)->toBe('completed');
+ expect($run->results[0]['status'])->toBe('applied');
+ expect($run->results[0]['created_policy_id'])->toBe('autopilot-created');
+});
diff --git a/tests/Unit/GraphContractRegistryActualDataTest.php b/tests/Unit/GraphContractRegistryActualDataTest.php
index 0bb4065..813aecf 100644
--- a/tests/Unit/GraphContractRegistryActualDataTest.php
+++ b/tests/Unit/GraphContractRegistryActualDataTest.php
@@ -49,3 +49,47 @@
// Null values should be preserved (Graph might need them)
expect(array_key_exists('settingValueTemplateReference', $sanitized[0]['settingInstance']['choiceSettingValue']))->toBeTrue();
});
+
+it('exposes autopilot assignments paths', function () {
+ $contract = $this->registry->get('windowsAutopilotDeploymentProfile');
+
+ expect($contract)->not->toBeEmpty();
+ expect($contract['assignments_list_path'] ?? null)
+ ->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments');
+ expect($contract['assignments_create_path'] ?? null)
+ ->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments');
+ expect($contract['assignments_delete_path'] ?? null)
+ ->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments/{assignmentId}');
+ expect($this->registry->matchesTypeFamily(
+ 'windowsAutopilotDeploymentProfile',
+ '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile'
+ ))->toBeTrue();
+ expect($this->registry->matchesTypeFamily(
+ 'windowsAutopilotDeploymentProfile',
+ '#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile'
+ ))->toBeTrue();
+});
+
+it('sanitizes autopilot update payload by stripping odata and assignments', function () {
+ $payload = [
+ '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile',
+ 'id' => 'profile-1',
+ 'displayName' => 'Autopilot Profile',
+ 'assignments' => [['id' => 'assignment-1']],
+ 'managementServiceAppId' => 'service-app',
+ 'outOfBoxExperienceSetting' => ['deviceUsageType' => 'shared'],
+ 'hardwareHashExtractionEnabled' => true,
+ 'locale' => 'de-DE',
+ ];
+
+ $sanitized = $this->registry->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $payload);
+
+ expect($sanitized)->toHaveKey('displayName');
+ expect($sanitized)->toHaveKey('@odata.type');
+ expect($sanitized)->not->toHaveKey('id');
+ expect($sanitized)->not->toHaveKey('assignments');
+ expect($sanitized)->not->toHaveKey('managementServiceAppId');
+ expect($sanitized)->not->toHaveKey('outOfBoxExperienceSetting');
+ expect($sanitized)->not->toHaveKey('hardwareHashExtractionEnabled');
+ expect($sanitized)->not->toHaveKey('locale');
+});