feat/042-inventory-dependencies-graph #50

Merged
ahmido merged 22 commits from feat/042-inventory-dependencies-graph into dev 2026-01-10 12:50:08 +00:00
23 changed files with 1809 additions and 21 deletions
Showing only changes of commit 478eae35cd - Show all commits

View File

@ -4,9 +4,22 @@
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventorySyncRunResource; use App\Filament\Resources\InventorySyncRunResource;
use App\Jobs\RunInventorySyncJob;
use App\Models\InventorySyncRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Action as HintAction;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Support\Enums\Size;
use UnitEnum; use UnitEnum;
class InventoryLanding extends Page class InventoryLanding extends Page
@ -19,6 +32,182 @@ class InventoryLanding extends Page
protected string $view = 'filament.pages.inventory-landing'; protected string $view = 'filament.pages.inventory-landing';
protected function getHeaderActions(): array
{
return [
Action::make('run_inventory_sync')
->label('Run Inventory Sync')
->icon('heroicon-o-arrow-path')
->color('warning')
->form([
Select::make('policy_types')
->label('Policy types')
->multiple()
->searchable()
->preload()
->native(false)
->hintActions([
fn (Select $component): HintAction => HintAction::make('select_all_policy_types')
->label('Select all')
->link()
->size(Size::Small)
->action(function (InventorySyncService $inventorySyncService) use ($component): void {
$component->state($inventorySyncService->defaultSelectionPayload()['policy_types']);
}),
fn (Select $component): HintAction => HintAction::make('clear_policy_types')
->label('Clear')
->link()
->size(Size::Small)
->action(function () use ($component): void {
$component->state([]);
}),
])
->options(function (): array {
return collect(config('tenantpilot.supported_policy_types', []))
->filter(fn (array $meta): bool => filled($meta['type'] ?? null))
->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other'))
->mapWithKeys(function ($items, string $category): array {
$options = collect($items)
->mapWithKeys(function (array $meta): array {
$type = (string) $meta['type'];
$label = (string) ($meta['label'] ?? $type);
$platform = (string) ($meta['platform'] ?? 'all');
return [$type => "{$label}{$platform}"];
})
->all();
return [$category => $options];
})
->all();
})
->default([])
->dehydrated()
->required()
->rules([
'array',
'min:1',
new \App\Rules\SupportedPolicyTypesRule,
])
->columnSpanFull(),
Toggle::make('include_dependencies')
->label('Include dependencies')
->helperText('Include dependency extraction where supported.')
->default(true)
->dehydrated()
->rules(['boolean'])
->columnSpanFull(),
Hidden::make('tenant_id')
->default(fn (): ?string => Tenant::current()?->getKey())
->dehydrated(),
])
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->canSyncTenant(Tenant::current());
})
->action(function (array $data, BulkOperationService $bulkOperationService, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
if (! $user->canSyncTenant($tenant)) {
abort(403, 'Not allowed');
}
$requestedTenantId = $data['tenant_id'] ?? null;
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
Notification::make()
->title('Not allowed')
->danger()
->send();
abort(403, 'Not allowed');
}
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
if (array_key_exists('policy_types', $data)) {
$selectionPayload['policy_types'] = $data['policy_types'];
}
if (array_key_exists('include_dependencies', $data)) {
$selectionPayload['include_dependencies'] = (bool) $data['include_dependencies'];
}
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
$existing = InventorySyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_hash', $computed['selection_hash'])
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
->first();
if ($existing instanceof InventorySyncRun) {
Notification::make()
->title('Sync already running')
->body('An inventory sync is already running for this tenant. Check the progress widget for status.')
->warning()
->send();
return;
}
$run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']);
$policyTypes = $computed['selection']['policy_types'] ?? [];
if (! is_array($policyTypes)) {
$policyTypes = [];
}
$bulkRun = $bulkOperationService->createRun(
tenant: $tenant,
user: $user,
resource: 'inventory',
action: 'sync',
itemIds: $policyTypes,
totalItems: count($policyTypes),
);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.dispatched',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'bulk_run_id' => $bulkRun->id,
'selection_hash' => $run->selection_hash,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
);
Notification::make()
->title('Inventory sync started')
->body('Sync dispatched. Check the bottom-right progress widget for status.')
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->success()
->sendToDatabase($user)
->send();
RunInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
bulkRunId: (int) $bulkRun->getKey(),
inventorySyncRunId: (int) $run->getKey(),
);
}),
];
}
public function getInventoryItemsUrl(): string public function getInventoryItemsUrl(): string
{ {
return InventoryItemResource::getUrl('index', tenant: Tenant::current()); return InventoryItemResource::getUrl('index', tenant: Tenant::current());

View File

@ -36,6 +36,9 @@ public static function infolist(Schema $schema): Schema
->schema([ ->schema([
Section::make('Sync Run') Section::make('Sync Run')
->schema([ ->schema([
TextEntry::make('user.name')
->label('Initiator')
->placeholder('—'),
TextEntry::make('status') TextEntry::make('status')
->badge() ->badge()
->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)), ->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)),
@ -82,6 +85,10 @@ public static function table(Table $table): Table
return $table return $table
->defaultSort('id', 'desc') ->defaultSort('id', 'desc')
->columns([ ->columns([
Tables\Columns\TextColumn::make('user.name')
->label('Initiator')
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->badge() ->badge()
->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)), ->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)),
@ -112,6 +119,7 @@ public static function getEloquentQuery(): Builder
$tenantId = Tenant::current()->getKey(); $tenantId = Tenant::current()->getKey();
return parent::getEloquentQuery() return parent::getEloquentQuery()
->with('user')
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)); ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
} }

View File

@ -0,0 +1,221 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class RunInventorySyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
public int $tenantId,
public int $userId,
public int $bulkRunId,
public int $inventorySyncRunId,
) {}
/**
* Execute the job.
*/
public function handle(BulkOperationService $bulkOperationService, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void
{
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
}
$user = User::query()->find($this->userId);
if (! $user instanceof User) {
throw new RuntimeException('User not found.');
}
$bulkRun = BulkOperationRun::query()->find($this->bulkRunId);
if (! $bulkRun instanceof BulkOperationRun) {
throw new RuntimeException('BulkOperationRun not found.');
}
$run = InventorySyncRun::query()->find($this->inventorySyncRunId);
if (! $run instanceof InventorySyncRun) {
throw new RuntimeException('InventorySyncRun not found.');
}
$bulkOperationService->start($bulkRun);
$processedPolicyTypes = [];
$run = $inventorySyncService->executePendingRun(
$run,
$tenant,
function (string $policyType, bool $success, ?string $errorCode) use ($bulkOperationService, $bulkRun, &$processedPolicyTypes): void {
$processedPolicyTypes[] = $policyType;
if ($success) {
$bulkOperationService->recordSuccess($bulkRun);
return;
}
$bulkOperationService->recordFailure($bulkRun, $policyType, $errorCode ?? 'failed');
},
);
$policyTypes = is_array($bulkRun->item_ids ?? null) ? $bulkRun->item_ids : [];
if ($policyTypes === []) {
$policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : [];
}
if ($run->status === InventorySyncRun::STATUS_SUCCESS) {
$bulkOperationService->complete($bulkRun);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.completed',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'bulk_run_id' => $bulkRun->id,
'selection_hash' => $run->selection_hash,
'observed' => $run->items_observed_count,
'upserted' => $run->items_upserted_count,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
);
Notification::make()
->title('Inventory sync completed')
->body('Inventory sync finished successfully.')
->success()
->sendToDatabase($user)
->send();
return;
}
if ($run->status === InventorySyncRun::STATUS_PARTIAL) {
$bulkOperationService->complete($bulkRun);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.partial',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'bulk_run_id' => $bulkRun->id,
'selection_hash' => $run->selection_hash,
'observed' => $run->items_observed_count,
'upserted' => $run->items_upserted_count,
'errors' => $run->errors_count,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
status: 'failure',
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
);
Notification::make()
->title('Inventory sync completed with errors')
->body('Inventory sync finished with some errors. Review the run details for error codes.')
->warning()
->sendToDatabase($user)
->send();
return;
}
if ($run->status === InventorySyncRun::STATUS_SKIPPED) {
$reason = (string) (($run->error_codes ?? [])[0] ?? 'skipped');
foreach ($policyTypes as $policyType) {
$bulkOperationService->recordSkippedWithReason($bulkRun, (string) $policyType, $reason);
}
$bulkOperationService->complete($bulkRun);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.skipped',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'bulk_run_id' => $bulkRun->id,
'selection_hash' => $run->selection_hash,
'reason' => $reason,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
);
Notification::make()
->title('Inventory sync skipped')
->body('Inventory sync could not start due to locks or concurrency limits.')
->warning()
->sendToDatabase($user)
->send();
return;
}
$reason = (string) (($run->error_codes ?? [])[0] ?? 'failed');
$missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes)));
foreach ($missingPolicyTypes as $policyType) {
$bulkOperationService->recordFailure($bulkRun, (string) $policyType, $reason);
}
$bulkOperationService->complete($bulkRun);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.failed',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'bulk_run_id' => $bulkRun->id,
'selection_hash' => $run->selection_hash,
'reason' => $reason,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
status: 'failure',
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
);
Notification::make()
->title('Inventory sync failed')
->body('Inventory sync finished with errors.')
->danger()
->sendToDatabase($user)
->send();
}
}

