feat/042-inventory-dependencies-graph #50
@ -22,12 +22,14 @@ public function extractForPolicyData(InventoryItem $item, array $policyData): ar
|
|||||||
$warnings = [];
|
$warnings = [];
|
||||||
$edges = collect();
|
$edges = collect();
|
||||||
|
|
||||||
$edges = $edges->merge($this->extractAssignedTo($item, $policyData, $warnings));
|
$edges = $edges->merge($this->extractAssignments($item, $policyData, $warnings));
|
||||||
$edges = $edges->merge($this->extractScopedBy($item, $policyData));
|
$edges = $edges->merge($this->extractScopedBy($item, $policyData));
|
||||||
|
|
||||||
// Enforce max 50 outbound edges by priority: assigned_to > scoped_by > others
|
// Enforce max 50 outbound edges by priority: assigned_to > scoped_by > others
|
||||||
$priorities = [
|
$priorities = [
|
||||||
RelationshipType::AssignedTo->value => 1,
|
RelationshipType::AssignedToInclude->value => 1,
|
||||||
|
RelationshipType::AssignedToExclude->value => 2,
|
||||||
|
RelationshipType::UsesAssignmentFilter->value => 3,
|
||||||
RelationshipType::ScopedBy->value => 2,
|
RelationshipType::ScopedBy->value => 2,
|
||||||
RelationshipType::Targets->value => 3,
|
RelationshipType::Targets->value => 3,
|
||||||
RelationshipType::DependsOn->value => 4,
|
RelationshipType::DependsOn->value => 4,
|
||||||
@ -67,7 +69,7 @@ public function extractForPolicyData(InventoryItem $item, array $policyData): ar
|
|||||||
* @param array<string, mixed> $policyData
|
* @param array<string, mixed> $policyData
|
||||||
* @return Collection<int, array<string, mixed>>
|
* @return Collection<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
private function extractAssignedTo(InventoryItem $item, array $policyData, array &$warnings): Collection
|
private function extractAssignments(InventoryItem $item, array $policyData, array &$warnings): Collection
|
||||||
{
|
{
|
||||||
$assignments = Arr::get($policyData, 'assignments');
|
$assignments = Arr::get($policyData, 'assignments');
|
||||||
if (! is_array($assignments)) {
|
if (! is_array($assignments)) {
|
||||||
@ -81,32 +83,78 @@ private function extractAssignedTo(InventoryItem $item, array $policyData, array
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Known shapes: ['target' => ['groupId' => '...']] or direct ['groupId' => '...']
|
$policyId = (string) ($policyData['id'] ?? $item->external_id);
|
||||||
|
|
||||||
|
$target = Arr::get($assignment, 'target');
|
||||||
|
$odataType = is_array($target) ? (Arr::get($target, '@odata.type') ?? Arr::get($target, '@OData.Type')) : null;
|
||||||
|
$odataType = is_string($odataType) ? strtolower($odataType) : null;
|
||||||
|
|
||||||
$groupId = Arr::get($assignment, 'target.groupId') ?? Arr::get($assignment, 'groupId');
|
$groupId = Arr::get($assignment, 'target.groupId') ?? Arr::get($assignment, 'groupId');
|
||||||
|
$groupId = is_string($groupId) ? trim($groupId) : null;
|
||||||
|
|
||||||
|
$filterId = Arr::get($assignment, 'deviceAndAppManagementAssignmentFilterId')
|
||||||
|
?? Arr::get($assignment, 'assignmentFilterId')
|
||||||
|
?? Arr::get($assignment, 'target.deviceAndAppManagementAssignmentFilterId');
|
||||||
|
$filterId = is_string($filterId) ? trim($filterId) : null;
|
||||||
|
|
||||||
|
$filterType = Arr::get($assignment, 'deviceAndAppManagementAssignmentFilterType')
|
||||||
|
?? Arr::get($assignment, 'assignmentFilterType')
|
||||||
|
?? Arr::get($assignment, 'target.deviceAndAppManagementAssignmentFilterType');
|
||||||
|
$filterType = is_string($filterType) ? strtolower(trim($filterType)) : null;
|
||||||
|
$filterMode = in_array($filterType, ['include', 'exclude'], true) ? $filterType : null;
|
||||||
|
|
||||||
|
if (is_string($filterId) && $filterId !== '') {
|
||||||
|
$edges[] = [
|
||||||
|
'tenant_id' => (int) $item->tenant_id,
|
||||||
|
'source_type' => 'inventory_item',
|
||||||
|
'source_id' => (string) $item->external_id,
|
||||||
|
'target_type' => 'foundation_object',
|
||||||
|
'target_id' => $filterId,
|
||||||
|
'relationship_type' => RelationshipType::UsesAssignmentFilter->value,
|
||||||
|
'metadata' => array_filter([
|
||||||
|
'last_known_name' => null,
|
||||||
|
'foundation_type' => 'assignment_filter',
|
||||||
|
'filter_mode' => $filterMode,
|
||||||
|
], fn ($v) => $v !== null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (is_string($groupId) && $groupId !== '') {
|
if (is_string($groupId) && $groupId !== '') {
|
||||||
|
$relationshipType = RelationshipType::AssignedToInclude->value;
|
||||||
|
if (is_string($odataType) && str_contains($odataType, 'exclusion')) {
|
||||||
|
$relationshipType = RelationshipType::AssignedToExclude->value;
|
||||||
|
}
|
||||||
|
|
||||||
$edges[] = [
|
$edges[] = [
|
||||||
'tenant_id' => (int) $item->tenant_id,
|
'tenant_id' => (int) $item->tenant_id,
|
||||||
'source_type' => 'inventory_item',
|
'source_type' => 'inventory_item',
|
||||||
'source_id' => (string) $item->external_id,
|
'source_id' => (string) $item->external_id,
|
||||||
'target_type' => 'foundation_object',
|
'target_type' => 'foundation_object',
|
||||||
'target_id' => $groupId,
|
'target_id' => $groupId,
|
||||||
'relationship_type' => RelationshipType::AssignedTo->value,
|
'relationship_type' => $relationshipType,
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'last_known_name' => null,
|
'last_known_name' => null,
|
||||||
'foundation_type' => 'aad_group',
|
'foundation_type' => 'aad_group',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
} else {
|
|
||||||
$warning = [
|
|
||||||
'type' => 'unsupported_reference',
|
|
||||||
'policy_id' => (string) ($policyData['id'] ?? $item->external_id),
|
|
||||||
'raw_ref' => $assignment,
|
|
||||||
'reason' => 'unsupported_assignment_target_shape',
|
|
||||||
];
|
|
||||||
|
|
||||||
$warnings[] = $warning;
|
continue;
|
||||||
Log::info('Unsupported reference shape encountered', $warning);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Known non-group targets (e.g. allDevices/allLicensedUsers) are out-of-scope for edges.
|
||||||
|
if (is_string($odataType) && (str_contains($odataType, 'alldevices') || str_contains($odataType, 'alllicensedusers') || str_contains($odataType, 'allusers'))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$warning = [
|
||||||
|
'type' => 'unsupported_reference',
|
||||||
|
'policy_id' => $policyId,
|
||||||
|
'raw_ref' => $assignment,
|
||||||
|
'reason' => 'unsupported_assignment_target_shape',
|
||||||
|
];
|
||||||
|
|
||||||
|
$warnings[] = $warning;
|
||||||
|
Log::info('Unsupported reference shape encountered', $warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
return collect($edges);
|
return collect($edges);
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Contracts\Cache\Lock;
|
use Illuminate\Contracts\Cache\Lock;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class InventorySyncService
|
class InventorySyncService
|
||||||
@ -290,6 +291,18 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
|||||||
|
|
||||||
$observed++;
|
$observed++;
|
||||||
|
|
||||||
|
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true);
|
||||||
|
|
||||||
|
if ($includeDeps && $this->shouldHydrateAssignments($policyType)) {
|
||||||
|
$existingAssignments = $policyData['assignments'] ?? null;
|
||||||
|
if (! is_array($existingAssignments)) {
|
||||||
|
$hydratedAssignments = $this->fetchAssignmentsForPolicyType($policyType, $tenant, $externalId, $warnings);
|
||||||
|
if (is_array($hydratedAssignments)) {
|
||||||
|
$policyData['assignments'] = $hydratedAssignments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? null;
|
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? null;
|
||||||
$displayName = is_string($displayName) ? $displayName : null;
|
$displayName = is_string($displayName) ? $displayName : null;
|
||||||
|
|
||||||
@ -327,7 +340,6 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
|||||||
$upserted++;
|
$upserted++;
|
||||||
|
|
||||||
// Extract dependencies if requested in selection
|
// Extract dependencies if requested in selection
|
||||||
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true);
|
|
||||||
if ($includeDeps) {
|
if ($includeDeps) {
|
||||||
$warnings = array_merge(
|
$warnings = array_merge(
|
||||||
$warnings,
|
$warnings,
|
||||||
@ -386,6 +398,67 @@ private function shouldSkipPolicyForSelectedType(string $selectedPolicyType, arr
|
|||||||
return $this->resolveConfigurationPolicyType($policyData) !== $selectedPolicyType;
|
return $this->resolveConfigurationPolicyType($policyData) !== $selectedPolicyType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldHydrateAssignments(string $policyType): bool
|
||||||
|
{
|
||||||
|
return in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $warnings
|
||||||
|
* @return null|array<int, mixed>
|
||||||
|
*/
|
||||||
|
private function fetchAssignmentsForPolicyType(string $policyType, Tenant $tenant, string $externalId, array &$warnings): ?array
|
||||||
|
{
|
||||||
|
$pathTemplate = config("graph_contracts.types.{$policyType}.assignments_list_path");
|
||||||
|
if (! is_string($pathTemplate) || $pathTemplate === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = str_replace('{id}', $externalId, $pathTemplate);
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'tenant' => $tenant->tenant_id ?? $tenant->external_id,
|
||||||
|
'client_id' => $tenant->app_client_id,
|
||||||
|
'client_secret' => $tenant->app_client_secret,
|
||||||
|
];
|
||||||
|
|
||||||
|
$maxAttempts = 3;
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||||
|
$response = $this->graphClient->request('GET', $path, $options);
|
||||||
|
|
||||||
|
if (! $response->failed()) {
|
||||||
|
$data = $response->data;
|
||||||
|
if (is_array($data) && array_key_exists('value', $data) && is_array($data['value'])) {
|
||||||
|
return $data['value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($data)) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (int) ($response->status ?? 0);
|
||||||
|
if (! in_array($status, [429, 503], true)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$warning = [
|
||||||
|
'type' => 'assignments_fetch_failed',
|
||||||
|
'policy_id' => $externalId,
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'reason' => 'graph_assignments_list_failed',
|
||||||
|
];
|
||||||
|
|
||||||
|
$warnings[] = $warning;
|
||||||
|
Log::info('Failed to fetch policy assignments', $warning);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveConfigurationPolicyType(array $policyData): string
|
private function resolveConfigurationPolicyType(array $policyData): string
|
||||||
{
|
{
|
||||||
$templateReference = $policyData['templateReference'] ?? null;
|
$templateReference = $policyData['templateReference'] ?? null;
|
||||||
|
|||||||
@ -5,6 +5,9 @@
|
|||||||
enum RelationshipType: string
|
enum RelationshipType: string
|
||||||
{
|
{
|
||||||
case AssignedTo = 'assigned_to';
|
case AssignedTo = 'assigned_to';
|
||||||
|
case AssignedToInclude = 'assigned_to_include';
|
||||||
|
case AssignedToExclude = 'assigned_to_exclude';
|
||||||
|
case UsesAssignmentFilter = 'uses_assignment_filter';
|
||||||
case ScopedBy = 'scoped_by';
|
case ScopedBy = 'scoped_by';
|
||||||
case Targets = 'targets';
|
case Targets = 'targets';
|
||||||
case DependsOn = 'depends_on';
|
case DependsOn = 'depends_on';
|
||||||
@ -13,6 +16,9 @@ public function label(): string
|
|||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::AssignedTo => 'Assigned to',
|
self::AssignedTo => 'Assigned to',
|
||||||
|
self::AssignedToInclude => 'Assigned to (include)',
|
||||||
|
self::AssignedToExclude => 'Assigned to (exclude)',
|
||||||
|
self::UsesAssignmentFilter => 'Uses assignment filter',
|
||||||
self::ScopedBy => 'Scoped by',
|
self::ScopedBy => 'Scoped by',
|
||||||
self::Targets => 'Targets',
|
self::Targets => 'Targets',
|
||||||
self::DependsOn => 'Depends on',
|
self::DependsOn => 'Depends on',
|
||||||
|
|||||||
@ -17,14 +17,15 @@
|
|||||||
"source_id": { "type": "string" },
|
"source_id": { "type": "string" },
|
||||||
"target_type": { "type": "string", "enum": ["inventory_item", "foundation_object", "missing"] },
|
"target_type": { "type": "string", "enum": ["inventory_item", "foundation_object", "missing"] },
|
||||||
"target_id": { "type": ["string", "null"] },
|
"target_id": { "type": ["string", "null"] },
|
||||||
"relationship_type": { "type": "string", "enum": ["assigned_to", "scoped_by", "targets", "depends_on"] },
|
"relationship_type": { "type": "string", "enum": ["assigned_to", "assigned_to_include", "assigned_to_exclude", "uses_assignment_filter", "scoped_by", "targets", "depends_on"] },
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"type": ["object", "null"],
|
"type": ["object", "null"],
|
||||||
"additionalProperties": true,
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"last_known_name": { "type": ["string", "null"] },
|
"last_known_name": { "type": ["string", "null"] },
|
||||||
"raw_ref": {},
|
"raw_ref": {},
|
||||||
"foundation_type": { "type": "string", "enum": ["aad_group", "scope_tag", "device_category"] }
|
"foundation_type": { "type": "string", "enum": ["aad_group", "scope_tag", "device_category", "assignment_filter"] },
|
||||||
|
"filter_mode": { "type": ["string", "null"], "enum": ["include", "exclude", null] }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"created_at": { "type": ["string", "null"], "description": "ISO-8601 timestamp" },
|
"created_at": { "type": ["string", "null"], "description": "ISO-8601 timestamp" },
|
||||||
|
|||||||
@ -52,10 +52,16 @@ #### InventoryLink.metadata
|
|||||||
Required when `target_type='foundation_object'`:
|
Required when `target_type='foundation_object'`:
|
||||||
- `foundation_type` (string enum-like): `aad_group` | `scope_tag` | `device_category`
|
- `foundation_type` (string enum-like): `aad_group` | `scope_tag` | `device_category`
|
||||||
|
|
||||||
|
Additional metadata (when applicable):
|
||||||
|
- `filter_mode` (string enum-like): `include` | `exclude` (for `foundation_type='assignment_filter'`)
|
||||||
|
|
||||||
## Enums
|
## Enums
|
||||||
|
|
||||||
### RelationshipType
|
### RelationshipType
|
||||||
- `assigned_to`
|
- `assigned_to`
|
||||||
|
- `assigned_to_include`
|
||||||
|
- `assigned_to_exclude`
|
||||||
|
- `uses_assignment_filter`
|
||||||
- `scoped_by`
|
- `scoped_by`
|
||||||
- `targets`
|
- `targets`
|
||||||
- `depends_on`
|
- `depends_on`
|
||||||
|
|||||||
@ -58,7 +58,10 @@ ## Functional Requirements
|
|||||||
- **FR1: Relationship taxonomy**
|
- **FR1: Relationship taxonomy**
|
||||||
Define a normalized set of relationship types covering inventory→inventory and inventory→foundation edges.
|
Define a normalized set of relationship types covering inventory→inventory and inventory→foundation edges.
|
||||||
Supported types (MVP):
|
Supported types (MVP):
|
||||||
- `assigned_to` (Policy → AAD Group)
|
- `assigned_to` (Policy → AAD Group) *(legacy/general)*
|
||||||
|
- `assigned_to_include` (Policy → AAD Group; include assignment)
|
||||||
|
- `assigned_to_exclude` (Policy → AAD Group; exclude assignment)
|
||||||
|
- `uses_assignment_filter` (Policy → Assignment Filter; metadata `filter_mode=include|exclude`)
|
||||||
- `scoped_by` (Policy → Scope Tag)
|
- `scoped_by` (Policy → Scope Tag)
|
||||||
- `targets` (Update Policy → Device Category, conditional logic)
|
- `targets` (Update Policy → Device Category, conditional logic)
|
||||||
- `depends_on` (Generic prerequisite, e.g., Compliance Policy referenced by Conditional Access)
|
- `depends_on` (Generic prerequisite, e.g., Compliance Policy referenced by Conditional Access)
|
||||||
@ -85,6 +88,7 @@ ## Functional Requirements
|
|||||||
- AAD Groups (`aad_group`)
|
- AAD Groups (`aad_group`)
|
||||||
- Scope Tags (`scope_tag`)
|
- Scope Tags (`scope_tag`)
|
||||||
- Device Categories (`device_category`)
|
- Device Categories (`device_category`)
|
||||||
|
- Assignment Filters (`assignment_filter`)
|
||||||
|
|
||||||
**Out-of-scope foundation types** (for this iteration): Conditional Access Policies, Compliance Policies as foundation nodes (only as inventory items).
|
**Out-of-scope foundation types** (for this iteration): Conditional Access Policies, Compliance Policies as foundation nodes (only as inventory items).
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,7 @@ ## Phase 2: Foundational (Blocking Prerequisites)
|
|||||||
- [x] T004 Ensure `inventory_links` schema (unique key + indexes) matches spec in `database/migrations/2026_01_07_150000_create_inventory_links_table.php`
|
- [x] T004 Ensure `inventory_links` schema (unique key + indexes) matches spec in `database/migrations/2026_01_07_150000_create_inventory_links_table.php`
|
||||||
- [x] T005 [P] Ensure `InventoryLink` casts and tenant-safe query patterns in `app/Models/InventoryLink.php`
|
- [x] T005 [P] Ensure `InventoryLink` casts and tenant-safe query patterns in `app/Models/InventoryLink.php`
|
||||||
- [x] T006 [P] Ensure factory coverage for dependency edges in `database/factories/InventoryLinkFactory.php`
|
- [x] T006 [P] Ensure factory coverage for dependency edges in `database/factories/InventoryLinkFactory.php`
|
||||||
- [x] T007 Align idempotent upsert semantics for edges in `app/Services/Inventory/DependencyExtractionService.php`
|
- [x] T007 Align idempotent upsert semantics for edges in `app/Services/Inventory/DependencyExtractionService.php` (including hydrated assignments when not present in list payloads)
|
||||||
- [x] T008 Implement warning-only handling for unknown/unsupported reference shapes in `app/Services/Inventory/DependencyExtractionService.php`
|
- [x] T008 Implement warning-only handling for unknown/unsupported reference shapes in `app/Services/Inventory/DependencyExtractionService.php`
|
||||||
- [x] T009 Persist warnings on the sync run record at `InventorySyncRun.error_context.warnings[]` via `app/Services/Inventory/InventorySyncService.php`
|
- [x] T009 Persist warnings on the sync run record at `InventorySyncRun.error_context.warnings[]` via `app/Services/Inventory/InventorySyncService.php`
|
||||||
- [x] T010 Implement limit-only inbound/outbound queries with optional relationship filter in `app/Services/Inventory/DependencyQueryService.php` (ordered by `created_at DESC`)
|
- [x] T010 Implement limit-only inbound/outbound queries with optional relationship filter in `app/Services/Inventory/DependencyQueryService.php` (ordered by `created_at DESC`)
|
||||||
|
|||||||
@ -273,3 +273,96 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
expect($inbound[1]->source_id)->toBe('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb');
|
expect($inbound[1]->source_id)->toBe('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb');
|
||||||
expect($inbound[0]->created_at->greaterThan($inbound[1]->created_at))->toBeTrue();
|
expect($inbound[0]->created_at->greaterThan($inbound[1]->created_at))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hydrates settings catalog assignments and extracts include/exclude/filter edges', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$this->app->bind(GraphClientInterface::class, function () {
|
||||||
|
return new class implements GraphClientInterface
|
||||||
|
{
|
||||||
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, [[
|
||||||
|
'id' => 'sc-1',
|
||||||
|
'name' => 'Settings Catalog Policy',
|
||||||
|
'roleScopeTagIds' => ['scope-tag-1'],
|
||||||
|
// assignments omitted intentionally (must be hydrated via /assignments)
|
||||||
|
]], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
if ($method === 'GET' && $path === '/deviceManagement/configurationPolicies/sc-1/assignments') {
|
||||||
|
return new GraphResponse(true, [
|
||||||
|
'value' => [
|
||||||
|
[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'group-inc-1',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.exclusionGroupAssignmentTarget',
|
||||||
|
'groupId' => 'group-exc-1',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'group-inc-2',
|
||||||
|
],
|
||||||
|
'deviceAndAppManagementAssignmentFilterId' => 'filter-1',
|
||||||
|
'deviceAndAppManagementAssignmentFilterType' => 'include',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GraphResponse(true, [], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, [], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, [], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, [], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, [], 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$svc = app(InventorySyncService::class);
|
||||||
|
|
||||||
|
$run = $svc->syncNow($tenant, [
|
||||||
|
'policy_types' => ['settingsCatalogPolicy'],
|
||||||
|
'categories' => [],
|
||||||
|
'include_foundations' => false,
|
||||||
|
'include_dependencies' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($run->status)->toBe('success');
|
||||||
|
|
||||||
|
$edges = InventoryLink::query()->where('tenant_id', $tenant->getKey())->get();
|
||||||
|
expect($edges->where('relationship_type', 'scoped_by'))->toHaveCount(1);
|
||||||
|
expect($edges->where('relationship_type', 'assigned_to_include'))->toHaveCount(2);
|
||||||
|
expect($edges->where('relationship_type', 'assigned_to_exclude'))->toHaveCount(1);
|
||||||
|
expect($edges->where('relationship_type', 'uses_assignment_filter'))->toHaveCount(1);
|
||||||
|
|
||||||
|
$filterEdge = $edges->firstWhere('relationship_type', 'uses_assignment_filter');
|
||||||
|
expect($filterEdge)->not->toBeNull();
|
||||||
|
expect($filterEdge->metadata['foundation_type'] ?? null)->toBe('assignment_filter');
|
||||||
|
expect($filterEdge->metadata['filter_mode'] ?? null)->toBe('include');
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user