TenantAtlas/tests/Feature/DependencyExtractionFeatureTest.php
ahmido da18d3cb14 feat/042-inventory-dependencies-graph (#50)
Dieses PR liefert den Inventory Dependencies Graph end-to-end: Abhängigkeiten (Edges) werden aus Inventory-Sync-Daten extrahiert, tenant-sicher gespeichert und in der Inventory Item Detailansicht angezeigt.

Ziel: Admins können Prerequisites + Blast Radius (direct) schnell erkennen, ohne Snapshot/Restore anzufassen.

⸻

Was ist drin?

Dependency Graph (Edges)
	•	inventory_links Schema + Indizes + idempotentes Upsert (Unique Key)
	•	Relationship Types (u.a.):
	•	assigned_to_include, assigned_to_exclude
	•	uses_assignment_filter
	•	scoped_by_scope_tag
	•	UI: Inventory Item → Dependencies Section
	•	Direction Filter: All / Inbound / Outbound
	•	Relationship Filter: All + spezifische Relationship Types
	•	Missing-Badge + sicheres Tooltip (safe subset)

Safety / Observability
	•	Unknown/unsupported Shapes erzeugen keine Edges, sondern:
	•	Warning in InventorySyncRun.error_context.warnings[]
	•	optional info-log (ohne Secrets)
	•	Limit-only Semantik (MVP): bis zu 50 Edges pro Richtung (max 100 bei “All”)
	•	Blast Radius in MVP = direct only (kein depth>1 traversal)

Name Resolution (lokal, ohne Entra Calls)
	•	Resolver/DTO Layer für deterministische Labels (kein “Unknown” mehr)
	•	Auflösung aus lokaler DB nur für Foundations, wenn vorhanden:
	•	scope_tag → roleScopeTag
	•	assignment_filter → assignmentFilter
	•	aad_group bleibt bewusst external ref: “Group (external): …” (keine Graph/Entra Lookups im UI)
	•	Zentraler FoundationTypeMap als Source-of-Truth (keine Hardcodings)

⸻

Out of Scope / Follow-up
	•	Entra Group Name Resolution (braucht eigenes “Group Inventory” Modul + Permissions)
	•	Foundations als Inventory Items / Coverage Tab (Scope Tags / Assignment Filters sichtbar & syncbar)
→ folgt als separater PR (Inventory Core/UI), damit 042 sauber “Edges-only” bleibt.

⸻

Tests / Verifikation
	•	Targeted Pest Tests (Unit + Feature + UI smoke) für:
	•	deterministische Edge-Erzeugung + idempotent upsert
	•	tenant isolation (UI/Query)
	•	warnings auf Run Record
	•	resolver/name rendering + links (wo möglich)
	•	pint --dirty ausgeführt

⸻

Manual QA (UI)
	1.	Inventory Sync Run mit include_dependencies=true starten
	2.	Inventory Item öffnen → Dependencies prüfen:
	•	include/exclude + filter + scoped_by sichtbar (wenn vorhanden)
	•	Relationship/Direction Filter funktionieren
	•	keine “Unknown” Labels mehr, sondern deterministische Labels

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #50
2026-01-10 12:50:08 +00: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');
});