TenantAtlas/tests/Unit/GraphContractRegistryTest.php
ahmido 32c3a64147 feat(112): LIST $expand parity + Entra principal names (#136)
Implements LIST `$expand` parity with GET by forwarding caller-provided, contract-allowlisted expands.

Key changes:
- Entra Admin Roles scan now requests `expand=principal` for role assignments so `principal.displayName` can render.
- `$expand` normalization/sanitization: top-level comma split (commas inside balanced parentheses preserved), trim, dedupe, allowlist exact match, caps (max 10 tokens, max 200 chars/token).
- Diagnostics when expands are removed/truncated (non-prod warning, production low-noise).

Tests:
- Adds/extends unit coverage for Graph contract sanitization, list request shaping, and the EntraAdminRolesReportService.

Spec artifacts included under `specs/112-list-expand-parity/`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #136
2026-02-25 23:54:20 +00:00

191 lines
7.2 KiB
PHP

<?php
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphResponse;
use Illuminate\Support\Facades\Log;
beforeEach(function () {
config()->set('graph_contracts.types.deviceConfiguration', [
'resource' => 'deviceManagement/deviceConfigurations',
'allowed_select' => ['id', 'displayName'],
'allowed_expand' => ['assignments', 'roleDefinition($select=id,displayName)'],
'type_family' => [
'#microsoft.graph.deviceConfiguration',
'#microsoft.graph.windows10CustomConfiguration',
],
]);
config()->set('graph_contracts.types.settingsCatalogPolicy', [
'resource' => 'deviceManagement/configurationPolicies',
'allowed_select' => ['id'],
'allowed_expand' => [],
'type_family' => ['#microsoft.graph.deviceManagementConfigurationPolicy'],
'update_whitelist' => ['name', 'description'],
'update_map' => ['displayName' => 'name'],
'update_strip_keys' => ['platforms', 'technologies', 'templateReference', 'assignments'],
'settings_write' => [
'path_template' => 'deviceManagement/configurationPolicies/{id}/settings/{settingId}',
'method' => 'PATCH',
],
]);
$this->registry = app(GraphContractRegistry::class);
});
it('sanitizes disallowed select and expand values', function () {
$result = $this->registry->sanitizeQuery('deviceConfiguration', [
'$select' => ['id', 'displayName', 'unsupported'],
'$expand' => ['assignments', 'badExpand'],
]);
$query = $result['query'];
$warnings = $result['warnings'];
expect($query['$select'])->toBe('id,displayName');
expect($query['$expand'])->toBe('assignments');
expect($warnings)->not->toBeEmpty();
});
it('splits $expand strings on top-level commas and preserves commas inside parentheses', function () {
$result = $this->registry->sanitizeQuery('deviceConfiguration', [
'$expand' => 'assignments, roleDefinition($select=id,displayName)',
]);
expect($result['query']['$expand'])->toBe('assignments,roleDefinition($select=id,displayName)');
});
it('dedupes and drops empty $expand tokens', function () {
$result = $this->registry->sanitizeQuery('deviceConfiguration', [
'$expand' => 'assignments, assignments,,',
]);
expect($result['query']['$expand'])->toBe('assignments');
});
it('caps $expand token length and maximum allowed items', function () {
$longToken = str_repeat('a', 201);
$allowedTokens = [];
foreach (range(1, 11) as $number) {
$allowedTokens[] = "token{$number}";
}
config()->set('graph_contracts.types.deviceConfiguration.allowed_expand', array_merge(['assignments', $longToken], $allowedTokens));
$result = $this->registry->sanitizeQuery('deviceConfiguration', [
'$expand' => array_merge([$longToken, 'assignments'], $allowedTokens),
]);
expect($result['query']['$expand'])->toBe(implode(',', array_merge(['assignments'], array_slice($allowedTokens, 0, 9))));
});
it('drops disallowed $expand tokens with exact-match allowlists', function () {
$result = $this->registry->sanitizeQuery('entraRoleAssignments', [
'$expand' => 'principal,principal($select=displayName)',
]);
expect($result['query']['$expand'])->toBe('principal');
});
it('emits non-production diagnostics when $expand is sanitized', function () {
Log::spy();
$result = $this->registry->sanitizeQuery('entraRoleAssignments', [
'$expand' => 'principal,badExpand',
]);
expect($result['query']['$expand'])->toBe('principal');
Log::shouldHaveReceived('warning')
->once()
->withArgs(function (string $message, array $context): bool {
return $message === 'Graph query sanitized'
&& ($context['policy_type'] ?? null) === 'entraRoleAssignments'
&& ($context['query_key'] ?? null) === '$expand'
&& in_array('badExpand', $context['removed'] ?? [], true);
});
});
it('uses debug-level diagnostics in production when $expand is sanitized', function () {
Log::spy();
$originalEnvironment = app()->environment();
app()->detectEnvironment(fn () => 'production');
$result = $this->registry->sanitizeQuery('entraRoleAssignments', [
'$expand' => 'principal,badExpand',
]);
expect($result['query']['$expand'])->toBe('principal');
Log::shouldHaveReceived('debug')
->once()
->withArgs(function (string $message, array $context): bool {
return $message === 'Graph query sanitized'
&& ($context['policy_type'] ?? null) === 'entraRoleAssignments'
&& ($context['query_key'] ?? null) === '$expand'
&& in_array('badExpand', $context['removed'] ?? [], true);
});
Log::shouldNotHaveReceived('warning');
app()->detectEnvironment(fn () => $originalEnvironment);
});
it('matches derived types within family', function () {
expect($this->registry->matchesTypeFamily('deviceConfiguration', '#microsoft.graph.windows10CustomConfiguration'))->toBeTrue();
expect($this->registry->matchesTypeFamily('deviceConfiguration', '#microsoft.graph.androidCompliancePolicy'))->toBeFalse();
});
it('detects capability errors for downgrade', function () {
$response = new GraphResponse(
success: false,
data: [],
status: 400,
errors: [['message' => 'Request is invalid due to $select']]
);
$shouldDowngrade = $this->registry->shouldDowngradeOnCapabilityError($response, ['$select' => ['displayName']]);
expect($shouldDowngrade)->toBeTrue();
});
it('provides contract for settings catalog policies', function () {
$contract = config('graph_contracts.types.settingsCatalogPolicy');
expect($contract)->not->toBeEmpty();
expect($contract['resource'])->toBe('deviceManagement/configurationPolicies');
expect($this->registry->matchesTypeFamily('settingsCatalogPolicy', '#microsoft.graph.deviceManagementConfigurationPolicy'))->toBeTrue();
});
it('sanitizes update payloads for settings catalog policies', function () {
$payload = [
'id' => 'scp-1',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'displayName' => 'Config',
'Description' => 'desc',
'version' => 5,
'createdDateTime' => '2024-01-01T00:00:00Z',
'settings' => [
[
'id' => 'setting-1',
'settingInstance' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
'settingDefinitionId' => 'setting_definition',
'simpleSettingValue' => ['value' => 'bar'],
],
],
],
'Platforms' => ['windows'],
'unknown' => 'drop-me',
];
$sanitized = $this->registry->sanitizeUpdatePayload('settingsCatalogPolicy', $payload);
expect($sanitized)->toHaveKeys(['name', 'description']);
expect($sanitized)->not->toHaveKey('id');
expect($sanitized)->not->toHaveKey('@odata.type');
expect($sanitized)->not->toHaveKey('version');
expect($sanitized)->not->toHaveKey('platforms');
expect($sanitized)->not->toHaveKey('settings');
});