View File

@ -33,11 +33,18 @@ class InventorySyncRun extends Model
'finished_at' => 'datetime', 'finished_at' => 'datetime',
]; ];
public const STATUS_PENDING = 'pending';
public function tenant(): BelongsTo public function tenant(): BelongsTo
{ {
return $this->belongsTo(Tenant::class); return $this->belongsTo(Tenant::class);
} }
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeCompleted(Builder $query): Builder public function scopeCompleted(Builder $query): Builder
{ {
return $query return $query

View File

@ -235,13 +235,9 @@ private function isEndpointSecurityConfigurationPolicy(array $policyData): bool
return false; return false;
} }
foreach ($templateReference as $value) { $templateFamily = $templateReference['templateFamily'] ?? null;
if (is_string($value) && stripos($value, 'endpoint') !== false) {
return true;
}
}
return false; return is_string($templateFamily) && str_starts_with(strtolower(trim($templateFamily)), 'endpointsecurity');
} }
private function isSecurityBaselineConfigurationPolicy(array $policyData): bool private function isSecurityBaselineConfigurationPolicy(array $policyData): bool
@ -253,17 +249,8 @@ private function isSecurityBaselineConfigurationPolicy(array $policyData): bool
} }
$templateFamily = $templateReference['templateFamily'] ?? null; $templateFamily = $templateReference['templateFamily'] ?? null;
if (is_string($templateFamily) && stripos($templateFamily, 'baseline') !== false) {
return true;
}
foreach ($templateReference as $value) { return is_string($templateFamily) && strcasecmp(trim($templateFamily), 'securityBaseline') === 0;
if (is_string($value) && stripos($value, 'baseline') !== false) {
return true;
}
}
return false;
} }
private function isEnrollmentStatusPageItem(array $policyData): bool private function isEnrollmentStatusPageItem(array $policyData): bool

View File

