TenantAtlas/tests/Feature/DependencyExtractionFeatureTest.php
2026-01-10 10:48:24 +01:00

370 lines
14 KiB
PHP

<?php
use App\Models\InventoryItem;
use App\Models\InventoryLink;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\InventorySyncService;
class FakeGraphClientForDeps implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, [
[
'id' => 'pol-1',
'displayName' => 'Test Policy',
'assignments' => [
['target' => ['groupId' => 'group-1']],
['target' => ['groupId' => 'group-2']],
],
'roleScopeTagIds' => ['scope-tag-1', 'scope-tag-2'],
],
], 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);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true, [], 200);
}
}
it('extracts edges during inventory sync and marks missing appropriately', function () {
$tenant = Tenant::factory()->create();
$this->app->bind(GraphClientInterface::class, fn () => new FakeGraphClientForDeps);
$svc = app(InventorySyncService::class);
$run = $svc->syncNow($tenant, [
'policy_types' => ['deviceConfiguration'],
'categories' => [],
'include_foundations' => false,
'include_dependencies' => true,
]);
expect($run->status)->toBe('success');
$edges = InventoryLink::query()->where('tenant_id', $tenant->getKey())->get();
// 2 assigned_to + 2 scoped_by = 4
expect($edges->count())->toBe(4);
});
it('respects 50-edge limit for outbound extraction', function () {
$tenant = Tenant::factory()->create();
// Fake client returning 60 group assignments
$this->app->bind(GraphClientInterface::class, function () {
return new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
$assignments = [];
for ($i = 1; $i <= 60; $i++) {
$assignments[] = ['target' => ['groupId' => 'g-'.$i]];
}
return new GraphResponse(true, [[
'id' => 'pol-2',
'displayName' => 'Big Assignments',
'assignments' => $assignments,
]]);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
};
});
$svc = app(InventorySyncService::class);
$svc->syncNow($tenant, [
'policy_types' => ['deviceConfiguration'],
'categories' => [],
'include_foundations' => false,
'include_dependencies' => true,
]);
$count = InventoryLink::query()->where('tenant_id', $tenant->getKey())->count();
expect($count)->toBe(50);
});
it('persists unsupported reference warnings on the sync run record', 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' => 'pol-warn-1',
'displayName' => 'Unsupported Assignment Target',
'assignments' => [
['target' => ['filterId' => 'filter-only-no-group']],
],
]]);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
};
});
$svc = app(InventorySyncService::class);
$run = $svc->syncNow($tenant, [
'policy_types' => ['deviceConfiguration'],
'categories' => [],
'include_foundations' => false,
'include_dependencies' => true,
]);
$warnings = $run->error_context['warnings'] ?? null;
expect($warnings)->toBeArray()->toHaveCount(1);
expect($warnings[0]['type'] ?? null)->toBe('unsupported_reference');
expect(InventoryLink::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
});
it('orders inbound/outbound edges by created_at desc and applies limit-only behavior', function () {
$tenant = Tenant::factory()->create();
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$svc = app(DependencyQueryService::class);
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'foundation_object',
'target_id' => '11111111-1111-1111-1111-111111111111',
'relationship_type' => 'assigned_to',
'created_at' => now()->subMinutes(10),
'updated_at' => now()->subMinutes(10),
]);
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'foundation_object',
'target_id' => '22222222-2222-2222-2222-222222222222',
'relationship_type' => 'assigned_to',
'created_at' => now()->subMinutes(5),
'updated_at' => now()->subMinutes(5),
]);
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'foundation_object',
'target_id' => '33333333-3333-3333-3333-333333333333',
'relationship_type' => 'assigned_to',
'created_at' => now()->subMinutes(1),
'updated_at' => now()->subMinutes(1),
]);
$outbound = $svc->getOutboundEdges($item, null, 2);
expect($outbound)->toHaveCount(2);
expect($outbound[0]->target_id)->toBe('33333333-3333-3333-3333-333333333333');
expect($outbound[1]->target_id)->toBe('22222222-2222-2222-2222-222222222222');
expect($outbound[0]->created_at->greaterThan($outbound[1]->created_at))->toBeTrue();
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'target_type' => 'inventory_item',
'target_id' => $item->external_id,
'relationship_type' => 'depends_on',
'created_at' => now()->subMinutes(9),
'updated_at' => now()->subMinutes(9),
]);
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'target_type' => 'inventory_item',
'target_id' => $item->external_id,
'relationship_type' => 'depends_on',
'created_at' => now()->subMinutes(2),
'updated_at' => now()->subMinutes(2),
]);
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
'target_type' => 'inventory_item',
'target_id' => $item->external_id,
'relationship_type' => 'depends_on',
'created_at' => now()->subMinutes(1),
'updated_at' => now()->subMinutes(1),
]);
$inbound = $svc->getInboundEdges($item, null, 2);
expect($inbound)->toHaveCount(2);
expect($inbound[0]->source_id)->toBe('cccccccc-cccc-cccc-cccc-cccccccccccc');
expect($inbound[1]->source_id)->toBe('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb');
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 present but empty (must still be hydrated via /assignments)
'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');
});