From 83f18142540160da2e13b61913ad5cc213d44be1 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 4 Jan 2026 03:01:11 +0000 Subject: [PATCH] feat/024-terms-and-conditions (#30) Added termsAndConditions to the supported policy list and Graph contract so Intune sync/backup/restore paths (and scope tag handling) treat Terms & Conditions like other enrollment policies, ensuring listings, snapshots, assignments CRUD, and restore modes flow naturally (tenantpilot.php (lines 168-225), graph_contracts.php (lines 520-560), InteractsWithODataTypes.php (lines 10-30)). Exposed a dedicated TermsAndConditionsNormalizer and tagged it in AppServiceProvider so the Filament UI shows readable rows (display name, title, acceptance statement, body, scope tags) and the diff engine flattens them consistently (TermsAndConditionsNormalizer.php (lines 1-94), AppServiceProvider.php (lines 43-58)). Added Pest coverage for the new type that checks config/contract entries, assignment restore behavior, normalized output, and PolicySync ingestion (TermsAndConditionsPolicyTypeTest.php (lines 70-200)). Tests: TermsAndConditionsPolicyTypeTest.php ./vendor/bin/pint --dirty Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/30 --- app/Providers/AppServiceProvider.php | 2 + .../Intune/TermsAndConditionsNormalizer.php | 94 ++++++++ .../Concerns/InteractsWithODataTypes.php | 4 + config/graph_contracts.php | 42 ++++ config/tenantpilot.php | 10 + .../TermsAndConditionsPolicyTypeTest.php | 218 ++++++++++++++++++ 6 files changed, 370 insertions(+) create mode 100644 app/Services/Intune/TermsAndConditionsNormalizer.php create mode 100644 tests/Feature/TermsAndConditionsPolicyTypeTest.php 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); +});