@ -5,6 +5,7 @@
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\InventorySyncRun; use App\Models\InventorySyncRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
@ -30,9 +31,9 @@ public function __construct(
*/ */
public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun
{ {
$normalizedSelection = $this->selectionHasher->normalize($selectionPayload); $computed = $this->normalizeAndHashSelection($selectionPayload);
$normalizedSelection['policy_types'] = $this->policyTypeResolver->filterRuntime($normalizedSelection['policy_types']); $normalizedSelection = $computed['selection'];
$selectionHash = $this->selectionHasher->hash($normalizedSelection); $selectionHash = $computed['selection_hash'];
$now = CarbonImmutable::now('UTC'); $now = CarbonImmutable::now('UTC');
@ -76,6 +77,7 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
$run = InventorySyncRun::query()->create([ $run = InventorySyncRun::query()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'user_id' => null,
'selection_hash' => $selectionHash, 'selection_hash' => $selectionHash,
'selection_payload' => $normalizedSelection, 'selection_payload' => $normalizedSelection,
'status' => InventorySyncRun::STATUS_RUNNING, 'status' => InventorySyncRun::STATUS_RUNNING,
@ -98,10 +100,139 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
} }
} }
/**
* @return array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool}
*/
public function defaultSelectionPayload(): array
{
return [
'policy_types' => $this->policyTypeResolver->supportedPolicyTypes(),
'categories' => [],
'include_foundations' => true,
'include_dependencies' => true,
];
}
/**
* @param array<string, mixed> $selectionPayload
* @return array{selection: array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool}, selection_hash: string}
*/
public function normalizeAndHashSelection(array $selectionPayload): array
{
$normalizedSelection = $this->selectionHasher->normalize($selectionPayload);
$normalizedSelection['policy_types'] = $this->policyTypeResolver->filterRuntime($normalizedSelection['policy_types']);
$selectionHash = $this->selectionHasher->hash($normalizedSelection);
return [
'selection' => $normalizedSelection,
'selection_hash' => $selectionHash,
];
}
/**
* Creates a pending run record attributed to the initiating user so the run remains observable even if queue workers are down.
*
* @param array<string, mixed> $selectionPayload
*/
public function createPendingRunForUser(Tenant $tenant, User $user, array $selectionPayload): InventorySyncRun
{
$computed = $this->normalizeAndHashSelection($selectionPayload);
return InventorySyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'selection_hash' => $computed['selection_hash'],
'selection_payload' => $computed['selection'],
'status' => InventorySyncRun::STATUS_PENDING,
'had_errors' => false,
'error_codes' => [],
'error_context' => null,
'started_at' => null,
'finished_at' => null,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'errors_count' => 0,
]);
}
/**
* Executes an existing pending run under locks/concurrency, updating that run to running/skipped/terminal.
*/
/**
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
*/
public function executePendingRun(InventorySyncRun $run, Tenant $tenant, ?callable $onPolicyTypeProcessed = null): InventorySyncRun
{
$computed = $this->normalizeAndHashSelection($run->selection_payload ?? []);
$normalizedSelection = $computed['selection'];
$selectionHash = $computed['selection_hash'];
$now = CarbonImmutable::now('UTC');
$run->update([
'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,
]);
$globalSlot = $this->concurrencyLimiter->acquireGlobalSlot();
if (! $globalSlot instanceof Lock) {
return $this->markExistingRunSkipped(
run: $run,
now: $now,
errorCode: 'concurrency_limit_global',
);
}
$tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id);
if (! $tenantSlot instanceof Lock) {
$globalSlot->release();
return $this->markExistingRunSkipped(
run: $run,
now: $now,
errorCode: 'concurrency_limit_tenant',
);
}
$selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900);
if (! $selectionLock->get()) {
$tenantSlot->release();
$globalSlot->release();
return $this->markExistingRunSkipped(
run: $run,
now: $now,
errorCode: 'lock_contended',
);
}
try {
return $this->executeRun($run, $tenant, $normalizedSelection, $onPolicyTypeProcessed);
} finally {
$selectionLock->release();
$tenantSlot->release();
$globalSlot->release();
}
}
/** /**
* @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection * @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 /**
* @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
*/
private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): InventorySyncRun
{ {
$observed = 0; $observed = 0;
$upserted = 0; $upserted = 0;
@ -117,6 +248,11 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
$typeConfig = $typesConfig[$policyType] ?? null; $typeConfig = $typesConfig[$policyType] ?? null;
if (! is_array($typeConfig)) { if (! is_array($typeConfig)) {
$hadErrors = true;
$errors++;
$errorCodes[] = 'unsupported_type';
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type');
continue; continue;
} }
@ -131,7 +267,9 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
if ($response->failed()) { if ($response->failed()) {
$hadErrors = true; $hadErrors = true;
$errors++; $errors++;
$errorCodes[] = $this->mapGraphFailureToErrorCode($response); $errorCode = $this->mapGraphFailureToErrorCode($response);
$errorCodes[] = $errorCode;
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode);
continue; continue;
} }
@ -141,6 +279,10 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
continue; continue;
} }
if ($this->shouldSkipPolicyForSelectedType($policyType, $policyData)) {
continue;
}
$externalId = $policyData['id'] ?? $policyData['external_id'] ?? null; $externalId = $policyData['id'] ?? $policyData['external_id'] ?? null;
if (! is_string($externalId) || $externalId === '') { if (! is_string($externalId) || $externalId === '') {
continue; continue;
@ -194,6 +336,8 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
); );
} }
} }
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null);
} }
$status = $hadErrors ? InventorySyncRun::STATUS_PARTIAL : InventorySyncRun::STATUS_SUCCESS; $status = $hadErrors ? InventorySyncRun::STATUS_PARTIAL : InventorySyncRun::STATUS_SUCCESS;
@ -231,6 +375,55 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
} }
} }
private function shouldSkipPolicyForSelectedType(string $selectedPolicyType, array $policyData): bool
{
$configurationPolicyTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
if (! in_array($selectedPolicyType, $configurationPolicyTypes, true)) {
return false;
}
return $this->resolveConfigurationPolicyType($policyData) !== $selectedPolicyType;
}
private function resolveConfigurationPolicyType(array $policyData): string
{
$templateReference = $policyData['templateReference'] ?? null;
$templateFamily = null;
if (is_array($templateReference)) {
$templateFamily = $templateReference['templateFamily'] ?? null;
}
if (is_string($templateFamily) && strcasecmp(trim($templateFamily), 'securityBaseline') === 0) {
return 'securityBaselinePolicy';
}
if ($this->isEndpointSecurityConfigurationPolicy($policyData, $templateFamily)) {
return 'endpointSecurityPolicy';
}
return 'settingsCatalogPolicy';
}
private function isEndpointSecurityConfigurationPolicy(array $policyData, ?string $templateFamily): bool
{
$technologies = $policyData['technologies'] ?? null;
if (is_string($technologies) && strcasecmp(trim($technologies), 'endpointSecurity') === 0) {
return true;
}
if (is_array($technologies)) {
foreach ($technologies as $technology) {
if (is_string($technology) && strcasecmp(trim($technology), 'endpointSecurity') === 0) {
return true;
}
}
}
return is_string($templateFamily) && str_starts_with(strtolower(trim($templateFamily)), 'endpointsecurity');
}
/** /**
* @return array<string, array<string, mixed>> * @return array<string, array<string, mixed>>
*/ */
@ -267,6 +460,7 @@ private function createSkippedRun(
): InventorySyncRun { ): InventorySyncRun {
return InventorySyncRun::query()->create([ return InventorySyncRun::query()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'user_id' => null,
'selection_hash' => $selectionHash, 'selection_hash' => $selectionHash,
'selection_payload' => $selectionPayload, 'selection_payload' => $selectionPayload,
'status' => InventorySyncRun::STATUS_SKIPPED, 'status' => InventorySyncRun::STATUS_SKIPPED,
@ -281,6 +475,23 @@ private function createSkippedRun(
]); ]);
} }
private function markExistingRunSkipped(InventorySyncRun $run, CarbonImmutable $now, string $errorCode): InventorySyncRun
{
$run->update([
'status' => InventorySyncRun::STATUS_SKIPPED,
'had_errors' => true,
'error_codes' => [$errorCode],
'error_context' => null,
'started_at' => $run->started_at ?? $now,
'finished_at' => $now,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'errors_count' => 0,
]);
return $run->refresh();
}
private function mapGraphFailureToErrorCode(GraphResponse $response): string private function mapGraphFailureToErrorCode(GraphResponse $response): string
{ {
$status = (int) ($response->status ?? 0); $status = (int) ($response->status ?? 0);

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('inventory_sync_runs', function (Blueprint $table) {
$table->foreignId('user_id')
->nullable()
->constrained()
->nullOnDelete()
->after('tenant_id');
$table->index(['tenant_id', 'user_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventory_sync_runs', function (Blueprint $table) {
$table->dropIndex(['tenant_id', 'user_id']);
$table->dropConstrainedForeignId('user_id');
});
}
};

View File

@ -14,10 +14,13 @@ services:
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}' - '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
environment: environment:
WWWUSER: '${WWWUSER:-1000}' WWWUSER: '${WWWUSER:-1000}'
WWWGROUP: '${WWWGROUP:-1000}'
LARAVEL_SAIL: 1 LARAVEL_SAIL: 1
APP_SERVICE: laravel.test APP_SERVICE: laravel.test
entrypoint: ["/bin/sh", "-c", "mkdir -p /var/www/html/node_modules && chown ${WWWUSER:-1000}:${WWWGROUP:-1000} /var/www/html/node_modules || true; exec start-container"]
volumes: volumes:
- '.:/var/www/html' - '.:/var/www/html'
- '/var/www/html/node_modules'
networks: networks:
- sail - sail
depends_on: depends_on:
@ -36,10 +39,13 @@ services:
- 'host.docker.internal:host-gateway' - 'host.docker.internal:host-gateway'
environment: environment:
WWWUSER: '${WWWUSER:-1000}' WWWUSER: '${WWWUSER:-1000}'
WWWGROUP: '${WWWGROUP:-1000}'
LARAVEL_SAIL: 1 LARAVEL_SAIL: 1
APP_SERVICE: queue APP_SERVICE: queue
entrypoint: ["/bin/sh", "-c", "mkdir -p /var/www/html/node_modules && chown ${WWWUSER:-1000}:${WWWGROUP:-1000} /var/www/html/node_modules || true; exec start-container"]
volumes: volumes:
- '.:/var/www/html' - '.:/var/www/html'
- '/var/www/html/node_modules'
networks: networks:
- sail - sail
depends_on: depends_on:

View File

@ -0,0 +1,15 @@
# Plan: Settings Catalog Classification
## Approach
- Trace how Inventory derives **Type** and **Category** for policies.
- Fix policy-type resolution/canonicalization so Settings Catalog cannot be classified as Security Baselines.
- Add a regression test at the classification layer.
## Expected Touch Points
- `app/Services/Intune/PolicyClassificationService.php`
- Possibly Inventory mapping/display logic if category is derived elsewhere
- `tests/` regression coverage
## Rollout
- Code change affects future sync runs.
- To correct existing rows, rerun the Inventory Sync for the tenant.

View File

@ -0,0 +1,18 @@
# Spec: Settings Catalog Classification
## Problem
Some Settings Catalog policies show up as **Type: Security Baselines** and **Category: Endpoint Security** in Inventory UI.
## Goal
Ensure Settings Catalog policies are consistently classified as:
- **Type**: Settings Catalog Policy
- **Category**: Configuration
## Non-Goals
- Changing UI layout or adding new filters
- Bulk cleanup of historical data beyond what a normal re-sync updates
## Acceptance Criteria
- Classification logic never maps Settings Catalog policies to Security Baselines
- A regression test covers the misclassification scenario
- After the next Inventory Sync, affected items are stored with the correct `policy_type`/category

View File

@ -0,0 +1,7 @@
# Tasks: Settings Catalog Classification
- [x] T001 Run Spec Kit prerequisites + gather context
- [x] T002 Locate where Inventory Type/Category is derived
- [x] T003 Fix Settings Catalog misclassification in policy classification
- [x] T004 Add regression test
- [x] T005 Run targeted tests + Pint (dirty)

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Inventory Sync Button
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-09
**Feature**: [specs/046-inventory-sync-button/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@ -0,0 +1,33 @@
# Contracts: Internal UI Actions (046)
This feature does not introduce a public HTTP API. It adds an internal Filament UI action that dispatches a queued job.
## Action: Start Inventory Sync
**Surface**: Filament Page action (Inventory area)
**Inputs**
- `tenant_id`: from `Tenant::current()` (required)
- `selection_payload` (required; default “full inventory”):
- `policy_types`: `PolicyTypeResolver::supportedPolicyTypes()`
- `categories`: `[]`
- `include_foundations`: `true`
- `include_dependencies`: `true`
- `user_id`: `auth()->id()` (required)
**Side effects**
- Creates `BulkOperationRun` (resource `inventory`, action `sync`, total_items `1`) so the bottom-right progress widget displays the operation.
- Dispatches `RunInventorySyncJob` with the needed identifiers.
- Creates an `InventorySyncRun` with status `running` once the job begins execution.
- Writes audit log entries:
- `inventory.sync.dispatched`
- terminal: `inventory.sync.completed|inventory.sync.failed|inventory.sync.skipped`
- Sends Filament database notifications to the initiating user:
- started
- completed/failed/skipped
**Failure modes**
- Tenant not selected: action should be unavailable or no-op.
- Unauthorized user: action hidden/denied.
- Concurrency/lock gating: no overlapping run; user receives an informational message; run may be marked `skipped` with an error code.
- Queue not running: operation remains queued; user can observe this via progress widget + run list.

View File

@ -0,0 +1,71 @@
# Phase 1 Design: Data Model (046)
## Entities
### InventorySyncRun
Existing table: `inventory_sync_runs`
**Purpose**: Observable record of one inventory sync execution for a tenant + selection.
**Existing fields (selected)**
- `id`
- `tenant_id` (FK)
- `selection_hash` (sha256)
- `selection_payload` (jsonb)
- `status` (`running|success|partial|failed|skipped`)
- `had_errors` (bool)
- `error_codes` (jsonb)
- `error_context` (jsonb)
- `started_at`, `finished_at`
- `items_observed_count`, `items_upserted_count`, `errors_count`
**Planned additions (to satisfy FR-008)**
- `user_id` (nullable FK to `users.id`)
- Meaning: initiator of the run (UI-triggered). `null` allowed for system/scheduled runs.
**Relationships**
- `InventorySyncRun belongsTo Tenant`
- `InventorySyncRun belongsTo User` (new, nullable)
**Validation rules (selection payload)**
- `policy_types`: list of strings; filtered through `PolicyTypeResolver::filterRuntime(...)`
- `categories`: list of strings (optional; currently not used for the UI-triggered “full inventory” run)
- `include_foundations`: boolean
- `include_dependencies`: boolean
### BulkOperationRun
Existing table: `bulk_operation_runs`
**Purpose**: Generic progress tracking for background operations. Used by the bottom-right progress widget.
**Relevant fields**
- `tenant_id`, `user_id`
- `resource`, `action` (used for display)
- `status` (`pending|running|completed|completed_with_errors|failed|aborted`)
- Counters: `total_items`, `processed_items`, `succeeded`, `failed`, `skipped`
- `audit_log_id` (FK)
**Usage for Inventory Sync**
- `resource = 'inventory'`, `action = 'sync'`
- `total_items = 1`
- `item_ids = [selection_hash]` (or `[inventory_sync_run_id]` after creation)
### AuditLog
Existing table: `audit_logs`
**Purpose**: Immutable audit trail with actor identity snapshot.
**Usage for Inventory Sync**
- Emit `inventory.sync.dispatched` and a terminal event like `inventory.sync.completed` / `inventory.sync.failed`.
- `resource_type = 'inventory_sync_run'`, `resource_id = (string) $run->id`
- Actor fields filled from authenticated user.
## State transitions
### InventorySyncRun
- `running``success|partial|failed`
- `running``skipped` (lock/concurrency)
### BulkOperationRun
- `pending``running``completed|completed_with_errors|failed|aborted`

View File

@ -0,0 +1,140 @@
# Implementation Plan: Inventory Sync Button (046)
**Branch**: `046-inventory-sync-button` | **Date**: 2026-01-08 | **Spec**: `specs/046-inventory-sync-button/spec.md`
**Input**: Feature specification from `specs/046-inventory-sync-button/spec.md`
## Summary
Add a “Run Inventory Sync” action in the Inventory area that dispatches a queued job, records a new `InventorySyncRun` attributed to the initiating user, and provides visibility via:
- Filament database notifications
- Existing bottom-right progress widget (powered by `BulkOperationRun` + `BulkOperationProgress`)
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3
**Storage**: PostgreSQL (JSONB used for run payload + error context)
**Testing**: Pest v4 (Laravel test runner)
**Target Platform**: Containerized (Laravel Sail locally), deployed via Dokploy
**Project Type**: Web application (Laravel + Filament admin panel)
**Performance Goals**: UI action should enqueue quickly (<2s perceived) and never block on Graph calls
**Constraints**: Tenant-isolated, idempotent start semantics, observable background execution, queue workers may be down
**Scale/Scope**: Single-tenant interactive action; one run per tenant/selection at a time (locks/concurrency)
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: This feature refreshes Inventory (“last observed”) and does not touch snapshots/backups.
- Read/write separation: No Graph writes. Only reads + local DB writes (run records + inventory items).
- Graph contract path: Inventory sync already uses `GraphClientInterface`; this feature does not add new Graph endpoints.
- Deterministic capabilities: Uses `PolicyTypeResolver` + `config('tenantpilot.supported_policy_types')` to derive default selection.
- Tenant isolation: Uses `Tenant::current()` for all run creation and progress tracking.
- Automation: Uses existing inventory locks/concurrency; runs are observable via `InventorySyncRun` and `BulkOperationRun`.
- Data minimization: Inventory sync continues to store metadata + sanitized meta JSON; no secrets in notifications/logs.
## Project Structure
### Documentation (this feature)
```text
specs/046-inventory-sync-button/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── internal-actions.md
└── tasks.md # Phase 2 output (/speckit.tasks) - not created here
```
### Source Code (repository root)
```text
app/
├── Filament/
│ └── Pages/
│ └── InventoryLanding.php # add header action “Run Inventory Sync”
├── Jobs/
│ └── RunInventorySyncJob.php # new queued job
├── Models/
│ ├── InventorySyncRun.php # add user relationship (nullable)
│ └── BulkOperationRun.php # reused by progress widget
├── Services/
│ ├── BulkOperationService.php # reused for progress + audit
│ ├── Intune/
│ │ └── AuditLogger.php # reused for inventory audit trail
│ └── Inventory/
│ └── InventorySyncService.php # add entrypoint to run sync attributed to user
database/migrations/
└── xxxx_xx_xx_xxxxxx_add_user_id_to_inventory_sync_runs_table.php
resources/views/
└── filament/pages/inventory-landing.blade.php # may need to show action if not already in header
tests/Feature/
└── Inventory/InventorySyncButtonTest.php # new Pest feature test
```
**Structure Decision**: Web application, implemented in Filament page actions + Laravel queued job.
## Phase 0 Output (Research)
Completed in `specs/046-inventory-sync-button/research.md`.
Key decisions used in this plan:
- Reuse existing bottom-right progress widget by creating a `BulkOperationRun` (`resource=inventory`, `action=sync`).
- Authorize using existing tenant role capability: `User::canSyncTenant(Tenant::current())`.
- Default selection = “full inventory” (all supported policy types, foundations + dependencies enabled).
## Phase 1 Output (Design)
Completed in:
- `specs/046-inventory-sync-button/data-model.md`
- `specs/046-inventory-sync-button/contracts/internal-actions.md`
## Phase 2 Plan (Implementation Outline)
### 1) Data model: store initiator on run record
- Add nullable `user_id` foreign key to `inventory_sync_runs`.
- Add `InventorySyncRun::user()` relationship.
- Backfill is not required (existing rows can remain `null`).
### 2) Job orchestration: queued execution + progress tracking
- Add `RunInventorySyncJob` that:
- Loads the tenant + user + `BulkOperationRun`.
- Marks the `BulkOperationRun` as running (`BulkOperationService::start`).
- Runs inventory sync via `InventorySyncService` in a way that sets `InventorySyncRun.user_id`.
- Records bulk progress as a single-item operation:
- success: `recordSuccess` + `complete`
- failure/skip: `recordFailure`/`recordSkippedWithReason` + `complete` (or `fail` for hard failures)
- Sends Filament database notifications to the initiating user on completion/failure.
### 3) UI: Inventory “Run Inventory Sync” action
- Add a header action on `InventoryLanding`:
- Visible/authorized only if `auth()->user()` is a `User` and `canSyncTenant(Tenant::current())`.
- On click:
- Compute default selection payload.
- Pre-flight check: if a matching `InventorySyncRun` is currently `running` (same tenant + selection hash), show an informational notification and do not dispatch.
- Create a `BulkOperationRun` via `BulkOperationService::createRun(...)` with `total_items=1`.
- Send a “started” Filament DB notification with the “check bottom right progress bar” copy.
- Dispatch `RunInventorySyncJob` with identifiers.
### 4) Audit trail
- Use `AuditLogger` to emit at minimum:
- `inventory.sync.dispatched`
- terminal event keyed by outcome: `inventory.sync.completed|inventory.sync.failed|inventory.sync.skipped`
### 5) Tests (Pest)
- New feature test covering:
- authorized user can dispatch a sync from the Inventory page and it creates a `BulkOperationRun`.
- `RunInventorySyncJob` sets `InventorySyncRun.user_id`.
- unauthorized user cannot see/execute the action.
- concurrency case: when a run is already `running`, a second dispatch is prevented.

View File

@ -0,0 +1,36 @@
# Quickstart: Inventory Sync Button (046)
## Goal
Start an Inventory Sync for the currently selected tenant from the Filament UI, observe progress in:
- Filament database notifications panel
- Bottom-right progress widget (BulkOperationProgress)
## Local prerequisites
- Sail running
- Queue worker running (required for the new queued sync job)
## Run locally
1. Start the stack:
- `./vendor/bin/sail up -d`
2. Run a queue worker:
- `./vendor/bin/sail artisan queue:work --tries=1`
3. Open the admin panel and pick a tenant:
- Visit `/admin` and select a tenant context.
4. Navigate to Inventory:
- Use the left navigation “Inventory”.
5. Click “Run Inventory Sync”:
- Select which policy types to include (or click “Select all”).
- (Optional) Toggle “Include dependencies” if you want dependency extraction where supported.
- You should see:
- A database notification (started)
- A bottom-right progress widget entry for `Sync inventory`
- A new Inventory Sync Run record in the Inventory Sync Runs list
## Notes
- If the queue worker is not running, the progress widget will remain in “Queued…” and the run will not advance.
- Concurrency/lock gating may skip the run; the UI should show a clear informational message and a run record will reflect the skip reason.

View File

@ -0,0 +1,63 @@
# Phase 0 Research: Inventory Sync Button (046)
**Date**: 2026-01-09
## Findings
### Existing patterns to reuse
#### DB-backed notifications
- Filament DB notifications are already used in multiple places.
- Example: Policy Sync action calls `Filament\Notifications\Notification::make()->sendToDatabase(auth()->user())->send()`.
#### Bottom-right progress widget
- The bottom-right progress widget is implemented by `App\Livewire\BulkOperationProgress` and renders `resources/views/livewire/bulk-operation-progress.blade.php`.
- It polls `BulkOperationRun` filtered by `tenant_id = Tenant::current()->id` and `user_id = auth()->id()`.
- It is injected globally into Filament via a render hook in `App\Providers\Filament\AdminPanelProvider`.
#### Inventory Sync run records
- Inventory sync runs are already persisted in `inventory_sync_runs` with counts and status.
- Current `InventorySyncService::syncNow(...)` runs inline and uses locks/concurrency to create/update `InventorySyncRun`.
#### Authorization
- The app already uses tenant-role based authorization for sync operations (e.g. `User::canSyncTenant($tenant)` in `TenantResource`).
#### Inventory selection payload
- Inventory Sync requires a selection payload with shape: `{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool}`.
- There is no existing UI picker for inventory selection.
## Decisions
### Decision: Start Inventory Sync as a queued job
- **Chosen**: Dispatch an Inventory Sync job from the UI action.
- **Rationale**: Aligns with existing background operation UX and avoids blocking Livewire requests.
- **Alternatives considered**:
- Run inline (current `syncNow`) — rejected due to UX (slow request) and mismatch with existing “progress widget” expectations.
### Decision: Use DB notifications + progress widget UX consistent with Policy/Bulk operations
- **Chosen**: Create a `BulkOperationRun` (resource `inventory`, action `sync`) so the existing bottom-right widget shows progress; also send DB notifications at start and completion/failure.
- **Rationale**: Matches established UX language and avoids inventing new UI surfaces.
- **Alternatives considered**:
- Only show toast notifications — rejected; user explicitly requires DB notification panel + progress widget.
### Decision: Authorize via tenant role sync permission
- **Chosen**: Gate the UI action using `auth()->user()->canSyncTenant(Tenant::current())`.
- **Rationale**: Aligns with existing “sync” authorization patterns already used for tenant/policy operations.
- **Alternatives considered**:
- Introduce new permission strings/roles — rejected for MVP; adds RBAC surface area.
### Decision: Default selection = “full inventory”
- **Chosen**: Dispatch inventory sync with policy types set to `PolicyTypeResolver::supportedPolicyTypes()`, empty categories, and `include_foundations=true`, `include_dependencies=true`.
- **Rationale**: Simplest interpretation of “Run Inventory Sync” without inventing a new picker UX.
- **Alternatives considered**:
- Reuse backup policy picker UI — rejected; different domain (backup selection), more UX than requested.
### Decision: Attribute initiator on run record and audit trail
- **Chosen**: Store initiator identity on `InventorySyncRun` and also emit an audit record.
- **Rationale**: Improves traceability and aligns with constitution principle “Automation must be Idempotent & Observable”.
- **Alternatives considered**:
- Audit log only — rejected (you chose C).
## Open Questions (for Phase 1 design)
- None remaining for planning; implementation will add a dedicated queued job.

View File

@ -0,0 +1,130 @@
# Feature Specification: Inventory Sync Button
**Feature Branch**: `046-inventory-sync-button`
**Created**: 2026-01-09
**Status**: Draft
**Input**: User description: "Add an Inventory Sync button"
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - Run Inventory Sync from UI (Priority: P1)
As an admin, I want a button in the admin UI to start an Inventory Sync for the currently selected tenant, so I can refresh inventory data without needing CLI.
**Why this priority**: Inventory is a core admin surface; the ability to refresh on-demand reduces troubleshooting time and improves trust in the data.
**Independent Test**: Can be fully tested by clicking a "Run Inventory Sync" button and verifying a new Sync Run record appears and progresses to a terminal state.
**Acceptance Scenarios**:
1. **Given** I am viewing Inventory for a tenant, **When** I click "Run Inventory Sync", **Then** a new Inventory Sync Run is created for that tenant and work is started asynchronously.
2. **Given** a sync run is started, **When** I view in-app notifications and the progress widget, **Then** I can see the sync progress and final outcome.
3. **Given** a sync run finishes (success/partial/failed), **When** I open the Inventory Sync Runs list, **Then** the run is visible with its terminal status and error summary (if any).
4. **Given** I am viewing Inventory for tenant A, **When** a request/payload/state attempts to start a sync for tenant B, **Then** the request is rejected (403/AccessDenied), I see “Not allowed”, and no run/job is created for tenant B.
---
### User Story 2 - Safe feedback when sync cannot start (Priority: P2)
As an admin, I want clear feedback when an Inventory Sync cannot be started (for example due to locks or concurrency), so I understand what to do next.
**Why this priority**: Inventory sync runs are lock- and concurrency-gated; unclear failures lead to repeated clicks and confusion.
**Independent Test**: Can be tested by attempting to start a sync while another sync is already running and verifying the UI shows a clear message and does not create duplicate runs.
**Acceptance Scenarios**:
1. **Given** a sync for the same tenant/selection is already running, **When** I click "Run Inventory Sync", **Then** the system does not start a second overlapping run and provides a clear explanation.
---
### User Story 3 - Permissioned access (Priority: P3)
As a tenant-scoped admin, I want only authorized users to be able to start Inventory Sync runs, so tenant data and Graph access are not triggered by unauthorized users.
**Why this priority**: Starting a sync triggers tenant-scoped Graph reads and creates operational records.
**Independent Test**: Can be tested by using a user without the required permission and verifying the action is not available or is denied.
**Acceptance Scenarios**:
1. **Given** I do not have permission to run inventory sync, **When** I view the Inventory area, **Then** I cannot trigger a sync and I receive an access denied message if I attempt it.
---
[Add more user stories as needed, each with an assigned priority]
### Edge Cases
- If a sync is already `running` for the same tenant + selection, the UI MUST not dispatch another run and MUST show an informational message.
- If tenant credentials are missing/invalid, the run MUST end in a terminal failure state and the initiating user MUST be notified.
- If the user clicks multiple times quickly, only one run MUST be dispatched; subsequent attempts MUST be blocked with a clear message.
- If the UI is closed after dispatch, the run MUST still finish and remain observable later via runs list + notifications.
- If queue workers are down, the run MUST remain observable as “queued/pending” and the UI MUST not pretend completion.
- If a request/payload/state attempts cross-tenant initiation, the system MUST reject the attempt server-side (403/AccessDenied) and MUST NOT create any run/job for another tenant.
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls or any write/change behavior,
the spec MUST describe contract registry updates, safety gates (preview/confirmation/audit), tenant isolation, and tests.
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
- **FR-001**: System MUST provide an admin UI action to start an Inventory Sync for the currently selected tenant.
- **FR-002**: System MUST run Inventory Sync exclusively in the context of the current tenant (`Tenant::current()`). If any request/payload/state attempts to target a different tenant (e.g. provides `tenant_id` that does not match `Tenant::current()->id`), the system MUST reject the attempt server-side with 403/AccessDenied, MUST show a user-visible “Not allowed” message, and MUST NOT create any `InventorySyncRun`, `BulkOperationRun`, or queued job for the foreign tenant.
- **FR-003**: System MUST create an observable Inventory Sync Run record when a sync is started.
- **FR-004**: System MUST start the Inventory Sync asynchronously (queued/background execution), not inline in the request.
- **FR-005**: System MUST enforce existing concurrency/lock rules and MUST NOT start overlapping runs for the same tenant/selection.
- **FR-006**: System MUST show a clear success message when a run is started and a clear informational message when a run cannot be started due to locks/concurrency.
- **FR-007**: System MUST enforce authorization for starting sync runs.
- **FR-008**: System MUST record who initiated the sync run (user identity) on the run record AND in the audit trail for auditability.
- **FR-009**: System MUST provide in-app visibility into sync progress and outcome via a DB-backed notifications panel.
- **FR-010**: System MUST provide in-app visibility into sync progress and outcome via a bottom-right progress widget, consistent with the Policy Sync experience.
### Assumptions
- The Inventory Sync capability already exists and can be triggered by the application.
- An Inventory Sync Runs list/detail view already exists (or will exist) where users can observe runs.
### Dependencies
- Tenant context switching is available so the action always targets the current tenant.
- Existing concurrency/lock rules are enforced consistently for all sync starts (UI-triggered or otherwise).
- The application has an existing mechanism for DB-backed in-app notifications and a progress widget (used by Policy Sync).
*Example of marking unclear requirements:*
<!-- no additional clarifications -->
### Key Entities *(include if feature involves data)*
- **Inventory Sync Run**: A record representing a single inventory sync execution for a specific tenant and selection; includes status, timestamps, and error summary.
- **Tenant**: The tenant context the user is currently operating on; determines credentials/scope for inventory sync.
- **User (Initiator)**: The authenticated user who triggered the sync run; used for auditability and access control.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An authorized admin can start an Inventory Sync from the UI in under 10 seconds (time to find + click action).
- **SC-002**: In a normal tenant, 95% of sync start attempts either create a run or provide a clear lock/concurrency explanation within 2 seconds.
- **SC-003**: In usability testing, at least 90% of admins can successfully start a sync and locate the resulting run and its progress without guidance.
- **SC-004**: Support/debug time for "inventory looks stale" issues is reduced (measured as fewer repeated sync/troubleshooting requests), after rollout.

View File

@ -0,0 +1,171 @@
---
description: "Task list for Inventory Sync Button (046)"
---
# Tasks: Inventory Sync Button (046)
**Input**: Design documents from `/specs/046-inventory-sync-button/`
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/
**Tests**: REQUIRED (Pest) — this feature changes runtime behavior.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `- [ ] T### [P?] [Story] Description with file path`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Ensure feature docs and baseline scaffolding are in place.
- [ ] T001 Verify feature docs exist and are current in specs/046-inventory-sync-button/{spec.md,plan.md,research.md,data-model.md,quickstart.md,contracts/internal-actions.md}
- [X] T001 Verify feature docs exist and are current in specs/046-inventory-sync-button/{spec.md,plan.md,research.md,data-model.md,quickstart.md,contracts/internal-actions.md}
- [X] T002 [P] Locate existing Inventory landing UI and runs list pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Resources/InventorySyncRunResource/**
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core infrastructure that MUST be complete before ANY user story work can be finished end-to-end.
- [X] T003 Add nullable initiator FK to inventory sync runs in database/migrations/xxxx_xx_xx_xxxxxx_add_user_id_to_inventory_sync_runs_table.php
- [X] T004 [P] Add `user()` relationship + casts/guarding as needed in app/Models/InventorySyncRun.php
- [X] T005 [P] Add `use Illuminate\Database\Eloquent\Relations\BelongsTo;` + `user(): BelongsTo` in app/Models/InventorySyncRun.php
- [X] T006 Add queued job skeleton for inventory sync in app/Jobs/RunInventorySyncJob.php
- [X] T007 [P] Add default inventory selection builder (full inventory) and deterministic selection_hash helper as methods on app/Services/Inventory/InventorySyncService.php (no new factory/hasher classes)
- [X] T008 Add service entrypoint that attributes initiator on run record in app/Services/Inventory/InventorySyncService.php
**Checkpoint**: DB schema + job/service primitives exist.
---
## Phase 3: User Story 1 — Run Inventory Sync from UI (Priority: P1) 🎯 MVP
**Goal**: Provide a “Run Inventory Sync” action for the current tenant that dispatches work asynchronously and is observable.
**Independent Test**: Clicking the action creates a `BulkOperationRun` and an `InventorySyncRun`, then job completes and updates both + DB notifications.
### Tests for User Story 1 (REQUIRED)
- [X] T010 [P] [US1] Add feature test covering action dispatch + run creation AND cross-tenant rejection (403 + “Not allowed” + no run/job created) in tests/Feature/Inventory/InventorySyncButtonTest.php
- [X] T011 [P] [US1] Add feature test covering queued job success updates bulk run + inventory run in tests/Feature/Inventory/RunInventorySyncJobTest.php
### Implementation for User Story 1
- [X] T012 [US1] Add “Run Inventory Sync” header action to app/Filament/Pages/InventoryLanding.php
- [X] T013 [US1] If needed, surface the action in resources/views/filament/pages/inventory-landing.blade.php (only if header actions arent visible)
- [X] T014 [US1] On click, build default selection via app/Services/Inventory/InventorySyncService.php
- [X] T015 [US1] On click, create BulkOperationRun (resource `inventory`, action `sync`, total_items=1) via app/Services/BulkOperationService.php (inline from the action handler)
- [X] T016 [US1] On click, send “started” Filament DB notification in app/Filament/Pages/InventoryLanding.php
- [X] T017 [US1] On click, dispatch app/Jobs/RunInventorySyncJob.php with tenant_id, user_id, bulk_run_id, selection_payload
- [X] T018 [US1] Implement app/Jobs/RunInventorySyncJob.php to start bulk run + call InventorySyncService entrypoint
- [X] T019 [US1] In job, map InventorySyncRun terminal outcome → BulkOperationService counters/status in app/Jobs/RunInventorySyncJob.php
- [X] T020 [US1] In job, send completion/failure DB notification to the initiating user in app/Jobs/RunInventorySyncJob.php
- [X] T021 [US1] Emit audit events for dispatched + completed/failed/skipped using app/Services/Intune/AuditLogger.php from app/Jobs/RunInventorySyncJob.php
**Checkpoint**: US1 works end-to-end with queue worker running.
---
## Phase 4: User Story 2 — Safe feedback when sync cannot start (Priority: P2)
**Goal**: Prevent duplicate/overlapping runs and provide clear feedback when sync cant start.
**Independent Test**: With a `running` InventorySyncRun for the same tenant+selection, the action does not dispatch a new job and shows an informational message.
### Tests for User Story 2 (REQUIRED)
- [X] T022 [P] [US2] Add feature test for “already running” preflight blocking in tests/Feature/Inventory/InventorySyncButtonTest.php
- [X] T023 [P] [US2] Add feature test for lock/concurrency skip outcome mapping in tests/Feature/Inventory/RunInventorySyncJobTest.php
### Implementation for User Story 2
- [X] T024 [US2] Add preflight check for existing running selection_hash in app/Filament/Pages/InventoryLanding.php
- [X] T025 [US2] Compute selection_hash using app/Services/Inventory/InventorySyncService.php (same logic used for run creation) and use it for the preflight check in app/Filament/Pages/InventoryLanding.php
- [X] T026 [US2] Add clear Filament notification message when blocked due to running/locks in app/Filament/Pages/InventoryLanding.php
- [X] T027 [US2] Ensure RunInventorySyncJob handles `skipped` InventorySyncRun (concurrency/lock) and marks BulkOperationRun as completed_with_errors or completed (skipped) with reason in app/Jobs/RunInventorySyncJob.php
**Checkpoint**: User gets clear feedback; duplicates are prevented.
---
## Phase 5: User Story 3 — Permissioned access (Priority: P3)
**Goal**: Only authorized users can start Inventory Sync.
**Independent Test**: Unauthorized users do not see the action (or cannot execute it) and no runs are created.
### Tests for User Story 3 (REQUIRED)
- [X] T028 [P] [US3] Add feature test that unauthorized user cannot see/execute the action in tests/Feature/Inventory/InventorySyncButtonTest.php
### Implementation for User Story 3
- [X] T029 [US3] Gate action visibility/authorization via User::canSyncTenant(Tenant::current()) in app/Filament/Pages/InventoryLanding.php
- [X] T030 [US3] Ensure server-side authorization check is enforced in action handler (not only visibility) in app/Filament/Pages/InventoryLanding.php
**Checkpoint**: Authorization is enforced and tested.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Make delivery safe, consistent, and easy to validate.
- [X] T031 [P] Add/adjust translation-safe, consistent notification copy (start/complete/fail) in app/Filament/Pages/InventoryLanding.php and app/Jobs/RunInventorySyncJob.php
- [X] T032 Ensure `InventorySyncRunResource` shows initiator when present (user_id) in app/Filament/Resources/InventorySyncRunResource.php
- [X] T033 [P] Run formatter on changed files: `./vendor/bin/pint --dirty`
- [X] T034 Run targeted tests: `./vendor/bin/sail artisan test --filter=InventorySync` (or specific test files)
- [X] T035 Validate quickstart steps remain accurate in specs/046-inventory-sync-button/quickstart.md
- [X] T036 Make BulkOperation progress reflect selected policy types (total_items = #types; per-type success/failure) in app/Filament/Pages/InventoryLanding.php and app/Jobs/RunInventorySyncJob.php
- [X] T037 Add `include_dependencies` toggle to the “Run Inventory Sync” modal in app/Filament/Pages/InventoryLanding.php and cover via tests/Feature/Inventory/InventorySyncButtonTest.php
---
## Dependencies & Execution Order
### User Story dependency graph
- Setup → Foundational → US1 → (US2, US3) → Polish
Rationale:
- US1 establishes the action + job + progress/notifications baseline.
- US2 and US3 are incremental safety/guardrails on top of the same action.
### Parallel opportunities
- Phase 2: T004, T005, T007 can run in parallel (separate files).
- US1: T010 and T011 can be written in parallel.
- US2: T022 and T023 can be written in parallel.
---
## Parallel Example: User Story 1
Parallelizable work for US1 (after Phase 2 is complete):
```text
Run in parallel:
- T010 + T011 (tests)
- T012 + T018 (UI action + job implementation)
Then:
- T015T017 (wire dispatch + notifications)
- T019T021 (status mapping + DB notif + audit)
```
---
## Implementation Strategy
### MVP scope (recommended)
- Deliver US1 only (Phases 13) and validate via quickstart.
- Then add US2 and US3 as hardening increments.

View File

@ -0,0 +1,66 @@
# Brainstorming: Was fehlt, damit TenantPilot als Suite ein „Must-have“ für Unternehmen & MSPs wird?
Ziel: Features, die Microsoft in Intune voraussichtlich **nicht** in dieser Qualität nachrüstet und für die Mittelstand/MSPs **wirklich zahlen** (Portfolio, Governance, Standardisierung, Automation, Nachweisbarkeit).
---
## 1) MSP Portfolio & Operations (Multi-Tenant)
- **Multi-Tenant Health Dashboard**: pro Tenant „Backup ok?“, „Sync ok?“, „Drift Findings“, „High-risk Changes last 7d“
- **SLA-/Compliance-Reports** pro Kunde (PDF/Export): Coverage, letzter erfolgreicher Backup, Restore-Test-Status
- **Alerting** (Teams/Email/Webhook): failed runs, throttling, permissions drift, „kein erfolgreicher Backup seit X Tagen“
- **Troubleshooting Center**: Error Codes → konkrete Fix-Schritte (fehlende Permissions, 429, contract mismatch, mapping fehlt)
---
## 2) Drift & Change Governance (Zahlhebel #1)
- **Drift Findings mit Risikostufe** (high/medium/low) + „was hat sich geändert?“
- **Change Approval Workflow**: DEV→PROD Promotion nur mit Approval + Audit Pack
- **Guardrails / Policy Freeze Windows**: definierte Zeitfenster ohne High-Risk Changes
- **Tamper Detection**: Policy geändert außerhalb genehmigter Changes → Alert + Audit
---
## 3) Standardisierung & Policy Quality („Intune Linting“)
- **Policy Linter**: Naming, Scope Tag Pflicht, keine All-Users Assignments bei High Risk, Mindest-Settings
- **Company Standards als Templates**: eigene Standards („Company Standard 2026“) statt nur Microsoft Baselines
- **Policy Hygiene**: Duplicate Finder, Unassigned Policies, Orphaned filters/tags, stale/deleted policies
---
## 4) Tenant-to-Tenant / Staging→Prod Promotion
- **Compare/Diff zwischen Tenants** (DEV vs PROD) auf Policy-/Settings-Ebene
- **Mapping UI**: Gruppen, Scope Tags, Filters, Named Locations (CA), App-Refs
- **Promotion Plan**: Preview → Dry-run → Cutover → Verify (post-apply compare)
---
## 5) Security Suite Layer (ohne „Defender ersetzen“)
- **Security Posture Score** (aus euren Policies/Standards, nicht nur Microsoft Scores)
- **Blast Radius Anzeige**: welche Geräte/Benutzer/Groups wären betroffen, wenn X geändert wird
- **Opt-in High-Risk Enablement**: Baselines/CA als Module (create-new + verify, niemals blind update)
---
## 6) Recovery Confidence (Killer-Feature)
- **Automatisierte Restore-Tests** in Test-Tenants („Nightly restore rehearsal“)
- **Recovery Readiness Report**: „Wir können 92% der kritischen Config-Typen restoren“
- **Preflight Score**: Missing prereqs, permissions, mapping completeness → Score + konkrete ToDos
---
## 7) Script & Secrets Governance
- **Script Diff + Approval + Rollback** (PowerShell/macOS)
- **Secret Scanning** in Scripts (API keys/tokens) + block/alert
- **Allowlist/Signing Workflow** (Governance, Wer darf was ausrollen?)
---
# Was fehlt „heute“ im Vergleich zu eurem aktuellen Stand?
Ihr habt schon sehr stark: Backup/Restore, Versioning, Wizard, Bulk/Queue + Progress, Inventory, Scheduling (MVP), Dependencies (in Arbeit).
Was euch zum Must-have macht:
1. **MSP Portfolio + Alerting**
2. **Drift + Approvals/Governance**
3. **Standardisierung/Linting**
4. **Promotion DEV→PROD**
5. **Recovery Confidence (automatisierte Tests)**

View File

@ -0,0 +1,167 @@
<?php
use App\Filament\Pages\InventoryLanding;
use App\Jobs\RunInventorySyncJob;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Services\Inventory\InventorySyncService;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('dispatches inventory sync and creates observable run records', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$sync = app(InventorySyncService::class);
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
Livewire::test(InventoryLanding::class)
->callAction('run_inventory_sync', data: ['policy_types' => $allTypes]);
Queue::assertPushed(RunInventorySyncJob::class);
$run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first();
expect($run)->not->toBeNull();
expect($run->user_id)->toBe($user->id);
expect($run->status)->toBe(InventorySyncRun::STATUS_PENDING);
$bulkRun = BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'inventory')
->where('action', 'sync')
->latest('id')
->first();
expect($bulkRun)->not->toBeNull();
});
it('dispatches inventory sync for selected policy types', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$sync = app(InventorySyncService::class);
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
$selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes)));
Livewire::test(InventoryLanding::class)
->mountAction('run_inventory_sync')
->set('mountedActions.0.data.policy_types', $selectedTypes)
->assertActionDataSet(['policy_types' => $selectedTypes])
->callMountedAction()
->assertHasNoActionErrors();
Queue::assertPushed(RunInventorySyncJob::class);
$run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first();
expect($run)->not->toBeNull();
expect($run->selection_payload['policy_types'] ?? [])->toEqualCanonicalizing($selectedTypes);
});
it('persists include dependencies toggle into the run selection payload', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$sync = app(InventorySyncService::class);
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
$selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes)));
Livewire::test(InventoryLanding::class)
->callAction('run_inventory_sync', data: [
'policy_types' => $selectedTypes,
'include_dependencies' => false,
])
->assertHasNoActionErrors();
$run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first();
expect($run)->not->toBeNull();
expect((bool) ($run->selection_payload['include_dependencies'] ?? true))->toBeFalse();
});
it('rejects cross-tenant initiation attempts (403) with no side effects', function () {
Queue::fake();
[$user, $tenantA] = createUserWithTenant(role: 'owner');
$tenantB = Tenant::factory()->create();
$this->actingAs($user);
Filament::setTenant($tenantA, true);
$sync = app(InventorySyncService::class);
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
Livewire::test(InventoryLanding::class)
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
->assertStatus(403);
Queue::assertNothingPushed();
expect(InventorySyncRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse();
expect(BulkOperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse();
});
it('blocks dispatch when a matching run is already pending or running', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$sync = app(InventorySyncService::class);
$selectionPayload = $sync->defaultSelectionPayload();
$computed = $sync->normalizeAndHashSelection($selectionPayload);
InventorySyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'selection_hash' => $computed['selection_hash'],
'selection_payload' => $computed['selection'],
'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,
]);
Livewire::test(InventoryLanding::class)
->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]);
Queue::assertNothingPushed();
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
expect(BulkOperationRun::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
});
it('forbids unauthorized users from starting inventory sync', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(InventoryLanding::class)
->assertActionHidden('run_inventory_sync');
Queue::assertNothingPushed();
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
});

View File

@ -101,6 +101,61 @@ public function request(string $method, string $path, array $options = []): Grap
expect($items->first()->last_seen_run_id)->toBe($runB->id); expect($items->first()->last_seen_run_id)->toBe($runB->id);
}); });
test('configuration policy inventory filtering: settings catalog is not stored as security baseline', function () {
$tenant = Tenant::factory()->create();
$settingsCatalogLookalike = [
'id' => 'pol-1',
'name' => 'Windows 11 SettingsCatalog-Test',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'technologies' => ['mdm'],
'templateReference' => [
'templateDisplayName' => 'Windows Security Baseline (name only)',
],
];
$securityBaseline = [
'id' => 'pol-2',
'name' => 'Baseline Policy',
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'templateReference' => [
'templateFamily' => 'securityBaseline',
],
];
app()->instance(GraphClientInterface::class, fakeGraphClient([
'settingsCatalogPolicy' => [$settingsCatalogLookalike, $securityBaseline],
'securityBaselinePolicy' => [$settingsCatalogLookalike, $securityBaseline],
]));
$selection = [
'policy_types' => ['settingsCatalogPolicy', 'securityBaselinePolicy'],
'categories' => ['Configuration', 'Endpoint Security'],
'include_foundations' => false,
'include_dependencies' => false,
];
app(InventorySyncService::class)->syncNow($tenant, $selection);
expect(\App\Models\InventoryItem::query()
->where('tenant_id', $tenant->id)
->where('policy_type', 'securityBaselinePolicy')
->where('external_id', 'pol-1')
->exists())->toBeFalse();
expect(\App\Models\InventoryItem::query()
->where('tenant_id', $tenant->id)
->where('policy_type', 'settingsCatalogPolicy')
->where('external_id', 'pol-1')
->exists())->toBeTrue();
expect(\App\Models\InventoryItem::query()
->where('tenant_id', $tenant->id)
->where('policy_type', 'securityBaselinePolicy')
->where('external_id', 'pol-2')
->exists())->toBeTrue();
});
test('meta whitelist drops unknown keys without failing', function () { test('meta whitelist drops unknown keys without failing', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();

View File

@ -0,0 +1,117 @@
<?php
use App\Jobs\RunInventorySyncJob;
use App\Models\InventorySyncRun;
use App\Services\BulkOperationService;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService;
use Mockery\MockInterface;
it('executes a pending inventory sync run and updates bulk progress + initiator attribution', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->mock(GraphClientInterface::class, function (MockInterface $mock) {
$mock->shouldReceive('listPolicies')
->atLeast()
->once()
->andReturn(new GraphResponse(true, [], 200));
});
$sync = app(InventorySyncService::class);
$selectionPayload = $sync->defaultSelectionPayload();
$computed = $sync->normalizeAndHashSelection($selectionPayload);
$policyTypes = $computed['selection']['policy_types'];
$run = $sync->createPendingRunForUser($tenant, $user, $selectionPayload);
$bulkRun = app(BulkOperationService::class)->createRun(
tenant: $tenant,
user: $user,
resource: 'inventory',
action: 'sync',
itemIds: $policyTypes,
totalItems: count($policyTypes),
);
$job = new RunInventorySyncJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
bulkRunId: (int) $bulkRun->getKey(),
inventorySyncRunId: (int) $run->getKey(),
);
$job->handle(app(BulkOperationService::class), $sync, app(AuditLogger::class));
$run->refresh();
$bulkRun->refresh();
expect($run->user_id)->toBe($user->id);
expect($run->status)->toBe(InventorySyncRun::STATUS_SUCCESS);
expect($run->started_at)->not->toBeNull();
expect($run->finished_at)->not->toBeNull();
expect($bulkRun->status)->toBe('completed');
expect($bulkRun->processed_items)->toBe(count($policyTypes));
expect($bulkRun->succeeded)->toBe(count($policyTypes));
});
it('maps skipped inventory sync runs to bulk progress as skipped with reason', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$sync = app(InventorySyncService::class);
$selectionPayload = $sync->defaultSelectionPayload();
$run = $sync->createPendingRunForUser($tenant, $user, $selectionPayload);
$computed = $sync->normalizeAndHashSelection($selectionPayload);
$policyTypes = $computed['selection']['policy_types'];
$bulkRun = app(BulkOperationService::class)->createRun(
tenant: $tenant,
user: $user,
resource: 'inventory',
action: 'sync',
itemIds: $policyTypes,
totalItems: count($policyTypes),
);
$mockSync = \Mockery::mock(InventorySyncService::class);
$mockSync
->shouldReceive('executePendingRun')
->once()
->andReturnUsing(function (InventorySyncRun $inventorySyncRun) {
$inventorySyncRun->forceFill([
'status' => InventorySyncRun::STATUS_SKIPPED,
'error_codes' => ['locked'],
'selection_payload' => $inventorySyncRun->selection_payload ?? [],
'started_at' => now(),
'finished_at' => now(),
])->save();
return $inventorySyncRun;
});
$job = new RunInventorySyncJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
bulkRunId: (int) $bulkRun->getKey(),
inventorySyncRunId: (int) $run->getKey(),
);
$job->handle(app(BulkOperationService::class), $mockSync, app(AuditLogger::class));
$run->refresh();
$bulkRun->refresh();
expect($run->status)->toBe(InventorySyncRun::STATUS_SKIPPED);
expect($bulkRun->status)->toBe('completed')
->and($bulkRun->processed_items)->toBe(count($policyTypes))
->and($bulkRun->skipped)->toBe(count($policyTypes))
->and($bulkRun->succeeded)->toBe(0)
->and($bulkRun->failed)->toBe(0);
expect($bulkRun->failures)->toBeArray();
expect($bulkRun->failures[0]['type'] ?? null)->toBe('skipped');
expect($bulkRun->failures[0]['reason'] ?? null)->toBe('locked');
});