diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b5c7c32..fbe5206 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -13,6 +13,7 @@ use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer; use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; +use App\Services\Intune\TermsAndConditionsNormalizer; use App\Services\Intune\WindowsDriverUpdateProfileNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; @@ -50,6 +51,7 @@ public function register(): void ManagedDeviceAppConfigurationNormalizer::class, ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, + TermsAndConditionsNormalizer::class, WindowsDriverUpdateProfileNormalizer::class, WindowsFeatureUpdateProfileNormalizer::class, WindowsQualityUpdateProfileNormalizer::class, diff --git a/app/Services/Intune/TermsAndConditionsNormalizer.php b/app/Services/Intune/TermsAndConditionsNormalizer.php new file mode 100644 index 0000000..9af263e --- /dev/null +++ b/app/Services/Intune/TermsAndConditionsNormalizer.php @@ -0,0 +1,94 @@ +>, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = is_array($snapshot) ? $snapshot : []; + $entries = []; + + $this->pushEntry($entries, 'Display name', Arr::get($snapshot, 'displayName')); + $this->pushEntry($entries, 'Title', Arr::get($snapshot, 'title')); + $this->pushEntry($entries, 'Description', Arr::get($snapshot, 'description')); + $this->pushEntry($entries, 'Acceptance statement', Arr::get($snapshot, 'acceptanceStatement')); + $this->pushEntry($entries, 'Body text', $this->limitText(Arr::get($snapshot, 'bodyText'))); + $this->pushEntry($entries, 'Version', Arr::get($snapshot, 'version')); + + $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); + if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { + $this->pushEntry($entries, 'Scope tag IDs', array_values($roleScopeTagIds)); + } + + if ($entries === []) { + return [ + 'status' => 'warning', + 'settings' => [], + 'warnings' => ['Terms & Conditions snapshot contains no readable fields.'], + ]; + } + + return [ + 'status' => 'ok', + 'settings' => [ + [ + 'type' => 'keyValue', + 'title' => 'Terms & Conditions', + 'entries' => $entries, + ], + ], + 'warnings' => [], + ]; + } + + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + /** + * @param array> $entries + */ + private function pushEntry(array &$entries, string $key, mixed $value): void + { + if ($value === null) { + return; + } + + if (is_string($value) && $value === '') { + return; + } + + $entries[] = [ + 'key' => $key, + 'value' => $value, + ]; + } + + private function limitText(mixed $value): mixed + { + if (! is_string($value)) { + return $value; + } + + return Str::limit($value, 1000); + } +} diff --git a/app/Support/Concerns/InteractsWithODataTypes.php b/app/Support/Concerns/InteractsWithODataTypes.php index 3a2d09b..bc9ddc3 100644 --- a/app/Support/Concerns/InteractsWithODataTypes.php +++ b/app/Support/Concerns/InteractsWithODataTypes.php @@ -62,6 +62,10 @@ protected static function odataTypeMap(): array 'windows' => '#microsoft.graph.deviceHealthScript', 'all' => '#microsoft.graph.deviceHealthScript', ], + 'termsAndConditions' => [ + 'windows' => '#microsoft.graph.termsAndConditions', + 'all' => '#microsoft.graph.termsAndConditions', + ], 'deviceComplianceScript' => [ 'windows' => '#microsoft.graph.deviceComplianceScript', 'all' => '#microsoft.graph.deviceComplianceScript', diff --git a/config/graph_contracts.php b/config/graph_contracts.php index c2b9c5c..5abff69 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -536,6 +536,48 @@ 'assignments_create_method' => 'POST', 'assignments_payload_key' => 'enrollmentConfigurationAssignments', ], + 'termsAndConditions' => [ + 'resource' => 'deviceManagement/termsAndConditions', + 'allowed_select' => [ + 'id', + 'displayName', + 'description', + 'title', + 'bodyText', + 'acceptanceStatement', + 'version', + 'roleScopeTagIds', + 'lastModifiedDateTime', + 'createdDateTime', + ], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.termsAndConditions', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'createdDateTime', + 'lastModifiedDateTime', + 'modifiedDateTime', + 'version', + 'acceptanceStatuses', + 'assignments', + 'groupAssignments', + ], + 'assignments_list_path' => '/deviceManagement/termsAndConditions/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/termsAndConditions/{id}/assignments', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'termsAndConditionsAssignments', + 'assignments_update_path' => '/deviceManagement/termsAndConditions/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/termsAndConditions/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'windowsAutopilotDeploymentProfile' => [ 'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], diff --git a/config/tenantpilot.php b/config/tenantpilot.php index f0f8062..85cddb9 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -195,6 +195,16 @@ 'restore' => 'preview-only', 'risk' => 'high', ], + [ + 'type' => 'termsAndConditions', + 'label' => 'Terms & Conditions', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/termsAndConditions', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], [ 'type' => 'endpointSecurityIntent', 'label' => 'Endpoint Security Intents', diff --git a/tests/Feature/TermsAndConditionsPolicyTypeTest.php b/tests/Feature/TermsAndConditionsPolicyTypeTest.php new file mode 100644 index 0000000..84cce93 --- /dev/null +++ b/tests/Feature/TermsAndConditionsPolicyTypeTest.php @@ -0,0 +1,218 @@ + + */ + public array $requestCalls = []; + + /** + * @param array $requestResponses + */ + public function __construct( + private readonly GraphResponse $applyPolicyResponse, + private array $requestResponses = [], + ) {} + + 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 + { + return $this->applyPolicyResponse; + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requestCalls[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'payload' => $options['json'] ?? null, + ]; + + return array_shift($this->requestResponses) ?? new GraphResponse(true, []); + } +} + +it('includes terms and conditions policy type in supported types', function () { + $byType = collect(config('tenantpilot.supported_policy_types', [])) + ->keyBy('type'); + + expect($byType)->toHaveKey('termsAndConditions'); + expect($byType['termsAndConditions']['endpoint'] ?? null)->toBe('deviceManagement/termsAndConditions'); +}); + +it('defines terms and conditions graph contract with assignments paths', function () { + $contract = config('graph_contracts.types.termsAndConditions'); + + expect($contract)->toBeArray(); + expect($contract['resource'] ?? null)->toBe('deviceManagement/termsAndConditions'); + expect($contract['assignments_list_path'] ?? null)->toBe('/deviceManagement/termsAndConditions/{id}/assignments'); + expect($contract['assignments_payload_key'] ?? null)->toBe('termsAndConditionsAssignments'); +}); + +it('restores terms and conditions assignments via assignments endpoint', function () { + $client = new TermsAndConditionsRestoreGraphClient( + applyPolicyResponse: new GraphResponse(true, []), + requestResponses: [ + new GraphResponse(true, ['value' => []]), // existing assignments list + new GraphResponse(true, []), // create assignments + ], + ); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(['tenant_id' => 'tenant-1']); + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'tc-1', + 'policy_type' => 'termsAndConditions', + 'platform' => 'all', + ]); + + $backupSet = \App\Models\BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = \App\Models\BackupItem::factory()->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' => [ + 'id' => $policy->external_id, + '@odata.type' => '#microsoft.graph.termsAndConditions', + ], + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-1', + ], + ], + ], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + groupMapping: [ + 'source-group-1' => 'target-group-1', + ], + ); + + $postCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST') + ->values(); + + expect($postCalls)->toHaveCount(1); + expect($postCalls[0]['path'])->toBe('/deviceManagement/termsAndConditions/tc-1/assignments'); + + $payload = $postCalls[0]['payload'] ?? []; + expect($payload['target']['groupId'] ?? null)->toBe('target-group-1'); +}); + +it('normalizes terms and conditions key fields', function () { + $normalized = app(PolicyNormalizer::class)->normalize([ + '@odata.type' => '#microsoft.graph.termsAndConditions', + 'displayName' => 'Terms and Conditions Alpha', + 'title' => 'Alpha terms', + 'description' => 'Long form description', + 'acceptanceStatement' => 'I agree', + 'bodyText' => str_repeat('Line.', 100), + 'version' => 3, + 'roleScopeTagIds' => ['0', '1'], + ], 'termsAndConditions', 'all'); + + $entries = $normalized['settings'][0]['entries'] ?? []; + $byKey = collect($entries)->keyBy('key'); + + expect($byKey['Display name']['value'] ?? null)->toBe('Terms and Conditions Alpha'); + expect($byKey['Title']['value'] ?? null)->toBe('Alpha terms'); + expect($byKey['Acceptance statement']['value'] ?? null)->toBe('I agree'); + expect($byKey['Version']['value'] ?? null)->toBe(3); + expect($byKey['Scope tag IDs']['value'] ?? null)->toBe(['0', '1']); +}); + +it('syncs terms and conditions from graph', function () { + $tenant = Tenant::factory()->create(['status' => 'active']); + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->once() + ->with('termsAndConditions', mockery::type('array')) + ->andReturn(new GraphResponse( + success: true, + data: [ + [ + 'id' => 'tc-1', + 'displayName' => 'T&C', + '@odata.type' => '#microsoft.graph.termsAndConditions', + ], + ], + )); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'termsAndConditions', 'platform' => 'all'], + ]); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'termsAndConditions')->count()) + ->toBe(1); +});