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 <ahmeddarrazi@adsmac.local> Reviewed-on: #30
This commit is contained in:
parent
057f2cbeb6
commit
83f1814254
@ -13,6 +13,7 @@
|
|||||||
use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer;
|
use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer;
|
||||||
use App\Services\Intune\ScriptsPolicyNormalizer;
|
use App\Services\Intune\ScriptsPolicyNormalizer;
|
||||||
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
|
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
|
||||||
|
use App\Services\Intune\TermsAndConditionsNormalizer;
|
||||||
use App\Services\Intune\WindowsDriverUpdateProfileNormalizer;
|
use App\Services\Intune\WindowsDriverUpdateProfileNormalizer;
|
||||||
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
||||||
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
|
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
|
||||||
@ -50,6 +51,7 @@ public function register(): void
|
|||||||
ManagedDeviceAppConfigurationNormalizer::class,
|
ManagedDeviceAppConfigurationNormalizer::class,
|
||||||
ScriptsPolicyNormalizer::class,
|
ScriptsPolicyNormalizer::class,
|
||||||
SettingsCatalogPolicyNormalizer::class,
|
SettingsCatalogPolicyNormalizer::class,
|
||||||
|
TermsAndConditionsNormalizer::class,
|
||||||
WindowsDriverUpdateProfileNormalizer::class,
|
WindowsDriverUpdateProfileNormalizer::class,
|
||||||
WindowsFeatureUpdateProfileNormalizer::class,
|
WindowsFeatureUpdateProfileNormalizer::class,
|
||||||
WindowsQualityUpdateProfileNormalizer::class,
|
WindowsQualityUpdateProfileNormalizer::class,
|
||||||
|
|||||||
94
app/Services/Intune/TermsAndConditionsNormalizer.php
Normal file
94
app/Services/Intune/TermsAndConditionsNormalizer.php
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class TermsAndConditionsNormalizer implements PolicyTypeNormalizer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DefaultPolicyNormalizer $defaultNormalizer,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function supports(string $policyType): bool
|
||||||
|
{
|
||||||
|
return $policyType === 'termsAndConditions';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{status: string, settings: array<int, array<string, mixed>>, warnings: array<int, string>}
|
||||||
|
*/
|
||||||
|
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<int, array<string, mixed>> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -62,6 +62,10 @@ protected static function odataTypeMap(): array
|
|||||||
'windows' => '#microsoft.graph.deviceHealthScript',
|
'windows' => '#microsoft.graph.deviceHealthScript',
|
||||||
'all' => '#microsoft.graph.deviceHealthScript',
|
'all' => '#microsoft.graph.deviceHealthScript',
|
||||||
],
|
],
|
||||||
|
'termsAndConditions' => [
|
||||||
|
'windows' => '#microsoft.graph.termsAndConditions',
|
||||||
|
'all' => '#microsoft.graph.termsAndConditions',
|
||||||
|
],
|
||||||
'deviceComplianceScript' => [
|
'deviceComplianceScript' => [
|
||||||
'windows' => '#microsoft.graph.deviceComplianceScript',
|
'windows' => '#microsoft.graph.deviceComplianceScript',
|
||||||
'all' => '#microsoft.graph.deviceComplianceScript',
|
'all' => '#microsoft.graph.deviceComplianceScript',
|
||||||
|
|||||||
@ -536,6 +536,48 @@
|
|||||||
'assignments_create_method' => 'POST',
|
'assignments_create_method' => 'POST',
|
||||||
'assignments_payload_key' => 'enrollmentConfigurationAssignments',
|
'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' => [
|
'windowsAutopilotDeploymentProfile' => [
|
||||||
'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles',
|
'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles',
|
||||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'],
|
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'],
|
||||||
|
|||||||
@ -195,6 +195,16 @@
|
|||||||
'restore' => 'preview-only',
|
'restore' => 'preview-only',
|
||||||
'risk' => 'high',
|
'risk' => 'high',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'type' => 'termsAndConditions',
|
||||||
|
'label' => 'Terms & Conditions',
|
||||||
|
'category' => 'Enrollment',
|
||||||
|
'platform' => 'all',
|
||||||
|
'endpoint' => 'deviceManagement/termsAndConditions',
|
||||||
|
'backup' => 'full',
|
||||||
|
'restore' => 'enabled',
|
||||||
|
'risk' => 'medium-high',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'type' => 'endpointSecurityIntent',
|
'type' => 'endpointSecurityIntent',
|
||||||
'label' => 'Endpoint Security Intents',
|
'label' => 'Endpoint Security Intents',
|
||||||
|
|||||||
218
tests/Feature/TermsAndConditionsPolicyTypeTest.php
Normal file
218
tests/Feature/TermsAndConditionsPolicyTypeTest.php
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphLogger;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
|
use App\Services\Intune\PolicySyncService;
|
||||||
|
use App\Services\Intune\RestoreService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
use function Pest\Laravel\mock;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
class TermsAndConditionsRestoreGraphClient implements GraphClientInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int, array{method:string,path:string,payload:array|null}>
|
||||||
|
*/
|
||||||
|
public array $requestCalls = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, GraphResponse> $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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user