feat/042-inventory-dependencies-graph #50
@ -4,9 +4,22 @@
|
||||
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Jobs\RunInventorySyncJob;
|
||||
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 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\Support\Enums\Size;
|
||||
use UnitEnum;
|
||||
|
||||
class InventoryLanding extends Page
|
||||
@ -19,6 +32,182 @@ class InventoryLanding extends Page
|
||||
|
||||
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
|
||||
{
|
||||
return InventoryItemResource::getUrl('index', tenant: Tenant::current());
|
||||
|
||||
@ -36,6 +36,9 @@ public static function infolist(Schema $schema): Schema
|
||||
->schema([
|
||||
Section::make('Sync Run')
|
||||
->schema([
|
||||
TextEntry::make('user.name')
|
||||
->label('Initiator')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)),
|
||||
@ -82,6 +85,10 @@ public static function table(Table $table): Table
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->label('Initiator')
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)),
|
||||
@ -112,6 +119,7 @@ public static function getEloquentQuery(): Builder
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with('user')
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||
}
|
||||
|
||||
|
||||
221
app/Jobs/RunInventorySyncJob.php
Normal file
221
app/Jobs/RunInventorySyncJob.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -33,11 +33,18 @@ class InventorySyncRun extends Model
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function scopeCompleted(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
|
||||
@ -235,13 +235,9 @@ private function isEndpointSecurityConfigurationPolicy(array $policyData): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($templateReference as $value) {
|
||||
if (is_string($value) && stripos($value, 'endpoint') !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$templateFamily = $templateReference['templateFamily'] ?? null;
|
||||
|
||||
return false;
|
||||
return is_string($templateFamily) && str_starts_with(strtolower(trim($templateFamily)), 'endpointsecurity');
|
||||
}
|
||||
|
||||
private function isSecurityBaselineConfigurationPolicy(array $policyData): bool
|
||||
@ -253,17 +249,8 @@ private function isSecurityBaselineConfigurationPolicy(array $policyData): bool
|
||||
}
|
||||
|
||||
$templateFamily = $templateReference['templateFamily'] ?? null;
|
||||
if (is_string($templateFamily) && stripos($templateFamily, 'baseline') !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($templateReference as $value) {
|
||||
if (is_string($value) && stripos($value, 'baseline') !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return is_string($templateFamily) && strcasecmp(trim($templateFamily), 'securityBaseline') === 0;
|
||||
}
|
||||
|
||||
private function isEnrollmentStatusPageItem(array $policyData): bool
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
@ -30,9 +31,9 @@ public function __construct(
|
||||
*/
|
||||
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);
|
||||
$computed = $this->normalizeAndHashSelection($selectionPayload);
|
||||
$normalizedSelection = $computed['selection'];
|
||||
$selectionHash = $computed['selection_hash'];
|
||||
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
|
||||
@ -76,6 +77,7 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
|
||||
|
||||
$run = InventorySyncRun::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => null,
|
||||
'selection_hash' => $selectionHash,
|
||||
'selection_payload' => $normalizedSelection,
|
||||
'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
|
||||
*/
|
||||
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;
|
||||
$upserted = 0;
|
||||
@ -117,6 +248,11 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
||||
$typeConfig = $typesConfig[$policyType] ?? null;
|
||||
|
||||
if (! is_array($typeConfig)) {
|
||||
$hadErrors = true;
|
||||
$errors++;
|
||||
$errorCodes[] = 'unsupported_type';
|
||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -131,7 +267,9 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
||||
if ($response->failed()) {
|
||||
$hadErrors = true;
|
||||
$errors++;
|
||||
$errorCodes[] = $this->mapGraphFailureToErrorCode($response);
|
||||
$errorCode = $this->mapGraphFailureToErrorCode($response);
|
||||
$errorCodes[] = $errorCode;
|
||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode);
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -141,6 +279,10 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->shouldSkipPolicyForSelectedType($policyType, $policyData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$externalId = $policyData['id'] ?? $policyData['external_id'] ?? null;
|
||||
if (! is_string($externalId) || $externalId === '') {
|
||||
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;
|
||||
@ -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>>
|
||||
*/
|
||||
@ -267,6 +460,7 @@ private function createSkippedRun(
|
||||
): InventorySyncRun {
|
||||
return InventorySyncRun::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => null,
|
||||
'selection_hash' => $selectionHash,
|
||||
'selection_payload' => $selectionPayload,
|
||||
'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
|
||||
{
|
||||
$status = (int) ($response->status ?? 0);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -14,10 +14,13 @@ services:
|
||||
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
||||
environment:
|
||||
WWWUSER: '${WWWUSER:-1000}'
|
||||
WWWGROUP: '${WWWGROUP:-1000}'
|
||||
LARAVEL_SAIL: 1
|
||||
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:
|
||||
- '.:/var/www/html'
|
||||
- '/var/www/html/node_modules'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
@ -36,10 +39,13 @@ services:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
environment:
|
||||
WWWUSER: '${WWWUSER:-1000}'
|
||||
WWWGROUP: '${WWWGROUP:-1000}'
|
||||
LARAVEL_SAIL: 1
|
||||
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:
|
||||
- '.:/var/www/html'
|
||||
- '/var/www/html/node_modules'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
|
||||
15
specs/045-settingscatalog-classification/plan.md
Normal file
15
specs/045-settingscatalog-classification/plan.md
Normal 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.
|
||||
18
specs/045-settingscatalog-classification/spec.md
Normal file
18
specs/045-settingscatalog-classification/spec.md
Normal 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
|
||||
7
specs/045-settingscatalog-classification/tasks.md
Normal file
7
specs/045-settingscatalog-classification/tasks.md
Normal 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)
|
||||
35
specs/046-inventory-sync-button/checklists/requirements.md
Normal file
35
specs/046-inventory-sync-button/checklists/requirements.md
Normal 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`
|
||||
|
||||
@ -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.
|
||||
71
specs/046-inventory-sync-button/data-model.md
Normal file
71
specs/046-inventory-sync-button/data-model.md
Normal 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`
|
||||
|
||||
140
specs/046-inventory-sync-button/plan.md
Normal file
140
specs/046-inventory-sync-button/plan.md
Normal 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.
|
||||
36
specs/046-inventory-sync-button/quickstart.md
Normal file
36
specs/046-inventory-sync-button/quickstart.md
Normal 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.
|
||||
63
specs/046-inventory-sync-button/research.md
Normal file
63
specs/046-inventory-sync-button/research.md
Normal 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.
|
||||
130
specs/046-inventory-sync-button/spec.md
Normal file
130
specs/046-inventory-sync-button/spec.md
Normal 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.
|
||||
171
specs/046-inventory-sync-button/tasks.md
Normal file
171
specs/046-inventory-sync-button/tasks.md
Normal 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 aren’t 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 can’t 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:
|
||||
- T015–T017 (wire dispatch + notifications)
|
||||
- T019–T021 (status mapping + DB notif + audit)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP scope (recommended)
|
||||
|
||||
- Deliver US1 only (Phases 1–3) and validate via quickstart.
|
||||
- Then add US2 and US3 as hardening increments.
|
||||
66
specs/0800-future-features/brainstorming.md
Normal file
66
specs/0800-future-features/brainstorming.md
Normal 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)**
|
||||
167
tests/Feature/Inventory/InventorySyncButtonTest.php
Normal file
167
tests/Feature/Inventory/InventorySyncButtonTest.php
Normal 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();
|
||||
});
|
||||
@ -101,6 +101,61 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
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 () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
|
||||
117
tests/Feature/Inventory/RunInventorySyncJobTest.php
Normal file
117
tests/Feature/Inventory/RunInventorySyncJobTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user