TenantAtlas/tests/Feature/Inventory/InventorySyncServiceTest.php
ahmido 8ae7a7234e feat/040-inventory-core (#43)
Summary

Implements Inventory Core (Spec 040): a tenant-scoped, mutable “last observed” inventory catalog + sync run logging, with deterministic selection hashing and safe derived “missing” semantics.

This establishes the foundation for Inventory UI (041), Dependencies Graph (042), Compare/Promotion (043), and Drift (044).

What’s included
	•	DB schema
	•	inventory_items (unique: tenant_id + policy_type + external_id; indexes; last_seen_at, last_seen_run_id)
	•	inventory_sync_runs (tenant_id, selection_hash/payload, status, started/finished, counts, error_codes, correlation_id)
	•	Selection hashing
	•	Deterministic selection_hash via canonical JSON (sorted keys + sorted arrays) + sha256
	•	Sync semantics
	•	Idempotent upsert (no duplicates)
	•	Updates last_seen_* when observed
	•	Enforces tenant scoping for all reads/writes
	•	Guardrail: inventory sync does not create snapshots/backups
	•	Missing semantics (derived)
	•	“missing” computed relative to latest completed run for same (tenant_id, selection_hash)
	•	Low confidence when latest run is partial/failed or had_errors=true
	•	Selection isolation (runs for other selections don’t affect missing)
	•	deleted is reserved (not produced here)
	•	Safety
	•	meta_jsonb whitelist enforced (unknown keys dropped; never fail sync)
	•	Safe error persistence (no bearer tokens / secrets)
	•	Locking to prevent overlapping runs for same tenant+selection
	•	Concurrency limiter (global + per-tenant) and throttling resilience (429/503 backoff + jitter)

Tests

Added Pest coverage for:
	•	selection_hash determinism (array order invariant)
	•	upsert idempotency + last_seen updates
	•	missing derived semantics + selection isolation
	•	low confidence missing on partial/had_errors
	•	meta whitelist drop (no exception)
	•	lock prevents overlapping runs
	•	no snapshots/backups side effects
	•	safe error persistence (no bearer tokens)

Non-goals
	•	Inventory UI pages/resources (Spec 041)
	•	Dependency graph hydration (Spec 042)
	•	Cross-tenant compare/promotion flows (Spec 043)
	•	Drift analysis dashboards (Spec 044)

Review focus
	•	Data model correctness + indexes/constraints
	•	Selection hash canonicalization (determinism)
	•	Missing semantics (latest completed run + confidence rule)
	•	Guardrails (no snapshot/backups side effects)
	•	Safety: error_code taxonomy + safe persistence/logging

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #43
2026-01-07 14:54:24 +00:00

297 lines
10 KiB
PHP

<?php
use App\Models\BackupItem;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Inventory\InventoryMetaSanitizer;
use App\Services\Inventory\InventoryMissingService;
use App\Services\Inventory\InventorySyncService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
uses(RefreshDatabase::class);
function fakeGraphClient(array $policiesByType = [], array $failedTypes = [], ?Throwable $throwable = null): GraphClientInterface
{
return new class($policiesByType, $failedTypes, $throwable) implements GraphClientInterface
{
public function __construct(
private readonly array $policiesByType,
private readonly array $failedTypes,
private readonly ?Throwable $throwable,
) {}
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
if ($this->throwable instanceof Throwable) {
throw $this->throwable;
}
if (in_array($policyType, $this->failedTypes, true)) {
return new GraphResponse(false, [], 403, ['error' => ['code' => 'Forbidden', 'message' => 'forbidden']], [], []);
}
return new GraphResponse(true, $this->policiesByType[$policyType] ?? []);
}
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, []);
}
};
}
test('inventory sync upserts and updates last_seen fields without duplicates', function () {
$tenant = Tenant::factory()->create();
app()->instance(GraphClientInterface::class, fakeGraphClient([
'deviceConfiguration' => [
['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'],
],
]));
$service = app(InventorySyncService::class);
$selection = [
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
'include_foundations' => false,
'include_dependencies' => false,
];
$runA = $service->syncNow($tenant, $selection);
expect($runA->status)->toBe('success');
$item = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->first();
expect($item)->not->toBeNull();
expect($item->external_id)->toBe('cfg-1');
expect($item->last_seen_run_id)->toBe($runA->id);
$runB = $service->syncNow($tenant, $selection);
$items = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->get();
expect($items)->toHaveCount(1);
$items->first()->refresh();
expect($items->first()->last_seen_run_id)->toBe($runB->id);
});
test('meta whitelist drops unknown keys without failing', function () {
$tenant = Tenant::factory()->create();
$sanitizer = app(InventoryMetaSanitizer::class);
$meta = $sanitizer->sanitize([
'odata_type' => '#microsoft.graph.deviceConfiguration',
'etag' => 'W/\"123\"',
'scope_tag_ids' => ['0', 'tag-1'],
'assignment_target_count' => '5',
'warnings' => ['ok'],
'unknown_key' => 'should_not_persist',
]);
$item = \App\Models\InventoryItem::query()->create([
'tenant_id' => $tenant->id,
'policy_type' => 'deviceConfiguration',
'external_id' => 'cfg-1',
'display_name' => 'Config 1',
'meta_jsonb' => $meta,
'last_seen_at' => now(),
'last_seen_run_id' => null,
]);
$item->refresh();
$stored = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
expect($stored)->not->toHaveKey('unknown_key');
expect($stored['assignment_target_count'] ?? null)->toBe(5);
});
test('inventory missing is derived from latest completed run and low confidence on partial runs', function () {
$tenant = Tenant::factory()->create();
$selection = [
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
'include_foundations' => false,
'include_dependencies' => false,
];
app()->instance(GraphClientInterface::class, fakeGraphClient([
'deviceConfiguration' => [
['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'],
],
]));
app(InventorySyncService::class)->syncNow($tenant, $selection);
app()->instance(GraphClientInterface::class, fakeGraphClient([
'deviceConfiguration' => [],
]));
app(InventorySyncService::class)->syncNow($tenant, $selection);
$missingService = app(InventoryMissingService::class);
$result = $missingService->missingForSelection($tenant, $selection);
expect($result['missing'])->toHaveCount(1);
expect($result['lowConfidence'])->toBeFalse();
app()->instance(GraphClientInterface::class, fakeGraphClient([
'deviceConfiguration' => [],
], failedTypes: ['deviceConfiguration']));
app(InventorySyncService::class)->syncNow($tenant, $selection);
$result2 = $missingService->missingForSelection($tenant, $selection);
expect($result2['missing'])->toHaveCount(1);
expect($result2['lowConfidence'])->toBeTrue();
});
test('selection isolation: run for selection Y does not affect selection X missing', function () {
$tenant = Tenant::factory()->create();
$selectionX = [
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
'include_foundations' => false,
'include_dependencies' => false,
];
$selectionY = [
'policy_types' => ['deviceCompliancePolicy'],
'categories' => ['Compliance'],
'include_foundations' => false,
'include_dependencies' => false,
];
app()->instance(GraphClientInterface::class, fakeGraphClient([
'deviceConfiguration' => [
['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'],
],
'deviceCompliancePolicy' => [
['id' => 'cmp-1', 'displayName' => 'Compliance 1', '@odata.type' => '#microsoft.graph.deviceCompliancePolicy'],
],
]));
$service = app(InventorySyncService::class);
$service->syncNow($tenant, $selectionX);
$service->syncNow($tenant, $selectionY);
$missingService = app(InventoryMissingService::class);
$resultX = $missingService->missingForSelection($tenant, $selectionX);
expect($resultX['missing'])->toHaveCount(0);
});
test('lock prevents overlapping runs for same tenant and selection', function () {
$tenant = Tenant::factory()->create();
app()->instance(GraphClientInterface::class, fakeGraphClient([
'deviceConfiguration' => [],
]));
$service = app(InventorySyncService::class);
$selection = [
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
'include_foundations' => false,
'include_dependencies' => false,
];
$hash = app(\App\Services\Inventory\InventorySelectionHasher::class)->hash($selection);
$lock = Cache::lock("inventory_sync:tenant:{$tenant->id}:selection:{$hash}", 900);
expect($lock->get())->toBeTrue();
$run = $service->syncNow($tenant, $selection);
expect($run->status)->toBe('skipped');
expect($run->error_codes)->toContain('lock_contended');
$lock->release();
});
test('inventory sync does not create snapshot or backup rows', function () {
$tenant = Tenant::factory()->create();
$baseline = [
'policy_versions' => PolicyVersion::query()->count(),
'backup_sets' => BackupSet::query()->count(),
'backup_items' => BackupItem::query()->count(),
'backup_schedules' => BackupSchedule::query()->count(),
'backup_schedule_runs' => BackupScheduleRun::query()->count(),
];
app()->instance(GraphClientInterface::class, fakeGraphClient([
'deviceConfiguration' => [],
]));
$service = app(InventorySyncService::class);
$service->syncNow($tenant, [
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
'include_foundations' => false,
'include_dependencies' => false,
]);
expect(PolicyVersion::query()->count())->toBe($baseline['policy_versions']);
expect(BackupSet::query()->count())->toBe($baseline['backup_sets']);
expect(BackupItem::query()->count())->toBe($baseline['backup_items']);
expect(BackupSchedule::query()->count())->toBe($baseline['backup_schedules']);
expect(BackupScheduleRun::query()->count())->toBe($baseline['backup_schedule_runs']);
});
test('run error persistence is safe and does not include bearer tokens', function () {
$tenant = Tenant::factory()->create();
$throwable = new RuntimeException('Graph failed: Bearer abc.def.ghi');
app()->instance(GraphClientInterface::class, fakeGraphClient(throwable: $throwable));
$service = app(InventorySyncService::class);
$run = $service->syncNow($tenant, [
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
'include_foundations' => false,
'include_dependencies' => false,
]);
expect($run->status)->toBe('failed');
$context = is_array($run->error_context) ? $run->error_context : [];
$message = (string) ($context['message'] ?? '');
expect($message)->not->toContain('abc.def.ghi');
expect($message)->toContain('Bearer [REDACTED]');
});