feat: inventory core schema + sync
This commit is contained in:
parent
cae0c58433
commit
63f4f878ad
30
app/Models/InventoryItem.php
Normal file
30
app/Models/InventoryItem.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventoryItem extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\InventoryItemFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'meta_jsonb' => 'array',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function lastSeenRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventorySyncRun::class, 'last_seen_run_id');
|
||||
}
|
||||
}
|
||||
52
app/Models/InventorySyncRun.php
Normal file
52
app/Models/InventorySyncRun.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventorySyncRun extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\InventorySyncRunFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_RUNNING = 'running';
|
||||
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
|
||||
public const STATUS_PARTIAL = 'partial';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'selection_payload' => 'array',
|
||||
'had_errors' => 'boolean',
|
||||
'error_codes' => 'array',
|
||||
'error_context' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function scopeCompleted(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->whereIn('status', [
|
||||
self::STATUS_SUCCESS,
|
||||
self::STATUS_PARTIAL,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_SKIPPED,
|
||||
])
|
||||
->whereNotNull('finished_at');
|
||||
}
|
||||
}
|
||||
40
app/Services/Inventory/InventoryConcurrencyLimiter.php
Normal file
40
app/Services/Inventory/InventoryConcurrencyLimiter.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Inventory;
|
||||
|
||||
use Illuminate\Contracts\Cache\Lock;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class InventoryConcurrencyLimiter
|
||||
{
|
||||
public function __construct(private readonly int $lockTtlSeconds = 900) {}
|
||||
|
||||
public function acquireGlobalSlot(): ?Lock
|
||||
{
|
||||
$max = (int) config('tenantpilot.inventory_sync.concurrency.global_max', 2);
|
||||
$max = max(0, $max);
|
||||
|
||||
return $this->acquireSlot('inventory_sync:global:slot:', $max);
|
||||
}
|
||||
|
||||
public function acquireTenantSlot(int $tenantId): ?Lock
|
||||
{
|
||||
$max = (int) config('tenantpilot.inventory_sync.concurrency.per_tenant_max', 1);
|
||||
$max = max(0, $max);
|
||||
|
||||
return $this->acquireSlot("inventory_sync:tenant:{$tenantId}:slot:", $max);
|
||||
}
|
||||
|
||||
private function acquireSlot(string $prefix, int $max): ?Lock
|
||||
{
|
||||
for ($slot = 0; $slot < $max; $slot++) {
|
||||
$lock = Cache::lock($prefix.$slot, $this->lockTtlSeconds);
|
||||
|
||||
if ($lock->get()) {
|
||||
return $lock;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
101
app/Services/Inventory/InventoryMetaSanitizer.php
Normal file
101
app/Services/Inventory/InventoryMetaSanitizer.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Inventory;
|
||||
|
||||
class InventoryMetaSanitizer
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @return array{odata_type?: string, etag?: string|null, scope_tag_ids?: list<string>, assignment_target_count?: int|null, warnings?: list<string>}
|
||||
*/
|
||||
public function sanitize(array $meta): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
$odataType = $meta['odata_type'] ?? null;
|
||||
if (is_string($odataType) && trim($odataType) !== '') {
|
||||
$sanitized['odata_type'] = trim($odataType);
|
||||
}
|
||||
|
||||
$etag = $meta['etag'] ?? null;
|
||||
if ($etag === null || is_string($etag)) {
|
||||
$sanitized['etag'] = $etag === null ? null : trim($etag);
|
||||
}
|
||||
|
||||
$scopeTagIds = $meta['scope_tag_ids'] ?? null;
|
||||
if (is_array($scopeTagIds)) {
|
||||
$sanitized['scope_tag_ids'] = $this->stringList($scopeTagIds);
|
||||
}
|
||||
|
||||
$assignmentTargetCount = $meta['assignment_target_count'] ?? null;
|
||||
if (is_int($assignmentTargetCount)) {
|
||||
$sanitized['assignment_target_count'] = $assignmentTargetCount;
|
||||
} elseif (is_numeric($assignmentTargetCount)) {
|
||||
$sanitized['assignment_target_count'] = (int) $assignmentTargetCount;
|
||||
} elseif ($assignmentTargetCount === null) {
|
||||
$sanitized['assignment_target_count'] = null;
|
||||
}
|
||||
|
||||
$warnings = $meta['warnings'] ?? null;
|
||||
if (is_array($warnings)) {
|
||||
$sanitized['warnings'] = $this->boundedStringList($warnings, 25, 200);
|
||||
}
|
||||
|
||||
return array_filter(
|
||||
$sanitized,
|
||||
static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $values
|
||||
* @return list<string>
|
||||
*/
|
||||
private function stringList(array $values): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($values as $value) {
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = $value;
|
||||
}
|
||||
|
||||
return array_values(array_unique($result));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $values
|
||||
* @return list<string>
|
||||
*/
|
||||
private function boundedStringList(array $values, int $maxItems, int $maxLen): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach ($values as $value) {
|
||||
if (count($items) >= $maxItems) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = mb_substr($value, 0, $maxLen);
|
||||
}
|
||||
|
||||
return array_values(array_unique($items));
|
||||
}
|
||||
}
|
||||
66
app/Services/Inventory/InventoryMissingService.php
Normal file
66
app/Services/Inventory/InventoryMissingService.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Inventory;
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class InventoryMissingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InventorySelectionHasher $selectionHasher,
|
||||
private readonly PolicyTypeResolver $policyTypeResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
* @return array{latestRun: InventorySyncRun|null, missing: Collection<int, InventoryItem>, lowConfidence: bool}
|
||||
*/
|
||||
public function missingForSelection(Tenant $tenant, array $selectionPayload): array
|
||||
{
|
||||
$normalized = $this->selectionHasher->normalize($selectionPayload);
|
||||
$normalized['policy_types'] = $this->policyTypeResolver->filterRuntime($normalized['policy_types']);
|
||||
$selectionHash = $this->selectionHasher->hash($normalized);
|
||||
|
||||
$latestRun = InventorySyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_hash', $selectionHash)
|
||||
->whereIn('status', [
|
||||
InventorySyncRun::STATUS_SUCCESS,
|
||||
InventorySyncRun::STATUS_PARTIAL,
|
||||
InventorySyncRun::STATUS_FAILED,
|
||||
InventorySyncRun::STATUS_SKIPPED,
|
||||
])
|
||||
->orderByDesc('finished_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $latestRun) {
|
||||
return [
|
||||
'latestRun' => null,
|
||||
'missing' => InventoryItem::query()->whereRaw('1 = 0')->get(),
|
||||
'lowConfidence' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$missingQuery = InventoryItem::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereIn('policy_type', $normalized['policy_types'])
|
||||
->where(function ($query) use ($latestRun): void {
|
||||
$query
|
||||
->whereNull('last_seen_run_id')
|
||||
->orWhere('last_seen_run_id', '!=', $latestRun->getKey());
|
||||
});
|
||||
|
||||
$lowConfidence = $latestRun->status !== InventorySyncRun::STATUS_SUCCESS || (bool) ($latestRun->had_errors ?? false);
|
||||
|
||||
return [
|
||||
'latestRun' => $latestRun,
|
||||
'missing' => $missingQuery->get(),
|
||||
'lowConfidence' => $lowConfidence,
|
||||
];
|
||||
}
|
||||
}
|
||||
89
app/Services/Inventory/InventorySelectionHasher.php
Normal file
89
app/Services/Inventory/InventorySelectionHasher.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Inventory;
|
||||
|
||||
class InventorySelectionHasher
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
* @return array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool}
|
||||
*/
|
||||
public function normalize(array $selectionPayload): array
|
||||
{
|
||||
$policyTypes = $this->stringList($selectionPayload['policy_types'] ?? []);
|
||||
sort($policyTypes);
|
||||
|
||||
$categories = $this->stringList($selectionPayload['categories'] ?? []);
|
||||
sort($categories);
|
||||
|
||||
return [
|
||||
'policy_types' => $policyTypes,
|
||||
'categories' => $categories,
|
||||
'include_foundations' => (bool) ($selectionPayload['include_foundations'] ?? false),
|
||||
'include_dependencies' => (bool) ($selectionPayload['include_dependencies'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
*/
|
||||
public function canonicalJson(array $selectionPayload): string
|
||||
{
|
||||
$normalized = $this->normalize($selectionPayload);
|
||||
$normalized = $this->ksortRecursive($normalized);
|
||||
|
||||
return (string) json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
*/
|
||||
public function hash(array $selectionPayload): string
|
||||
{
|
||||
return hash('sha256', $this->canonicalJson($selectionPayload));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function stringList(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($value as $item) {
|
||||
if (! is_string($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = trim($item);
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = $item;
|
||||
}
|
||||
|
||||
return array_values(array_unique($result));
|
||||
}
|
||||
|
||||
private function ksortRecursive(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$isList = array_is_list($value);
|
||||
if (! $isList) {
|
||||
ksort($value);
|
||||
}
|
||||
|
||||
foreach ($value as $key => $child) {
|
||||
$value[$key] = $this->ksortRecursive($child);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
323
app/Services/Inventory/InventorySyncService.php
Normal file
323
app/Services/Inventory/InventorySyncService.php
Normal file
@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Inventory;
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Cache\Lock;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
class InventorySyncService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GraphClientInterface $graphClient,
|
||||
private readonly PolicyTypeResolver $policyTypeResolver,
|
||||
private readonly InventorySelectionHasher $selectionHasher,
|
||||
private readonly InventoryMetaSanitizer $metaSanitizer,
|
||||
private readonly InventoryConcurrencyLimiter $concurrencyLimiter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Runs an inventory sync inline (no queue), enforcing locks/concurrency and creating an observable run record.
|
||||
*
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
*/
|
||||
public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun
|
||||
{
|
||||
$normalizedSelection = $this->selectionHasher->normalize($selectionPayload);
|
||||
$normalizedSelection['policy_types'] = $this->policyTypeResolver->filterRuntime($normalizedSelection['policy_types']);
|
||||
$selectionHash = $this->selectionHasher->hash($normalizedSelection);
|
||||
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
|
||||
$globalSlot = $this->concurrencyLimiter->acquireGlobalSlot();
|
||||
if (! $globalSlot instanceof Lock) {
|
||||
return $this->createSkippedRun(
|
||||
tenant: $tenant,
|
||||
selectionHash: $selectionHash,
|
||||
selectionPayload: $normalizedSelection,
|
||||
now: $now,
|
||||
errorCode: 'concurrency_limit_global',
|
||||
);
|
||||
}
|
||||
|
||||
$tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id);
|
||||
if (! $tenantSlot instanceof Lock) {
|
||||
$globalSlot->release();
|
||||
|
||||
return $this->createSkippedRun(
|
||||
tenant: $tenant,
|
||||
selectionHash: $selectionHash,
|
||||
selectionPayload: $normalizedSelection,
|
||||
now: $now,
|
||||
errorCode: 'concurrency_limit_tenant',
|
||||
);
|
||||
}
|
||||
|
||||
$selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900);
|
||||
if (! $selectionLock->get()) {
|
||||
$tenantSlot->release();
|
||||
$globalSlot->release();
|
||||
|
||||
return $this->createSkippedRun(
|
||||
tenant: $tenant,
|
||||
selectionHash: $selectionHash,
|
||||
selectionPayload: $normalizedSelection,
|
||||
now: $now,
|
||||
errorCode: 'lock_contended',
|
||||
);
|
||||
}
|
||||
|
||||
$run = InventorySyncRun::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'selection_hash' => $selectionHash,
|
||||
'selection_payload' => $normalizedSelection,
|
||||
'status' => InventorySyncRun::STATUS_RUNNING,
|
||||
'had_errors' => false,
|
||||
'error_codes' => [],
|
||||
'error_context' => null,
|
||||
'started_at' => $now,
|
||||
'finished_at' => null,
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'errors_count' => 0,
|
||||
]);
|
||||
|
||||
try {
|
||||
return $this->executeRun($run, $tenant, $normalizedSelection);
|
||||
} finally {
|
||||
$selectionLock->release();
|
||||
$tenantSlot->release();
|
||||
$globalSlot->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection
|
||||
*/
|
||||
private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normalizedSelection): InventorySyncRun
|
||||
{
|
||||
$observed = 0;
|
||||
$upserted = 0;
|
||||
$errors = 0;
|
||||
$errorCodes = [];
|
||||
$hadErrors = false;
|
||||
|
||||
try {
|
||||
$typesConfig = $this->supportedTypeConfigByType();
|
||||
|
||||
foreach ($normalizedSelection['policy_types'] as $policyType) {
|
||||
$typeConfig = $typesConfig[$policyType] ?? null;
|
||||
|
||||
if (! is_array($typeConfig)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$response = $this->listPoliciesWithRetry($policyType, [
|
||||
'tenant' => $tenant->tenant_id ?? $tenant->external_id,
|
||||
'client_id' => $tenant->app_client_id,
|
||||
'client_secret' => $tenant->app_client_secret,
|
||||
'platform' => $typeConfig['platform'] ?? null,
|
||||
'filter' => $typeConfig['filter'] ?? null,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
$hadErrors = true;
|
||||
$errors++;
|
||||
$errorCodes[] = $this->mapGraphFailureToErrorCode($response);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($response->data as $policyData) {
|
||||
if (! is_array($policyData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$externalId = $policyData['id'] ?? $policyData['external_id'] ?? null;
|
||||
if (! is_string($externalId) || $externalId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$observed++;
|
||||
|
||||
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? null;
|
||||
$displayName = is_string($displayName) ? $displayName : null;
|
||||
|
||||
$scopeTagIds = $policyData['roleScopeTagIds'] ?? null;
|
||||
$assignmentTargetCount = null;
|
||||
$assignments = $policyData['assignments'] ?? null;
|
||||
if (is_array($assignments)) {
|
||||
$assignmentTargetCount = count($assignments);
|
||||
}
|
||||
|
||||
$meta = $this->metaSanitizer->sanitize([
|
||||
'odata_type' => $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null,
|
||||
'etag' => $policyData['@odata.etag'] ?? null,
|
||||
'scope_tag_ids' => is_array($scopeTagIds) ? $scopeTagIds : null,
|
||||
'assignment_target_count' => $assignmentTargetCount,
|
||||
'warnings' => [],
|
||||
]);
|
||||
|
||||
InventoryItem::query()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'policy_type' => $policyType,
|
||||
'external_id' => $externalId,
|
||||
],
|
||||
[
|
||||
'display_name' => $displayName,
|
||||
'category' => $typeConfig['category'] ?? null,
|
||||
'platform' => $typeConfig['platform'] ?? null,
|
||||
'meta_jsonb' => $meta,
|
||||
'last_seen_at' => now(),
|
||||
'last_seen_run_id' => $run->getKey(),
|
||||
]
|
||||
);
|
||||
|
||||
$upserted++;
|
||||
}
|
||||
}
|
||||
|
||||
$status = $hadErrors ? InventorySyncRun::STATUS_PARTIAL : InventorySyncRun::STATUS_SUCCESS;
|
||||
|
||||
$run->update([
|
||||
'status' => $status,
|
||||
'had_errors' => $hadErrors,
|
||||
'error_codes' => array_values(array_unique($errorCodes)),
|
||||
'error_context' => null,
|
||||
'items_observed_count' => $observed,
|
||||
'items_upserted_count' => $upserted,
|
||||
'errors_count' => $errors,
|
||||
'finished_at' => CarbonImmutable::now('UTC'),
|
||||
]);
|
||||
|
||||
return $run->refresh();
|
||||
} catch (Throwable $throwable) {
|
||||
$run->update([
|
||||
'status' => InventorySyncRun::STATUS_FAILED,
|
||||
'had_errors' => true,
|
||||
'error_codes' => ['unexpected_exception'],
|
||||
'error_context' => $this->safeErrorContext($throwable),
|
||||
'items_observed_count' => $observed,
|
||||
'items_upserted_count' => $upserted,
|
||||
'errors_count' => $errors + 1,
|
||||
'finished_at' => CarbonImmutable::now('UTC'),
|
||||
]);
|
||||
|
||||
return $run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function supportedTypeConfigByType(): array
|
||||
{
|
||||
/** @var array<int, array<string, mixed>> $supported */
|
||||
$supported = config('tenantpilot.supported_policy_types', []);
|
||||
|
||||
$byType = [];
|
||||
foreach ($supported as $config) {
|
||||
$type = $config['type'] ?? null;
|
||||
if (is_string($type) && $type !== '') {
|
||||
$byType[$type] = $config;
|
||||
}
|
||||
}
|
||||
|
||||
return $byType;
|
||||
}
|
||||
|
||||
private function selectionLockKey(Tenant $tenant, string $selectionHash): string
|
||||
{
|
||||
return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
*/
|
||||
private function createSkippedRun(
|
||||
Tenant $tenant,
|
||||
string $selectionHash,
|
||||
array $selectionPayload,
|
||||
CarbonImmutable $now,
|
||||
string $errorCode,
|
||||
): InventorySyncRun {
|
||||
return InventorySyncRun::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'selection_hash' => $selectionHash,
|
||||
'selection_payload' => $selectionPayload,
|
||||
'status' => InventorySyncRun::STATUS_SKIPPED,
|
||||
'had_errors' => true,
|
||||
'error_codes' => [$errorCode],
|
||||
'error_context' => null,
|
||||
'started_at' => $now,
|
||||
'finished_at' => $now,
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'errors_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function mapGraphFailureToErrorCode(GraphResponse $response): string
|
||||
{
|
||||
$status = (int) ($response->status ?? 0);
|
||||
|
||||
return match ($status) {
|
||||
403 => 'graph_forbidden',
|
||||
429 => 'graph_throttled',
|
||||
503 => 'graph_transient',
|
||||
default => 'graph_transient',
|
||||
};
|
||||
}
|
||||
|
||||
private function listPoliciesWithRetry(string $policyType, array $options): GraphResponse
|
||||
{
|
||||
$maxAttempts = 3;
|
||||
|
||||
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||
$response = $this->graphClient->listPolicies($policyType, $options);
|
||||
|
||||
if (! $response->failed()) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$status = (int) ($response->status ?? 0);
|
||||
if (! in_array($status, [429, 503], true)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($attempt >= $maxAttempts) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$baseMs = 250 * (2 ** ($attempt - 1));
|
||||
$jitterMs = random_int(0, 250);
|
||||
usleep(($baseMs + $jitterMs) * 1000);
|
||||
}
|
||||
|
||||
return new GraphResponse(false, [], null, ['error' => ['code' => 'unexpected_exception', 'message' => 'retry loop failed']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function safeErrorContext(Throwable $throwable): array
|
||||
{
|
||||
$message = $throwable->getMessage();
|
||||
|
||||
$message = preg_replace('/Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*/', 'Bearer [REDACTED]', (string) $message);
|
||||
$message = mb_substr((string) $message, 0, 500);
|
||||
|
||||
return [
|
||||
'exception_class' => get_class($throwable),
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -320,6 +320,13 @@
|
||||
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
|
||||
],
|
||||
|
||||
'inventory_sync' => [
|
||||
'concurrency' => [
|
||||
'global_max' => (int) env('TENANTPILOT_INVENTORY_SYNC_CONCURRENCY_GLOBAL_MAX', 2),
|
||||
'per_tenant_max' => (int) env('TENANTPILOT_INVENTORY_SYNC_CONCURRENCY_PER_TENANT_MAX', 1),
|
||||
],
|
||||
],
|
||||
|
||||
'display' => [
|
||||
'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false),
|
||||
'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000),
|
||||
|
||||
38
database/factories/InventoryItemFactory.php
Normal file
38
database/factories/InventoryItemFactory.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\InventoryItem>
|
||||
*/
|
||||
class InventoryItemFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'external_id' => fake()->uuid(),
|
||||
'display_name' => fake()->words(3, true),
|
||||
'category' => 'Configuration',
|
||||
'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10', 'windows']),
|
||||
'meta_jsonb' => [
|
||||
'odata_type' => '#microsoft.graph.deviceConfiguration',
|
||||
'etag' => null,
|
||||
'scope_tag_ids' => [],
|
||||
'assignment_target_count' => null,
|
||||
'warnings' => [],
|
||||
],
|
||||
'last_seen_at' => now(),
|
||||
'last_seen_run_id' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
43
database/factories/InventorySyncRunFactory.php
Normal file
43
database/factories/InventorySyncRunFactory.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\InventorySyncRun>
|
||||
*/
|
||||
class InventorySyncRunFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$selectionPayload = [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'categories' => ['Configuration'],
|
||||
'include_foundations' => false,
|
||||
'include_dependencies' => false,
|
||||
];
|
||||
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'selection_hash' => hash('sha256', (string) json_encode($selectionPayload)),
|
||||
'selection_payload' => $selectionPayload,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'had_errors' => false,
|
||||
'error_codes' => [],
|
||||
'error_context' => null,
|
||||
'started_at' => now()->subMinute(),
|
||||
'finished_at' => now(),
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'errors_count' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// No-op: replaced by 2026_01_07_142720_create_inventory_items_table.php
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// No-op
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('inventory_sync_runs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('tenant_id')->constrained();
|
||||
|
||||
$table->string('selection_hash', 64);
|
||||
$table->jsonb('selection_payload')->nullable();
|
||||
|
||||
$table->string('status');
|
||||
$table->boolean('had_errors')->default(false);
|
||||
$table->jsonb('error_codes')->nullable();
|
||||
$table->jsonb('error_context')->nullable();
|
||||
|
||||
$table->timestampTz('started_at')->nullable();
|
||||
$table->timestampTz('finished_at')->nullable();
|
||||
|
||||
$table->unsignedInteger('items_observed_count')->default(0);
|
||||
$table->unsignedInteger('items_upserted_count')->default(0);
|
||||
$table->unsignedInteger('errors_count')->default(0);
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['tenant_id', 'selection_hash']);
|
||||
$table->index(['tenant_id', 'status']);
|
||||
$table->index(['tenant_id', 'finished_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('inventory_sync_runs');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('inventory_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained();
|
||||
$table->string('policy_type');
|
||||
$table->string('external_id');
|
||||
$table->string('display_name')->nullable();
|
||||
$table->string('category')->nullable();
|
||||
$table->string('platform')->nullable();
|
||||
$table->jsonb('meta_jsonb')->nullable();
|
||||
$table->timestampTz('last_seen_at')->nullable();
|
||||
$table->foreignId('last_seen_run_id')
|
||||
->nullable()
|
||||
->constrained('inventory_sync_runs')
|
||||
->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['tenant_id', 'policy_type', 'external_id']);
|
||||
$table->index(['tenant_id', 'policy_type']);
|
||||
$table->index(['tenant_id', 'category']);
|
||||
$table->index('last_seen_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('inventory_items');
|
||||
}
|
||||
};
|
||||
@ -4,35 +4,35 @@ # Tasks: Inventory Core (040)
|
||||
|
||||
## P1 — MVP (US1/US2)
|
||||
|
||||
- [ ] T001 [US1] Add migrations: `inventory_items` (unique: tenant_id+policy_type+external_id; indexes; last_seen fields)
|
||||
- [ ] T002 [US1] Add migrations: `inventory_sync_runs` (tenant_id, selection_hash, status, started/finished, counts, stable error codes)
|
||||
- [ ] T003 [US1] Implement deterministic `selection_hash` (canonical JSON: sorted keys + sorted arrays; sha256)
|
||||
- [ ] T004 [US1] Implement inventory upsert semantics (idempotent, no duplicates)
|
||||
- [ ] T005 [US1] Enforce tenant isolation for all inventory + run read/write paths
|
||||
- [ ] T006 [US2] Implement derived “missing” query semantics vs latest completed run for same (tenant_id, selection_hash)
|
||||
- [ ] T007 [US2] Missing confidence rule: partial/failed or had_errors => low confidence
|
||||
- [ ] T008 [US2] Enforce `meta_jsonb` whitelist (drop unknown keys; never fail sync)
|
||||
- [ ] T009 [US1] Guardrail: inventory sync must not create snapshots/backups (no writes to `policy_versions`/`backup_*`)
|
||||
- [X] T001 [US1] Add migrations: `inventory_items` (unique: tenant_id+policy_type+external_id; indexes; last_seen fields)
|
||||
- [X] T002 [US1] Add migrations: `inventory_sync_runs` (tenant_id, selection_hash, status, started/finished, counts, stable error codes)
|
||||
- [X] T003 [US1] Implement deterministic `selection_hash` (canonical JSON: sorted keys + sorted arrays; sha256)
|
||||
- [X] T004 [US1] Implement inventory upsert semantics (idempotent, no duplicates)
|
||||
- [X] T005 [US1] Enforce tenant isolation for all inventory + run read/write paths
|
||||
- [X] T006 [US2] Implement derived “missing” query semantics vs latest completed run for same (tenant_id, selection_hash)
|
||||
- [X] T007 [US2] Missing confidence rule: partial/failed or had_errors => low confidence
|
||||
- [X] T008 [US2] Enforce `meta_jsonb` whitelist (drop unknown keys; never fail sync)
|
||||
- [X] T009 [US1] Guardrail: inventory sync must not create snapshots/backups (no writes to `policy_versions`/`backup_*`)
|
||||
|
||||
## P2 — Observability & Safety (US3 + NFR)
|
||||
|
||||
- [ ] T010 [US3] Run lifecycle: ensure run records include counts + stable error codes (visible and actionable)
|
||||
- [ ] T011 [NFR] Locking/idempotency: prevent overlapping runs per (tenant_id, selection_hash)
|
||||
- [ ] T012 [NFR] Concurrency: enforce global + per-tenant limits (queue/semaphore strategy)
|
||||
- [ ] T013 [NFR] Throttling resilience: backoff + jitter for transient Graph failures (429/503)
|
||||
- [ ] T014 [NFR] Safe logging & safe persistence: store only stable `error_codes` + bounded safe context in run records (no secrets/tokens; no log parsing required)
|
||||
- [X] T010 [US3] Run lifecycle: ensure run records include counts + stable error codes (visible and actionable)
|
||||
- [X] T011 [NFR] Locking/idempotency: prevent overlapping runs per (tenant_id, selection_hash)
|
||||
- [X] T012 [NFR] Concurrency: enforce global + per-tenant limits (queue/semaphore strategy)
|
||||
- [X] T013 [NFR] Throttling resilience: backoff + jitter for transient Graph failures (429/503)
|
||||
- [X] T014 [NFR] Safe logging & safe persistence: store only stable `error_codes` + bounded safe context in run records (no secrets/tokens; no log parsing required)
|
||||
|
||||
## Tests (Required for runtime behavior)
|
||||
|
||||
- [ ] T020 [US1] Pest: upsert prevents duplicates; `last_seen_at` and `last_seen_run_id` update
|
||||
- [ ] T021 [US2] Pest: missing derived per latest completed run for same (tenant_id, selection_hash)
|
||||
- [ ] T022 [US2] Pest: selection isolation (run for selection Y does not affect selection X)
|
||||
- [ ] T023 [US2] Pest: partial/failed/had_errors => missing is low confidence
|
||||
- [ ] T024 [US2] Pest: meta whitelist drops unknown keys (no exception; no persistence)
|
||||
- [ ] T025 [NFR] Pest: selection_hash determinism (array ordering invariant)
|
||||
- [ ] T026 [NFR] Pest: lock prevents overlapping runs for same (tenant_id, selection_hash)
|
||||
- [ ] T027 [NFR] Pest: run error persistence contains no secrets/tokens (assert error context is bounded; no “Bearer ” / access token patterns; prefer error_codes)
|
||||
- [ ] T028 [US1] Pest: inventory sync creates no rows in `policy_versions` and `backup_*` tables (assert counts unchanged)
|
||||
- [X] T020 [US1] Pest: upsert prevents duplicates; `last_seen_at` and `last_seen_run_id` update
|
||||
- [X] T021 [US2] Pest: missing derived per latest completed run for same (tenant_id, selection_hash)
|
||||
- [X] T022 [US2] Pest: selection isolation (run for selection Y does not affect selection X)
|
||||
- [X] T023 [US2] Pest: partial/failed/had_errors => missing is low confidence
|
||||
- [X] T024 [US2] Pest: meta whitelist drops unknown keys (no exception; no persistence)
|
||||
- [X] T025 [NFR] Pest: selection_hash determinism (array ordering invariant)
|
||||
- [X] T026 [NFR] Pest: lock prevents overlapping runs for same (tenant_id, selection_hash)
|
||||
- [X] T027 [NFR] Pest: run error persistence contains no secrets/tokens (assert error context is bounded; no “Bearer ” / access token patterns; prefer error_codes)
|
||||
- [X] T028 [US1] Pest: inventory sync creates no rows in `policy_versions` and `backup_*` tables (assert counts unchanged)
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
296
tests/Feature/Inventory/InventorySyncServiceTest.php
Normal file
296
tests/Feature/Inventory/InventorySyncServiceTest.php
Normal file
@ -0,0 +1,296 @@
|
||||
<?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]');
|
||||
});
|
||||
23
tests/Unit/Inventory/InventorySelectionHasherTest.php
Normal file
23
tests/Unit/Inventory/InventorySelectionHasherTest.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Inventory\InventorySelectionHasher;
|
||||
|
||||
it('computes the same selection_hash regardless of array ordering', function () {
|
||||
$hasher = app(InventorySelectionHasher::class);
|
||||
|
||||
$payloadA = [
|
||||
'policy_types' => ['deviceCompliancePolicy', 'deviceConfiguration'],
|
||||
'categories' => ['Compliance', 'Configuration'],
|
||||
'include_foundations' => true,
|
||||
'include_dependencies' => false,
|
||||
];
|
||||
|
||||
$payloadB = [
|
||||
'include_dependencies' => false,
|
||||
'include_foundations' => true,
|
||||
'categories' => ['Configuration', 'Compliance'],
|
||||
'policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
|
||||
];
|
||||
|
||||
expect($hasher->hash($payloadA))->toBe($hasher->hash($payloadB));
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user