Compare commits

...

3 Commits

Author SHA1 Message Date
Ahmed Darrazi
856fe9da60 merge: agent session work 2026-01-04 14:24:09 +01:00
Ahmed Darrazi
0fece3acd6 Merge remote-tracking branch 'origin/dev' into feat/027-enrollment-config-subtypes-session-1767532527
# Conflicts:
#	config/graph_contracts.php
2026-01-04 14:21:55 +01:00
83f1814254 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
2026-01-04 03:01:11 +00:00
6 changed files with 370 additions and 0 deletions

View File

@ -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,

View 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);
}
}

View File

@ -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',

View File

@ -584,6 +584,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'],

View File

@ -226,6 +226,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',

View 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);
});