diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 546f125..26df7a3 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -27,6 +27,7 @@ ## Active Technologies - PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub) - PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub) - PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub) +- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal) - PHP 8.4.15 (feat/005-bulk-operations) @@ -46,8 +47,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 087-legacy-runs-removal: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4 - 088-remove-tenant-graphoptions-legacy: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4 - 086-retire-legacy-runs-into-operation-runs: Spec docs updated (PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4) -- 085-tenant-operate-hub: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 diff --git a/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php b/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php index 54f88ce..8c0a69b 100644 --- a/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php +++ b/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; -use App\Services\OperationRunService; use App\Models\Tenant; +use App\Services\OperationRunService; use Carbon\CarbonImmutable; use Illuminate\Console\Command; @@ -50,7 +50,7 @@ public function handle(): int $opService = app(OperationRunService::class); $opRun = $opService->ensureRunWithIdentityStrict( tenant: $tenant, - type: 'directory_groups.sync', + type: 'entra_group_sync', identityInputs: [ 'selection_key' => $selectionKey, 'slot_key' => $slotKey, @@ -65,6 +65,7 @@ public function handle(): int if (! $opRun->wasRecentlyCreated) { $skipped++; + continue; } diff --git a/app/Console/Commands/TenantpilotPurgeNonPersistentData.php b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php index 21c9ff2..ce7977f 100644 --- a/app/Console/Commands/TenantpilotPurgeNonPersistentData.php +++ b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php @@ -5,7 +5,6 @@ use App\Models\AuditLog; use App\Models\BackupItem; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; use App\Models\BackupSet; use App\Models\OperationRun; use App\Models\Policy; @@ -14,6 +13,7 @@ use App\Models\Tenant; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use RuntimeException; class TenantpilotPurgeNonPersistentData extends Command @@ -80,10 +80,6 @@ public function handle(): int } DB::transaction(function () use ($tenant): void { - BackupScheduleRun::query() - ->where('tenant_id', $tenant->id) - ->delete(); - BackupSchedule::query() ->where('tenant_id', $tenant->id) ->delete(); @@ -117,6 +113,8 @@ public function handle(): int ->delete(); }); + $this->recordPurgeOperationRun($tenant, $counts); + $this->info('Purged.'); } @@ -150,7 +148,6 @@ private function resolveTenants() private function countsForTenant(Tenant $tenant): array { return [ - 'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(), 'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(), 'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(), 'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(), @@ -161,4 +158,39 @@ private function countsForTenant(Tenant $tenant): array 'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(), ]; } + + /** + * @param array $counts + */ + private function recordPurgeOperationRun(Tenant $tenant, array $counts): void + { + OperationRun::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->id, + 'user_id' => null, + 'initiator_name' => 'System', + 'type' => 'backup_schedule_purge', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => hash('sha256', implode(':', [ + (string) $tenant->id, + 'backup_schedule_purge', + now()->toISOString(), + Str::uuid()->toString(), + ])), + 'summary_counts' => [ + 'total' => array_sum($counts), + 'processed' => array_sum($counts), + 'succeeded' => array_sum($counts), + 'failed' => 0, + ], + 'failure_summary' => [], + 'context' => [ + 'source' => 'tenantpilot:purge-nonpersistent', + 'deleted_rows' => $counts, + ], + 'started_at' => now(), + 'completed_at' => now(), + ]); + } } diff --git a/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php b/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php index d9c1860..a1f9bbe 100644 --- a/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php +++ b/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php @@ -2,11 +2,11 @@ namespace App\Console\Commands; -use App\Models\BackupScheduleRun; +use App\Models\BackupSchedule; use App\Models\OperationRun; use App\Models\Tenant; use App\Services\OperationRunService; -use App\Support\OpsUx\RunFailureSanitizer; +use App\Support\OperationRunOutcome; use Illuminate\Console\Command; class TenantpilotReconcileBackupScheduleOperationRuns extends Command @@ -16,7 +16,7 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command {--older-than=5 : Only reconcile runs older than N minutes} {--dry-run : Do not write changes}'; - protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.'; + protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.'; public function handle(OperationRunService $operationRunService): int { @@ -25,7 +25,7 @@ public function handle(OperationRunService $operationRunService): int $dryRun = (bool) $this->option('dry-run'); $query = OperationRun::query() - ->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry']) + ->where('type', 'backup_schedule_run') ->whereIn('status', ['queued', 'running']); if ($olderThanMinutes > 0) { @@ -49,29 +49,18 @@ public function handle(OperationRunService $operationRunService): int $failed = 0; foreach ($query->cursor() as $operationRun) { - $backupScheduleRunId = data_get($operationRun->context, 'backup_schedule_run_id'); + $backupScheduleId = data_get($operationRun->context, 'backup_schedule_id'); - if (! is_numeric($backupScheduleRunId)) { - $skipped++; - - continue; - } - - $scheduleRun = BackupScheduleRun::query() - ->whereKey((int) $backupScheduleRunId) - ->where('tenant_id', $operationRun->tenant_id) - ->first(); - - if (! $scheduleRun) { + if (! is_numeric($backupScheduleId)) { if (! $dryRun) { $operationRunService->updateRun( $operationRun, status: 'completed', - outcome: 'failed', + outcome: OperationRunOutcome::Failed->value, failures: [ [ - 'code' => 'backup_schedule_run.not_found', - 'message' => RunFailureSanitizer::sanitizeMessage('Backup schedule run not found.'), + 'code' => 'backup_schedule.missing_context', + 'message' => 'Backup schedule context is missing from this operation run.', ], ], ); @@ -82,13 +71,34 @@ public function handle(OperationRunService $operationRunService): int continue; } - if ($scheduleRun->status === BackupScheduleRun::STATUS_RUNNING) { - if (! $dryRun) { - $operationRunService->updateRun($operationRun, 'running', 'pending'); + $schedule = BackupSchedule::query() + ->whereKey((int) $backupScheduleId) + ->where('tenant_id', (int) $operationRun->tenant_id) + ->first(); - if ($scheduleRun->started_at) { - $operationRun->forceFill(['started_at' => $scheduleRun->started_at])->save(); - } + if (! $schedule instanceof BackupSchedule) { + if (! $dryRun) { + $operationRunService->updateRun( + $operationRun, + status: 'completed', + outcome: OperationRunOutcome::Failed->value, + failures: [ + [ + 'code' => 'backup_schedule.not_found', + 'message' => 'Backup schedule not found for this operation run.', + ], + ], + ); + } + + $failed++; + + continue; + } + + if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) { + if (! $dryRun) { + $operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.'); } $reconciled++; @@ -96,104 +106,27 @@ public function handle(OperationRunService $operationRunService): int continue; } - $outcome = match ($scheduleRun->status) { - BackupScheduleRun::STATUS_SUCCESS => 'succeeded', - BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded', - BackupScheduleRun::STATUS_SKIPPED => 'succeeded', - BackupScheduleRun::STATUS_CANCELED => 'failed', - default => 'failed', - }; - - $summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : []; - $syncFailures = $summary['sync_failures'] ?? []; - - $policiesTotal = (int) ($summary['policies_total'] ?? 0); - $policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0); - $syncFailuresCount = is_array($syncFailures) ? count($syncFailures) : 0; - - $processed = $policiesBackedUp + $syncFailuresCount; - if ($policiesTotal > 0) { - $processed = min($policiesTotal, $processed); - } - - $summaryCounts = array_filter([ - 'total' => $policiesTotal, - 'processed' => $processed, - 'succeeded' => $policiesBackedUp, - 'failed' => $syncFailuresCount, - 'skipped' => $scheduleRun->status === BackupScheduleRun::STATUS_SKIPPED ? 1 : 0, - 'items' => $policiesTotal, - ], fn (mixed $value): bool => is_int($value) && $value !== 0); - - $failures = []; - - if ($scheduleRun->status === BackupScheduleRun::STATUS_CANCELED) { - $failures[] = [ - 'code' => 'backup_schedule_run.cancelled', - 'message' => 'Backup schedule run was cancelled.', - ]; - } - - if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) { - $failures[] = [ - 'code' => (string) ($scheduleRun->error_code ?: 'backup_schedule_run.error'), - 'message' => RunFailureSanitizer::sanitizeMessage((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')), - ]; - } - - if (is_array($syncFailures)) { - foreach ($syncFailures as $failure) { - if (! is_array($failure)) { - continue; - } - - $policyType = (string) ($failure['policy_type'] ?? 'unknown'); - $status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null; - $errors = $failure['errors'] ?? null; - - $firstErrorMessage = null; - if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) { - $firstErrorMessage = $errors[0]['message'] ?? null; - } - - $message = $status !== null - ? "{$policyType}: Graph returned {$status}" - : "{$policyType}: Graph request failed"; - - if (is_string($firstErrorMessage) && $firstErrorMessage !== '') { - $message .= ' - '.trim($firstErrorMessage); - } - - $failures[] = [ - 'code' => $status !== null ? "graph.http_{$status}" : 'graph.error', - 'message' => RunFailureSanitizer::sanitizeMessage($message), - ]; + if ($operationRun->status === 'running') { + if (! $dryRun) { + $operationRunService->updateRun( + $operationRun, + status: 'completed', + outcome: OperationRunOutcome::Failed->value, + failures: [ + [ + 'code' => 'backup_schedule.stalled', + 'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.', + ], + ], + ); } + + $reconciled++; + + continue; } - if (! $dryRun) { - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ - 'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id, - 'backup_schedule_run_id' => (int) $scheduleRun->getKey(), - ]), - ]); - - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: $outcome, - summaryCounts: $summaryCounts, - failures: $failures, - ); - - $operationRun->forceFill([ - 'started_at' => $scheduleRun->started_at ?? $operationRun->started_at, - 'completed_at' => $scheduleRun->finished_at ?? $operationRun->completed_at, - ])->save(); - } - - $reconciled++; + $skipped++; } $this->info(sprintf( diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php index c225bb5..2d6ecfe 100644 --- a/app/Filament/Pages/DriftLanding.php +++ b/app/Filament/Pages/DriftLanding.php @@ -3,10 +3,8 @@ namespace App\Filament\Pages; use App\Filament\Resources\FindingResource; -use App\Filament\Resources\InventorySyncRunResource; use App\Jobs\GenerateDriftFindingsJob; use App\Models\Finding; -use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; @@ -16,6 +14,8 @@ use App\Services\Operations\BulkSelectionIdentity; use App\Support\Auth\Capabilities; use App\Support\OperationRunLinks; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use BackedEnum; @@ -67,21 +67,35 @@ public function mount(): void abort(403, 'Not allowed'); } - $latestSuccessful = InventorySyncRun::query() + $latestSuccessful = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('status', InventorySyncRun::STATUS_SUCCESS) - ->whereNotNull('finished_at') - ->orderByDesc('finished_at') + ->where('type', 'inventory_sync') + ->where('status', OperationRunStatus::Completed->value) + ->whereIn('outcome', [ + OperationRunOutcome::Succeeded->value, + OperationRunOutcome::PartiallySucceeded->value, + ]) + ->whereNotNull('completed_at') + ->orderByDesc('completed_at') ->first(); - if (! $latestSuccessful instanceof InventorySyncRun) { + if (! $latestSuccessful instanceof OperationRun) { $this->state = 'blocked'; $this->message = 'No successful inventory runs found yet.'; return; } - $scopeKey = (string) $latestSuccessful->selection_hash; + $latestContext = is_array($latestSuccessful->context) ? $latestSuccessful->context : []; + $scopeKey = (string) ($latestContext['selection_hash'] ?? ''); + + if ($scopeKey === '') { + $this->state = 'blocked'; + $this->message = 'No inventory scope key was found on the latest successful inventory run.'; + + return; + } + $this->scopeKey = $scopeKey; $selector = app(DriftRunSelector::class); @@ -100,15 +114,15 @@ public function mount(): void $this->baselineRunId = (int) $baseline->getKey(); $this->currentRunId = (int) $current->getKey(); - $this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString(); - $this->currentFinishedAt = $current->finished_at?->toDateTimeString(); + $this->baselineFinishedAt = $baseline->completed_at?->toDateTimeString(); + $this->currentFinishedAt = $current->completed_at?->toDateTimeString(); $existingOperationRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'drift.generate') + ->where('type', 'drift_generate_findings') ->where('context->scope_key', $scopeKey) - ->where('context->baseline_run_id', (int) $baseline->getKey()) - ->where('context->current_run_id', (int) $current->getKey()) + ->where('context->baseline_operation_run_id', (int) $baseline->getKey()) + ->where('context->current_operation_run_id', (int) $current->getKey()) ->latest('id') ->first(); @@ -120,8 +134,8 @@ public function mount(): void ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('scope_key', $scopeKey) - ->where('baseline_run_id', $baseline->getKey()) - ->where('current_run_id', $current->getKey()) + ->where('baseline_operation_run_id', $baseline->getKey()) + ->where('current_operation_run_id', $current->getKey()) ->exists(); if ($exists) { @@ -130,8 +144,8 @@ public function mount(): void ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('scope_key', $scopeKey) - ->where('baseline_run_id', $baseline->getKey()) - ->where('current_run_id', $current->getKey()) + ->where('baseline_operation_run_id', $baseline->getKey()) + ->where('current_operation_run_id', $current->getKey()) ->where('status', Finding::STATUS_NEW) ->count(); @@ -189,8 +203,8 @@ public function mount(): void $selection = app(BulkSelectionIdentity::class); $selectionIdentity = $selection->fromQuery([ 'scope_key' => $scopeKey, - 'baseline_run_id' => (int) $baseline->getKey(), - 'current_run_id' => (int) $current->getKey(), + 'baseline_operation_run_id' => (int) $baseline->getKey(), + 'current_operation_run_id' => (int) $current->getKey(), ]); /** @var OperationRunService $opService */ @@ -198,7 +212,7 @@ public function mount(): void $opRun = $opService->enqueueBulkOperation( tenant: $tenant, - type: 'drift.generate', + type: 'drift_generate_findings', targetScope: [ 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), ], @@ -216,8 +230,8 @@ public function mount(): void initiator: $user, extraContext: [ 'scope_key' => $scopeKey, - 'baseline_run_id' => (int) $baseline->getKey(), - 'current_run_id' => (int) $current->getKey(), + 'baseline_operation_run_id' => (int) $baseline->getKey(), + 'current_operation_run_id' => (int) $current->getKey(), ], emitQueuedNotification: false, ); @@ -261,7 +275,7 @@ public function getBaselineRunUrl(): ?string return null; } - return InventorySyncRunResource::getUrl('view', ['record' => $this->baselineRunId], tenant: Tenant::current()); + return route('admin.operations.view', ['run' => $this->baselineRunId]); } public function getCurrentRunUrl(): ?string @@ -270,7 +284,7 @@ public function getCurrentRunUrl(): ?string return null; } - return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current()); + return route('admin.operations.view', ['run' => $this->currentRunId]); } public function getOperationRunUrl(): ?string diff --git a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index cfac15a..6b50fc6 100644 --- a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -1707,7 +1707,7 @@ private function dispatchBootstrapJob( OperationRun $run, ): void { match ($operationType) { - 'inventory.sync' => ProviderInventorySyncJob::dispatch( + 'inventory_sync' => ProviderInventorySyncJob::dispatch( tenantId: $tenantId, userId: $userId, providerConnectionId: $providerConnectionId, @@ -1726,7 +1726,7 @@ private function dispatchBootstrapJob( private function resolveBootstrapCapability(string $operationType): ?string { return match ($operationType) { - 'inventory.sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC, + 'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC, 'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC, default => null, }; diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index 2808630..3974097 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -5,7 +5,6 @@ use App\Exceptions\InvalidPolicyTypeException; use App\Filament\Resources\BackupScheduleResource\Pages; use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager; -use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager; use App\Jobs\RunBackupScheduleJob; use App\Models\BackupSchedule; use App\Models\Tenant; @@ -22,6 +21,7 @@ use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeRenderer; use App\Support\OperationRunLinks; +use App\Support\OperationRunOutcome; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; @@ -282,32 +282,40 @@ public static function table(Table $table): Table ->label('Last run status') ->badge() ->formatStateUsing(function (?string $state): string { - if (! filled($state)) { + $outcome = static::scheduleStatusToOutcome($state); + + if (! filled($outcome)) { return '—'; } - return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->label; + return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->label; }) ->color(function (?string $state): string { - if (! filled($state)) { + $outcome = static::scheduleStatusToOutcome($state); + + if (! filled($outcome)) { return 'gray'; } - return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->color; + return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->color; }) ->icon(function (?string $state): ?string { - if (! filled($state)) { + $outcome = static::scheduleStatusToOutcome($state); + + if (! filled($outcome)) { return null; } - return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->icon; + return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->icon; }) ->iconColor(function (?string $state): string { - if (! filled($state)) { + $outcome = static::scheduleStatusToOutcome($state); + + if (! filled($outcome)) { return 'gray'; } - $spec = BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state); + $spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome); return $spec->iconColor ?? $spec->color; }), @@ -389,7 +397,7 @@ public static function table(Table $table): Table $nonce = (string) Str::uuid(); $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'backup_schedule.run_now', + type: 'backup_schedule_run', identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), 'nonce' => $nonce, @@ -458,7 +466,7 @@ public static function table(Table $table): Table $nonce = (string) Str::uuid(); $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'backup_schedule.retry', + type: 'backup_schedule_run', identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), 'nonce' => $nonce, @@ -552,7 +560,7 @@ public static function table(Table $table): Table $nonce = (string) Str::uuid(); $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'backup_schedule.run_now', + type: 'backup_schedule_run', identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), 'nonce' => $nonce, @@ -649,7 +657,7 @@ public static function table(Table $table): Table $nonce = (string) Str::uuid(); $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'backup_schedule.retry', + type: 'backup_schedule_run', identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), 'nonce' => $nonce, @@ -734,7 +742,6 @@ public static function getRelations(): array { return [ BackupScheduleOperationRunsRelationManager::class, - BackupScheduleRunsRelationManager::class, ]; } @@ -904,6 +911,18 @@ protected static function policyTypeLabelMap(): array ->all(); } + protected static function scheduleStatusToOutcome(?string $status): ?string + { + return match (strtolower(trim((string) $status))) { + 'running' => OperationRunOutcome::Pending->value, + 'success' => OperationRunOutcome::Succeeded->value, + 'partial' => OperationRunOutcome::PartiallySucceeded->value, + 'skipped' => OperationRunOutcome::Blocked->value, + 'failed', 'canceled' => OperationRunOutcome::Failed->value, + default => null, + }; + } + protected static function dayOfWeekOptions(): array { return [ diff --git a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php deleted file mode 100644 index c615989..0000000 --- a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php +++ /dev/null @@ -1,107 +0,0 @@ -modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet')) - ->defaultSort('scheduled_for', 'desc') - ->columns([ - Tables\Columns\TextColumn::make('scheduled_for') - ->label('Scheduled for') - ->dateTime(), - Tables\Columns\TextColumn::make('status') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupScheduleRunStatus)) - ->color(BadgeRenderer::color(BadgeDomain::BackupScheduleRunStatus)) - ->icon(BadgeRenderer::icon(BadgeDomain::BackupScheduleRunStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupScheduleRunStatus)), - Tables\Columns\TextColumn::make('duration') - ->label('Duration') - ->getStateUsing(function (BackupScheduleRun $record): string { - if (! $record->started_at || ! $record->finished_at) { - return '—'; - } - - $seconds = max(0, $record->started_at->diffInSeconds($record->finished_at)); - - if ($seconds < 60) { - return $seconds.'s'; - } - - $minutes = intdiv($seconds, 60); - $rem = $seconds % 60; - - return sprintf('%dm %ds', $minutes, $rem); - }), - Tables\Columns\TextColumn::make('counts') - ->label('Counts') - ->getStateUsing(function (BackupScheduleRun $record): string { - $summary = is_array($record->summary) ? $record->summary : []; - - $total = (int) ($summary['policies_total'] ?? 0); - $backedUp = (int) ($summary['policies_backed_up'] ?? 0); - $errors = (int) ($summary['errors_count'] ?? 0); - - if ($total === 0 && $backedUp === 0 && $errors === 0) { - return '—'; - } - - return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors); - }), - Tables\Columns\TextColumn::make('error_code') - ->label('Error') - ->badge() - ->default('—'), - Tables\Columns\TextColumn::make('error_message') - ->label('Message') - ->default('—') - ->limit(80) - ->wrap(), - Tables\Columns\TextColumn::make('backup_set_id') - ->label('Backup set') - ->default('—') - ->url(function (BackupScheduleRun $record): ?string { - if (! $record->backup_set_id) { - return null; - } - - return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current()); - }) - ->openUrlInNewTab(true), - ]) - ->filters([]) - ->headerActions([]) - ->actions([ - Actions\Action::make('view') - ->label('View') - ->icon('heroicon-o-eye') - ->modalHeading('View backup schedule run') - ->modalSubmitAction(false) - ->modalCancelActionLabel('Close') - ->modalContent(function (BackupScheduleRun $record): View { - return view('filament.modals.backup-schedule-run-view', [ - 'run' => $record, - ]); - }), - ]) - ->bulkActions([]); - } -} diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php index f1c68ee..20f1a09 100644 --- a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php +++ b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php @@ -3,7 +3,6 @@ namespace App\Filament\Resources\EntraGroupResource\Pages; use App\Filament\Resources\EntraGroupResource; -use App\Filament\Resources\EntraGroupSyncRunResource; use App\Jobs\EntraGroupSyncJob; use App\Models\Tenant; use App\Models\User; @@ -23,10 +22,10 @@ class ListEntraGroups extends ListRecords protected function getHeaderActions(): array { return [ - Action::make('view_group_sync_runs') - ->label('Group Sync Runs') + Action::make('view_operations') + ->label('Operations') ->icon('heroicon-o-clock') - ->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())) + ->url(fn (): string => OperationRunLinks::index(Tenant::current())) ->visible(fn (): bool => (bool) Tenant::current()), UiEnforcement::forAction( Action::make('sync_groups') @@ -48,7 +47,7 @@ protected function getHeaderActions(): array $opService = app(OperationRunService::class); $opRun = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'directory_groups.sync', + type: 'entra_group_sync', identityInputs: ['selection_key' => $selectionKey], context: [ 'selection_key' => $selectionKey, diff --git a/app/Filament/Resources/EntraGroupSyncRunResource.php b/app/Filament/Resources/EntraGroupSyncRunResource.php deleted file mode 100644 index feb1f81..0000000 --- a/app/Filament/Resources/EntraGroupSyncRunResource.php +++ /dev/null @@ -1,168 +0,0 @@ -exempt(ActionSurfaceSlot::ListHeader, 'Group sync runs list intentionally has no header actions; group sync is started from Directory group sync surfaces.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) - ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.') - ->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating group sync.') - ->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.'); - } - - public static function form(Schema $schema): Schema - { - return $schema; - } - - public static function infolist(Schema $schema): Schema - { - return $schema - ->schema([ - Section::make('Legacy run view') - ->description('Canonical monitoring is now available in Monitoring → Operations.') - ->schema([ - TextEntry::make('canonical_view') - ->label('Canonical view') - ->state('View in Operations') - ->url(fn (EntraGroupSyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant)) - ->badge() - ->color('primary'), - ]) - ->columnSpanFull(), - - Section::make('Sync Run') - ->schema([ - TextEntry::make('initiator.name') - ->label('Initiator') - ->placeholder('—'), - TextEntry::make('status') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus)) - ->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus)) - ->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)), - TextEntry::make('selection_key')->label('Selection'), - TextEntry::make('slot_key')->label('Slot')->placeholder('—')->copyable(), - TextEntry::make('started_at')->dateTime(), - TextEntry::make('finished_at')->dateTime(), - TextEntry::make('pages_fetched')->label('Pages')->numeric(), - TextEntry::make('items_observed_count')->label('Observed')->numeric(), - TextEntry::make('items_upserted_count')->label('Upserted')->numeric(), - TextEntry::make('error_count')->label('Errors')->numeric(), - TextEntry::make('safety_stop_triggered')->label('Safety stop')->badge(), - TextEntry::make('safety_stop_reason')->label('Stop reason')->placeholder('—'), - ]) - ->columns(2) - ->columnSpanFull(), - - Section::make('Error Summary') - ->schema([ - TextEntry::make('error_code')->placeholder('—'), - TextEntry::make('error_category')->placeholder('—'), - ViewEntry::make('error_summary') - ->label('Safe error summary') - ->view('filament.infolists.entries.snapshot-json') - ->state(fn (EntraGroupSyncRun $record) => $record->error_summary ? ['summary' => $record->error_summary] : []) - ->columnSpanFull(), - ]) - ->columns(2) - ->columnSpanFull(), - ]); - } - - public static function table(Table $table): Table - { - return $table - ->defaultSort('id', 'desc') - ->modifyQueryUsing(function (Builder $query): Builder { - $tenantId = Tenant::current()?->getKey(); - - return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); - }) - ->columns([ - Tables\Columns\TextColumn::make('initiator.name') - ->label('Initiator') - ->placeholder('—') - ->toggleable(), - Tables\Columns\TextColumn::make('status') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus)) - ->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus)) - ->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)), - Tables\Columns\TextColumn::make('selection_key') - ->label('Selection') - ->limit(24) - ->copyable(), - Tables\Columns\TextColumn::make('slot_key') - ->label('Slot') - ->placeholder('—') - ->limit(16) - ->copyable(), - Tables\Columns\TextColumn::make('started_at')->since(), - Tables\Columns\TextColumn::make('finished_at')->since(), - Tables\Columns\TextColumn::make('pages_fetched')->label('Pages')->numeric(), - Tables\Columns\TextColumn::make('items_observed_count')->label('Observed')->numeric(), - Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(), - Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(), - ]) - ->recordUrl(static fn (EntraGroupSyncRun $record): ?string => static::canView($record) - ? static::getUrl('view', ['record' => $record]) - : null) - ->actions([]) - ->bulkActions([]); - } - - public static function getEloquentQuery(): Builder - { - return parent::getEloquentQuery() - ->with('initiator') - ->latest('id'); - } - - public static function getPages(): array - { - return [ - 'index' => Pages\ListEntraGroupSyncRuns::route('/'), - 'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'), - ]; - } -} diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php deleted file mode 100644 index 563d411..0000000 --- a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php +++ /dev/null @@ -1,11 +0,0 @@ -getRecord(); - - if ($legacyRun instanceof EntraGroupSyncRun && is_numeric($legacyRun->operation_run_id)) { - $this->redirect(OperationRunLinks::tenantlessView((int) $legacyRun->operation_run_id)); - } - } -} diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index b612330..e2e075a 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -117,16 +117,16 @@ public static function infolist(Schema $schema): Schema TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'), TextEntry::make('subject_type')->label('Subject type'), TextEntry::make('subject_external_id')->label('External ID')->copyable(), - TextEntry::make('baseline_run_id') + TextEntry::make('baseline_operation_run_id') ->label('Baseline run') - ->url(fn (Finding $record): ?string => $record->baseline_run_id - ? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current()) + ->url(fn (Finding $record): ?string => $record->baseline_operation_run_id + ? route('admin.operations.view', ['run' => (int) $record->baseline_operation_run_id]) : null) ->openUrlInNewTab(), - TextEntry::make('current_run_id') + TextEntry::make('current_operation_run_id') ->label('Current run') - ->url(fn (Finding $record): ?string => $record->current_run_id - ? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current()) + ->url(fn (Finding $record): ?string => $record->current_operation_run_id + ? route('admin.operations.view', ['run' => (int) $record->current_operation_run_id]) : null) ->openUrlInNewTab(), TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'), @@ -297,22 +297,22 @@ public static function table(Table $table): Table Tables\Filters\Filter::make('run_ids') ->label('Run IDs') ->form([ - TextInput::make('baseline_run_id') + TextInput::make('baseline_operation_run_id') ->label('Baseline run id') ->numeric(), - TextInput::make('current_run_id') + TextInput::make('current_operation_run_id') ->label('Current run id') ->numeric(), ]) ->query(function (Builder $query, array $data): Builder { - $baselineRunId = $data['baseline_run_id'] ?? null; + $baselineRunId = $data['baseline_operation_run_id'] ?? null; if (is_numeric($baselineRunId)) { - $query->where('baseline_run_id', (int) $baselineRunId); + $query->where('baseline_operation_run_id', (int) $baselineRunId); } - $currentRunId = $data['current_run_id'] ?? null; + $currentRunId = $data['current_operation_run_id'] ?? null; if (is_numeric($currentRunId)) { - $query->where('current_run_id', (int) $currentRunId); + $query->where('current_operation_run_id', (int) $currentRunId); } return $query; diff --git a/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/app/Filament/Resources/FindingResource/Pages/ListFindings.php index 612baa1..2b175a2 100644 --- a/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -113,14 +113,14 @@ protected function buildAllMatchingQuery(): Builder } $runIdsState = $this->getTableFilterState('run_ids') ?? []; - $baselineRunId = Arr::get($runIdsState, 'baseline_run_id'); + $baselineRunId = Arr::get($runIdsState, 'baseline_operation_run_id'); if (is_numeric($baselineRunId)) { - $query->where('baseline_run_id', (int) $baselineRunId); + $query->where('baseline_operation_run_id', (int) $baselineRunId); } - $currentRunId = Arr::get($runIdsState, 'current_run_id'); + $currentRunId = Arr::get($runIdsState, 'current_operation_run_id'); if (is_numeric($currentRunId)) { - $query->where('current_run_id', (int) $currentRunId); + $query->where('current_operation_run_id', (int) $currentRunId); } return $query; diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 408ecbf..96cc8a2 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -138,17 +138,6 @@ public static function infolist(Schema $schema): Schema return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]); }) ->openUrlInNewTab(), - TextEntry::make('last_seen_run_id') - ->label('Last inventory sync (legacy)') - ->visible(fn (InventoryItem $record): bool => blank($record->last_seen_operation_run_id) && filled($record->last_seen_run_id)) - ->url(function (InventoryItem $record): ?string { - if (! $record->last_seen_run_id) { - return null; - } - - return InventorySyncRunResource::getUrl('view', ['record' => $record->last_seen_run_id], tenant: Tenant::current()); - }) - ->openUrlInNewTab(), TextEntry::make('support_restore') ->label('Restore') ->badge() @@ -247,7 +236,7 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('last_seen_at') ->label('Last seen') ->since(), - Tables\Columns\TextColumn::make('lastSeenRun.status') + Tables\Columns\TextColumn::make('lastSeenRun.outcome') ->label('Run') ->badge() ->formatStateUsing(function (?string $state): string { @@ -255,28 +244,28 @@ public static function table(Table $table): Table return '—'; } - return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->label; + return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->label; }) ->color(function (?string $state): string { if (! filled($state)) { return 'gray'; } - return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->color; + return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->color; }) ->icon(function (?string $state): ?string { if (! filled($state)) { return null; } - return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->icon; + return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->icon; }) ->iconColor(function (?string $state): ?string { if (! filled($state)) { return 'gray'; } - $spec = BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state); + $spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state); return $spec->iconColor ?? $spec->color; }), diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index 536b706..2816305 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -153,12 +153,15 @@ protected function getHeaderActions(): array $opService = app(OperationRunService::class); $opRun = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'inventory.sync', + type: 'inventory_sync', identityInputs: [ 'selection_hash' => $computed['selection_hash'], ], context: array_merge($computed['selection'], [ 'selection_hash' => $computed['selection_hash'], + 'target_scope' => [ + 'entra_tenant_id' => $tenant->graphTenantId(), + ], ]), initiator: $user, ); diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php deleted file mode 100644 index 84f7e54..0000000 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ /dev/null @@ -1,230 +0,0 @@ -exempt(ActionSurfaceSlot::ListHeader, 'Inventory sync runs list intentionally has no header actions; sync is started from Inventory surfaces.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) - ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.') - ->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating inventory sync.') - ->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.'); - } - - public static function canViewAny(): bool - { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return false; - } - - /** @var CapabilityResolver $resolver */ - $resolver = app(CapabilityResolver::class); - - return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); - } - - public static function canView(Model $record): bool - { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return false; - } - - /** @var CapabilityResolver $resolver */ - $resolver = app(CapabilityResolver::class); - - if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) { - return false; - } - - if ($record instanceof InventorySyncRun) { - return (int) $record->tenant_id === (int) $tenant->getKey(); - } - - return true; - } - - public static function getNavigationLabel(): string - { - return 'Sync History'; - } - - public static function form(Schema $schema): Schema - { - return $schema; - } - - public static function infolist(Schema $schema): Schema - { - return $schema - ->schema([ - Section::make('Legacy run view') - ->description('Canonical monitoring is now available in Monitoring → Operations.') - ->schema([ - TextEntry::make('canonical_view') - ->label('Canonical view') - ->state('View in Operations') - ->url(fn (InventorySyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant)) - ->badge() - ->color('primary'), - ]) - ->columnSpanFull(), - - Section::make('Sync Run') - ->schema([ - TextEntry::make('user.name') - ->label('Initiator') - ->placeholder('—'), - TextEntry::make('status') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus)) - ->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus)) - ->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)), - TextEntry::make('selection_hash')->label('Selection hash')->copyable(), - TextEntry::make('started_at')->dateTime(), - TextEntry::make('finished_at')->dateTime(), - TextEntry::make('items_observed_count')->label('Observed')->numeric(), - TextEntry::make('items_upserted_count')->label('Upserted')->numeric(), - TextEntry::make('errors_count')->label('Errors')->numeric(), - TextEntry::make('had_errors') - ->label('Had errors') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanHasErrors)) - ->color(BadgeRenderer::color(BadgeDomain::BooleanHasErrors)) - ->icon(BadgeRenderer::icon(BadgeDomain::BooleanHasErrors)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanHasErrors)), - ]) - ->columns(2) - ->columnSpanFull(), - - Section::make('Selection Payload') - ->schema([ - ViewEntry::make('selection_payload') - ->label('') - ->view('filament.infolists.entries.snapshot-json') - ->state(fn (InventorySyncRun $record) => $record->selection_payload ?? []) - ->columnSpanFull(), - ]) - ->columnSpanFull(), - - Section::make('Error Summary') - ->schema([ - ViewEntry::make('error_codes') - ->label('Error codes') - ->view('filament.infolists.entries.snapshot-json') - ->state(fn (InventorySyncRun $record) => $record->error_codes ?? []) - ->columnSpanFull(), - ViewEntry::make('error_context') - ->label('Safe error context') - ->view('filament.infolists.entries.snapshot-json') - ->state(fn (InventorySyncRun $record) => $record->error_context ?? []) - ->columnSpanFull(), - ]) - ->columnSpanFull(), - ]); - } - - 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() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus)) - ->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus)) - ->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)), - Tables\Columns\TextColumn::make('selection_hash') - ->label('Selection') - ->copyable() - ->limit(12), - Tables\Columns\TextColumn::make('started_at')->since(), - Tables\Columns\TextColumn::make('finished_at')->since(), - Tables\Columns\TextColumn::make('items_observed_count') - ->label('Observed') - ->numeric(), - Tables\Columns\TextColumn::make('items_upserted_count') - ->label('Upserted') - ->numeric(), - Tables\Columns\TextColumn::make('errors_count') - ->label('Errors') - ->numeric(), - ]) - ->recordUrl(static fn (Model $record): ?string => static::canView($record) - ? static::getUrl('view', ['record' => $record]) - : null) - ->actions([]) - ->bulkActions([]); - } - - public static function getEloquentQuery(): Builder - { - $tenantId = Tenant::current()?->getKey(); - - return parent::getEloquentQuery() - ->with('user') - ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)); - } - - public static function getPages(): array - { - return [ - 'index' => Pages\ListInventorySyncRuns::route('/'), - 'view' => Pages\ViewInventorySyncRun::route('/{record}'), - ]; - } -} diff --git a/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php b/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php deleted file mode 100644 index 61536de..0000000 --- a/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php +++ /dev/null @@ -1,19 +0,0 @@ -getRecord(); - - if ($legacyRun instanceof InventorySyncRun && is_numeric($legacyRun->operation_run_id)) { - $this->redirect(OperationRunLinks::tenantlessView((int) $legacyRun->operation_run_id)); - } - } -} diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index 7abd062..abcca39 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -370,7 +370,7 @@ public static function table(Table $table): Table $result = $gate->start( tenant: $tenant, connection: $record, - operationType: 'inventory.sync', + operationType: 'inventory_sync', dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { ProviderInventorySyncJob::dispatch( tenantId: (int) $tenant->getKey(), diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index 5662e27..bcf7850 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -423,7 +423,7 @@ protected function getHeaderActions(): array $result = $gate->start( tenant: $tenant, connection: $record, - operationType: 'inventory.sync', + operationType: 'inventory_sync', dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { ProviderInventorySyncJob::dispatch( tenantId: (int) $tenant->getKey(), diff --git a/app/Filament/Widgets/Dashboard/DashboardKpis.php b/app/Filament/Widgets/Dashboard/DashboardKpis.php index b6522dc..5f177e4 100644 --- a/app/Filament/Widgets/Dashboard/DashboardKpis.php +++ b/app/Filament/Widgets/Dashboard/DashboardKpis.php @@ -68,7 +68,7 @@ protected function getStats(): array $inventoryActiveRuns = (int) OperationRun::query() ->where('tenant_id', $tenantId) - ->where('type', 'inventory.sync') + ->where('type', 'inventory_sync') ->active() ->count(); diff --git a/app/Filament/Widgets/Dashboard/NeedsAttention.php b/app/Filament/Widgets/Dashboard/NeedsAttention.php index 6539c1d..8f8d2a0 100644 --- a/app/Filament/Widgets/Dashboard/NeedsAttention.php +++ b/app/Filament/Widgets/Dashboard/NeedsAttention.php @@ -58,7 +58,7 @@ protected function getViewData(): array $latestDriftSuccess = OperationRun::query() ->where('tenant_id', $tenantId) - ->where('type', 'drift.generate') + ->where('type', 'drift_generate_findings') ->where('status', 'completed') ->where('outcome', 'succeeded') ->whereNotNull('completed_at') @@ -89,7 +89,7 @@ protected function getViewData(): array $latestDriftFailure = OperationRun::query() ->where('tenant_id', $tenantId) - ->where('type', 'drift.generate') + ->where('type', 'drift_generate_findings') ->where('status', 'completed') ->where('outcome', 'failed') ->latest('id') diff --git a/app/Filament/Widgets/Inventory/InventoryKpiHeader.php b/app/Filament/Widgets/Inventory/InventoryKpiHeader.php index f4128ea..632ff49 100644 --- a/app/Filament/Widgets/Inventory/InventoryKpiHeader.php +++ b/app/Filament/Widgets/Inventory/InventoryKpiHeader.php @@ -4,15 +4,16 @@ namespace App\Filament\Widgets\Inventory; -use App\Filament\Resources\InventorySyncRunResource; use App\Models\InventoryItem; -use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\Tenant; use App\Services\Inventory\CoverageCapabilitiesResolver; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\Inventory\InventoryKpiBadges; use App\Support\Inventory\InventoryPolicyTypeMeta; -use App\Support\Inventory\InventorySyncStatusBadge; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; use Filament\Facades\Filament; use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget\Stat; @@ -80,8 +81,12 @@ protected function getStats(): array ? (int) round(($restorableItems / $totalItems) * 100) : 0; - $lastRun = InventorySyncRun::query() + $lastRun = OperationRun::query() ->where('tenant_id', $tenantId) + ->where('type', 'inventory_sync') + ->where('status', OperationRunStatus::Completed->value) + ->whereNotNull('completed_at') + ->latest('completed_at') ->latest('id') ->first(); @@ -91,21 +96,20 @@ protected function getStats(): array $lastInventorySyncStatusIcon = 'heroicon-m-clock'; $lastInventorySyncViewUrl = null; - if ($lastRun instanceof InventorySyncRun) { - $timestamp = $lastRun->finished_at ?? $lastRun->started_at; + if ($lastRun instanceof OperationRun) { + $timestamp = $lastRun->completed_at ?? $lastRun->started_at ?? $lastRun->created_at; if ($timestamp) { $lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]); } - $status = (string) ($lastRun->status ?? ''); + $outcome = (string) ($lastRun->outcome ?? OperationRunOutcome::Pending->value); + $badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome); + $lastInventorySyncStatusLabel = $badge->label; + $lastInventorySyncStatusColor = $badge->color; + $lastInventorySyncStatusIcon = (string) ($badge->icon ?? 'heroicon-m-clock'); - $badge = InventorySyncStatusBadge::for($status); - $lastInventorySyncStatusLabel = $badge['label']; - $lastInventorySyncStatusColor = $badge['color']; - $lastInventorySyncStatusIcon = $badge['icon']; - - $lastInventorySyncViewUrl = InventorySyncRunResource::getUrl('view', ['record' => $lastRun], tenant: $tenant); + $lastInventorySyncViewUrl = route('admin.operations.view', ['run' => (int) $lastRun->getKey()]); } $badgeColor = $lastInventorySyncStatusColor; @@ -135,7 +139,7 @@ protected function getStats(): array $inventoryOps = (int) OperationRun::query() ->where('tenant_id', $tenantId) - ->where('type', 'inventory.sync') + ->where('type', 'inventory_sync') ->active() ->count(); diff --git a/app/Jobs/ApplyBackupScheduleRetentionJob.php b/app/Jobs/ApplyBackupScheduleRetentionJob.php index 09f98bf..aed8b52 100644 --- a/app/Jobs/ApplyBackupScheduleRetentionJob.php +++ b/app/Jobs/ApplyBackupScheduleRetentionJob.php @@ -3,12 +3,15 @@ namespace App\Jobs; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; use App\Models\BackupSet; +use App\Models\OperationRun; use App\Services\Intune\AuditLogger; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Collection; +use Illuminate\Support\Str; class ApplyBackupScheduleRetentionJob implements ShouldQueue { @@ -26,6 +29,21 @@ public function handle(AuditLogger $auditLogger): void return; } + $operationRun = OperationRun::query()->create([ + 'workspace_id' => (int) $schedule->tenant->workspace_id, + 'tenant_id' => (int) $schedule->tenant_id, + 'user_id' => null, + 'initiator_name' => 'System', + 'type' => 'backup_schedule_retention', + 'status' => OperationRunStatus::Running->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':backup_schedule_retention:'.$schedule->id.':'.Str::uuid()->toString()), + 'context' => [ + 'backup_schedule_id' => (int) $schedule->id, + ], + 'started_at' => now(), + ]); + $keepLast = (int) ($schedule->retention_keep_last ?? 30); if ($keepLast < 1) { @@ -33,55 +51,65 @@ public function handle(AuditLogger $auditLogger): void } /** @var Collection $keepBackupSetIds */ - $keepBackupSetIds = BackupScheduleRun::query() - ->where('backup_schedule_id', $schedule->id) - ->whereNotNull('backup_set_id') - ->orderByDesc('scheduled_for') + $keepBackupSetIds = OperationRun::query() + ->where('tenant_id', (int) $schedule->tenant_id) + ->where('type', 'backup_schedule_run') + ->where('status', OperationRunStatus::Completed->value) + ->where('context->backup_schedule_id', (int) $schedule->id) + ->whereNotNull('context->backup_set_id') + ->orderByDesc('completed_at') + ->orderByDesc('id') ->limit($keepLast) - ->pluck('backup_set_id') - ->filter() + ->get() + ->map(fn (OperationRun $run): ?int => is_numeric(data_get($run->context, 'backup_set_id')) ? (int) data_get($run->context, 'backup_set_id') : null) + ->filter(fn (?int $id): bool => is_int($id) && $id > 0) ->values(); - /** @var Collection $deleteBackupSetIds */ - $deleteBackupSetIds = BackupScheduleRun::query() - ->where('backup_schedule_id', $schedule->id) - ->whereNotNull('backup_set_id') - ->when($keepBackupSetIds->isNotEmpty(), fn ($query) => $query->whereNotIn('backup_set_id', $keepBackupSetIds->all())) - ->pluck('backup_set_id') - ->filter() + /** @var Collection $allBackupSetIds */ + $allBackupSetIds = OperationRun::query() + ->where('tenant_id', (int) $schedule->tenant_id) + ->where('type', 'backup_schedule_run') + ->where('status', OperationRunStatus::Completed->value) + ->where('context->backup_schedule_id', (int) $schedule->id) + ->whereNotNull('context->backup_set_id') + ->get() + ->map(fn (OperationRun $run): ?int => is_numeric(data_get($run->context, 'backup_set_id')) ? (int) data_get($run->context, 'backup_set_id') : null) + ->filter(fn (?int $id): bool => is_int($id) && $id > 0) ->unique() ->values(); - if ($deleteBackupSetIds->isEmpty()) { - $auditLogger->log( - tenant: $schedule->tenant, - action: 'backup_schedule.retention_applied', - resourceType: 'backup_schedule', - resourceId: (string) $schedule->id, - status: 'success', - context: [ - 'metadata' => [ - 'keep_last' => $keepLast, - 'deleted_backup_sets' => 0, - ], - ], - ); - - return; - } + /** @var Collection $deleteBackupSetIds */ + $deleteBackupSetIds = $allBackupSetIds + ->reject(fn (int $backupSetId): bool => $keepBackupSetIds->contains($backupSetId)) + ->values(); $deletedCount = 0; - BackupSet::query() - ->where('tenant_id', $schedule->tenant_id) - ->whereIn('id', $deleteBackupSetIds->all()) - ->whereNull('deleted_at') - ->chunkById(200, function (Collection $sets) use (&$deletedCount): void { - foreach ($sets as $set) { - $set->delete(); - $deletedCount++; - } - }); + if ($deleteBackupSetIds->isNotEmpty()) { + BackupSet::query() + ->where('tenant_id', $schedule->tenant_id) + ->whereIn('id', $deleteBackupSetIds->all()) + ->whereNull('deleted_at') + ->chunkById(200, function (Collection $sets) use (&$deletedCount): void { + foreach ($sets as $set) { + $set->delete(); + $deletedCount++; + } + }); + } + + $operationRun->update([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'summary_counts' => [ + 'total' => (int) $deleteBackupSetIds->count(), + 'processed' => (int) $deleteBackupSetIds->count(), + 'succeeded' => $deletedCount, + 'failed' => max(0, (int) $deleteBackupSetIds->count() - $deletedCount), + 'updated' => $deletedCount, + ], + 'completed_at' => now(), + ]); $auditLogger->log( tenant: $schedule->tenant, @@ -93,6 +121,7 @@ public function handle(AuditLogger $auditLogger): void 'metadata' => [ 'keep_last' => $keepLast, 'deleted_backup_sets' => $deletedCount, + 'operation_run_id' => (int) $operationRun->getKey(), ], ], ); diff --git a/app/Jobs/EntraGroupSyncJob.php b/app/Jobs/EntraGroupSyncJob.php index 2466301..4fe87f5 100644 --- a/app/Jobs/EntraGroupSyncJob.php +++ b/app/Jobs/EntraGroupSyncJob.php @@ -3,13 +3,12 @@ namespace App\Jobs; use App\Jobs\Middleware\TrackOperationRun; -use App\Models\EntraGroupSyncRun; use App\Models\OperationRun; use App\Models\Tenant; use App\Services\Directory\EntraGroupSyncService; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; -use Carbon\CarbonImmutable; +use App\Support\OperationRunOutcome; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -51,81 +50,40 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog throw new RuntimeException('Tenant not found.'); } - $legacyRun = $this->resolveLegacyRun($tenant); - - if ($legacyRun instanceof EntraGroupSyncRun) { - if ($legacyRun->status !== EntraGroupSyncRun::STATUS_PENDING) { - return; - } - - $legacyRun->update([ - 'status' => EntraGroupSyncRun::STATUS_RUNNING, - 'started_at' => CarbonImmutable::now('UTC'), - ]); - - $auditLogger->log( - tenant: $tenant, - action: 'directory_groups.sync.started', - context: [ - 'selection_key' => $legacyRun->selection_key, - 'run_id' => $legacyRun->getKey(), - 'slot_key' => $legacyRun->slot_key, - ], - actorId: $legacyRun->initiator_user_id, - status: 'success', - resourceType: 'entra_group_sync_run', - resourceId: (string) $legacyRun->getKey(), - ); - } else { - $auditLogger->log( - tenant: $tenant, - action: 'directory_groups.sync.started', - context: [ - 'selection_key' => $this->selectionKey, - 'slot_key' => $this->slotKey, - ], - actorId: $this->operationRun->user_id, - status: 'success', - resourceType: 'operation_run', - resourceId: (string) $this->operationRun->getKey(), - ); - } - - $result = $syncService->sync($tenant, $this->selectionKey); - - - $terminalStatus = EntraGroupSyncRun::STATUS_SUCCEEDED; - - if ($result['error_code'] !== null) { - $terminalStatus = EntraGroupSyncRun::STATUS_FAILED; - } elseif ($result['safety_stop_triggered'] === true) { - $terminalStatus = EntraGroupSyncRun::STATUS_PARTIAL; - } - - if ($legacyRun instanceof EntraGroupSyncRun) { - $legacyRun->update([ - 'status' => $terminalStatus, - 'pages_fetched' => $result['pages_fetched'], - 'items_observed_count' => $result['items_observed_count'], - 'items_upserted_count' => $result['items_upserted_count'], - 'error_count' => $result['error_count'], - 'safety_stop_triggered' => $result['safety_stop_triggered'], - 'safety_stop_reason' => $result['safety_stop_reason'], - 'error_code' => $result['error_code'], - 'error_category' => $result['error_category'], - 'error_summary' => $result['error_summary'], - 'finished_at' => CarbonImmutable::now('UTC'), - ]); - } - /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); + if ($this->operationRun->status === 'queued') { + $opService->updateRun($this->operationRun, 'running'); + } + + $auditLogger->log( + tenant: $tenant, + action: 'directory_groups.sync.started', + context: [ + 'selection_key' => $this->selectionKey, + 'slot_key' => $this->slotKey, + ], + actorId: $this->operationRun->user_id, + status: 'success', + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + ); + + $result = $syncService->sync($tenant, $this->selectionKey); + + $terminalStatus = 'succeeded'; + + if ($result['error_code'] !== null) { + $terminalStatus = 'failed'; + } elseif ($result['safety_stop_triggered'] === true) { + $terminalStatus = 'partial'; + } + $opOutcome = match ($terminalStatus) { - EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded', - EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded', - EntraGroupSyncRun::STATUS_FAILED => 'failed', - default => 'failed', + 'succeeded' => OperationRunOutcome::Succeeded->value, + 'partial' => OperationRunOutcome::PartiallySucceeded->value, + default => OperationRunOutcome::Failed->value, }; $failures = []; @@ -141,48 +99,19 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog 'completed', $opOutcome, [ - // NOTE: summary_counts are normalized to a fixed whitelist for Ops UX. - // Keep keys aligned with App\Support\OpsUx\OperationSummaryKeys. - 'total' => $result['items_observed_count'], - 'processed' => $result['items_observed_count'], - 'updated' => $result['items_upserted_count'], - 'failed' => $result['error_count'], + 'total' => (int) $result['items_observed_count'], + 'processed' => (int) $result['items_observed_count'], + 'updated' => (int) $result['items_upserted_count'], + 'failed' => (int) $result['error_count'], ], $failures, ); - if ($legacyRun instanceof EntraGroupSyncRun) { - $auditLogger->log( - tenant: $tenant, - action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED - ? 'directory_groups.sync.succeeded' - : ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL - ? 'directory_groups.sync.partial' - : 'directory_groups.sync.failed'), - context: [ - 'selection_key' => $legacyRun->selection_key, - 'run_id' => $legacyRun->getKey(), - 'slot_key' => $legacyRun->slot_key, - 'pages_fetched' => $legacyRun->pages_fetched, - 'items_observed_count' => $legacyRun->items_observed_count, - 'items_upserted_count' => $legacyRun->items_upserted_count, - 'error_code' => $legacyRun->error_code, - 'error_category' => $legacyRun->error_category, - ], - actorId: $legacyRun->initiator_user_id, - status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success', - resourceType: 'entra_group_sync_run', - resourceId: (string) $legacyRun->getKey(), - ); - - return; - } - $auditLogger->log( tenant: $tenant, - action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED + action: $terminalStatus === 'succeeded' ? 'directory_groups.sync.succeeded' - : ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL + : ($terminalStatus === 'partial' ? 'directory_groups.sync.partial' : 'directory_groups.sync.failed'), context: [ @@ -195,41 +124,9 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog 'error_category' => $result['error_category'], ], actorId: $this->operationRun->user_id, - status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success', + status: $terminalStatus === 'failed' ? 'failed' : 'success', resourceType: 'operation_run', resourceId: (string) $this->operationRun->getKey(), ); } - - private function resolveLegacyRun(Tenant $tenant): ?EntraGroupSyncRun - { - if ($this->runId !== null) { - $run = EntraGroupSyncRun::query() - ->whereKey($this->runId) - ->where('tenant_id', $tenant->getKey()) - ->first(); - - if ($run instanceof EntraGroupSyncRun) { - return $run; - } - - return null; - } - - if ($this->slotKey !== null) { - $run = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', $this->selectionKey) - ->where('slot_key', $this->slotKey) - ->first(); - - if ($run instanceof EntraGroupSyncRun) { - return $run; - } - - return null; - } - - return null; - } } diff --git a/app/Jobs/GenerateDriftFindingsJob.php b/app/Jobs/GenerateDriftFindingsJob.php index e0d5055..c2ceb1a 100644 --- a/app/Jobs/GenerateDriftFindingsJob.php +++ b/app/Jobs/GenerateDriftFindingsJob.php @@ -2,7 +2,6 @@ namespace App\Jobs; -use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\Tenant; use App\Services\Drift\DriftFindingGenerator; @@ -45,8 +44,8 @@ public function handle( ): void { Log::info('GenerateDriftFindingsJob: started', [ 'tenant_id' => $this->tenantId, - 'baseline_run_id' => $this->baselineRunId, - 'current_run_id' => $this->currentRunId, + 'baseline_operation_run_id' => $this->baselineRunId, + 'current_operation_run_id' => $this->currentRunId, 'scope_key' => $this->scopeKey, ]); @@ -78,13 +77,21 @@ public function handle( throw new RuntimeException('Tenant not found.'); } - $baseline = InventorySyncRun::query()->find($this->baselineRunId); - if (! $baseline instanceof InventorySyncRun) { + $baseline = OperationRun::query() + ->whereKey($this->baselineRunId) + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'inventory_sync') + ->first(); + if (! $baseline instanceof OperationRun) { throw new RuntimeException('Baseline run not found.'); } - $current = InventorySyncRun::query()->find($this->currentRunId); - if (! $current instanceof InventorySyncRun) { + $current = OperationRun::query() + ->whereKey($this->currentRunId) + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'inventory_sync') + ->first(); + if (! $current instanceof OperationRun) { throw new RuntimeException('Current run not found.'); } @@ -104,8 +111,8 @@ public function handle( Log::info('GenerateDriftFindingsJob: completed', [ 'tenant_id' => $this->tenantId, - 'baseline_run_id' => $this->baselineRunId, - 'current_run_id' => $this->currentRunId, + 'baseline_operation_run_id' => $this->baselineRunId, + 'current_operation_run_id' => $this->currentRunId, 'scope_key' => $this->scopeKey, 'created_findings_count' => $created, ]); @@ -120,8 +127,8 @@ public function handle( } catch (Throwable $e) { Log::error('GenerateDriftFindingsJob: failed', [ 'tenant_id' => $this->tenantId, - 'baseline_run_id' => $this->baselineRunId, - 'current_run_id' => $this->currentRunId, + 'baseline_operation_run_id' => $this->baselineRunId, + 'current_operation_run_id' => $this->currentRunId, 'scope_key' => $this->scopeKey, 'error' => $e->getMessage(), ]); @@ -132,7 +139,7 @@ public function handle( ]); $runs->appendFailures($this->operationRun, [[ - 'code' => 'drift.generate.failed', + 'code' => 'drift_generate_findings.failed', 'message' => $e->getMessage(), ]]); diff --git a/app/Jobs/RunBackupScheduleJob.php b/app/Jobs/RunBackupScheduleJob.php index 26964b0..2bba52b 100644 --- a/app/Jobs/RunBackupScheduleJob.php +++ b/app/Jobs/RunBackupScheduleJob.php @@ -4,9 +4,9 @@ use App\Jobs\Middleware\TrackOperationRun; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; use App\Models\OperationRun; use App\Models\Tenant; +use App\Models\User; use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\RunErrorMapper; use App\Services\BackupScheduling\ScheduleTimeService; @@ -15,6 +15,7 @@ use App\Services\Intune\PolicySyncService; use App\Services\OperationRunService; use App\Support\OperationRunLinks; +use App\Support\OperationRunOutcome; use Carbon\CarbonImmutable; use Filament\Actions\Action; use Filament\Notifications\Notification; @@ -23,15 +24,23 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Arr; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Str; class RunBackupScheduleJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + private const string STATUS_RUNNING = 'running'; + + private const string STATUS_SUCCESS = 'success'; + + private const string STATUS_PARTIAL = 'partial'; + + private const string STATUS_FAILED = 'failed'; + + private const string STATUS_SKIPPED = 'skipped'; + public int $tries = 3; public ?OperationRun $operationRun = null; @@ -63,317 +72,19 @@ public function handle( return; } - if ($this->backupScheduleId !== null) { - $this->handleFromScheduleId( - backupScheduleId: $this->backupScheduleId, - policySyncService: $policySyncService, - backupService: $backupService, - policyTypeResolver: $policyTypeResolver, - scheduleTimeService: $scheduleTimeService, - auditLogger: $auditLogger, - errorMapper: $errorMapper, + $backupScheduleId = $this->resolveBackupScheduleId(); + + if ($backupScheduleId <= 0) { + $this->markOperationRunFailed( + run: $this->operationRun, + summaryCounts: [], + reasonCode: 'schedule_not_provided', + reason: 'No backup schedule was provided for this run.', ); return; } - $run = BackupScheduleRun::query() - ->with(['schedule', 'tenant', 'user']) - ->find($this->backupScheduleRunId); - - if (! $run) { - if ($this->operationRun) { - $this->markOperationRunFailed( - run: $this->operationRun, - summaryCounts: [], - reasonCode: 'run_not_found', - reason: 'Backup schedule run not found.', - ); - } - - return; - } - - $tenant = $run->tenant; - - if ($this->operationRun) { - $this->operationRun->update([ - 'context' => array_merge($this->operationRun->context ?? [], [ - 'backup_schedule_id' => (int) $run->backup_schedule_id, - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); - - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - - if ($this->operationRun->status === 'queued') { - $operationRunService->updateRun($this->operationRun, 'running'); - } - } - - $schedule = $run->schedule; - - if (! $schedule instanceof BackupSchedule) { - $run->update([ - 'status' => BackupScheduleRun::STATUS_FAILED, - 'error_code' => RunErrorMapper::ERROR_UNKNOWN, - 'error_message' => 'Schedule not found.', - 'finished_at' => CarbonImmutable::now('UTC'), - ]); - - if ($this->operationRun) { - $this->markOperationRunFailed( - run: $this->operationRun, - summaryCounts: [ - 'total' => 0, - 'processed' => 0, - 'failed' => 1, - ], - reasonCode: 'schedule_not_found', - reason: 'Schedule not found.', - ); - } - - return; - } - - if (! $tenant) { - $run->update([ - 'status' => BackupScheduleRun::STATUS_FAILED, - 'error_code' => RunErrorMapper::ERROR_UNKNOWN, - 'error_message' => 'Tenant not found.', - 'finished_at' => CarbonImmutable::now('UTC'), - ]); - - if ($this->operationRun) { - $this->markOperationRunFailed( - run: $this->operationRun, - summaryCounts: [ - 'total' => 0, - 'processed' => 0, - 'failed' => 1, - ], - reasonCode: 'tenant_not_found', - reason: 'Tenant not found.', - ); - } - - return; - } - - $lock = Cache::lock("backup_schedule:{$schedule->id}", 900); - - if (! $lock->get()) { - $this->finishRun( - run: $run, - schedule: $schedule, - status: BackupScheduleRun::STATUS_SKIPPED, - errorCode: 'CONCURRENT_RUN', - errorMessage: 'Another run is already in progress for this schedule.', - summary: ['reason' => 'concurrent_run'], - scheduleTimeService: $scheduleTimeService, - ); - - $this->syncOperationRunFromRun( - tenant: $tenant, - schedule: $schedule, - run: $run->refresh(), - ); - - return; - } - - try { - $nowUtc = CarbonImmutable::now('UTC'); - - $run->forceFill([ - 'started_at' => $run->started_at ?? $nowUtc, - 'status' => BackupScheduleRun::STATUS_RUNNING, - ])->save(); - - $this->notifyRunStarted($run, $schedule); - - $auditLogger->log( - tenant: $tenant, - action: 'backup_schedule.run_started', - context: [ - 'metadata' => [ - 'backup_schedule_id' => $schedule->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $run->scheduled_for?->toDateTimeString(), - ], - ], - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, - status: 'success' - ); - - $runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? [])); - $validTypes = $runtime['valid']; - $unknownTypes = $runtime['unknown']; - - if (empty($validTypes)) { - $this->finishRun( - run: $run, - schedule: $schedule, - status: BackupScheduleRun::STATUS_SKIPPED, - errorCode: 'UNKNOWN_POLICY_TYPE', - errorMessage: 'All configured policy types are unknown.', - summary: [ - 'unknown_policy_types' => $unknownTypes, - ], - scheduleTimeService: $scheduleTimeService, - ); - - $this->syncOperationRunFromRun( - tenant: $tenant, - schedule: $schedule, - run: $run->refresh(), - ); - - return; - } - - $supported = array_values(array_filter( - config('tenantpilot.supported_policy_types', []), - fn (array $typeConfig): bool => in_array($typeConfig['type'] ?? null, $validTypes, true), - )); - - $syncReport = $policySyncService->syncPoliciesWithReport($tenant, $supported); - - $policyIds = $syncReport['synced'] ?? []; - $syncFailures = $syncReport['failures'] ?? []; - - $backupSet = $backupService->createBackupSet( - tenant: $tenant, - policyIds: $policyIds, - actorEmail: null, - actorName: null, - name: 'Scheduled backup: '.$schedule->name, - includeAssignments: false, - includeScopeTags: false, - includeFoundations: (bool) ($schedule->include_foundations ?? false), - ); - - $status = match ($backupSet->status) { - 'completed' => BackupScheduleRun::STATUS_SUCCESS, - 'partial' => BackupScheduleRun::STATUS_PARTIAL, - 'failed' => BackupScheduleRun::STATUS_FAILED, - default => BackupScheduleRun::STATUS_SUCCESS, - }; - - $errorCode = null; - $errorMessage = null; - - $summary = [ - 'policies_total' => count($policyIds), - 'policies_backed_up' => (int) ($backupSet->item_count ?? 0), - 'sync_failures' => $syncFailures, - ]; - - if (! empty($unknownTypes)) { - $status = BackupScheduleRun::STATUS_PARTIAL; - $errorCode = 'UNKNOWN_POLICY_TYPE'; - $errorMessage = 'Some configured policy types are unknown and were skipped.'; - $summary['unknown_policy_types'] = $unknownTypes; - } - - $this->finishRun( - run: $run, - schedule: $schedule, - status: $status, - errorCode: $errorCode, - errorMessage: $errorMessage, - summary: $summary, - scheduleTimeService: $scheduleTimeService, - backupSetId: (string) $backupSet->id, - ); - - $this->syncOperationRunFromRun( - tenant: $tenant, - schedule: $schedule, - run: $run->refresh(), - ); - - $auditLogger->log( - tenant: $tenant, - action: 'backup_schedule.run_finished', - context: [ - 'metadata' => [ - 'backup_schedule_id' => $schedule->id, - 'backup_schedule_run_id' => $run->id, - 'status' => $status, - 'error_code' => $errorCode, - ], - ], - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, - status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial' - ); - } catch (\Throwable $throwable) { - $attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1; - $mapped = $errorMapper->map($throwable, $attempt, $this->tries); - - if ($mapped['shouldRetry']) { - if ($this->operationRun) { - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - $operationRunService->updateRun($this->operationRun, 'running', 'pending'); - } - - $this->release($mapped['delay']); - - return; - } - - $this->finishRun( - run: $run, - schedule: $schedule, - status: BackupScheduleRun::STATUS_FAILED, - errorCode: $mapped['error_code'], - errorMessage: $mapped['error_message'], - summary: [ - 'exception' => get_class($throwable), - 'attempt' => $attempt, - ], - scheduleTimeService: $scheduleTimeService, - ); - - $this->syncOperationRunFromRun( - tenant: $tenant, - schedule: $schedule, - run: $run->refresh(), - ); - - $auditLogger->log( - tenant: $tenant, - action: 'backup_schedule.run_failed', - context: [ - 'metadata' => [ - 'backup_schedule_id' => $schedule->id, - 'backup_schedule_run_id' => $run->id, - 'error_code' => $mapped['error_code'], - ], - ], - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, - status: 'failed' - ); - } finally { - optional($lock)->release(); - } - } - - private function handleFromScheduleId( - int $backupScheduleId, - PolicySyncService $policySyncService, - BackupService $backupService, - PolicyTypeResolver $policyTypeResolver, - ScheduleTimeService $scheduleTimeService, - AuditLogger $auditLogger, - RunErrorMapper $errorMapper, - ): void { $schedule = BackupSchedule::query() ->with('tenant') ->find($backupScheduleId); @@ -402,15 +113,15 @@ private function handleFromScheduleId( return; } + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + $this->operationRun->update([ 'context' => array_merge($this->operationRun->context ?? [], [ 'backup_schedule_id' => (int) $schedule->getKey(), ]), ]); - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - if ($this->operationRun->status === 'queued') { $operationRunService->updateRun($this->operationRun, 'running'); } @@ -422,7 +133,7 @@ private function handleFromScheduleId( $this->finishSchedule( schedule: $schedule, - status: BackupScheduleRun::STATUS_SKIPPED, + status: self::STATUS_SKIPPED, scheduleTimeService: $scheduleTimeService, nowUtc: $nowUtc, ); @@ -430,11 +141,12 @@ private function handleFromScheduleId( $operationRunService->updateRun( $this->operationRun, status: 'completed', - outcome: 'failed', + outcome: OperationRunOutcome::Blocked->value, summaryCounts: [ 'total' => 0, 'processed' => 0, - 'failed' => 1, + 'failed' => 0, + 'skipped' => 1, ], failures: [ [ @@ -447,7 +159,7 @@ private function handleFromScheduleId( $this->notifyScheduleRunFinished( tenant: $tenant, schedule: $schedule, - status: BackupScheduleRun::STATUS_SKIPPED, + status: self::STATUS_SKIPPED, errorMessage: 'Another run is already in progress for this schedule.', ); @@ -495,7 +207,7 @@ private function handleFromScheduleId( if (empty($validTypes)) { $this->finishSchedule( schedule: $schedule, - status: BackupScheduleRun::STATUS_SKIPPED, + status: self::STATUS_SKIPPED, scheduleTimeService: $scheduleTimeService, nowUtc: $nowUtc, ); @@ -503,11 +215,12 @@ private function handleFromScheduleId( $operationRunService->updateRun( $this->operationRun, status: 'completed', - outcome: 'failed', + outcome: OperationRunOutcome::Blocked->value, summaryCounts: [ 'total' => 0, 'processed' => 0, - 'failed' => 1, + 'failed' => 0, + 'skipped' => 1, ], failures: [ [ @@ -520,7 +233,7 @@ private function handleFromScheduleId( $this->notifyScheduleRunFinished( tenant: $tenant, schedule: $schedule, - status: BackupScheduleRun::STATUS_SKIPPED, + status: self::STATUS_SKIPPED, errorMessage: 'All configured policy types are unknown.', ); @@ -549,17 +262,17 @@ private function handleFromScheduleId( ); $status = match ($backupSet->status) { - 'completed' => BackupScheduleRun::STATUS_SUCCESS, - 'partial' => BackupScheduleRun::STATUS_PARTIAL, - 'failed' => BackupScheduleRun::STATUS_FAILED, - default => BackupScheduleRun::STATUS_SUCCESS, + 'completed' => self::STATUS_SUCCESS, + 'partial' => self::STATUS_PARTIAL, + 'failed' => self::STATUS_FAILED, + default => self::STATUS_SUCCESS, }; $errorCode = null; $errorMessage = null; if (! empty($unknownTypes)) { - $status = BackupScheduleRun::STATUS_PARTIAL; + $status = self::STATUS_PARTIAL; $errorCode = 'UNKNOWN_POLICY_TYPE'; $errorMessage = 'Some configured policy types are unknown and were skipped.'; } @@ -626,9 +339,10 @@ private function handleFromScheduleId( ]); $outcome = match ($status) { - BackupScheduleRun::STATUS_SUCCESS => 'succeeded', - BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded', - default => 'failed', + self::STATUS_SUCCESS => OperationRunOutcome::Succeeded->value, + self::STATUS_PARTIAL => OperationRunOutcome::PartiallySucceeded->value, + self::STATUS_SKIPPED => OperationRunOutcome::Blocked->value, + default => OperationRunOutcome::Failed->value, }; $operationRunService->updateRun( @@ -653,8 +367,8 @@ private function handleFromScheduleId( errorMessage: $errorMessage, ); - if (in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) { - Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id)); + if (in_array($status, [self::STATUS_SUCCESS, self::STATUS_PARTIAL], true)) { + Bus::dispatch(new ApplyBackupScheduleRetentionJob((int) $schedule->getKey())); } $auditLogger->log( @@ -670,14 +384,14 @@ private function handleFromScheduleId( ], resourceType: 'operation_run', resourceId: (string) $this->operationRun->getKey(), - status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial' + status: in_array($status, [self::STATUS_SUCCESS], true) ? 'success' : 'partial' ); } catch (\Throwable $throwable) { $attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1; $mapped = $errorMapper->map($throwable, $attempt, $this->tries); if ($mapped['shouldRetry']) { - $operationRunService->updateRun($this->operationRun, 'running', 'pending'); + $operationRunService->updateRun($this->operationRun, 'running', OperationRunOutcome::Pending->value); $this->release($mapped['delay']); @@ -688,7 +402,7 @@ private function handleFromScheduleId( $this->finishSchedule( schedule: $schedule, - status: BackupScheduleRun::STATUS_FAILED, + status: self::STATUS_FAILED, scheduleTimeService: $scheduleTimeService, nowUtc: $nowUtc, ); @@ -696,7 +410,7 @@ private function handleFromScheduleId( $operationRunService->updateRun( $this->operationRun, status: 'completed', - outcome: 'failed', + outcome: OperationRunOutcome::Failed->value, summaryCounts: [ 'total' => 0, 'processed' => 0, @@ -713,7 +427,7 @@ private function handleFromScheduleId( $this->notifyScheduleRunFinished( tenant: $tenant, schedule: $schedule, - status: BackupScheduleRun::STATUS_FAILED, + status: self::STATUS_FAILED, errorMessage: (string) $mapped['error_message'], ); @@ -736,6 +450,17 @@ private function handleFromScheduleId( } } + private function resolveBackupScheduleId(): int + { + if ($this->backupScheduleId !== null && $this->backupScheduleId > 0) { + return $this->backupScheduleId; + } + + $contextScheduleId = data_get($this->operationRun?->context, 'backup_schedule_id'); + + return is_numeric($contextScheduleId) ? (int) $contextScheduleId : 0; + } + private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedule): void { $userId = $this->operationRun?->user_id; @@ -744,9 +469,9 @@ private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedu return; } - $user = \App\Models\User::query()->find($userId); + $user = User::query()->find($userId); - if (! $user) { + if (! $user instanceof User) { return; } @@ -774,16 +499,16 @@ private function notifyScheduleRunFinished( return; } - $user = \App\Models\User::query()->find($userId); + $user = User::query()->find($userId); - if (! $user) { + if (! $user instanceof User) { return; } $title = match ($status) { - BackupScheduleRun::STATUS_SUCCESS => 'Backup completed', - BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)', - BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped', + self::STATUS_SUCCESS => 'Backup completed', + self::STATUS_PARTIAL => 'Backup completed (partial)', + self::STATUS_SKIPPED => 'Backup skipped', default => 'Backup failed', }; @@ -796,8 +521,8 @@ private function notifyScheduleRunFinished( } match ($status) { - BackupScheduleRun::STATUS_SUCCESS => $notification->success(), - BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(), + self::STATUS_SUCCESS => $notification->success(), + self::STATUS_PARTIAL, self::STATUS_SKIPPED => $notification->warning(), default => $notification->danger(), }; @@ -823,163 +548,6 @@ private function finishSchedule( ])->saveQuietly(); } - private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedule): void - { - $user = $run->user; - - if (! $user) { - return; - } - - $notification = Notification::make() - ->title('Backup started') - ->body(sprintf('Schedule "%s" has started.', $schedule->name)) - ->info() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $run->tenant) : OperationRunLinks::index($run->tenant)), - ]); - - $notification->sendToDatabase($user); - } - - private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $schedule): void - { - $user = $run->user; - - if (! $user) { - return; - } - - $title = match ($run->status) { - BackupScheduleRun::STATUS_SUCCESS => 'Backup completed', - BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)', - BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped', - default => 'Backup failed', - }; - - $notification = Notification::make() - ->title($title) - ->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $run->status)); - - if (filled($run->error_message)) { - $notification->body($notification->getBody()."\n".$run->error_message); - } - - match ($run->status) { - BackupScheduleRun::STATUS_SUCCESS => $notification->success(), - BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(), - default => $notification->danger(), - }; - - $notification - ->actions([ - Action::make('view_run') - ->label('View run') - ->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $run->tenant) : OperationRunLinks::index($run->tenant)), - ]) - ->sendToDatabase($user); - } - - private function syncOperationRunFromRun( - Tenant $tenant, - BackupSchedule $schedule, - BackupScheduleRun $run, - ): void { - if (! $this->operationRun) { - return; - } - - $outcome = match ($run->status) { - BackupScheduleRun::STATUS_SUCCESS => 'succeeded', - BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded', - // Note: 'cancelled' is a reserved OperationRun outcome token. - // We treat schedule SKIPPED/CANCELED as terminal failures with a failure entry. - BackupScheduleRun::STATUS_SKIPPED, - BackupScheduleRun::STATUS_CANCELED => 'failed', - default => 'failed', - }; - - $summary = is_array($run->summary) ? $run->summary : []; - $syncFailures = $summary['sync_failures'] ?? []; - - $policiesTotal = (int) ($summary['policies_total'] ?? 0); - $policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0); - $syncFailureCount = is_array($syncFailures) ? count($syncFailures) : 0; - - $failedCount = max(0, $policiesTotal - $policiesBackedUp); - - $summaryCounts = [ - 'total' => $policiesTotal, - 'processed' => $policiesTotal, - 'succeeded' => $policiesBackedUp, - 'failed' => $failedCount, - 'skipped' => 0, - 'created' => filled($run->backup_set_id) ? 1 : 0, - 'updated' => $policiesBackedUp, - 'items' => $policiesTotal, - ]; - - $failures = []; - - if (filled($run->error_message) || filled($run->error_code)) { - $failures[] = [ - 'code' => strtolower((string) ($run->error_code ?: 'backup_schedule_error')), - 'message' => (string) ($run->error_message ?: 'Backup schedule run failed.'), - ]; - } - - if (is_array($syncFailures)) { - foreach ($syncFailures as $failure) { - if (! is_array($failure)) { - continue; - } - - $policyType = (string) ($failure['policy_type'] ?? 'unknown'); - $status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null; - $errors = $failure['errors'] ?? null; - - $firstErrorMessage = null; - if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) { - $firstErrorMessage = $errors[0]['message'] ?? null; - } - - $message = $status !== null - ? "{$policyType}: Graph returned {$status}" - : "{$policyType}: Graph request failed"; - - if (is_string($firstErrorMessage) && $firstErrorMessage !== '') { - $message .= ' - '.trim($firstErrorMessage); - } - - $failures[] = [ - 'code' => $status !== null ? 'graph_http_'.(string) $status : 'graph_error', - 'message' => $message, - ]; - } - } - - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - - $this->operationRun->update([ - 'context' => array_merge($this->operationRun->context ?? [], [ - 'backup_schedule_id' => (int) $schedule->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - 'backup_set_id' => $run->backup_set_id ? (int) $run->backup_set_id : null, - ]), - ]); - - $operationRunService->updateRun( - $this->operationRun, - status: 'completed', - outcome: $outcome, - summaryCounts: $summaryCounts, - failures: $failures, - ); - } - private function markOperationRunFailed( OperationRun $run, array $summaryCounts, @@ -992,7 +560,7 @@ private function markOperationRunFailed( $operationRunService->updateRun( $run, status: 'completed', - outcome: 'failed', + outcome: OperationRunOutcome::Failed->value, summaryCounts: $summaryCounts, failures: [ [ @@ -1002,38 +570,4 @@ private function markOperationRunFailed( ], ); } - - private function finishRun( - BackupScheduleRun $run, - BackupSchedule $schedule, - string $status, - ?string $errorCode, - ?string $errorMessage, - array $summary, - ScheduleTimeService $scheduleTimeService, - ?string $backupSetId = null, - ): void { - $nowUtc = CarbonImmutable::now('UTC'); - - $run->forceFill([ - 'status' => $status, - 'error_code' => $errorCode, - 'error_message' => $errorMessage, - 'summary' => Arr::wrap($summary), - 'finished_at' => $nowUtc, - 'backup_set_id' => $backupSetId, - ])->save(); - - $schedule->forceFill([ - 'last_run_at' => $nowUtc, - 'last_run_status' => $status, - 'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc), - ])->saveQuietly(); - - $this->notifyRunFinished($run, $schedule); - - if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) { - Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id)); - } - } } diff --git a/app/Jobs/RunInventorySyncJob.php b/app/Jobs/RunInventorySyncJob.php index 574e890..a84102f 100644 --- a/app/Jobs/RunInventorySyncJob.php +++ b/app/Jobs/RunInventorySyncJob.php @@ -11,6 +11,7 @@ use App\Services\OperationRunService; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\Providers\ProviderReasonCodes; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -79,7 +80,6 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $ // However, InventorySyncService execution logic might be complex with partial failures. // We might want to explicitly update the OperationRun if partial failures occur. - $result = $inventorySyncService->executeSelection( $this->operationRun, $tenant, @@ -97,10 +97,49 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe }, ); + $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $updatedContext['result'] = [ + 'had_errors' => (bool) ($result['had_errors'] ?? true), + 'error_codes' => is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [], + 'error_context' => is_array($result['error_context'] ?? null) ? $result['error_context'] : null, + ]; + + $this->operationRun->update([ + 'context' => $updatedContext, + ]); + + $this->operationRun->refresh(); + $status = (string) ($result['status'] ?? 'failed'); $errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : []; $reason = (string) ($errorCodes[0] ?? $status); + $errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : []; + $sanitizedErrorMessage = is_string($errorContext['message'] ?? null) ? (string) $errorContext['message'] : null; + + $reasonCode = null; + $sanitizedMessageWithoutReasonCode = null; + + if (is_string($sanitizedErrorMessage)) { + $sanitizedMessageWithoutReasonCode = preg_replace('/^\[[^\]]+\]\s*/', '', $sanitizedErrorMessage); + $sanitizedMessageWithoutReasonCode = is_string($sanitizedMessageWithoutReasonCode) + ? trim($sanitizedMessageWithoutReasonCode) + : null; + + if (preg_match('/^\[(?[^\]]+)\]/', $sanitizedErrorMessage, $m)) { + $candidate = (string) ($m['code'] ?? ''); + if ($candidate !== '' && ProviderReasonCodes::isKnown($candidate)) { + $reasonCode = $candidate; + } + } + } + + if ($reason === 'unexpected_exception' && is_string($sanitizedErrorMessage) && $sanitizedErrorMessage !== '') { + $reason = is_string($sanitizedMessageWithoutReasonCode) && $sanitizedMessageWithoutReasonCode !== '' + ? $sanitizedMessageWithoutReasonCode + : $sanitizedErrorMessage; + } + $itemsObserved = (int) ($result['items_observed_count'] ?? 0); $itemsUpserted = (int) ($result['items_upserted_count'] ?? 0); $errorsCount = (int) ($result['errors_count'] ?? 0); @@ -229,7 +268,7 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe 'failed' => max($failedCount, count($missingPolicyTypes)), ], failures: [ - ['code' => 'inventory.failed', 'message' => $reason], + ['code' => 'inventory.failed', 'reason_code' => $reasonCode, 'message' => $reason], ], ); diff --git a/app/Livewire/EntraGroupCachePickerTable.php b/app/Livewire/EntraGroupCachePickerTable.php index f79834d..47a0610 100644 --- a/app/Livewire/EntraGroupCachePickerTable.php +++ b/app/Livewire/EntraGroupCachePickerTable.php @@ -3,9 +3,9 @@ namespace App\Livewire; use App\Filament\Resources\EntraGroupResource; -use App\Filament\Resources\EntraGroupSyncRunResource; use App\Models\EntraGroup; use App\Models\Tenant; +use App\Support\OperationRunLinks; use Filament\Actions\Action; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; @@ -151,9 +151,9 @@ public function table(Table $table): Table ->icon('heroicon-o-user-group') ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())), Action::make('open_sync_runs') - ->label('Group Sync Runs') + ->label('Operations') ->icon('heroicon-o-clock') - ->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())), + ->url(fn (): string => OperationRunLinks::index(Tenant::current())), ]); } diff --git a/app/Models/BackupSchedule.php b/app/Models/BackupSchedule.php index 81172bf..ca1bb39 100644 --- a/app/Models/BackupSchedule.php +++ b/app/Models/BackupSchedule.php @@ -27,18 +27,13 @@ public function tenant(): BelongsTo return $this->belongsTo(Tenant::class); } - public function runs(): HasMany - { - return $this->hasMany(BackupScheduleRun::class); - } - public function operationRuns(): HasMany { return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id') ->whereIn('type', [ - 'backup_schedule.run_now', - 'backup_schedule.retry', - 'backup_schedule.scheduled', + 'backup_schedule_run', + 'backup_schedule_retention', + 'backup_schedule_purge', ]) ->where('context->backup_schedule_id', (int) $this->getKey()); } diff --git a/app/Models/BackupScheduleRun.php b/app/Models/BackupScheduleRun.php deleted file mode 100644 index 4091b20..0000000 --- a/app/Models/BackupScheduleRun.php +++ /dev/null @@ -1,53 +0,0 @@ - 'datetime', - 'started_at' => 'datetime', - 'finished_at' => 'datetime', - 'summary' => 'array', - ]; - - public function schedule(): BelongsTo - { - return $this->belongsTo(BackupSchedule::class, 'backup_schedule_id'); - } - - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - public function backupSet(): BelongsTo - { - return $this->belongsTo(BackupSet::class); - } -} diff --git a/app/Models/EntraGroupSyncRun.php b/app/Models/EntraGroupSyncRun.php deleted file mode 100644 index c02f752..0000000 --- a/app/Models/EntraGroupSyncRun.php +++ /dev/null @@ -1,40 +0,0 @@ - 'boolean', - 'started_at' => 'datetime', - 'finished_at' => 'datetime', - ]; - - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - - public function initiator(): BelongsTo - { - return $this->belongsTo(User::class, 'initiator_user_id'); - } -} diff --git a/app/Models/Finding.php b/app/Models/Finding.php index 5f94e31..e236895 100644 --- a/app/Models/Finding.php +++ b/app/Models/Finding.php @@ -37,12 +37,12 @@ public function tenant(): BelongsTo public function baselineRun(): BelongsTo { - return $this->belongsTo(InventorySyncRun::class, 'baseline_run_id'); + return $this->belongsTo(OperationRun::class, 'baseline_operation_run_id'); } public function currentRun(): BelongsTo { - return $this->belongsTo(InventorySyncRun::class, 'current_run_id'); + return $this->belongsTo(OperationRun::class, 'current_operation_run_id'); } public function acknowledgedByUser(): BelongsTo diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php index 44035b6..096b53a 100644 --- a/app/Models/InventoryItem.php +++ b/app/Models/InventoryItem.php @@ -25,7 +25,7 @@ public function tenant(): BelongsTo public function lastSeenRun(): BelongsTo { - return $this->belongsTo(InventorySyncRun::class, 'last_seen_run_id'); + return $this->belongsTo(OperationRun::class, 'last_seen_operation_run_id'); } public function lastSeenOperationRun(): BelongsTo diff --git a/app/Models/InventorySyncRun.php b/app/Models/InventorySyncRun.php deleted file mode 100644 index 7193444..0000000 --- a/app/Models/InventorySyncRun.php +++ /dev/null @@ -1,59 +0,0 @@ - */ - use HasFactory; - - public const STATUS_RUNNING = 'running'; - - public const STATUS_SUCCESS = 'success'; - - public const STATUS_PARTIAL = 'partial'; - - public const STATUS_FAILED = 'failed'; - - public const STATUS_SKIPPED = 'skipped'; - - protected $guarded = []; - - protected $casts = [ - 'selection_payload' => 'array', - 'had_errors' => 'boolean', - 'error_codes' => 'array', - 'error_context' => 'array', - 'started_at' => 'datetime', - 'finished_at' => 'datetime', - ]; - - public 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 - ->whereIn('status', [ - self::STATUS_SUCCESS, - self::STATUS_PARTIAL, - self::STATUS_FAILED, - self::STATUS_SKIPPED, - ]) - ->whereNotNull('finished_at'); - } -} diff --git a/app/Models/OperationRun.php b/app/Models/OperationRun.php index 4afaa36..6454951 100644 --- a/app/Models/OperationRun.php +++ b/app/Models/OperationRun.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Arr; class OperationRun extends Model { @@ -65,4 +66,65 @@ public function scopeActive(Builder $query): Builder { return $query->whereIn('status', ['queued', 'running']); } + + public function getSelectionHashAttribute(): ?string + { + $context = is_array($this->context) ? $this->context : []; + + return isset($context['selection_hash']) && is_string($context['selection_hash']) + ? $context['selection_hash'] + : null; + } + + public function setSelectionHashAttribute(?string $value): void + { + $context = is_array($this->context) ? $this->context : []; + $context['selection_hash'] = $value; + + $this->context = $context; + } + + /** + * @return array + */ + public function getSelectionPayloadAttribute(): array + { + $context = is_array($this->context) ? $this->context : []; + + return Arr::only($context, [ + 'policy_types', + 'categories', + 'include_foundations', + 'include_dependencies', + ]); + } + + /** + * @param array|null $value + */ + public function setSelectionPayloadAttribute(?array $value): void + { + $context = is_array($this->context) ? $this->context : []; + + if (is_array($value)) { + $context = array_merge($context, Arr::only($value, [ + 'policy_types', + 'categories', + 'include_foundations', + 'include_dependencies', + ])); + } + + $this->context = $context; + } + + public function getFinishedAtAttribute(): mixed + { + return $this->completed_at; + } + + public function setFinishedAtAttribute(mixed $value): void + { + $this->completed_at = $value; + } } diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 5d4015e..e441238 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -240,11 +240,6 @@ public function backupSchedules(): HasMany return $this->hasMany(BackupSchedule::class); } - public function backupScheduleRuns(): HasMany - { - return $this->hasMany(BackupScheduleRun::class); - } - public function policyVersions(): HasMany { return $this->hasMany(PolicyVersion::class); @@ -260,11 +255,6 @@ public function entraGroups(): HasMany return $this->hasMany(EntraGroup::class); } - public function entraGroupSyncRuns(): HasMany - { - return $this->hasMany(EntraGroupSyncRun::class); - } - public function auditLogs(): HasMany { return $this->hasMany(AuditLog::class); diff --git a/app/Notifications/RunStatusChangedNotification.php b/app/Notifications/RunStatusChangedNotification.php index 6805b59..44e93ac 100644 --- a/app/Notifications/RunStatusChangedNotification.php +++ b/app/Notifications/RunStatusChangedNotification.php @@ -2,7 +2,6 @@ namespace App\Notifications; -use App\Filament\Resources\EntraGroupSyncRunResource; use App\Filament\Resources\RestoreRunResource; use App\Models\Tenant; use App\Support\OperationRunLinks; @@ -61,16 +60,13 @@ public function toDatabase(object $notifiable): array $actions = []; - if (in_array($runType, ['bulk_operation', 'restore', 'directory_groups'], true) && $tenantId > 0 && $runId > 0) { + if ($tenantId > 0 && $runId > 0) { $tenant = Tenant::query()->find($tenantId); if ($tenant) { - $url = match ($runType) { - 'bulk_operation' => OperationRunLinks::view($runId, $tenant), - 'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), - 'directory_groups' => OperationRunLinks::view($runId, $tenant), - default => null, - }; + $url = $runType === 'restore' + ? RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant) + : OperationRunLinks::view($runId, $tenant); if (! $url) { return [ diff --git a/app/Policies/EntraGroupSyncRunPolicy.php b/app/Policies/EntraGroupSyncRunPolicy.php deleted file mode 100644 index 09865da..0000000 --- a/app/Policies/EntraGroupSyncRunPolicy.php +++ /dev/null @@ -1,39 +0,0 @@ -canAccessTenant($tenant); - } - - public function view(User $user, EntraGroupSyncRun $run): bool - { - $tenant = Tenant::current(); - - if (! $tenant) { - return false; - } - - if (! $user->canAccessTenant($tenant)) { - return false; - } - - return (int) $run->tenant_id === (int) $tenant->getKey(); - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 78ee8b6..96198e6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,7 +4,6 @@ use App\Models\BackupSchedule; use App\Models\EntraGroup; -use App\Models\EntraGroupSyncRun; use App\Models\Finding; use App\Models\OperationRun; use App\Models\ProviderCredential; @@ -16,7 +15,6 @@ use App\Observers\RestoreRunObserver; use App\Policies\BackupSchedulePolicy; use App\Policies\EntraGroupPolicy; -use App\Policies\EntraGroupSyncRunPolicy; use App\Policies\FindingPolicy; use App\Policies\OperationRunPolicy; use App\Services\Graph\GraphClientInterface; @@ -136,7 +134,6 @@ public function boot(): void Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class); Gate::policy(Finding::class, FindingPolicy::class); - Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class); Gate::policy(EntraGroup::class, EntraGroupPolicy::class); Gate::policy(OperationRun::class, OperationRunPolicy::class); } diff --git a/app/Services/BackupScheduling/BackupScheduleDispatcher.php b/app/Services/BackupScheduling/BackupScheduleDispatcher.php index ebaad3f..b03420b 100644 --- a/app/Services/BackupScheduling/BackupScheduleDispatcher.php +++ b/app/Services/BackupScheduling/BackupScheduleDispatcher.php @@ -67,7 +67,7 @@ public function dispatchDue(?array $tenantIdentifiers = null): array $operationRun = $this->operationRunService->ensureRunWithIdentityStrict( tenant: $schedule->tenant, - type: OperationRunType::BackupScheduleScheduled->value, + type: OperationRunType::BackupScheduleExecute->value, identityInputs: [ 'backup_schedule_id' => (int) $schedule->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), diff --git a/app/Services/Directory/EntraGroupSyncService.php b/app/Services/Directory/EntraGroupSyncService.php index e2bc547..a0e111a 100644 --- a/app/Services/Directory/EntraGroupSyncService.php +++ b/app/Services/Directory/EntraGroupSyncService.php @@ -30,7 +30,7 @@ public function startManualSync(Tenant $tenant, User $user): OperationRun $opService = app(OperationRunService::class); $opRun = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'directory_groups.sync', + type: 'entra_group_sync', identityInputs: ['selection_key' => $selectionKey], context: [ 'selection_key' => $selectionKey, diff --git a/app/Services/Drift/DriftFindingGenerator.php b/app/Services/Drift/DriftFindingGenerator.php index 1944925..99f7a8d 100644 --- a/app/Services/Drift/DriftFindingGenerator.php +++ b/app/Services/Drift/DriftFindingGenerator.php @@ -3,7 +3,7 @@ namespace App\Services\Drift; use App\Models\Finding; -use App\Models\InventorySyncRun; +use App\Models\OperationRun; use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; @@ -21,14 +21,14 @@ public function __construct( private readonly ScopeTagsNormalizer $scopeTagsNormalizer, ) {} - public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySyncRun $current, string $scopeKey): int + public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $current, string $scopeKey): int { - if (! $baseline->finished_at || ! $current->finished_at) { + if (! $baseline->completed_at || ! $current->completed_at) { throw new RuntimeException('Baseline/current run must be finished.'); } /** @var array $selection */ - $selection = is_array($current->selection_payload) ? $current->selection_payload : []; + $selection = is_array($current->context) ? $current->context : []; $policyTypes = Arr::get($selection, 'policy_types'); if (! is_array($policyTypes)) { @@ -114,8 +114,8 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy $finding->forceFill([ 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'scope_key' => $scopeKey, - 'baseline_run_id' => $baseline->getKey(), - 'current_run_id' => $current->getKey(), + 'baseline_operation_run_id' => $baseline->getKey(), + 'current_operation_run_id' => $current->getKey(), 'subject_type' => 'policy', 'subject_external_id' => (string) $policy->external_id, 'severity' => Finding::SEVERITY_MEDIUM, @@ -187,8 +187,8 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy $finding->forceFill([ 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'scope_key' => $scopeKey, - 'baseline_run_id' => $baseline->getKey(), - 'current_run_id' => $current->getKey(), + 'baseline_operation_run_id' => $baseline->getKey(), + 'current_operation_run_id' => $current->getKey(), 'subject_type' => 'assignment', 'subject_external_id' => (string) $policy->external_id, 'severity' => Finding::SEVERITY_MEDIUM, @@ -262,8 +262,8 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy $finding->forceFill([ 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'scope_key' => $scopeKey, - 'baseline_run_id' => $baseline->getKey(), - 'current_run_id' => $current->getKey(), + 'baseline_operation_run_id' => $baseline->getKey(), + 'current_operation_run_id' => $current->getKey(), 'subject_type' => 'scope_tag', 'subject_external_id' => (string) $policy->external_id, 'severity' => Finding::SEVERITY_MEDIUM, @@ -289,16 +289,16 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy return $created; } - private function versionForRun(Policy $policy, InventorySyncRun $run): ?PolicyVersion + private function versionForRun(Policy $policy, OperationRun $run): ?PolicyVersion { - if (! $run->finished_at) { + if (! $run->completed_at) { return null; } return PolicyVersion::query() ->where('tenant_id', $policy->tenant_id) ->where('policy_id', $policy->getKey()) - ->where('captured_at', '<=', $run->finished_at) + ->where('captured_at', '<=', $run->completed_at) ->latest('captured_at') ->first(); } diff --git a/app/Services/Drift/DriftRunSelector.php b/app/Services/Drift/DriftRunSelector.php index a320f5d..e761790 100644 --- a/app/Services/Drift/DriftRunSelector.php +++ b/app/Services/Drift/DriftRunSelector.php @@ -2,22 +2,29 @@ namespace App\Services\Drift; -use App\Models\InventorySyncRun; +use App\Models\OperationRun; use App\Models\Tenant; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; class DriftRunSelector { /** - * @return array{baseline:InventorySyncRun,current:InventorySyncRun}|null + * @return array{baseline:OperationRun,current:OperationRun}|null */ public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?array { - $runs = InventorySyncRun::query() + $runs = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('selection_hash', $scopeKey) - ->where('status', InventorySyncRun::STATUS_SUCCESS) - ->whereNotNull('finished_at') - ->orderByDesc('finished_at') + ->where('type', 'inventory_sync') + ->where('status', OperationRunStatus::Completed->value) + ->whereIn('outcome', [ + OperationRunOutcome::Succeeded->value, + OperationRunOutcome::PartiallySucceeded->value, + ]) + ->where('context->selection_hash', $scopeKey) + ->whereNotNull('completed_at') + ->orderByDesc('completed_at') ->limit(2) ->get(); @@ -28,7 +35,7 @@ public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?arr $current = $runs->first(); $baseline = $runs->last(); - if (! $baseline instanceof InventorySyncRun || ! $current instanceof InventorySyncRun) { + if (! $baseline instanceof OperationRun || ! $current instanceof OperationRun) { return null; } diff --git a/app/Services/Drift/DriftScopeKey.php b/app/Services/Drift/DriftScopeKey.php index f6e9405..fce5771 100644 --- a/app/Services/Drift/DriftScopeKey.php +++ b/app/Services/Drift/DriftScopeKey.php @@ -2,12 +2,14 @@ namespace App\Services\Drift; -use App\Models\InventorySyncRun; +use App\Models\OperationRun; class DriftScopeKey { - public function fromRun(InventorySyncRun $run): string + public function fromRun(OperationRun $run): string { - return (string) $run->selection_hash; + $context = is_array($run->context) ? $run->context : []; + + return (string) ($context['selection_hash'] ?? ''); } } diff --git a/app/Services/Inventory/InventoryMissingService.php b/app/Services/Inventory/InventoryMissingService.php index a14bc7d..76a3ffe 100644 --- a/app/Services/Inventory/InventoryMissingService.php +++ b/app/Services/Inventory/InventoryMissingService.php @@ -27,7 +27,7 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar $latestRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'inventory.sync') + ->where('type', 'inventory_sync') ->where('status', 'completed') ->where('context->selection_hash', $selectionHash) ->orderByDesc('completed_at') diff --git a/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php index 3a7c080..14acffc 100644 --- a/app/Services/Inventory/InventorySyncService.php +++ b/app/Services/Inventory/InventorySyncService.php @@ -3,7 +3,6 @@ namespace App\Services\Inventory; use App\Models\InventoryItem; -use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\ProviderConnection; use App\Models\Tenant; @@ -34,13 +33,13 @@ public function __construct( ) {} /** - * Runs an inventory sync immediately and persists a corresponding InventorySyncRun. + * Runs an inventory sync immediately and persists a canonical OperationRun. * * This is primarily used in tests and for synchronous workflows. * * @param array $selectionPayload */ - public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun + public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun { $computed = $this->normalizeAndHashSelection($selectionPayload); $normalizedSelection = $computed['selection']; @@ -54,40 +53,20 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR 'type' => OperationRunType::InventorySync->value, 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, - 'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory.sync:'.$selectionHash.':'.Str::uuid()->toString()), - 'context' => $normalizedSelection, - 'started_at' => now(), - ]); - - $run = InventorySyncRun::query()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'user_id' => null, - 'operation_run_id' => (int) $operationRun->getKey(), - 'selection_hash' => $selectionHash, - 'selection_payload' => $normalizedSelection, - 'status' => InventorySyncRun::STATUS_RUNNING, - 'had_errors' => false, + 'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory_sync:'.$selectionHash.':'.Str::uuid()->toString()), + 'context' => array_merge($normalizedSelection, [ + 'selection_hash' => $selectionHash, + ]), 'started_at' => now(), ]); $result = $this->executeSelection($operationRun, $tenant, $normalizedSelection); - $status = (string) ($result['status'] ?? InventorySyncRun::STATUS_FAILED); + $status = (string) ($result['status'] ?? 'failed'); $hadErrors = (bool) ($result['had_errors'] ?? true); - $errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : null; + $errorCodes = is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : []; $errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null; - $run->update([ - 'status' => $status, - 'had_errors' => $hadErrors, - 'error_codes' => $errorCodes, - 'error_context' => $errorContext, - 'items_observed_count' => (int) ($result['items_observed_count'] ?? 0), - 'items_upserted_count' => (int) ($result['items_upserted_count'] ?? 0), - 'errors_count' => (int) ($result['errors_count'] ?? 0), - 'finished_at' => now(), - ]); - $policyTypes = $normalizedSelection['policy_types'] ?? []; $policyTypes = is_array($policyTypes) ? $policyTypes : []; @@ -98,6 +77,28 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR default => OperationRunOutcome::Failed->value, }; + $failureSummary = []; + + if ($hadErrors && $errorCodes !== []) { + foreach (array_values(array_unique($errorCodes)) as $errorCode) { + if (! is_string($errorCode) || $errorCode === '') { + continue; + } + + $failureSummary[] = [ + 'code' => $errorCode, + 'message' => sprintf('Inventory sync reported %s.', str_replace('_', ' ', $errorCode)), + ]; + } + } + + $updatedContext = is_array($operationRun->context) ? $operationRun->context : []; + $updatedContext['result'] = [ + 'had_errors' => $hadErrors, + 'error_codes' => $errorCodes, + 'error_context' => $errorContext, + ]; + $operationRun->update([ 'status' => OperationRunStatus::Completed->value, 'outcome' => $operationOutcome, @@ -109,16 +110,18 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR 'items' => (int) ($result['items_observed_count'] ?? 0), 'updated' => (int) ($result['items_upserted_count'] ?? 0), ], + 'failure_summary' => $failureSummary, + 'context' => $updatedContext, 'completed_at' => now(), ]); - return $run->refresh(); + return $operationRun->refresh(); } /** * Runs an inventory sync (inline), enforcing locks/concurrency. * - * This method MUST NOT create or update InventorySyncRun rows; OperationRun is canonical. + * This method MUST NOT create or update legacy sync-run rows; OperationRun is canonical. * * @param array $selectionPayload * @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed diff --git a/app/Services/Providers/ProviderOperationRegistry.php b/app/Services/Providers/ProviderOperationRegistry.php index f75070e..e96f867 100644 --- a/app/Services/Providers/ProviderOperationRegistry.php +++ b/app/Services/Providers/ProviderOperationRegistry.php @@ -17,7 +17,7 @@ public function all(): array 'module' => 'health_check', 'label' => 'Provider connection check', ], - 'inventory.sync' => [ + 'inventory_sync' => [ 'provider' => 'microsoft', 'module' => 'inventory', 'label' => 'Inventory sync', diff --git a/app/Support/Badges/BadgeCatalog.php b/app/Support/Badges/BadgeCatalog.php index 4d03d4f..09a3b07 100644 --- a/app/Support/Badges/BadgeCatalog.php +++ b/app/Support/Badges/BadgeCatalog.php @@ -14,10 +14,7 @@ final class BadgeCatalog private const DOMAIN_MAPPERS = [ BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class, BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class, - BadgeDomain::InventorySyncRunStatus->value => Domains\InventorySyncRunStatusBadge::class, - BadgeDomain::BackupScheduleRunStatus->value => Domains\BackupScheduleRunStatusBadge::class, BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class, - BadgeDomain::EntraGroupSyncRunStatus->value => Domains\EntraGroupSyncRunStatusBadge::class, BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class, BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class, BadgeDomain::FindingStatus->value => Domains\FindingStatusBadge::class, diff --git a/app/Support/Badges/BadgeDomain.php b/app/Support/Badges/BadgeDomain.php index 50009ec..b8a93d4 100644 --- a/app/Support/Badges/BadgeDomain.php +++ b/app/Support/Badges/BadgeDomain.php @@ -6,10 +6,7 @@ enum BadgeDomain: string { case OperationRunStatus = 'operation_run_status'; case OperationRunOutcome = 'operation_run_outcome'; - case InventorySyncRunStatus = 'inventory_sync_run_status'; - case BackupScheduleRunStatus = 'backup_schedule_run_status'; case BackupSetStatus = 'backup_set_status'; - case EntraGroupSyncRunStatus = 'entra_group_sync_run_status'; case RestoreRunStatus = 'restore_run_status'; case RestoreCheckSeverity = 'restore_check_severity'; case FindingStatus = 'finding_status'; diff --git a/app/Support/Badges/Domains/BackupScheduleRunStatusBadge.php b/app/Support/Badges/Domains/BackupScheduleRunStatusBadge.php deleted file mode 100644 index cd60b74..0000000 --- a/app/Support/Badges/Domains/BackupScheduleRunStatusBadge.php +++ /dev/null @@ -1,26 +0,0 @@ - new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'), - BackupScheduleRun::STATUS_SUCCESS => new BadgeSpec('Success', 'success', 'heroicon-m-check-circle'), - BackupScheduleRun::STATUS_PARTIAL => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'), - BackupScheduleRun::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), - BackupScheduleRun::STATUS_CANCELED => new BadgeSpec('Canceled', 'gray', 'heroicon-m-minus-circle'), - BackupScheduleRun::STATUS_SKIPPED => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'), - default => BadgeSpec::unknown(), - }; - } -} diff --git a/app/Support/Badges/Domains/EntraGroupSyncRunStatusBadge.php b/app/Support/Badges/Domains/EntraGroupSyncRunStatusBadge.php deleted file mode 100644 index b01b341..0000000 --- a/app/Support/Badges/Domains/EntraGroupSyncRunStatusBadge.php +++ /dev/null @@ -1,25 +0,0 @@ - new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'), - EntraGroupSyncRun::STATUS_RUNNING => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'), - EntraGroupSyncRun::STATUS_SUCCEEDED => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'), - EntraGroupSyncRun::STATUS_PARTIAL => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'), - EntraGroupSyncRun::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), - default => BadgeSpec::unknown(), - }; - } -} diff --git a/app/Support/Badges/Domains/InventorySyncRunStatusBadge.php b/app/Support/Badges/Domains/InventorySyncRunStatusBadge.php deleted file mode 100644 index 8ebc9f6..0000000 --- a/app/Support/Badges/Domains/InventorySyncRunStatusBadge.php +++ /dev/null @@ -1,26 +0,0 @@ - new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'), - InventorySyncRun::STATUS_RUNNING => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'), - InventorySyncRun::STATUS_SUCCESS => new BadgeSpec('Success', 'success', 'heroicon-m-check-circle'), - InventorySyncRun::STATUS_PARTIAL => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'), - InventorySyncRun::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), - InventorySyncRun::STATUS_SKIPPED => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'), - default => BadgeSpec::unknown(), - }; - } -} diff --git a/app/Support/Inventory/InventorySyncStatusBadge.php b/app/Support/Inventory/InventorySyncStatusBadge.php deleted file mode 100644 index ffc18ed..0000000 --- a/app/Support/Inventory/InventorySyncStatusBadge.php +++ /dev/null @@ -1,55 +0,0 @@ - 'Success', - InventorySyncRun::STATUS_PARTIAL => 'Partial', - InventorySyncRun::STATUS_FAILED => 'Failed', - InventorySyncRun::STATUS_RUNNING => 'Running', - InventorySyncRun::STATUS_PENDING => 'Pending', - InventorySyncRun::STATUS_SKIPPED => 'Skipped', - 'queued' => 'Queued', - default => '—', - }; - - $color = match ($status) { - InventorySyncRun::STATUS_SUCCESS => 'success', - InventorySyncRun::STATUS_PARTIAL => 'warning', - InventorySyncRun::STATUS_FAILED => 'danger', - InventorySyncRun::STATUS_RUNNING => 'info', - InventorySyncRun::STATUS_PENDING, 'queued' => 'gray', - InventorySyncRun::STATUS_SKIPPED => 'gray', - default => 'gray', - }; - - $icon = match ($status) { - InventorySyncRun::STATUS_SUCCESS => 'heroicon-m-check-circle', - InventorySyncRun::STATUS_PARTIAL => 'heroicon-m-exclamation-triangle', - InventorySyncRun::STATUS_FAILED => 'heroicon-m-x-circle', - InventorySyncRun::STATUS_RUNNING => 'heroicon-m-arrow-path', - InventorySyncRun::STATUS_PENDING, 'queued' => 'heroicon-m-clock', - InventorySyncRun::STATUS_SKIPPED => 'heroicon-m-minus-circle', - default => 'heroicon-m-clock', - }; - - return [ - 'label' => $label, - 'color' => $color, - 'icon' => $icon, - ]; - } -} diff --git a/app/Support/OperationCatalog.php b/app/Support/OperationCatalog.php index 40af26f..d0009ba 100644 --- a/app/Support/OperationCatalog.php +++ b/app/Support/OperationCatalog.php @@ -19,18 +19,18 @@ public static function labels(): array 'policy.unignore' => 'Restore policies', 'policy.export' => 'Export policies to backup', 'provider.connection.check' => 'Provider connection check', - 'inventory.sync' => 'Inventory sync', + 'inventory_sync' => 'Inventory sync', 'compliance.snapshot' => 'Compliance snapshot', - 'directory_groups.sync' => 'Directory groups sync', - 'drift.generate' => 'Drift generation', + 'entra_group_sync' => 'Directory groups sync', + 'drift_generate_findings' => 'Drift generation', 'backup_set.add_policies' => 'Backup set update', 'backup_set.remove_policies' => 'Backup set update', 'backup_set.delete' => 'Archive backup sets', 'backup_set.restore' => 'Restore backup sets', 'backup_set.force_delete' => 'Delete backup sets', - 'backup_schedule.run_now' => 'Backup schedule run', - 'backup_schedule.retry' => 'Backup schedule retry', - 'backup_schedule.scheduled' => 'Backup schedule run', + 'backup_schedule_run' => 'Backup schedule run', + 'backup_schedule_retention' => 'Backup schedule retention', + 'backup_schedule_purge' => 'Backup schedule purge', 'restore.execute' => 'Restore execution', 'directory_role_definitions.sync' => 'Role definitions sync', 'restore_run.delete' => 'Delete restore runs', @@ -60,10 +60,10 @@ public static function expectedDurationSeconds(string $operationType): ?int 'policy.sync', 'policy.sync_one' => 90, 'provider.connection.check' => 30, 'policy.export' => 120, - 'inventory.sync' => 180, + 'inventory_sync' => 180, 'compliance.snapshot' => 180, - 'directory_groups.sync' => 120, - 'drift.generate' => 240, + 'entra_group_sync' => 120, + 'drift_generate_findings' => 240, default => null, }; } diff --git a/app/Support/OperationRunLinks.php b/app/Support/OperationRunLinks.php index 6f26d0e..6da3250 100644 --- a/app/Support/OperationRunLinks.php +++ b/app/Support/OperationRunLinks.php @@ -54,7 +54,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array $links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin'); } - if ($run->type === 'inventory.sync') { + if ($run->type === 'inventory_sync') { $links['Inventory'] = InventoryLanding::getUrl(panel: 'tenant', tenant: $tenant); } @@ -67,11 +67,11 @@ public static function related(OperationRun $run, ?Tenant $tenant): array } } - if ($run->type === 'directory_groups.sync') { + if ($run->type === 'entra_group_sync') { $links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant); } - if ($run->type === 'drift.generate') { + if ($run->type === 'drift_generate_findings') { $links['Drift'] = DriftLanding::getUrl(panel: 'tenant', tenant: $tenant); } @@ -84,7 +84,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array } } - if (in_array($run->type, ['backup_schedule.run_now', 'backup_schedule.retry'], true)) { + if (in_array($run->type, ['backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge'], true)) { $links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant); } diff --git a/app/Support/OperationRunType.php b/app/Support/OperationRunType.php index 1268ce5..b548bb6 100644 --- a/app/Support/OperationRunType.php +++ b/app/Support/OperationRunType.php @@ -4,16 +4,16 @@ enum OperationRunType: string { - case InventorySync = 'inventory.sync'; + case InventorySync = 'inventory_sync'; case PolicySync = 'policy.sync'; case PolicySyncOne = 'policy.sync_one'; - case DirectoryGroupsSync = 'directory_groups.sync'; - case DriftGenerate = 'drift.generate'; + case DirectoryGroupsSync = 'entra_group_sync'; + case DriftGenerate = 'drift_generate_findings'; case BackupSetAddPolicies = 'backup_set.add_policies'; case BackupSetRemovePolicies = 'backup_set.remove_policies'; - case BackupScheduleRunNow = 'backup_schedule.run_now'; - case BackupScheduleRetry = 'backup_schedule.retry'; - case BackupScheduleScheduled = 'backup_schedule.scheduled'; + case BackupScheduleExecute = 'backup_schedule_run'; + case BackupScheduleRetention = 'backup_schedule_retention'; + case BackupSchedulePurge = 'backup_schedule_purge'; case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync'; case RestoreExecute = 'restore.execute'; diff --git a/app/Support/Operations/OperationRunCapabilityResolver.php b/app/Support/Operations/OperationRunCapabilityResolver.php index b725faf..57791cb 100644 --- a/app/Support/Operations/OperationRunCapabilityResolver.php +++ b/app/Support/Operations/OperationRunCapabilityResolver.php @@ -15,9 +15,9 @@ public function requiredCapabilityForType(string $operationType): ?string } return match ($operationType) { - 'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN, - 'directory_groups.sync' => Capabilities::TENANT_SYNC, - 'backup_schedule.run_now', 'backup_schedule.retry', 'backup_schedule.scheduled' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN, + 'inventory_sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN, + 'entra_group_sync' => Capabilities::TENANT_SYNC, + 'backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN, 'restore.execute' => Capabilities::TENANT_MANAGE, 'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE, diff --git a/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php b/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php index 5ceab6d..dc81d40 100644 --- a/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php +++ b/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php @@ -36,7 +36,6 @@ public static function baseline(): self 'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.', 'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.', 'App\\Filament\\Resources\\BackupScheduleResource' => 'Backup schedule resource retrofit deferred to backup scheduling track.', - 'App\\Filament\\Resources\\BackupScheduleResource\\RelationManagers\\BackupScheduleRunsRelationManager' => 'Backup schedule runs relation manager retrofit deferred to backup scheduling track.', 'App\\Filament\\Resources\\BackupSetResource' => 'Backup set resource retrofit deferred to backup set track.', 'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.', 'App\\Filament\\Resources\\FindingResource' => 'Finding resource retrofit deferred to drift track.', diff --git a/database/factories/EntraGroupSyncRunFactory.php b/database/factories/EntraGroupSyncRunFactory.php deleted file mode 100644 index 9e5757c..0000000 --- a/database/factories/EntraGroupSyncRunFactory.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ -class EntraGroupSyncRunFactory extends Factory -{ - protected $model = EntraGroupSyncRun::class; - - public function definition(): array - { - return [ - 'tenant_id' => Tenant::factory(), - 'selection_key' => 'groups-v1:all', - 'slot_key' => null, - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => User::factory(), - 'pages_fetched' => 0, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'error_count' => 0, - 'safety_stop_triggered' => false, - 'safety_stop_reason' => null, - 'error_code' => null, - 'error_category' => null, - 'error_summary' => null, - 'started_at' => null, - 'finished_at' => null, - ]; - } - - public function scheduled(): static - { - return $this->state(fn (): array => [ - 'initiator_user_id' => null, - ]); - } -} diff --git a/database/factories/FindingFactory.php b/database/factories/FindingFactory.php index 408ce4e..3dbf160 100644 --- a/database/factories/FindingFactory.php +++ b/database/factories/FindingFactory.php @@ -22,8 +22,8 @@ public function definition(): array 'tenant_id' => Tenant::factory(), 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'scope_key' => hash('sha256', fake()->uuid()), - 'baseline_run_id' => null, - 'current_run_id' => null, + 'baseline_operation_run_id' => null, + 'current_operation_run_id' => null, 'fingerprint' => hash('sha256', fake()->uuid()), 'subject_type' => 'assignment', 'subject_external_id' => fake()->uuid(), diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php index 6cdffa2..e8d3d7f 100644 --- a/database/factories/InventoryItemFactory.php +++ b/database/factories/InventoryItemFactory.php @@ -32,7 +32,7 @@ public function definition(): array 'warnings' => [], ], 'last_seen_at' => now(), - 'last_seen_run_id' => null, + 'last_seen_operation_run_id' => null, ]; } } diff --git a/database/factories/InventorySyncRunFactory.php b/database/factories/InventorySyncRunFactory.php deleted file mode 100644 index 2246f57..0000000 --- a/database/factories/InventorySyncRunFactory.php +++ /dev/null @@ -1,43 +0,0 @@ - - */ -class InventorySyncRunFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - $selectionPayload = [ - 'policy_types' => ['deviceConfiguration'], - 'categories' => ['Configuration'], - 'include_foundations' => false, - 'include_dependencies' => false, - ]; - - return [ - 'tenant_id' => Tenant::factory(), - 'selection_hash' => hash('sha256', (string) json_encode($selectionPayload)), - 'selection_payload' => $selectionPayload, - 'status' => InventorySyncRun::STATUS_SUCCESS, - 'had_errors' => false, - 'error_codes' => [], - 'error_context' => null, - 'started_at' => now()->subMinute(), - 'finished_at' => now(), - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]; - } -} diff --git a/database/migrations/2026_02_10_004939_add_unique_index_for_backup_schedule_scheduled_operation_runs.php b/database/migrations/2026_02_10_004939_add_unique_index_for_backup_schedule_scheduled_operation_runs.php index 99ca489..6d41cd8 100644 --- a/database/migrations/2026_02_10_004939_add_unique_index_for_backup_schedule_scheduled_operation_runs.php +++ b/database/migrations/2026_02_10_004939_add_unique_index_for_backup_schedule_scheduled_operation_runs.php @@ -24,7 +24,7 @@ public function up(): void DB::statement(<<<'SQL' CREATE UNIQUE INDEX IF NOT EXISTS operation_runs_backup_schedule_scheduled_unique ON operation_runs (tenant_id, run_identity_hash) - WHERE type = 'backup_schedule.scheduled' + WHERE type = 'backup_schedule_run' SQL); } diff --git a/database/migrations/2026_02_12_000001_canonicalize_operation_run_types.php b/database/migrations/2026_02_12_000001_canonicalize_operation_run_types.php new file mode 100644 index 0000000..8387e7b --- /dev/null +++ b/database/migrations/2026_02_12_000001_canonicalize_operation_run_types.php @@ -0,0 +1,57 @@ + + */ + private array $upMap = [ + 'inventory.sync' => 'inventory_sync', + 'directory_groups.sync' => 'entra_group_sync', + 'drift.generate' => 'drift_generate_findings', + 'backup_schedule.run_now' => 'backup_schedule_run', + 'backup_schedule.retry' => 'backup_schedule_run', + 'backup_schedule.scheduled' => 'backup_schedule_run', + ]; + + /** + * @var array + */ + private array $downMap = [ + 'inventory_sync' => 'inventory.sync', + 'entra_group_sync' => 'directory_groups.sync', + 'drift_generate_findings' => 'drift.generate', + // Multiple legacy values are normalized into one canonical value. + 'backup_schedule_run' => 'backup_schedule.run_now', + ]; + + public function up(): void + { + if (! Schema::hasTable('operation_runs')) { + return; + } + + foreach ($this->upMap as $from => $to) { + DB::table('operation_runs') + ->where('type', $from) + ->update(['type' => $to]); + } + } + + public function down(): void + { + if (! Schema::hasTable('operation_runs')) { + return; + } + + foreach ($this->downMap as $from => $to) { + DB::table('operation_runs') + ->where('type', $from) + ->update(['type' => $to]); + } + } +}; diff --git a/database/migrations/2026_02_12_000002_add_operation_run_ids_to_findings_table.php b/database/migrations/2026_02_12_000002_add_operation_run_ids_to_findings_table.php new file mode 100644 index 0000000..e285793 --- /dev/null +++ b/database/migrations/2026_02_12_000002_add_operation_run_ids_to_findings_table.php @@ -0,0 +1,54 @@ +foreignId('baseline_operation_run_id') + ->nullable() + ->after('baseline_run_id') + ->constrained('operation_runs') + ->nullOnDelete(); + $table->index(['tenant_id', 'baseline_operation_run_id'], 'findings_tenant_baseline_operation_run_idx'); + } + + if (! Schema::hasColumn('findings', 'current_operation_run_id')) { + $table->foreignId('current_operation_run_id') + ->nullable() + ->after('current_run_id') + ->constrained('operation_runs') + ->nullOnDelete(); + $table->index(['tenant_id', 'current_operation_run_id'], 'findings_tenant_current_operation_run_idx'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('findings')) { + return; + } + + Schema::table('findings', function (Blueprint $table) { + if (Schema::hasColumn('findings', 'baseline_operation_run_id')) { + $table->dropIndex('findings_tenant_baseline_operation_run_idx'); + $table->dropConstrainedForeignId('baseline_operation_run_id'); + } + + if (Schema::hasColumn('findings', 'current_operation_run_id')) { + $table->dropIndex('findings_tenant_current_operation_run_idx'); + $table->dropConstrainedForeignId('current_operation_run_id'); + } + }); + } +}; diff --git a/database/migrations/2026_02_12_000003_backfill_findings_operation_run_ids.php b/database/migrations/2026_02_12_000003_backfill_findings_operation_run_ids.php new file mode 100644 index 0000000..ea1a555 --- /dev/null +++ b/database/migrations/2026_02_12_000003_backfill_findings_operation_run_ids.php @@ -0,0 +1,107 @@ +select(['id', 'baseline_run_id', 'current_run_id', 'baseline_operation_run_id', 'current_operation_run_id']) + ->orderBy('id') + ->chunkById(500, function (Collection $rows): void { + $legacyRunIds = $rows + ->flatMap(function (object $row): array { + return [ + $row->baseline_run_id, + $row->current_run_id, + ]; + }) + ->filter(fn (mixed $id): bool => is_numeric($id)) + ->map(fn (mixed $id): int => (int) $id) + ->unique() + ->values(); + + if ($legacyRunIds->isEmpty()) { + return; + } + + $operationRunIdByLegacyRunId = DB::table('inventory_sync_runs') + ->whereIn('id', $legacyRunIds->all()) + ->whereNotNull('operation_run_id') + ->pluck('operation_run_id', 'id') + ->map(fn (mixed $id): int => (int) $id); + + if ($operationRunIdByLegacyRunId->isEmpty()) { + return; + } + + $existingOperationRunIds = DB::table('operation_runs') + ->whereIn('id', $operationRunIdByLegacyRunId->values()->unique()->all()) + ->pluck('id') + ->mapWithKeys(fn (mixed $id): array => [(int) $id => true]); + + foreach ($rows as $row) { + $updates = []; + + if ($row->baseline_operation_run_id === null && is_numeric($row->baseline_run_id)) { + $candidate = $operationRunIdByLegacyRunId->get((int) $row->baseline_run_id); + + if (is_numeric($candidate) && $existingOperationRunIds->has((int) $candidate)) { + $updates['baseline_operation_run_id'] = (int) $candidate; + } + } + + if ($row->current_operation_run_id === null && is_numeric($row->current_run_id)) { + $candidate = $operationRunIdByLegacyRunId->get((int) $row->current_run_id); + + if (is_numeric($candidate) && $existingOperationRunIds->has((int) $candidate)) { + $updates['current_operation_run_id'] = (int) $candidate; + } + } + + if ($updates === []) { + continue; + } + + DB::table('findings') + ->where('id', (int) $row->id) + ->update($updates); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('findings')) { + return; + } + + $updates = []; + + if (Schema::hasColumn('findings', 'baseline_operation_run_id')) { + $updates['baseline_operation_run_id'] = null; + } + + if (Schema::hasColumn('findings', 'current_operation_run_id')) { + $updates['current_operation_run_id'] = null; + } + + if ($updates === []) { + return; + } + + DB::table('findings')->update($updates); + } +}; diff --git a/database/migrations/2026_02_12_000004_backfill_inventory_items_last_seen_operation_run_id.php b/database/migrations/2026_02_12_000004_backfill_inventory_items_last_seen_operation_run_id.php new file mode 100644 index 0000000..7f7f98e --- /dev/null +++ b/database/migrations/2026_02_12_000004_backfill_inventory_items_last_seen_operation_run_id.php @@ -0,0 +1,76 @@ +select(['id', 'last_seen_run_id', 'last_seen_operation_run_id']) + ->orderBy('id') + ->chunkById(500, function (Collection $rows): void { + $legacyRunIds = $rows + ->pluck('last_seen_run_id') + ->filter(fn (mixed $id): bool => is_numeric($id)) + ->map(fn (mixed $id): int => (int) $id) + ->unique() + ->values(); + + if ($legacyRunIds->isEmpty()) { + return; + } + + $operationRunIdByLegacyRunId = DB::table('inventory_sync_runs') + ->whereIn('id', $legacyRunIds->all()) + ->whereNotNull('operation_run_id') + ->pluck('operation_run_id', 'id') + ->map(fn (mixed $id): int => (int) $id); + + if ($operationRunIdByLegacyRunId->isEmpty()) { + return; + } + + $existingOperationRunIds = DB::table('operation_runs') + ->whereIn('id', $operationRunIdByLegacyRunId->values()->unique()->all()) + ->pluck('id') + ->mapWithKeys(fn (mixed $id): array => [(int) $id => true]); + + foreach ($rows as $row) { + if ($row->last_seen_operation_run_id !== null || ! is_numeric($row->last_seen_run_id)) { + continue; + } + + $candidate = $operationRunIdByLegacyRunId->get((int) $row->last_seen_run_id); + + if (! is_numeric($candidate) || ! $existingOperationRunIds->has((int) $candidate)) { + continue; + } + + DB::table('inventory_items') + ->where('id', (int) $row->id) + ->update(['last_seen_operation_run_id' => (int) $candidate]); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('inventory_items') || ! Schema::hasColumn('inventory_items', 'last_seen_operation_run_id')) { + return; + } + + DB::table('inventory_items')->update(['last_seen_operation_run_id' => null]); + } +}; diff --git a/database/migrations/2026_02_12_000005_drop_legacy_run_id_columns_from_findings_and_inventory_items.php b/database/migrations/2026_02_12_000005_drop_legacy_run_id_columns_from_findings_and_inventory_items.php new file mode 100644 index 0000000..1381ce6 --- /dev/null +++ b/database/migrations/2026_02_12_000005_drop_legacy_run_id_columns_from_findings_and_inventory_items.php @@ -0,0 +1,74 @@ +dropConstrainedForeignId('baseline_run_id'); + } + + if (Schema::hasColumn('findings', 'current_run_id')) { + $table->dropConstrainedForeignId('current_run_id'); + } + }); + } + + if (Schema::hasTable('inventory_items')) { + Schema::table('inventory_items', function (Blueprint $table) { + if (Schema::hasColumn('inventory_items', 'last_seen_run_id')) { + $table->dropConstrainedForeignId('last_seen_run_id'); + } + }); + } + } + + public function down(): void + { + if (! Schema::hasTable('inventory_sync_runs')) { + return; + } + + if (Schema::hasTable('findings')) { + Schema::table('findings', function (Blueprint $table) { + if (! Schema::hasColumn('findings', 'baseline_run_id')) { + $table->foreignId('baseline_run_id') + ->nullable() + ->after('scope_key') + ->constrained('inventory_sync_runs'); + $table->index(['tenant_id', 'baseline_run_id']); + } + + if (! Schema::hasColumn('findings', 'current_run_id')) { + $table->foreignId('current_run_id') + ->nullable() + ->after('baseline_run_id') + ->constrained('inventory_sync_runs'); + $table->index(['tenant_id', 'current_run_id']); + } + }); + } + + if (Schema::hasTable('inventory_items')) { + Schema::table('inventory_items', function (Blueprint $table) { + if (! Schema::hasColumn('inventory_items', 'last_seen_run_id')) { + $table->foreignId('last_seen_run_id') + ->nullable() + ->after('last_seen_at') + ->constrained('inventory_sync_runs') + ->nullOnDelete(); + } + }); + } + } +}; diff --git a/database/migrations/2026_02_12_000006_drop_legacy_run_tables.php b/database/migrations/2026_02_12_000006_drop_legacy_run_tables.php new file mode 100644 index 0000000..6dad093 --- /dev/null +++ b/database/migrations/2026_02_12_000006_drop_legacy_run_tables.php @@ -0,0 +1,100 @@ +id(); + $table->foreignId('tenant_id')->constrained(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('selection_hash', 64); + $table->jsonb('selection_payload')->nullable(); + $table->string('status'); + $table->boolean('had_errors')->default(false); + $table->jsonb('error_codes')->nullable(); + $table->jsonb('error_context')->nullable(); + $table->timestampTz('started_at')->nullable(); + $table->timestampTz('finished_at')->nullable(); + $table->unsignedInteger('items_observed_count')->default(0); + $table->unsignedInteger('items_upserted_count')->default(0); + $table->unsignedInteger('errors_count')->default(0); + $table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete(); + $table->timestamps(); + + $table->index(['tenant_id', 'selection_hash']); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'finished_at']); + $table->index(['tenant_id', 'user_id']); + $table->index('operation_run_id'); + }); + } + + if (! Schema::hasTable('entra_group_sync_runs')) { + Schema::create('entra_group_sync_runs', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('selection_key'); + $table->string('slot_key')->nullable(); + $table->string('status'); + $table->string('error_code')->nullable(); + $table->string('error_category')->nullable(); + $table->text('error_summary')->nullable(); + $table->boolean('safety_stop_triggered')->default(false); + $table->string('safety_stop_reason')->nullable(); + $table->unsignedInteger('pages_fetched')->default(0); + $table->unsignedInteger('items_observed_count')->default(0); + $table->unsignedInteger('items_upserted_count')->default(0); + $table->unsignedInteger('error_count')->default(0); + $table->foreignId('initiator_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestampTz('started_at')->nullable(); + $table->timestampTz('finished_at')->nullable(); + $table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete(); + $table->timestamps(); + + $table->index(['tenant_id', 'selection_key']); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'finished_at']); + $table->index('operation_run_id'); + $table->unique(['tenant_id', 'selection_key', 'slot_key']); + }); + } + + if (! Schema::hasTable('backup_schedule_runs')) { + Schema::create('backup_schedule_runs', function (Blueprint $table) { + $table->id(); + $table->foreignId('backup_schedule_id')->constrained('backup_schedules')->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->dateTime('scheduled_for'); + $table->dateTime('started_at')->nullable(); + $table->dateTime('finished_at')->nullable(); + $table->enum('status', ['running', 'success', 'partial', 'failed', 'canceled', 'skipped']); + $table->json('summary')->nullable(); + $table->string('error_code')->nullable(); + $table->text('error_message')->nullable(); + $table->foreignId('backup_set_id')->nullable()->constrained('backup_sets')->nullOnDelete(); + $table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['backup_schedule_id', 'scheduled_for']); + $table->index(['backup_schedule_id', 'scheduled_for']); + $table->index(['tenant_id', 'created_at']); + $table->index(['user_id', 'created_at'], 'backup_schedule_runs_user_created'); + $table->index('operation_run_id'); + }); + } + } +}; diff --git a/specs/087-legacy-runs-removal/checklists/requirements.md b/specs/087-legacy-runs-removal/checklists/requirements.md new file mode 100644 index 0000000..3deb7c3 --- /dev/null +++ b/specs/087-legacy-runs-removal/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Legacy Runs Removal + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-12 +**Feature**: [specs/087-legacy-runs-removal/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 + +- Validation pass on 2026-02-12. Ready for `/speckit.plan`. \ No newline at end of file diff --git a/specs/087-legacy-runs-removal/contracts/operations-runs.openapi.yaml b/specs/087-legacy-runs-removal/contracts/operations-runs.openapi.yaml new file mode 100644 index 0000000..8e11f12 --- /dev/null +++ b/specs/087-legacy-runs-removal/contracts/operations-runs.openapi.yaml @@ -0,0 +1,59 @@ +openapi: 3.0.3 +info: + title: TenantPilot Operations Runs (UI endpoints) + version: "1.0" + description: | + Minimal contract describing the canonical Operations run list and detail endpoints. + + Note: These are Filament (server-rendered / Livewire) endpoints, not a public JSON API. +servers: + - url: / +paths: + /admin/monitoring/operations: + get: + summary: Operations run list (canonical) + description: Canonical list of operation runs scoped by workspace entitlement. + responses: + "200": + description: HTML page + content: + text/html: + schema: + type: string + "302": + description: Redirect to login + /admin/operations/{runId}: + get: + summary: Operations run detail (canonical) + description: Canonical tenantless run viewer. + parameters: + - name: runId + in: path + required: true + schema: + type: integer + responses: + "200": + description: HTML page + content: + text/html: + schema: + type: string + "403": + description: Workspace member but missing capability + "404": + description: Not entitled to workspace scope (deny-as-not-found) + "302": + description: Redirect to login +components: + schemas: + OperationRunType: + type: string + description: Canonical run types created by this feature. + enum: + - inventory_sync + - drift_generate_findings + - entra_group_sync + - backup_schedule_run + - backup_schedule_retention + - backup_schedule_purge diff --git a/specs/087-legacy-runs-removal/data-model.md b/specs/087-legacy-runs-removal/data-model.md new file mode 100644 index 0000000..cca7870 --- /dev/null +++ b/specs/087-legacy-runs-removal/data-model.md @@ -0,0 +1,109 @@ +# Phase 1 — Data Model: Legacy Runs Removal (Spec 087) + +**Branch**: `087-legacy-runs-removal` +**Date**: 2026-02-12 + +## Canonical Entity: OperationRun + +**Table**: `operation_runs` +**Model**: `App\\Models\\OperationRun` + +Key fields (existing): +- `id` +- `workspace_id` (required) +- `tenant_id` (nullable; tenant-scoped runs use this) +- `user_id` (nullable) +- `initiator_name` +- `type` (canonical run_type) +- `status` / `outcome` +- `run_identity_hash` (idempotency) +- `summary_counts` (JSON) +- `failure_summary` (JSON) +- `context` (JSON) +- `started_at` / `completed_at` +- `created_at` / `updated_at` + +Spec impact: +- `type` must be standardized to the underscore identifiers listed in FR-012. + +## Legacy Entities (To Remove) + +### InventorySyncRun + +**Table**: `inventory_sync_runs` +**Model**: `App\\Models\\InventorySyncRun` + +Notes: +- Acts as a legacy duplicate run store. +- Has `operation_run_id` bridge column (added later). + +**Plan**: remove model + table, and rely on `operation_runs`. + +### EntraGroupSyncRun + +**Table**: `entra_group_sync_runs` +**Model**: `App\\Models\\EntraGroupSyncRun` + +Notes: +- Legacy duplicate run store. +- Has `operation_run_id` bridge column. + +**Plan**: remove model + table, and rely on `operation_runs`. + +### BackupScheduleRun + +**Table**: `backup_schedule_runs` +**Model**: `App\\Models\\BackupScheduleRun` + +Notes: +- Currently used for schedule run history + retention/purge selection. +- Has `operation_run_id` bridge column. + +**Plan**: remove model + table and replace schedule “run history” with querying `operation_runs` by type + context. + +## Drift Findings: References Must Become Canonical + +**Table**: `findings` +**Model**: `App\\Models\\Finding` + +Current fields (relevant): +- `baseline_run_id` → FK to `inventory_sync_runs` (nullable) +- `current_run_id` → FK to `inventory_sync_runs` (nullable) + +**Planned fields**: +- `baseline_operation_run_id` → FK to `operation_runs` (nullable) +- `current_operation_run_id` → FK to `operation_runs` (nullable) + +**Backfill rule**: +- If `baseline_run_id` points to an `inventory_sync_runs` row with a non-null `operation_run_id`, and that `operation_runs` row exists, copy it. +- Same for `current_run_id`. +- Otherwise leave null (matches spec edge-case expectations). + +## Inventory Items: Last Seen Run Reference Must Become Canonical + +**Table**: `inventory_items` +**Model**: `App\\Models\\InventoryItem` + +Current fields (relevant): +- `last_seen_run_id` → FK to `inventory_sync_runs` (nullable) +- `last_seen_operation_run_id` → FK to `operation_runs` (nullable; already exists) + +**Plan**: +- Backfill `last_seen_operation_run_id` via `inventory_sync_runs.operation_run_id` where possible. +- Drop `last_seen_run_id` after code is migrated. + +## Backup Schedules: Run History + +**Table**: `backup_schedules` +**Model**: `App\\Models\\BackupSchedule` + +Planned behavior: +- Remove `runs()` relationship that points to `backup_schedule_runs`. +- For UI/history, query `operation_runs` using: + - `type = backup_schedule_run|backup_schedule_retention|backup_schedule_purge` + - `context->backup_schedule_id = {id}` (and optionally scheduled time metadata) + +## Validation Rules (from spec) + +- All new run records created by this feature must have `type` in the FR-012 allow-list. +- Run visibility is workspace-scoped; non-members must be deny-as-not-found (404). diff --git a/specs/087-legacy-runs-removal/plan.md b/specs/087-legacy-runs-removal/plan.md new file mode 100644 index 0000000..7585ece --- /dev/null +++ b/specs/087-legacy-runs-removal/plan.md @@ -0,0 +1,144 @@ +# Implementation Plan: Legacy Runs Removal (Spec 087) + +**Branch**: `087-legacy-runs-removal` | **Date**: 2026-02-12 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/spec.md` + +## Summary + +Remove the “legacy run” worlds for inventory sync, Entra group sync, and backup schedules so that `operation_runs` is the only run tracking source. Migrate drift + inventory references away from `inventory_sync_runs`, remove legacy Filament run UI surfaces (no redirects), drop legacy run tables, and add an architecture guard test (`NoLegacyRuns`) to prevent regressions. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4 +**Storage**: PostgreSQL (Sail), SQLite in tests +**Testing**: Pest v4 (PHPUnit 12) +**Target Platform**: Containerized web app (Sail locally, Dokploy deploy) +**Project Type**: Web application (Laravel + Filament) +**Performance Goals**: Monitoring/Operations pages render DB-only; avoid N+1 when listing runs +**Constraints**: Legacy run URLs must be not found (no redirects). This does not apply to the existing tenant-scoped operations index convenience redirect (`/admin/t/{tenant}/operations` → `/admin/operations`). Non-member workspace access is 404; member missing capability is 403 +**Scale/Scope**: TenantPilot admin app; operations visibility + drift references are core operational surfaces + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Workspace isolation / RBAC-UX (404 vs 403): PASS (required by spec FR-008) +- Run observability: PASS (this feature eliminates duplicate run stores) +- Monitoring pages DB-only: PASS (no new render-time Graph calls) +- Badge semantics (BADGE-001): PASS (legacy run badge mappings are removed, not expanded) +- Filament action surface contract: PASS (feature mostly removes surfaces; modified surfaces must preserve inspection affordance + RBAC) + +## Project Structure + +### Documentation (this feature) + +```text +/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── operations-runs.openapi.yaml +└── checklists/ + └── requirements.md + +### Source Code (repository root) +```text +/Users/ahmeddarrazi/Documents/projects/TenantAtlas/ +├── app/ +│ ├── Filament/ +│ ├── Jobs/ +│ ├── Models/ +│ ├── Services/ +│ └── Support/ +├── database/ +│ ├── migrations/ +│ ├── factories/ +│ └── seeders/ +├── resources/ +├── routes/ +└── tests/ + └── Feature/ + └── Guards/ +``` + +**Structure Decision**: Laravel monolith with Filament resources/pages; this feature touches `app/`, `database/migrations/`, `resources/`, `routes/`, and `tests/Feature/`. + +## Phase 0 — Outline & Research (COMPLETE) + +Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/research.md` + +Research resolves remaining planning unknowns: +- Identifies legacy run tables and UI surfaces to remove. +- Resolves the `run_type` contract mismatch (spec underscore values vs current dotted enum values). +- Defines an FK cutover plan for drift + inventory references so legacy tables can be dropped safely. + +## Phase 1 — Design & Contracts (COMPLETE) + +Outputs: +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/contracts/operations-runs.openapi.yaml` +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/quickstart.md` + +Post-design constitution re-check: PASS (no new Graph/render concerns; run observability strengthened). + +## Phase 2 — Implementation Plan + +### Ordering constraints + +- Drift + inventory FKs currently point at legacy run tables; migrate those references before dropping tables. +- Legacy run pages must be removed without redirects; verify with 404 tests. + +### Step A — Canonical run types (FR-012) + +- Align stored `operation_runs.type` values with the underscore allow-list for the affected workflows. +- Provide a migration that rewrites existing dotted values used today into the canonical underscore forms where safe. + +### Step B — Stop legacy writes + remove legacy read dependencies + +- Inventory sync: eliminate writes to `inventory_sync_runs` and use `operation_runs` only. +- Entra group sync: eliminate writes to `entra_group_sync_runs` and use `operation_runs` only. +- Backup schedule: + - Eliminate writes to `backup_schedule_runs`. + - Persist schedule metadata in `operation_runs.context` (e.g., `backup_schedule_id`, `scheduled_for`, `reason`). + - Ensure retention and purge each create their own canonical runs (FR-011). + +### Step C — Drift + inventory reference cutover + +- Add `findings.baseline_operation_run_id` / `findings.current_operation_run_id` and backfill via `inventory_sync_runs.operation_run_id`. +- Backfill `inventory_items.last_seen_operation_run_id` where only `last_seen_run_id` exists. +- Update app code to use the canonical columns and tolerate null mappings. +- Drop legacy run ID columns after cutover. + +### Step D — Remove legacy UI surfaces (FR-003/FR-004) + +- Remove Filament resources/pages: + - `InventorySyncRunResource` + - `EntraGroupSyncRunResource` +- Remove backup schedule legacy run history relation manager + modal. +- Update links in drift/finding/inventory surfaces to use the canonical operations viewer. +- Keep (or explicitly remove) the tenant-scoped operations index convenience redirect separately; it is not a legacy *run* page. + +### Step E — Drop legacy tables + +- Drop `inventory_sync_runs`, `entra_group_sync_runs`, and `backup_schedule_runs` after FK cutover. + +### Step F — Architecture guard (FR-009) + +- Add a guard test under `tests/Feature/Guards/` scanning `app/`, `database/`, `resources/`, and `routes/` for legacy run tokens. + +### Test plan (minimum) + +- New: `NoLegacyRunsTest` (architecture guard) +- Legacy routes: assert old run URLs return 404 (no redirects) +- Drift + inventory: + - new records store canonical run references + - historical records without safe mapping render with null references + +## Complexity Tracking + +No constitution violations are required for this plan. + diff --git a/specs/087-legacy-runs-removal/quickstart.md b/specs/087-legacy-runs-removal/quickstart.md new file mode 100644 index 0000000..0495682 --- /dev/null +++ b/specs/087-legacy-runs-removal/quickstart.md @@ -0,0 +1,36 @@ +# Quickstart: Legacy Runs Removal (Spec 087) + +**Branch**: `087-legacy-runs-removal` + +## Local Dev (Sail) + +1) Start containers: + +- `vendor/bin/sail up -d` + +2) Run migrations: + +- `vendor/bin/sail artisan migrate` + +3) Run targeted tests (once implementation exists): + +- Minimum required pack: + - `vendor/bin/sail artisan test --compact tests/Feature/Guards/NoLegacyRunsTest.php tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php tests/Feature/Monitoring/MonitoringOperationsTest.php tests/Feature/Drift` +- Backup + directory regressions: + - `vendor/bin/sail artisan test --compact tests/Feature/BackupScheduling tests/Feature/DirectoryGroups` +- Canonical run flow checks: + - `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/Console/PurgeNonPersistentDataCommandTest.php tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php` + +## Manual Verification Checklist (once implementation exists) + +- Inventory sync run appears in Monitoring → Operations and opens via the canonical viewer. +- Entra group sync run appears in Monitoring → Operations and opens via the canonical viewer. +- Backup schedule run/retention/purge each appear in Monitoring → Operations. +- Legacy run URLs return 404 (no redirects): + - Inventory sync runs resource route + - Entra group sync runs resource route + - Backup schedule run history relation manager is removed from schedule detail + +## Notes + +- Per spec clarifications, no backfill of legacy run history is performed. Only reference migration from legacy IDs to existing `operation_runs` IDs is expected. diff --git a/specs/087-legacy-runs-removal/research.md b/specs/087-legacy-runs-removal/research.md new file mode 100644 index 0000000..202b55b --- /dev/null +++ b/specs/087-legacy-runs-removal/research.md @@ -0,0 +1,120 @@ +# Phase 0 — Research: Legacy Runs Removal (Spec 087) + +**Branch**: `087-legacy-runs-removal` +**Date**: 2026-02-12 +**Input Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/spec.md` + +## What “Legacy Runs” Means In This Repo + +**Decision**: “Legacy runs” for this spec are the tenant-scoped run tables + UI surfaces that duplicate `operation_runs` for: +- Inventory sync +- Entra group sync +- Backup schedule execution + +**Evidence (current repo state)**: +- Legacy tables exist: + - `inventory_sync_runs` ([database/migrations/2026_01_07_142719_create_inventory_sync_runs_table.php](../../database/migrations/2026_01_07_142719_create_inventory_sync_runs_table.php)) + - `entra_group_sync_runs` ([database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php](../../database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php)) + - `backup_schedule_runs` ([database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php](../../database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php)) +- These legacy tables already have bridging columns to canonical runs (`operation_run_id` was added later), indicating an ongoing migration to `operation_runs`: + - [database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php](../../database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php) + - [database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php](../../database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php) + - [database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php](../../database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php) + +## Canonical Run Types: Spec vs Current Code + +### Problem +The spec mandates canonical `run_type` identifiers: +- `inventory_sync` +- `drift_generate_findings` +- `entra_group_sync` +- `backup_schedule_run` +- `backup_schedule_retention` +- `backup_schedule_purge` + +But the current code uses dotted `OperationRunType` values such as: +- `inventory.sync` +- `directory_groups.sync` +- `drift.generate` +- `backup_schedule.run_now` / `backup_schedule.retry` / `backup_schedule.scheduled` + +([app/Support/OperationRunType.php](../../app/Support/OperationRunType.php)) + +### Decision +Adopt the spec’s underscore identifiers as the canonical stored values going forward. + +### Rationale +- The spec explicitly requires a standardized and enforced contract. +- Stored `type` values are used across UI and notifications; a single canonical scheme reduces “type sprawl.” + +### Backwards Compatibility Plan (for existing rows) +- Provide a one-time data migration that rewrites existing `operation_runs.type` values from the old dotted values to the new underscore values *for the affected categories only*. +- Keep a compatibility mapping in code (temporary) only if needed to avoid breaking filters/UI during rollout. + +### Alternatives Considered +- Keep dotted values and treat the spec’s list as “display labels.” Rejected because it violates FR-012’s explicit identifiers. +- Introduce a parallel “canonical_type” column. Rejected because it increases complexity and duplication. + +## Drift + Inventory References (FK Cutover) + +### Problem +Drift and inventory currently reference legacy run IDs: +- Findings: `baseline_run_id` / `current_run_id` are FKs to `inventory_sync_runs` ([database/migrations/2026_01_13_223311_create_findings_table.php](../../database/migrations/2026_01_13_223311_create_findings_table.php)) +- Inventory items: `last_seen_run_id` references `inventory_sync_runs` ([database/migrations/2026_01_07_142720_create_inventory_items_table.php](../../database/migrations/2026_01_07_142720_create_inventory_items_table.php)) + +But inventory items already have the new canonical field: +- `last_seen_operation_run_id` ([database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php](../../database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php)) + +### Decision +Migrate drift + inventory references to `operation_runs` and drop the legacy FK columns. + +### Migration Strategy +- Add new nullable columns: + - `findings.baseline_operation_run_id` + - `findings.current_operation_run_id` +- Backfill by joining through `inventory_sync_runs.operation_run_id` where available. +- Update app code to read/write the new fields. +- Drop old `*_run_id` FK columns. +- Only after that, drop the legacy tables. + +### Rationale +- Required to drop legacy tables without breaking referential integrity. +- Aligns with spec acceptance scenario: “historical drift findings where no safe mapping exists” remain functional (references can be null). + +## Legacy UI Surfaces Found (To Remove) + +**Inventory sync runs**: +- [app/Filament/Resources/InventorySyncRunResource.php](../../app/Filament/Resources/InventorySyncRunResource.php) + +**Entra group sync runs**: +- [app/Filament/Resources/EntraGroupSyncRunResource.php](../../app/Filament/Resources/EntraGroupSyncRunResource.php) + +**Backup schedule run history** (RelationManager + modal): +- [app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php](../../app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php) + +## Architecture Guard (NoLegacyRuns) + +### Decision +Add a CI/architecture Pest test similar to existing “NoLegacy…” guards: +- [tests/Feature/Guards/NoLegacyBulkOperationsTest.php](../../tests/Feature/Guards/NoLegacyBulkOperationsTest.php) + +### Guard Scope (per spec clarification) +Scan for forbidden tokens under: +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/` +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/database/` +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/` +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/routes/` + +Exclude: +- `vendor/`, `storage/`, `specs/`, `spechistory/`, `references/`, `bootstrap/cache/` + +### Initial Forbidden Tokens (starting list) +- `InventorySyncRun`, `inventory_sync_runs`, `InventorySyncRunResource` +- `EntraGroupSyncRun`, `entra_group_sync_runs`, `EntraGroupSyncRunResource` +- `BackupScheduleRun`, `backup_schedule_runs`, `BackupScheduleRunsRelationManager` + +## Open Questions +None remaining for planning. + +- “No backfill” is already clarified in the spec; the only data movement here is migrating *references* to existing `operation_runs`. +- Authorization semantics for canonical operations pages are clarified (404 vs 403) and must be preserved. diff --git a/specs/087-legacy-runs-removal/spec.md b/specs/087-legacy-runs-removal/spec.md new file mode 100644 index 0000000..c57c581 --- /dev/null +++ b/specs/087-legacy-runs-removal/spec.md @@ -0,0 +1,146 @@ +# Feature Specification: Legacy Runs Removal + +**Feature Branch**: `087-legacy-runs-removal` +**Created**: 2026-02-12 +**Status**: Draft +**Input**: User description: "Spec 087 — Legacy Runs Removal (RIGOROS)" + +## Clarifications + +### Session 2026-02-12 + +- Q: Should we backfill legacy run history into the canonical run system before dropping legacy tables? → A: No backfill (accept losing pre-existing legacy run history) +- Q: Should backup schedule retention/purge produce canonical run records? → A: Yes — retention and purge each produce their own canonical runs +- Q: Which canonical run_type contract should be enforced? → A: Use the explicit list: inventory_sync, drift_generate_findings, entra_group_sync, backup_schedule_run, backup_schedule_retention, backup_schedule_purge +- Q: For canonical run URLs, should access be 404 for non-members and 403 for members missing capability? → A: Yes — non-member/not entitled → 404; member missing capability → 403 +- Q: How broad should the “NoLegacyRuns” architecture guard scan be? → A: Scan app/, database/, resources/, and routes/ (exclude specs/ and references/) + +## Redirect Scope + +This spec’s “no redirects” requirement applies to legacy *run* pages and legacy run UI surfaces. + +The existing convenience route that redirects the tenant-scoped operations index to the canonical operations index (for example: `/admin/t/{tenant}/operations` → `/admin/operations`) is not considered a legacy run page. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Single canonical run history (Priority: P1) + +As a workspace member, I can see and open run history for inventory sync, Entra group sync, and backup schedules in a single, consistent place, without having to navigate multiple legacy run screens. + +**Why this priority**: Removes duplicated tracking and inconsistent UX, and reduces confusion about which “run” is authoritative. + +**Independent Test**: Trigger each run type once and verify it appears in the canonical Operations run list and can be opened via the canonical run detail view. + +**Acceptance Scenarios**: + +1. **Given** a workspace member with permission to view operations, **When** a new inventory sync run starts and finishes, **Then** a corresponding canonical run exists and is viewable via the canonical Operations UI. +2. **Given** a workspace member with permission to view operations, **When** a new Entra group sync run starts and finishes, **Then** a corresponding canonical run exists and is viewable via the canonical Operations UI. +3. **Given** a workspace member with permission to view operations, **When** a backup schedule run starts and finishes, **Then** a corresponding canonical run exists and is viewable via the canonical Operations UI. + +--- + +### User Story 2 - Legacy run UI is intentionally gone (Priority: P1) + +As a workspace member, I cannot access legacy run pages anymore. Old links intentionally fail, so there is no ambiguity about where runs are viewed. + +**Why this priority**: Prevents “run sprawl” and ensures there is exactly one canonical run UI. + +**Independent Test**: Attempt to access any known legacy run route and verify it is not available. + +**Acceptance Scenarios**: + +1. **Given** a user (any role), **When** they attempt to access a legacy run URL, **Then** the application responds as not found. +2. **Given** the application navigation, **When** a user browses the admin UI, **Then** no legacy run resources appear in navigation. + +--- + +### User Story 3 - Drift references canonical runs (Priority: P1) + +As a workspace member using drift-related features, I see baseline/current references tied to canonical runs, and drift logic does not depend on a legacy run store. + +**Why this priority**: Drift currently has structural coupling to a legacy run concept; removing it reduces risk and complexity. + +**Independent Test**: Create drift findings that reference baseline/current runs and confirm the references use canonical runs (or are empty if historical data cannot be mapped). + +**Acceptance Scenarios**: + +1. **Given** drift findings created after this feature ships, **When** a user views baseline/current references, **Then** the references point to canonical runs. +2. **Given** historical drift findings where no safe mapping exists, **When** a user views baseline/current references, **Then** the UI remains functional and references are empty rather than erroring. + +--- + +### Edge Cases + +- Historical runs exist with details that were only stored in legacy run records, and this history will not be backfilled into canonical runs. +- A user is a non-member of the workspace and attempts to view a run. +- A user is a workspace member but lacks the capability to view operations. +- A scheduled backup run is skipped/cancelled and still needs a canonical run record with a clear final outcome. +- Retention/purge runs remove old data; the user expects runs to remain auditable and consistent. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes long-running work and run tracking. It MUST ensure run observability via the canonical run system, consistent identity, and workspace-scoped visibility. + +**Constitution alignment (RBAC-UX):** This feature changes run visibility and removes legacy pages. It MUST explicitly define not-found vs forbidden behavior and enforce authorization server-side. + +**Constitution alignment (BADGE-001):** Any run status-like badges MUST be derived from canonical run status/outcome to keep semantics centralized. + +**Constitution alignment (Filament Action Surfaces):** This feature removes legacy run resources and updates run links to point to the canonical operations views. + +### Functional Requirements + +- **FR-001**: The system MUST treat the canonical run system as the single source of truth for all run tracking and display. +- **FR-002**: The system MUST NOT create or update legacy run records for inventory sync, Entra group sync, or backup schedule runs. +- **FR-003**: The system MUST remove all legacy run UI surfaces (resources/pages/relation managers/badges) so that runs are only accessible from the canonical Operations UI. +- **FR-004**: Requests to legacy run pages MUST behave as not found (no redirects). +- **FR-005**: Drift-related entities MUST reference canonical runs for baseline/current/last-seen run linkage. +- **FR-006**: Backup schedule retention/purge workflows MUST operate without relying on legacy run stores. +- **FR-007**: Entra group sync run history and links MUST reference canonical runs only. +- **FR-008**: The system MUST enforce workspace-first access rules for canonical run list and detail views: + - non-member or not entitled to the workspace scope → not found (404) + - member but missing capability → forbidden (403) +- **FR-009**: Automated architecture checks MUST prevent reintroduction of legacy run concepts by failing CI if legacy tokens are found anywhere in: `app/`, `database/`, `resources/`, `routes/`. +- **FR-010**: The system MUST NOT backfill historical legacy run records into the canonical run system; only new runs are guaranteed to be represented as canonical runs. +- **FR-011**: Backup schedule retention and purge executions MUST each create their own canonical run records, observable in the canonical Operations UI. +- **FR-012**: The system MUST standardize and enforce the canonical `run_type` identifiers for runs created by this feature: + - `inventory_sync` + - `drift_generate_findings` + - `entra_group_sync` + - `backup_schedule_run` + - `backup_schedule_retention` + - `backup_schedule_purge` + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Operations run list | Admin UI | None | Filtered list + open run | View | None | None | N/A | N/A | Yes (via canonical runs) | All run types converge here | +| Operations run detail | Admin UI | None | N/A | N/A | None | None | None | N/A | Yes (via canonical runs) | Single canonical view for run details | +| Backup schedule detail | Admin UI | None | Links to canonical runs | View | None | None | N/A | N/A | Yes | Legacy run tab removed | + +### Key Entities *(include if feature involves data)* + +- **Operation Run**: A canonical record representing a long-running operation, including type, status, timestamps, context, and summary. +- **Drift Finding**: A drift detection result that may reference baseline and current runs. +- **Inventory Item**: An inventory record that may reference the last run in which it was observed. +- **Backup Schedule**: A recurring configuration that triggers backup operations and may trigger retention/purge operations. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of new inventory sync, Entra group sync, and backup schedule executions produce exactly one canonical run record that is visible in the canonical Operations run list. +- **SC-002**: Legacy run pages are not accessible (attempts to access legacy run URLs result in not found). +- **SC-003**: Drift creation and drift UI flows do not depend on legacy run stores; baseline/current references resolve to canonical runs when mappings exist. +- **SC-004**: The automated architecture guard blocks reintroduction of legacy run concepts and fails the build when legacy tokens reappear. +- **SC-005**: Backup schedule retention/purge workflows complete successfully without legacy run stores and are observable through canonical runs. +- **SC-006**: Each retention and each purge execution produces a canonical run record (independent from the associated backup schedule run). + +## Assumptions + +- Historical legacy-only run details do not need to be preserved; pre-existing legacy run history may be lost after legacy tables are removed. +- The canonical Operations UI already exists and is the single supported run viewer. + +## Dependencies + +- Workspace-scoped run access rules remain enforced consistently across list and detail views. diff --git a/specs/087-legacy-runs-removal/tasks.md b/specs/087-legacy-runs-removal/tasks.md new file mode 100644 index 0000000..588b691 --- /dev/null +++ b/specs/087-legacy-runs-removal/tasks.md @@ -0,0 +1,179 @@ +--- +description: "Task list for Spec 087 implementation" +--- + +# Tasks: Legacy Runs Removal (Spec 087) + +**Input**: Design documents from `/specs/087-legacy-runs-removal/` + +**Tests**: REQUIRED (Pest) because this feature changes runtime behavior. + +--- + +## Phase 1: Setup (Docs + Local Validation) + +- [X] T001 Validate and (if needed) update specs/087-legacy-runs-removal/quickstart.md with the exact focused test commands for this spec +- [X] T002 Capture the final run_type allow-list in specs/087-legacy-runs-removal/spec.md (FR-012) and ensure tasks below only create those values + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**⚠️ CRITICAL**: Complete this phase before starting any user story work. + +- [X] T003 Update canonical run type identifiers to underscore values for this feature only: + - Update app/Support/OperationRunType.php (enum App\\Support\\OperationRunType) so new runs store the underscore identifiers from FR-012 + - Change ONLY the affected enum case values (leave unrelated run types unchanged): + - InventorySync + - DirectoryGroupsSync + - DriftGenerate + - BackupScheduleRunNow + - BackupScheduleRetry + - BackupScheduleScheduled + - Scope note: OperationRun creation accepts a raw string type; enforcement is via standardized usage + tests in this spec (no global renames) +- [X] T004 Create migration database/migrations/2026_02_12_000001_canonicalize_operation_run_types.php to rewrite existing operation_runs.type values (dotted → underscore) for affected workflows (FR-012). Acceptance criteria: perform these rewrites (and only these): + - inventory.sync → inventory_sync + - directory_groups.sync → entra_group_sync + - drift.generate → drift_generate_findings + - backup_schedule.run_now → backup_schedule_run + - backup_schedule.retry → backup_schedule_run + - backup_schedule.scheduled → backup_schedule_run +- [X] T005 [P] Add/adjust OperationRun RBAC semantics tests (404 vs 403) in tests/Feature/Monitoring/MonitoringOperationsTest.php and/or tests/Feature/Operations/ (FR-008) +- [X] T006 [P] Add architecture guard test tests/Feature/Guards/NoLegacyRunsTest.php scanning app/, database/, resources/, routes/ for legacy run tokens (FR-009). Use this initial forbidden token list: + - InventorySyncRun, inventory_sync_runs, InventorySyncRunResource + - EntraGroupSyncRun, entra_group_sync_runs, EntraGroupSyncRunResource + - BackupScheduleRun, backup_schedule_runs, BackupScheduleRunsRelationManager + Exclusions (minimum): vendor/, storage/, specs/, spechistory/, references/, bootstrap/cache/ +- [X] T007 [P] Remove legacy run badge domains/mappers and rely on OperationRun status/outcome badge domains by updating app/Support/Badges/BadgeDomain.php, app/Support/Badges/BadgeCatalog.php, and tests/Unit/Badges/RunStatusBadgesTest.php (BADGE-001) +- [X] T043 [P] Add explicit “no backfill of legacy run history” regression coverage: + - Confirm no migration/job creates new operation_runs by reading legacy run tables (reference/FK backfills are allowed) + - Add a focused test under tests/Feature/Guards/ or tests/Feature/Database/ to enforce this (FR-010) + +NOTE: routes/web.php currently has a legacy convenience redirect (/admin/t/{tenant}/operations → /admin/operations). It is not a legacy *run page*; keep or change it explicitly as part of US2 decisions. + +**Checkpoint**: Canonical run types are stable, guard exists, and operations RBAC semantics are enforced by tests. + +--- + +## Phase 3: User Story 1 — Single canonical run history (Priority: P1) 🎯 MVP + +**Goal**: New inventory sync, Entra group sync, and backup schedule executions produce exactly one canonical OperationRun each, visible in Operations UI. + +**Independent Test**: Trigger each run type once and verify it appears in the Operations run list and can be opened in the canonical run viewer. + +### Tests (write first) + +- [X] T008 [P] [US1] Update inventory sync run creation expectations in tests/Feature/Inventory/InventorySyncServiceTest.php to assert an operation_runs row exists with type inventory_sync +- [X] T009 [P] [US1] Update Entra group sync expectations in tests/Feature/DirectoryGroups/StartSyncTest.php and tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php to assert an operation_runs row exists with type entra_group_sync +- [X] T010 [P] [US1] Update backup schedule run expectations in tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php to assert an operation_runs row exists with type backup_schedule_run +- [X] T011 [P] [US1] Add tests for retention + purge creating independent canonical runs in tests/Feature/BackupScheduling/ApplyRetentionJobTest.php and tests/Feature/Console/PurgeNonPersistentDataCommandTest.php (backup_schedule_retention / backup_schedule_purge) +- [X] T044 [P] [US1] Add/adjust backup schedule tests to cover skipped/canceled schedule runs still producing a canonical operation_run with a clear terminal outcome (spec Edge Case) + +### Implementation + +- [X] T012 [US1] Refactor app/Services/Inventory/InventorySyncService.php to stop creating InventorySyncRun rows and instead create/update a canonical OperationRun (FR-001/FR-002) +- [X] T013 [US1] Refactor app/Jobs/EntraGroupSyncJob.php to stop updating EntraGroupSyncRun and instead create/update a canonical OperationRun (FR-001/FR-002) +- [X] T014 [US1] Refactor app/Jobs/RunBackupScheduleJob.php to stop reading/updating BackupScheduleRun status as the canonical record and instead use a canonical OperationRun with schedule metadata stored in operation_runs.context (FR-001/FR-002) +- [X] T015 [US1] Refactor app/Jobs/ApplyBackupScheduleRetentionJob.php to remove BackupScheduleRun dependency and track retention via a canonical OperationRun (FR-006/FR-011) +- [X] T016 [US1] Refactor app/Console/Commands/TenantpilotPurgeNonPersistentData.php to track purge via a canonical OperationRun and remove BackupScheduleRun queries (FR-006/FR-011) +- [X] T017 [US1] Refactor app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php to reconcile based on operation_runs only (remove BackupScheduleRun reads) (FR-001/FR-006) +- [X] T018 [US1] Update run status notifications/links to point to canonical run viewer by editing app/Notifications/RunStatusChangedNotification.php and app/Notifications/OperationRunQueued.php (FR-001) +- [X] T019 [US1] Update inventory run “last run” widget links to canonical runs by editing app/Filament/Widgets/Inventory/InventoryKpiHeader.php (FR-001) + +**Checkpoint**: US1 tests pass and every new execution yields a canonical OperationRun of the correct underscore run_type. + +--- + +## Phase 4: User Story 2 — Legacy run UI is intentionally gone (Priority: P1) + +**Goal**: Legacy run pages/resources are removed; old links return 404; no legacy run resources appear in navigation. + +**Independent Test**: Request known legacy run URLs and verify 404; verify navigation has no legacy run resources. + +### Tests (write first) + +- [X] T020 [P] [US2] Replace redirect-based legacy tests with 404 assertions in tests/Feature/Operations/LegacyRunRedirectTest.php (FR-004) +- [X] T021 [P] [US2] Add explicit “legacy run routes not found” coverage in tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php for inventory/entra/backup schedule legacy run URLs (FR-004) + +### Implementation + +- [X] T022 [US2] Remove legacy Inventory Sync run Filament resource by deleting app/Filament/Resources/InventorySyncRunResource.php and app/Filament/Resources/InventorySyncRunResource/Pages/ (FR-003) +- [X] T023 [US2] Remove legacy Entra Group Sync run Filament resource by deleting app/Filament/Resources/EntraGroupSyncRunResource.php and app/Filament/Resources/EntraGroupSyncRunResource/Pages/ (FR-003) +- [X] T024 [US2] Remove legacy backup schedule run history relation manager by deleting app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php (FR-003) +- [X] T025 [US2] Remove navigation links to legacy run resources by updating app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php and any other references found under app/Filament/ (FR-003) +- [X] T026 [US2] Remove legacy run UI tests and replace with canonical Operations coverage by updating/deleting tests/Feature/Filament/InventorySyncRunResourceTest.php, tests/Feature/Filament/EntraGroupSyncRunResourceTest.php, and tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php (FR-003) +- [X] T027 [US2] Remove legacy run badge mappers now unused by UI by deleting app/Support/Inventory/InventorySyncStatusBadge.php and app/Support/Badges/Domains/EntraGroupSyncRunStatusBadge.php (FR-003/BADGE-001) + +NOTE: Badge cleanup is handled by T007. If any legacy badge mapper is still referenced, remove it as part of T007 and treat this note as satisfied. + +**Checkpoint**: Legacy run URLs are 404 (no redirects) and legacy run resources no longer exist. + +--- + +## Phase 5: User Story 3 — Drift references canonical runs (Priority: P1) + +**Goal**: Drift baseline/current/last-seen references use canonical OperationRuns; historical records without a safe mapping render without errors. + +**Independent Test**: Generate drift findings and confirm baseline/current reference canonical run IDs; verify null-safe behavior when mapping is absent. + +### Tests (write first) + +- [X] T028 [P] [US3] Update drift baseline/current selection tests under tests/Feature/Drift/ (directory) to create OperationRun records instead of InventorySyncRun +- [X] T029 [P] [US3] Update RBAC drift landing UI enforcement in tests/Feature/Rbac/DriftLandingUiEnforcementTest.php to avoid InventorySyncRunResource and assert canonical run links + +### Data model + migrations + +- [X] T030 [US3] Create migration database/migrations/2026_02_12_000002_add_operation_run_ids_to_findings_table.php adding findings.baseline_operation_run_id and findings.current_operation_run_id (nullable FKs to operation_runs) +- [X] T031 [US3] Create migration database/migrations/2026_02_12_000003_backfill_findings_operation_run_ids.php that backfills the new findings columns by joining through inventory_sync_runs.operation_run_id (null-safe) +- [X] T032 [US3] Create migration database/migrations/2026_02_12_000004_backfill_inventory_items_last_seen_operation_run_id.php backfilling inventory_items.last_seen_operation_run_id from inventory_items.last_seen_run_id via inventory_sync_runs.operation_run_id + +### Application changes + +- [X] T033 [US3] Refactor drift run selection to use OperationRuns by updating app/Services/Drift/DriftRunSelector.php and app/Services/Drift/DriftScopeKey.php (FR-005) +- [X] T034 [US3] Refactor drift finding generation to accept/use OperationRuns by updating app/Services/Drift/DriftFindingGenerator.php (FR-005) +- [X] T035 [US3] Update drift landing UI to link to canonical OperationRuns (not legacy resources) by updating app/Filament/Pages/DriftLanding.php (FR-003/FR-005) + +### Cutover + cleanup + +- [X] T036 [US3] Create migration database/migrations/2026_02_12_000005_drop_legacy_run_id_columns_from_findings_and_inventory_items.php dropping findings.baseline_run_id/current_run_id and inventory_items.last_seen_run_id after code cutover (FR-005) + +**Checkpoint**: Drift reads/writes canonical operation run references and remains functional with null mappings. + +--- + +## Phase 6: Drop legacy tables (Data + Code) + +**Purpose**: Remove legacy run storage permanently after all cutovers. + +- [X] T037 Create migration database/migrations/2026_02_12_000006_drop_legacy_run_tables.php dropping inventory_sync_runs, entra_group_sync_runs, and backup_schedule_runs (FR-001) +- [X] T038 [P] Delete legacy run Eloquent models app/Models/InventorySyncRun.php, app/Models/EntraGroupSyncRun.php, and app/Models/BackupScheduleRun.php (FR-001) +- [X] T039 [P] Delete legacy factories database/factories/InventorySyncRunFactory.php and database/factories/EntraGroupSyncRunFactory.php and update any references in tests/Feature/ (FR-001) +- [X] T040 Update any remaining references to legacy run tables (tokens caught by the guard) under app/, database/, resources/, routes/ until tests/Feature/Guards/NoLegacyRunsTest.php passes (FR-009) + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +- [X] T041 Run formatting for touched files with vendor/bin/sail bin pint --dirty and fix any style issues in app/ and tests/ +- [X] T042 Run focused test pack for this spec (at minimum): tests/Feature/Guards/NoLegacyRunsTest.php, tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php, tests/Feature/Monitoring/MonitoringOperationsTest.php, tests/Feature/Drift/ (directory) + +--- + +## Dependencies & Execution Order + +### Story dependency graph + +- Phase 2 blocks everything. +- US1 (canonical run creation) should land before US2 (removing UI) to avoid removing the only user-visible run history. +- US3 (drift cutover) can proceed after Phase 2, but MUST complete before Phase 6 (dropping legacy tables). + +### Parallel opportunities (examples) + +- After Phase 2: + - US1 implementation tasks in app/Services/Inventory/InventorySyncService.php and app/Jobs/EntraGroupSyncJob.php can be done in parallel. + - US2 removal tasks (Filament resource deletion) can proceed in parallel with US3 migrations/code, as long as any shared files are coordinated. + +## Implementation Strategy (MVP) + +- MVP = Phase 2 + US1 + the minimum of US2 needed to remove navigation entry points. +- Then complete US3 cutover and only then drop legacy tables. diff --git a/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php b/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php index a5b1720..47cbaab 100644 --- a/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php +++ b/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php @@ -2,8 +2,8 @@ use App\Jobs\ApplyBackupScheduleRetentionJob; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; use App\Models\BackupSet; +use App\Models\OperationRun; use Filament\Facades\Filament; test('retention keeps last N backup sets per schedule', function () { @@ -35,18 +35,33 @@ ]); }); - // Oldest → newest - $scheduledFor = now('UTC')->startOfMinute()->subMinutes(10); + $completedAt = now('UTC')->startOfMinute()->subMinutes(10); + foreach ($sets as $set) { - BackupScheduleRun::query()->create([ - 'backup_schedule_id' => $schedule->id, - 'tenant_id' => $tenant->id, - 'scheduled_for' => $scheduledFor, - 'status' => BackupScheduleRun::STATUS_SUCCESS, - 'summary' => ['policies_total' => 0, 'policies_backed_up' => 0, 'errors_count' => 0], - 'backup_set_id' => $set->id, + OperationRun::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->id, + 'user_id' => null, + 'initiator_name' => 'System', + 'type' => 'backup_schedule_run', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => hash('sha256', 'retention-test:'.$schedule->id.':'.$set->id), + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + 'succeeded' => 0, + ], + 'failure_summary' => [], + 'context' => [ + 'backup_schedule_id' => (int) $schedule->id, + 'backup_set_id' => (int) $set->id, + ], + 'started_at' => $completedAt, + 'completed_at' => $completedAt, ]); - $scheduledFor = $scheduledFor->addMinute(); + + $completedAt = $completedAt->addMinute(); } ApplyBackupScheduleRetentionJob::dispatchSync($schedule->id); @@ -64,4 +79,15 @@ foreach ($deleted as $set) { $this->assertSoftDeleted('backup_sets', ['id' => $set->id]); } + + $retentionRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->id) + ->where('type', 'backup_schedule_retention') + ->latest('id') + ->first(); + + expect($retentionRun)->not->toBeNull(); + expect($retentionRun?->status)->toBe('completed'); + expect($retentionRun?->outcome)->toBe('succeeded'); + expect($retentionRun?->summary_counts['succeeded'] ?? null)->toBe(3); }); diff --git a/tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php b/tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php deleted file mode 100644 index ff8d995..0000000 --- a/tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php +++ /dev/null @@ -1,53 +0,0 @@ -create([ - 'tenant_id' => $tenant->id, - 'name' => 'Nightly', - 'is_enabled' => true, - 'timezone' => 'UTC', - 'frequency' => 'daily', - 'time_of_day' => '01:00:00', - 'days_of_week' => null, - 'policy_types' => ['deviceConfiguration'], - 'include_foundations' => true, - 'retention_keep_last' => 30, - ]); - - $backupSet = BackupSet::query()->create([ - 'tenant_id' => $tenant->id, - 'name' => 'Set 174', - 'status' => 'completed', - 'item_count' => 0, - ]); - - $run = BackupScheduleRun::query()->create([ - 'backup_schedule_id' => $schedule->id, - 'tenant_id' => $tenant->id, - 'scheduled_for' => now('UTC')->startOfMinute()->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_SUCCESS, - 'summary' => [ - 'policies_total' => 7, - 'policies_backed_up' => 7, - 'errors_count' => 0, - ], - 'error_code' => null, - 'error_message' => null, - 'backup_set_id' => $backupSet->id, - ]); - - $this->actingAs($user); - - $html = view('filament.modals.backup-schedule-run-view', ['run' => $run])->render(); - - expect($html)->toContain('Scheduled for'); - expect($html)->toContain('Status'); - expect($html)->toContain('Summary'); - expect($html)->toContain((string) $backupSet->id); -}); diff --git a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php index c2bb534..a410f30 100644 --- a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php +++ b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php @@ -35,11 +35,9 @@ $dispatcher->dispatchDue([$tenant->external_id]); $dispatcher->dispatchDue([$tenant->external_id]); - expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0); - expect(OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'backup_schedule.scheduled') + ->where('type', 'backup_schedule_run') ->count())->toBe(1); Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1); @@ -48,7 +46,7 @@ return $job->backupScheduleId !== null && $job->backupScheduleRunId === 0 && $job->operationRun?->tenant_id === $tenant->getKey() - && $job->operationRun?->type === 'backup_schedule.scheduled'; + && $job->operationRun?->type === 'backup_schedule_run'; }); }); @@ -77,7 +75,7 @@ $operationRunService->ensureRunWithIdentityStrict( tenant: $tenant, - type: 'backup_schedule.scheduled', + type: 'backup_schedule_run', identityInputs: [ 'backup_schedule_id' => (int) $schedule->id, 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), @@ -93,13 +91,11 @@ $dispatcher = app(BackupScheduleDispatcher::class); $dispatcher->dispatchDue([$tenant->external_id]); - - expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0); Bus::assertNotDispatched(RunBackupScheduleJob::class); expect(OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'backup_schedule.scheduled') + ->where('type', 'backup_schedule_run') ->count())->toBe(1); $schedule->refresh(); diff --git a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php index cfbdbd3..c163e70 100644 --- a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php +++ b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php @@ -1,17 +1,24 @@ null, ]); - $run = BackupScheduleRun::query()->create([ - 'backup_schedule_id' => $schedule->id, - 'tenant_id' => $tenant->id, - 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - ]); - /** @var OperationRunService $operationRunService */ $operationRunService = app(OperationRunService::class); $operationRun = $operationRunService->ensureRun( tenant: $tenant, - type: 'backup_schedule.run_now', + type: 'backup_schedule_run', inputs: ['backup_schedule_id' => (int) $schedule->id], initiator: $user, ); @@ -75,33 +75,35 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, Cache::flush(); - (new RunBackupScheduleJob($run->id, $operationRun))->handle( + (new RunBackupScheduleJob(0, $operationRun, (int) $schedule->id))->handle( app(PolicySyncService::class), app(BackupService::class), - app(\App\Services\BackupScheduling\PolicyTypeResolver::class), - app(\App\Services\BackupScheduling\ScheduleTimeService::class), - app(\App\Services\Intune\AuditLogger::class), - app(\App\Services\BackupScheduling\RunErrorMapper::class), + app(PolicyTypeResolver::class), + app(ScheduleTimeService::class), + app(AuditLogger::class), + app(RunErrorMapper::class), ); - $run->refresh(); - expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS); - expect($run->backup_set_id)->toBe($backupSet->id); + $schedule->refresh(); + expect($schedule->last_run_status)->toBe('success'); $operationRun->refresh(); expect($operationRun->status)->toBe('completed'); expect($operationRun->outcome)->toBe('succeeded'); expect($operationRun->context)->toMatchArray([ 'backup_schedule_id' => (int) $schedule->id, - 'backup_schedule_run_id' => (int) $run->id, 'backup_set_id' => (int) $backupSet->id, ]); expect($operationRun->summary_counts)->toMatchArray([ 'created' => 1, ]); + + Bus::assertDispatched(ApplyBackupScheduleRetentionJob::class); }); it('skips runs when all policy types are unknown', function () { + Bus::fake(); + CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -121,44 +123,41 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, 'next_run_at' => null, ]); - $run = BackupScheduleRun::query()->create([ - 'backup_schedule_id' => $schedule->id, - 'tenant_id' => $tenant->id, - 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - ]); - - Cache::flush(); - /** @var OperationRunService $operationRunService */ $operationRunService = app(OperationRunService::class); $operationRun = $operationRunService->ensureRun( tenant: $tenant, - type: 'backup_schedule.run_now', + type: 'backup_schedule_run', inputs: ['backup_schedule_id' => (int) $schedule->id], initiator: $user, ); - (new RunBackupScheduleJob($run->id, $operationRun))->handle( + Cache::flush(); + + (new RunBackupScheduleJob(0, $operationRun, (int) $schedule->id))->handle( app(PolicySyncService::class), app(BackupService::class), - app(\App\Services\BackupScheduling\PolicyTypeResolver::class), - app(\App\Services\BackupScheduling\ScheduleTimeService::class), - app(\App\Services\Intune\AuditLogger::class), - app(\App\Services\BackupScheduling\RunErrorMapper::class), + app(PolicyTypeResolver::class), + app(ScheduleTimeService::class), + app(AuditLogger::class), + app(RunErrorMapper::class), ); - $run->refresh(); - expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED); - expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE'); - expect($run->backup_set_id)->toBeNull(); + $schedule->refresh(); + expect($schedule->last_run_status)->toBe('skipped'); $operationRun->refresh(); expect($operationRun->status)->toBe('completed'); - expect($operationRun->outcome)->toBe('failed'); + expect($operationRun->outcome)->toBe('blocked'); expect($operationRun->failure_summary)->toMatchArray([ - ['code' => 'unknown_policy_type', 'message' => $run->error_message, 'reason_code' => 'unknown_error'], + [ + 'code' => 'unknown_policy_type', + 'message' => 'All configured policy types are unknown.', + 'reason_code' => 'unknown_error', + ], ]); + + Bus::assertNotDispatched(ApplyBackupScheduleRetentionJob::class); }); it('fails fast when operation run context is not passed into the job', function () { @@ -181,28 +180,18 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, 'next_run_at' => null, ]); - $run = BackupScheduleRun::query()->create([ - 'backup_schedule_id' => $schedule->id, - 'tenant_id' => $tenant->id, - 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - ]); - $queueJob = \Mockery::mock(Job::class); $queueJob->shouldReceive('fail')->once(); - $job = new RunBackupScheduleJob($run->id); + $job = new RunBackupScheduleJob(0, null, (int) $schedule->id); $job->setJob($queueJob); $job->handle( app(PolicySyncService::class), app(BackupService::class), - app(\App\Services\BackupScheduling\PolicyTypeResolver::class), - app(\App\Services\BackupScheduling\ScheduleTimeService::class), - app(\App\Services\Intune\AuditLogger::class), - app(\App\Services\BackupScheduling\RunErrorMapper::class), + app(PolicyTypeResolver::class), + app(ScheduleTimeService::class), + app(AuditLogger::class), + app(RunErrorMapper::class), ); - - $run->refresh(); - expect($run->status)->toBe(BackupScheduleRun::STATUS_RUNNING); }); diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php index 1f18b97..4225767 100644 --- a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php +++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -49,12 +49,9 @@ Livewire::test(ListBackupSchedules::class) ->callTableAction('runNow', $schedule); - expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) - ->toBe(0); - $operationRun = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule.run_now') + ->where('type', 'backup_schedule_run') ->first(); expect($operationRun)->not->toBeNull(); @@ -113,12 +110,9 @@ Livewire::test(ListBackupSchedules::class) ->callTableAction('runNow', $schedule); - expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) - ->toBe(0); - $runs = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule.run_now') + ->where('type', 'backup_schedule_run') ->pluck('id') ->all(); @@ -153,12 +147,9 @@ Livewire::test(ListBackupSchedules::class) ->callTableAction('retry', $schedule); - expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) - ->toBe(0); - $operationRun = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule.retry') + ->where('type', 'backup_schedule_run') ->first(); expect($operationRun)->not->toBeNull(); @@ -180,7 +171,7 @@ 'notifiable_type' => User::class, 'type' => OperationRunQueued::class, 'data->format' => 'filament', - 'data->title' => 'Backup schedule retry queued', + 'data->title' => 'Backup schedule run queued', ]); $notification = $user->notifications()->latest('id')->first(); @@ -216,12 +207,9 @@ Livewire::test(ListBackupSchedules::class) ->callTableAction('retry', $schedule); - expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) - ->toBe(0); - $runs = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule.retry') + ->where('type', 'backup_schedule_run') ->pluck('id') ->all(); @@ -265,12 +253,9 @@ // Action should be hidden/blocked for readonly users. } - expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) - ->toBe(0); - expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry']) + ->whereIn('type', ['backup_schedule_run', 'backup_schedule_run']) ->count()) ->toBe(0); }); @@ -312,18 +297,15 @@ Livewire::test(ListBackupSchedules::class) ->callTableBulkAction('bulk_run_now', collect([$scheduleA, $scheduleB])); - expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) - ->toBe(0); - expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule.run_now') + ->where('type', 'backup_schedule_run') ->count()) ->toBe(2); expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule.run_now') + ->where('type', 'backup_schedule_run') ->pluck('user_id') ->unique() ->values() @@ -381,18 +363,15 @@ Livewire::test(ListBackupSchedules::class) ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); - expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) - ->toBe(0); - expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule.retry') + ->where('type', 'backup_schedule_run') ->count()) ->toBe(2); expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule.retry') + ->where('type', 'backup_schedule_run') ->pluck('user_id') ->unique() ->values() @@ -452,7 +431,7 @@ $operationRunService = app(OperationRunService::class); $existing = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'backup_schedule.retry', + type: 'backup_schedule_run', identityInputs: [ 'backup_schedule_id' => (int) $scheduleA->getKey(), 'nonce' => 'existing', @@ -471,12 +450,9 @@ Livewire::test(ListBackupSchedules::class) ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); - expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) - ->toBe(0); - expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule.retry') + ->where('type', 'backup_schedule_run') ->count()) ->toBe(3); diff --git a/tests/Feature/BulkProgressNotificationTest.php b/tests/Feature/BulkProgressNotificationTest.php index bd95eff..f1f8b4a 100644 --- a/tests/Feature/BulkProgressNotificationTest.php +++ b/tests/Feature/BulkProgressNotificationTest.php @@ -50,7 +50,7 @@ 'tenant_id' => $tenant->id, 'user_id' => $user->id, 'initiator_name' => $user->name, - 'type' => 'backup_schedule.run_now', + 'type' => 'backup_schedule_run', 'status' => 'queued', 'outcome' => 'pending', 'context' => ['scope' => 'scheduled'], diff --git a/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php b/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php index d9be64d..43607e8 100644 --- a/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php +++ b/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php @@ -3,7 +3,6 @@ use App\Models\AuditLog; use App\Models\BackupItem; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; use App\Models\BackupSet; use App\Models\OperationRun; use App\Models\Policy; @@ -101,22 +100,9 @@ 'next_run_at' => now()->addHour(), ]); - BackupScheduleRun::create([ - 'backup_schedule_id' => $scheduleA->id, - 'tenant_id' => $tenantA->id, - 'scheduled_for' => now()->startOfMinute(), - 'started_at' => null, - 'finished_at' => null, - 'status' => BackupScheduleRun::STATUS_SUCCESS, - 'summary' => null, - 'error_code' => null, - 'error_message' => null, - 'backup_set_id' => $backupSetA->id, - ]); - expect(Policy::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); - expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); + expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); $this->artisan('tenantpilot:purge-nonpersistent', [ 'tenant' => $tenantA->id, @@ -130,8 +116,11 @@ expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); expect(RestoreRun::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); - expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); - expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(1); + expect(OperationRun::query() + ->where('tenant_id', $tenantA->id) + ->where('type', 'backup_schedule_purge') + ->exists())->toBeTrue(); expect(BackupSchedule::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); diff --git a/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php b/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php index c9946fd..69007a8 100644 --- a/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php +++ b/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php @@ -1,7 +1,6 @@ create(); $schedule = BackupSchedule::create([ @@ -29,39 +28,24 @@ ]); $startedAt = CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC'); - $finishedAt = CarbonImmutable::parse('2026-01-01 00:00:05', 'UTC'); - - $scheduleRun = BackupScheduleRun::create([ - 'backup_schedule_id' => $schedule->id, - 'tenant_id' => $tenant->id, - 'scheduled_for' => CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC'), - 'started_at' => $startedAt, - 'finished_at' => $finishedAt, - 'status' => BackupScheduleRun::STATUS_SUCCESS, - 'summary' => [ - 'policies_total' => 5, - 'policies_backed_up' => 18, - 'sync_failures' => [], - ], - 'error_code' => null, - 'error_message' => null, - 'backup_set_id' => null, - ]); $operationRun = OperationRun::create([ + 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => $tenant->id, 'user_id' => null, 'initiator_name' => 'System', - 'type' => 'backup_schedule.run_now', - 'status' => 'queued', + 'type' => 'backup_schedule_run', + 'status' => 'running', 'outcome' => 'pending', - 'run_identity_hash' => hash('sha256', 'backup_schedule.run_now|'.$scheduleRun->id), + 'run_identity_hash' => hash('sha256', 'backup_schedule_run:'.$schedule->id), 'summary_counts' => [], 'failure_summary' => [], 'context' => [ 'backup_schedule_id' => (int) $schedule->id, - 'backup_schedule_run_id' => (int) $scheduleRun->id, ], + 'started_at' => $startedAt, + 'created_at' => $startedAt, + 'updated_at' => $startedAt, ]); $this->artisan('tenantpilot:operation-runs:reconcile-backup-schedules', [ @@ -72,21 +56,15 @@ $operationRun->refresh(); expect($operationRun->status)->toBe('completed'); - expect($operationRun->outcome)->toBe('succeeded'); - expect($operationRun->failure_summary)->toBe([]); - - expect($operationRun->started_at?->format('Y-m-d H:i:s'))->toBe($startedAt->format('Y-m-d H:i:s')); - expect($operationRun->completed_at?->format('Y-m-d H:i:s'))->toBe($finishedAt->format('Y-m-d H:i:s')); - + expect($operationRun->outcome)->toBe('failed'); + expect($operationRun->failure_summary)->toMatchArray([ + [ + 'code' => 'backup_schedule.stalled', + 'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.', + 'reason_code' => 'unknown_error', + ], + ]); expect($operationRun->context)->toMatchArray([ 'backup_schedule_id' => (int) $schedule->id, - 'backup_schedule_run_id' => (int) $scheduleRun->id, - ]); - - expect($operationRun->summary_counts)->toMatchArray([ - 'total' => 5, - 'processed' => 5, - 'succeeded' => 18, - 'items' => 5, ]); }); diff --git a/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php b/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php index 150c6e4..1895e8c 100644 --- a/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php +++ b/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php @@ -17,25 +17,15 @@ CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-11 02:00:00', 'UTC')); - $legacyCountBefore = \App\Models\EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->count(); - Artisan::call('tenantpilot:directory-groups:dispatch', [ '--tenant' => [$tenant->tenant_id], ]); $slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z'; - $legacyCountAfter = \App\Models\EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->count(); - - expect($legacyCountAfter)->toBe($legacyCountBefore); - $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'directory_groups.sync') + ->where('type', 'entra_group_sync') ->where('context->slot_key', $slotKey) ->first(); diff --git a/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php b/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php index fd14735..edc03c0 100644 --- a/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php +++ b/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php @@ -26,22 +26,12 @@ $tenant->makeCurrent(); Filament::setTenant($tenant, true); - $legacyCountBefore = \App\Models\EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->count(); - Livewire::test(ListEntraGroups::class) ->callAction('sync_groups'); - $legacyCountAfter = \App\Models\EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->count(); - - expect($legacyCountAfter)->toBe($legacyCountBefore); - $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'directory_groups.sync') + ->where('type', 'entra_group_sync') ->latest('id') ->first(); diff --git a/tests/Feature/DirectoryGroups/StartSyncTest.php b/tests/Feature/DirectoryGroups/StartSyncTest.php index ca20948..7eb5530 100644 --- a/tests/Feature/DirectoryGroups/StartSyncTest.php +++ b/tests/Feature/DirectoryGroups/StartSyncTest.php @@ -17,7 +17,7 @@ expect($run)->toBeInstanceOf(OperationRun::class) ->and($run->tenant_id)->toBe($tenant->getKey()) ->and($run->user_id)->toBe($user->getKey()) - ->and($run->type)->toBe('directory_groups.sync') + ->and($run->type)->toBe('entra_group_sync') ->and($run->status)->toBe('queued') ->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all'); diff --git a/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php b/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php index 589c644..0f5eeb3 100644 --- a/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php +++ b/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php @@ -54,7 +54,7 @@ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, - type: 'directory_groups.sync', + type: 'entra_group_sync', inputs: ['selection_key' => 'groups-v1:all'], initiator: $user, ); diff --git a/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php b/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php index d1b243b..45cd123 100644 --- a/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php +++ b/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php @@ -34,7 +34,7 @@ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, - type: 'directory_groups.sync', + type: 'entra_group_sync', inputs: ['selection_key' => 'groups-v1:all'], initiator: $user, ); diff --git a/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php b/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php index dee9bc6..c7998f6 100644 --- a/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php +++ b/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php @@ -1,7 +1,6 @@ for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); diff --git a/tests/Feature/Drift/DriftBaselineSelectionTest.php b/tests/Feature/Drift/DriftBaselineSelectionTest.php index c0e273b..16f4e61 100644 --- a/tests/Feature/Drift/DriftBaselineSelectionTest.php +++ b/tests/Feature/Drift/DriftBaselineSelectionTest.php @@ -1,6 +1,5 @@ for($tenant)->create([ + createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(3), ]); - $baseline = InventorySyncRun::factory()->for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); - InventorySyncRun::factory()->for($tenant)->create([ + createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_FAILED, + 'status' => 'failed', 'finished_at' => now(), ]); @@ -48,9 +47,9 @@ $scopeKey = hash('sha256', 'scope-b'); - InventorySyncRun::factory()->for($tenant)->create([ + createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); diff --git a/tests/Feature/Drift/DriftCompletedRunWithZeroFindingsTest.php b/tests/Feature/Drift/DriftCompletedRunWithZeroFindingsTest.php index df94b37..20d0ce9 100644 --- a/tests/Feature/Drift/DriftCompletedRunWithZeroFindingsTest.php +++ b/tests/Feature/Drift/DriftCompletedRunWithZeroFindingsTest.php @@ -2,7 +2,6 @@ use App\Filament\Pages\DriftLanding; use App\Jobs\GenerateDriftFindingsJob; -use App\Models\InventorySyncRun; use App\Models\OperationRun; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; @@ -17,17 +16,17 @@ $scopeKey = hash('sha256', 'scope-zero-findings'); - $baseline = InventorySyncRun::factory()->for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); @@ -35,7 +34,7 @@ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'drift.generate', + 'type' => 'drift_generate_findings', 'status' => 'completed', 'outcome' => 'succeeded', 'run_identity_hash' => 'drift-zero-findings', @@ -48,8 +47,8 @@ ], 'context' => [ 'scope_key' => $scopeKey, - 'baseline_run_id' => (int) $baseline->getKey(), - 'current_run_id' => (int) $current->getKey(), + 'baseline_operation_run_id' => (int) $baseline->getKey(), + 'current_operation_run_id' => (int) $current->getKey(), ], ]); diff --git a/tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php b/tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php index 605617c..97a4f5a 100644 --- a/tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php +++ b/tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php @@ -4,7 +4,6 @@ use App\Models\EntraGroup; use App\Models\Finding; use App\Models\InventoryItem; -use App\Models\InventorySyncRun; use App\Models\Policy; use App\Models\PolicyVersion; use App\Services\Directory\EntraGroupLabelResolver; @@ -14,15 +13,15 @@ [$user, $tenant] = createUserWithTenant(role: 'manager'); - $baseline = InventorySyncRun::factory()->for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => hash('sha256', 'scope-assignments-diff'), - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $baseline->selection_hash, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); @@ -104,8 +103,8 @@ $finding = Finding::factory()->for($tenant)->create([ 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'scope_key' => (string) $current->selection_hash, - 'baseline_run_id' => $baseline->getKey(), - 'current_run_id' => $current->getKey(), + 'baseline_operation_run_id' => $baseline->getKey(), + 'current_operation_run_id' => $current->getKey(), 'subject_type' => 'assignment', 'subject_external_id' => $policy->external_id, 'evidence_jsonb' => [ diff --git a/tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php b/tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php index 3d905d3..0bc20fa 100644 --- a/tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php +++ b/tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php @@ -3,7 +3,6 @@ use App\Filament\Resources\FindingResource; use App\Models\Finding; use App\Models\InventoryItem; -use App\Models\InventorySyncRun; use App\Models\Policy; use App\Models\PolicyVersion; @@ -12,15 +11,15 @@ [$user, $tenant] = createUserWithTenant(role: 'manager'); - $baseline = InventorySyncRun::factory()->for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => hash('sha256', 'scope-scope-tags-diff'), - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $baseline->selection_hash, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); @@ -61,8 +60,8 @@ $finding = Finding::factory()->for($tenant)->create([ 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'scope_key' => (string) $current->selection_hash, - 'baseline_run_id' => $baseline->getKey(), - 'current_run_id' => $current->getKey(), + 'baseline_operation_run_id' => $baseline->getKey(), + 'current_operation_run_id' => $current->getKey(), 'subject_type' => 'scope_tag', 'subject_external_id' => $policy->external_id, 'evidence_jsonb' => [ diff --git a/tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php b/tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php index ffc4136..bb3d044 100644 --- a/tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php +++ b/tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php @@ -3,7 +3,6 @@ use App\Filament\Resources\FindingResource; use App\Models\Finding; use App\Models\InventoryItem; -use App\Models\InventorySyncRun; use App\Models\Policy; use App\Models\PolicyVersion; @@ -12,15 +11,15 @@ [$user, $tenant] = createUserWithTenant(role: 'manager'); - $baseline = InventorySyncRun::factory()->for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => hash('sha256', 'scope-settings-diff'), - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $baseline->selection_hash, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); @@ -59,8 +58,8 @@ $finding = Finding::factory()->for($tenant)->create([ 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'scope_key' => (string) $current->selection_hash, - 'baseline_run_id' => $baseline->getKey(), - 'current_run_id' => $current->getKey(), + 'baseline_operation_run_id' => $baseline->getKey(), + 'current_operation_run_id' => $current->getKey(), 'subject_type' => 'policy', 'subject_external_id' => $policy->external_id, 'evidence_jsonb' => [ diff --git a/tests/Feature/Drift/DriftFindingDetailTest.php b/tests/Feature/Drift/DriftFindingDetailTest.php index b216dc4..3c8b111 100644 --- a/tests/Feature/Drift/DriftFindingDetailTest.php +++ b/tests/Feature/Drift/DriftFindingDetailTest.php @@ -3,30 +3,29 @@ use App\Filament\Resources\FindingResource; use App\Models\Finding; use App\Models\InventoryItem; -use App\Models\InventorySyncRun; test('finding detail renders without Graph calls', function () { bindFailHardGraphClient(); [$user, $tenant] = createUserWithTenant(role: 'manager'); - $baseline = InventorySyncRun::factory()->for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => hash('sha256', 'scope-detail'), - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $baseline->selection_hash, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); $finding = Finding::factory()->for($tenant)->create([ 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'scope_key' => (string) $current->selection_hash, - 'baseline_run_id' => $baseline->getKey(), - 'current_run_id' => $current->getKey(), + 'baseline_operation_run_id' => $baseline->getKey(), + 'current_operation_run_id' => $current->getKey(), 'subject_type' => 'deviceConfiguration', 'subject_external_id' => 'policy-123', 'evidence_jsonb' => [ diff --git a/tests/Feature/Drift/DriftGenerationDeterminismTest.php b/tests/Feature/Drift/DriftGenerationDeterminismTest.php index c5ea172..6484da8 100644 --- a/tests/Feature/Drift/DriftGenerationDeterminismTest.php +++ b/tests/Feature/Drift/DriftGenerationDeterminismTest.php @@ -1,7 +1,6 @@ for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); diff --git a/tests/Feature/Drift/DriftGenerationDispatchTest.php b/tests/Feature/Drift/DriftGenerationDispatchTest.php index a79c8c0..7f95c1f 100644 --- a/tests/Feature/Drift/DriftGenerationDispatchTest.php +++ b/tests/Feature/Drift/DriftGenerationDispatchTest.php @@ -2,7 +2,6 @@ use App\Filament\Pages\DriftLanding; use App\Jobs\GenerateDriftFindingsJob; -use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Services\Graph\GraphClientInterface; use App\Support\OperationRunLinks; @@ -30,15 +29,15 @@ $scopeKey = hash('sha256', 'scope-dispatch'); - $baseline = InventorySyncRun::factory()->for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); @@ -46,7 +45,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'drift.generate') + ->where('type', 'drift_generate_findings') ->latest('id') ->first(); @@ -79,15 +78,15 @@ $scopeKey = hash('sha256', 'scope-idempotent'); - $baseline = InventorySyncRun::factory()->for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); @@ -98,7 +97,7 @@ expect(OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'drift.generate') + ->where('type', 'drift_generate_findings') ->count())->toBe(1); }); @@ -111,16 +110,19 @@ $scopeKey = hash('sha256', 'scope-blocked'); - InventorySyncRun::factory()->for($tenant)->create([ + createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); Livewire::test(DriftLanding::class); Queue::assertNothingPushed(); - expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'drift_generate_findings') + ->count())->toBe(0); }); test('opening Drift does not dispatch generation for readonly users', function () { @@ -132,20 +134,23 @@ $scopeKey = hash('sha256', 'scope-readonly-blocked'); - InventorySyncRun::factory()->for($tenant)->create([ + createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - InventorySyncRun::factory()->for($tenant)->create([ + createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); Livewire::test(DriftLanding::class); Queue::assertNothingPushed(); - expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'drift_generate_findings') + ->count())->toBe(0); }); diff --git a/tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php b/tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php index 3927771..dd6e6a0 100644 --- a/tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php +++ b/tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php @@ -1,7 +1,6 @@ for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); diff --git a/tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php b/tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php index 34ecbf3..001d89a 100644 --- a/tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php +++ b/tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php @@ -1,7 +1,6 @@ for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['deviceConfiguration']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['deviceConfiguration']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); diff --git a/tests/Feature/Drift/DriftPolicySnapshotMetadataOnlyDoesNotCreateFindingTest.php b/tests/Feature/Drift/DriftPolicySnapshotMetadataOnlyDoesNotCreateFindingTest.php index 539a343..1aab8c1 100644 --- a/tests/Feature/Drift/DriftPolicySnapshotMetadataOnlyDoesNotCreateFindingTest.php +++ b/tests/Feature/Drift/DriftPolicySnapshotMetadataOnlyDoesNotCreateFindingTest.php @@ -1,7 +1,6 @@ for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['deviceConfiguration']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['deviceConfiguration']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); diff --git a/tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php b/tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php index 15b8b8f..0d9da14 100644 --- a/tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php +++ b/tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php @@ -1,7 +1,6 @@ for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['deviceConfiguration']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['deviceConfiguration']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); diff --git a/tests/Feature/Drift/DriftScopeTagLegacyDefaultDoesNotCreateFindingTest.php b/tests/Feature/Drift/DriftScopeTagLegacyDefaultDoesNotCreateFindingTest.php index be29a5a..6812d74 100644 --- a/tests/Feature/Drift/DriftScopeTagLegacyDefaultDoesNotCreateFindingTest.php +++ b/tests/Feature/Drift/DriftScopeTagLegacyDefaultDoesNotCreateFindingTest.php @@ -1,7 +1,6 @@ for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['deviceConfiguration']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['deviceConfiguration']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); diff --git a/tests/Feature/Drift/DriftTenantIsolationTest.php b/tests/Feature/Drift/DriftTenantIsolationTest.php index a6661a2..ab37398 100644 --- a/tests/Feature/Drift/DriftTenantIsolationTest.php +++ b/tests/Feature/Drift/DriftTenantIsolationTest.php @@ -1,7 +1,6 @@ for($tenantA)->create([ + $baselineA = createInventorySyncOperationRun($tenantA, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $currentA = InventorySyncRun::factory()->for($tenantA)->create([ + $currentA = createInventorySyncOperationRun($tenantA, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); diff --git a/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php b/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php index b1a21f2..da7b162 100644 --- a/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php +++ b/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php @@ -1,7 +1,6 @@ for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); @@ -33,7 +32,7 @@ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'drift.generate', + 'type' => 'drift_generate_findings', 'status' => 'queued', 'outcome' => 'pending', 'run_identity_hash' => 'drift-hash-1', @@ -42,8 +41,8 @@ 'entra_tenant_id' => 'entra-1', ], 'scope_key' => $scopeKey, - 'baseline_run_id' => (int) $baseline->getKey(), - 'current_run_id' => (int) $current->getKey(), + 'baseline_operation_run_id' => (int) $baseline->getKey(), + 'current_operation_run_id' => (int) $current->getKey(), ], ]); @@ -88,15 +87,15 @@ $scopeKey = hash('sha256', 'scope-job-notification-failure'); - $baseline = InventorySyncRun::factory()->for($tenant)->create([ + $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDays(2), ]); - $current = InventorySyncRun::factory()->for($tenant)->create([ + $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'status' => 'success', 'finished_at' => now()->subDay(), ]); @@ -104,7 +103,7 @@ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'drift.generate', + 'type' => 'drift_generate_findings', 'status' => 'queued', 'outcome' => 'pending', 'run_identity_hash' => 'drift-hash-2', @@ -113,8 +112,8 @@ 'entra_tenant_id' => 'entra-1', ], 'scope_key' => $scopeKey, - 'baseline_run_id' => (int) $baseline->getKey(), - 'current_run_id' => (int) $current->getKey(), + 'baseline_operation_run_id' => (int) $baseline->getKey(), + 'current_operation_run_id' => (int) $current->getKey(), ], ]); diff --git a/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php deleted file mode 100644 index 6c13c71..0000000 --- a/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php +++ /dev/null @@ -1,84 +0,0 @@ -create(); - [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); - - $otherTenant = Tenant::factory()->create(); - - EntraGroupSyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => 'groups-v1:all', - 'slot_key' => 'slot-a', - 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, - ]); - - EntraGroupSyncRun::query()->create([ - 'tenant_id' => $otherTenant->getKey(), - 'selection_key' => 'groups-v1:all', - 'slot_key' => 'slot-b', - 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, - ]); - - $this->actingAs($user) - ->get(EntraGroupSyncRunResource::getUrl('index', tenant: $tenant)) - ->assertOk() - ->assertSee('slot-a') - ->assertDontSee('slot-b'); -}); - -test('entra group sync run view is forbidden cross-tenant (403)', function () { - $tenantA = Tenant::factory()->create(); - $tenantB = Tenant::factory()->create([ - 'workspace_id' => $tenantA->workspace_id, - ]); - - $runB = EntraGroupSyncRun::query()->create([ - 'tenant_id' => $tenantB->getKey(), - 'selection_key' => 'groups-v1:all', - 'slot_key' => null, - 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, - ]); - - $user = User::factory()->create(); - [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner'); - - $this->actingAs($user) - ->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA)) - ->assertForbidden(); -}); - -test('legacy sync runs list is read-only (no sync action)', function () { - Queue::fake(); - - [$user, $tenant] = createUserWithTenant(role: 'owner'); - - $this->actingAs($user); - - $tenant->makeCurrent(); - Filament::setTenant($tenant, true); - - $component = Livewire::test(ListEntraGroupSyncRuns::class)->instance(); - $action = $component->getAction([['name' => 'sync_groups']]); - - expect($action)->toBeNull(); - - Queue::assertNothingPushed(); -}); diff --git a/tests/Feature/Filament/InventoryHubDbOnlyTest.php b/tests/Feature/Filament/InventoryHubDbOnlyTest.php index 25b460a..1922da8 100644 --- a/tests/Feature/Filament/InventoryHubDbOnlyTest.php +++ b/tests/Feature/Filament/InventoryHubDbOnlyTest.php @@ -4,9 +4,8 @@ use App\Filament\Pages\InventoryCoverage; use App\Filament\Resources\InventoryItemResource; -use App\Filament\Resources\InventorySyncRunResource; use App\Models\InventoryItem; -use App\Models\InventorySyncRun; +use App\Models\OperationRun; use Illuminate\Support\Facades\Bus; it('renders Inventory hub surfaces DB-only (no outbound HTTP, no background work)', function (): void { @@ -20,10 +19,14 @@ 'platform' => 'windows', ]); - InventorySyncRun::factory()->create([ + OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'selection_hash' => str_repeat('a', 64), - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'workspace_id' => $tenant->workspace_id, + 'type' => 'inventory_sync', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'context' => ['selection_hash' => str_repeat('a', 64)], + 'completed_at' => now(), ]); $this->actingAs($user); @@ -36,10 +39,6 @@ ->assertSee('Run Inventory Sync') ->assertSee('Item A'); - $this->get(InventorySyncRunResource::getUrl('index', tenant: $tenant)) - ->assertOk() - ->assertSee(str_repeat('a', 12)); - $this->get(InventoryCoverage::getUrl(tenant: $tenant)) ->assertOk() ->assertSee('Policies'); diff --git a/tests/Feature/Filament/InventoryPagesTest.php b/tests/Feature/Filament/InventoryPagesTest.php index 21f87ba..99021f2 100644 --- a/tests/Feature/Filament/InventoryPagesTest.php +++ b/tests/Feature/Filament/InventoryPagesTest.php @@ -2,9 +2,8 @@ use App\Filament\Pages\InventoryCoverage; use App\Filament\Resources\InventoryItemResource; -use App\Filament\Resources\InventorySyncRunResource; use App\Models\InventoryItem; -use App\Models\InventorySyncRun; +use App\Models\OperationRun; use App\Models\Tenant; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); @@ -21,14 +20,17 @@ 'platform' => 'windows', ]); - InventorySyncRun::factory()->create([ + OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'selection_hash' => str_repeat('a', 64), - 'status' => InventorySyncRun::STATUS_SUCCESS, + 'workspace_id' => $tenant->workspace_id, + 'type' => 'inventory_sync', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'context' => ['selection_hash' => str_repeat('a', 64)], + 'completed_at' => now(), ]); $itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant); - $syncRunsUrl = InventorySyncRunResource::getUrl('index', tenant: $tenant); $coverageUrl = InventoryCoverage::getUrl(tenant: $tenant); $kpiLabels = [ @@ -43,24 +45,14 @@ ->get($itemsUrl) ->assertOk() ->assertSee('Run Inventory Sync') - ->assertSee($syncRunsUrl) ->assertSee($coverageUrl) ->assertSee($kpiLabels) ->assertSee('Item A'); - $this->actingAs($user) - ->get($syncRunsUrl) - ->assertOk() - ->assertSee($itemsUrl) - ->assertSee($coverageUrl) - ->assertSee($kpiLabels) - ->assertSee(str_repeat('a', 12)); - $this->actingAs($user) ->get(InventoryCoverage::getUrl(tenant: $tenant)) ->assertOk() ->assertSee($itemsUrl) - ->assertSee($syncRunsUrl) ->assertSee($kpiLabels) ->assertSee('Coverage') ->assertSee('Policies') diff --git a/tests/Feature/Filament/InventorySyncRunResourceTest.php b/tests/Feature/Filament/InventorySyncRunResourceTest.php deleted file mode 100644 index 4cccbfe..0000000 --- a/tests/Feature/Filament/InventorySyncRunResourceTest.php +++ /dev/null @@ -1,48 +0,0 @@ -create(); - [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); - - $otherTenant = Tenant::factory()->create(); - - InventorySyncRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_hash' => str_repeat('a', 64), - 'status' => InventorySyncRun::STATUS_SUCCESS, - ]); - - InventorySyncRun::factory()->create([ - 'tenant_id' => $otherTenant->getKey(), - 'selection_hash' => str_repeat('b', 64), - 'status' => InventorySyncRun::STATUS_SUCCESS, - ]); - - $this->actingAs($user) - ->get(InventorySyncRunResource::getUrl('index', tenant: $tenant)) - ->assertOk() - ->assertSee(str_repeat('a', 12)) - ->assertDontSee(str_repeat('b', 12)); -}); - -test('non-members are denied access to inventory sync run tenant routes (404)', function () { - $tenant = Tenant::factory()->create(); - $otherTenant = Tenant::factory()->create(); - - [$user] = createUserWithTenant($otherTenant, role: 'owner'); - - $this->actingAs($user) - ->get(InventorySyncRunResource::getUrl('index', tenant: $tenant)) - ->assertStatus(404); -}); diff --git a/tests/Feature/Filament/TenantDashboardDbOnlyTest.php b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php index 4a21e10..c19ca79 100644 --- a/tests/Feature/Filament/TenantDashboardDbOnlyTest.php +++ b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php @@ -19,7 +19,7 @@ OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', 'initiator_name' => 'System', diff --git a/tests/Feature/Filament/TenantDashboardTenantScopeTest.php b/tests/Feature/Filament/TenantDashboardTenantScopeTest.php index 1d61da5..c910839 100644 --- a/tests/Feature/Filament/TenantDashboardTenantScopeTest.php +++ b/tests/Feature/Filament/TenantDashboardTenantScopeTest.php @@ -26,7 +26,7 @@ OperationRun::factory()->create([ 'tenant_id' => $otherTenant->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'running', 'outcome' => 'pending', 'initiator_name' => 'System', diff --git a/tests/Feature/Guards/ActionSurfaceContractTest.php b/tests/Feature/Guards/ActionSurfaceContractTest.php index 3d2abd1..5039e17 100644 --- a/tests/Feature/Guards/ActionSurfaceContractTest.php +++ b/tests/Feature/Guards/ActionSurfaceContractTest.php @@ -4,15 +4,12 @@ use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems; -use App\Filament\Resources\InventorySyncRunResource; -use App\Filament\Resources\InventorySyncRunResource\Pages\ListInventorySyncRuns; use App\Filament\Resources\OperationRunResource; use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource\Pages\ListPolicies; use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; use App\Jobs\SyncPoliciesJob; use App\Models\InventoryItem; -use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\Tenant; use App\Support\OperationRunLinks; @@ -157,28 +154,6 @@ expect($recordUrl)->toBe(InventoryItemResource::getUrl('view', ['record' => $item])); }); -it('removes lone View buttons and uses clickable rows on the inventory sync runs list', function (): void { - [$user, $tenant] = createUserWithTenant(role: 'owner'); - - $this->actingAs($user); - $tenant->makeCurrent(); - Filament::setTenant($tenant, true); - - $run = InventorySyncRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - ]); - - $livewire = Livewire::test(ListInventorySyncRuns::class); - $table = $livewire->instance()->getTable(); - - expect($table->getActions())->toBeEmpty(); - - $recordUrl = $table->getRecordUrl($run); - - expect($recordUrl)->not->toBeNull(); - expect($recordUrl)->toBe(InventorySyncRunResource::getUrl('view', ['record' => $run])); -}); - it('keeps representative operation-start actions observable with actor and scope metadata', function (): void { Queue::fake(); bindFailHardGraphClient(); diff --git a/tests/Feature/Guards/NoLegacyRunBackfillTest.php b/tests/Feature/Guards/NoLegacyRunBackfillTest.php new file mode 100644 index 0000000..a2b9a8d --- /dev/null +++ b/tests/Feature/Guards/NoLegacyRunBackfillTest.php @@ -0,0 +1,63 @@ +(?:create|insert|upsert|updateOrCreate)\(.{0,1200}\b(?:InventorySyncRun|EntraGroupSyncRun|BackupScheduleRun)\b/is', + ]; + + /** @var Collection $files */ + $files = collect($directories) + ->filter(fn (string $dir): bool => is_dir($dir)) + ->flatMap(function (string $dir): array { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) + ); + + $paths = []; + + foreach ($iterator as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $paths[] = $file->getPathname(); + } + + return $paths; + }) + ->values(); + + $hits = []; + + foreach ($files as $path) { + $contents = file_get_contents($path); + + if (! is_string($contents) || $contents === '') { + continue; + } + + foreach ($forbiddenPatterns as $pattern) { + if (! preg_match($pattern, $contents, $matches, PREG_OFFSET_CAPTURE)) { + continue; + } + + $offset = (int) ($matches[0][1] ?? 0); + $lineNumber = substr_count(substr($contents, 0, $offset), "\n") + 1; + $excerpt = trim((string) ($matches[0][0] ?? '')); + + $hits[] = str_replace($root.'/', '', $path).':'.$lineNumber.' -> '.str($excerpt)->limit(160)->value(); + } + } + + expect($hits)->toBeEmpty('Found legacy-to-canonical backfill patterns:\n'.implode("\n", $hits)); +}); diff --git a/tests/Feature/Guards/NoLegacyRunsTest.php b/tests/Feature/Guards/NoLegacyRunsTest.php new file mode 100644 index 0000000..9b7364f --- /dev/null +++ b/tests/Feature/Guards/NoLegacyRunsTest.php @@ -0,0 +1,99 @@ + $files */ + $files = collect($directories) + ->filter(fn (string $dir): bool => is_dir($dir)) + ->flatMap(function (string $dir): array { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) + ); + + $paths = []; + + foreach ($iterator as $file) { + if (! $file->isFile()) { + continue; + } + + $paths[] = $file->getPathname(); + } + + return $paths; + }) + ->filter(function (string $path) use ($excludedPaths, $self): bool { + if ($self && realpath($path) === $self) { + return false; + } + + foreach ($excludedPaths as $excluded) { + if (str_starts_with($path, $excluded)) { + return false; + } + } + + return true; + }) + ->values(); + + $hits = []; + + foreach ($files as $path) { + $contents = file_get_contents($path); + + if (! is_string($contents) || $contents === '') { + continue; + } + + foreach ($forbiddenPatterns as $pattern) { + if (! preg_match($pattern, $contents)) { + continue; + } + + $lines = preg_split('/\R/', $contents) ?: []; + + foreach ($lines as $index => $line) { + if (preg_match($pattern, $line)) { + $relative = str_replace($root.'/', '', $path); + $hits[] = $relative.':'.($index + 1).' -> '.trim($line); + } + } + } + } + + expect($hits)->toBeEmpty('Legacy run references found:\n'.implode("\n", $hits)); +}); diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php index 03cc3ed..dd7b2a2 100644 --- a/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -36,7 +36,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', $user->id) - ->where('type', 'inventory.sync') + ->where('type', 'inventory_sync') ->latest('id') ->first(); @@ -73,7 +73,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'inventory.sync') + ->where('type', 'inventory_sync') ->latest('id') ->first(); @@ -106,7 +106,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'inventory.sync') + ->where('type', 'inventory_sync') ->latest('id') ->first(); @@ -139,7 +139,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'inventory.sync') + ->where('type', 'inventory_sync') ->latest('id') ->first(); @@ -172,7 +172,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'inventory.sync') + ->where('type', 'inventory_sync') ->latest('id') ->first(); @@ -217,7 +217,7 @@ $opService = app(OperationRunService::class); $existing = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'inventory.sync', + type: 'inventory_sync', identityInputs: [ 'selection_hash' => $computed['selection_hash'], ], @@ -237,7 +237,7 @@ Queue::assertNothingPushed(); expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->count())->toBe(0); - expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->count())->toBe(1); + expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory_sync')->count())->toBe(1); }); it('disables inventory sync start action for readonly users', function () { diff --git a/tests/Feature/Inventory/InventorySyncServiceTest.php b/tests/Feature/Inventory/InventorySyncServiceTest.php index 4eb621b..944df4d 100644 --- a/tests/Feature/Inventory/InventorySyncServiceTest.php +++ b/tests/Feature/Inventory/InventorySyncServiceTest.php @@ -2,7 +2,6 @@ use App\Models\BackupItem; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; use App\Models\BackupSet; use App\Models\PolicyVersion; use App\Models\ProviderConnection; @@ -115,7 +114,7 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array $opRun = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'inventory.sync', + type: 'inventory_sync', identityInputs: [ 'selection_hash' => $computed['selection_hash'], ], @@ -421,7 +420,7 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array 'display_name' => 'Config 1', 'meta_jsonb' => $meta, 'last_seen_at' => now(), - 'last_seen_run_id' => null, + 'last_seen_operation_run_id' => null, ]); $item->refresh(); @@ -543,7 +542,6 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array 'backup_sets' => BackupSet::query()->count(), 'backup_items' => BackupItem::query()->count(), 'backup_schedules' => BackupSchedule::query()->count(), - 'backup_schedule_runs' => BackupScheduleRun::query()->count(), ]; app()->instance(GraphClientInterface::class, fakeGraphClient([ @@ -561,7 +559,6 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array expect(BackupSet::query()->count())->toBe($baseline['backup_sets']); expect(BackupItem::query()->count())->toBe($baseline['backup_items']); expect(BackupSchedule::query()->count())->toBe($baseline['backup_schedules']); - expect(BackupScheduleRun::query()->count())->toBe($baseline['backup_schedule_runs']); }); test('run error persistence is safe and does not include bearer tokens', function () { diff --git a/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php b/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php index f1e5d26..15117b8 100644 --- a/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php +++ b/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php @@ -36,7 +36,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'inventory.sync') + ->where('type', 'inventory_sync') ->latest('id') ->first(); diff --git a/tests/Feature/Inventory/RunInventorySyncJobTest.php b/tests/Feature/Inventory/RunInventorySyncJobTest.php index a117992..16463a3 100644 --- a/tests/Feature/Inventory/RunInventorySyncJobTest.php +++ b/tests/Feature/Inventory/RunInventorySyncJobTest.php @@ -42,7 +42,7 @@ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, - type: 'inventory.sync', + type: 'inventory_sync', inputs: $computed['selection'], initiator: $user, ); @@ -60,6 +60,10 @@ expect($opRun->status)->toBe('completed'); expect($opRun->outcome)->toBe('succeeded'); + $context = is_array($opRun->context) ? $opRun->context : []; + expect($context)->toHaveKey('result'); + expect($context['result']['had_errors'] ?? null)->toBeFalse(); + $counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : []; expect((int) ($counts['total'] ?? 0))->toBe(count($policyTypes)); expect((int) ($counts['processed'] ?? 0))->toBe(count($policyTypes)); @@ -88,7 +92,7 @@ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, - type: 'inventory.sync', + type: 'inventory_sync', inputs: $computed['selection'], initiator: $user, ); @@ -124,6 +128,10 @@ expect($opRun->status)->toBe('completed'); expect($opRun->outcome)->toBe('failed'); + $context = is_array($opRun->context) ? $opRun->context : []; + expect($context)->toHaveKey('result'); + expect($context['result']['had_errors'] ?? null)->toBeTrue(); + $counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : []; expect((int) ($counts['processed'] ?? 0))->toBe(count($policyTypes)); expect((int) ($counts['skipped'] ?? 0))->toBe(count($policyTypes)); diff --git a/tests/Feature/ManagedTenantOnboardingWizardTest.php b/tests/Feature/ManagedTenantOnboardingWizardTest.php index b5ee7f7..aadb599 100644 --- a/tests/Feature/ManagedTenantOnboardingWizardTest.php +++ b/tests/Feature/ManagedTenantOnboardingWizardTest.php @@ -678,20 +678,20 @@ ]), ]); - $component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']); + $component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']); Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1); Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1); expect(OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->whereIn('type', ['inventory.sync', 'compliance.snapshot']) + ->whereIn('type', ['inventory_sync', 'compliance.snapshot']) ->count())->toBe(2); $session->refresh(); $runs = $session->state['bootstrap_operation_runs'] ?? []; expect($runs)->toBeArray(); - expect($runs['inventory.sync'] ?? null)->toBeInt(); + expect($runs['inventory_sync'] ?? null)->toBeInt(); expect($runs['compliance.snapshot'] ?? null)->toBeInt(); }); @@ -725,7 +725,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', 'run_identity_hash' => sha1('busy-'.(string) $connection->getKey()), diff --git a/tests/Feature/Monitoring/HeaderContextBarTest.php b/tests/Feature/Monitoring/HeaderContextBarTest.php index 99c1624..251fc22 100644 --- a/tests/Feature/Monitoring/HeaderContextBarTest.php +++ b/tests/Feature/Monitoring/HeaderContextBarTest.php @@ -144,7 +144,7 @@ OperationRun::factory()->create([ 'tenant_id' => (int) $tenantB->getKey(), 'workspace_id' => (int) $tenantB->workspace_id, - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'initiator_name' => 'TenantB', ]); diff --git a/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php b/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php index 7435970..e712a4b 100644 --- a/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php +++ b/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php @@ -36,7 +36,7 @@ $runB = OperationRun::factory()->create([ 'tenant_id' => (int) $tenantB->getKey(), 'workspace_id' => (int) $tenantB->workspace_id, - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'initiator_name' => 'TenantB', ]); @@ -103,7 +103,7 @@ $runB = OperationRun::factory()->create([ 'tenant_id' => (int) $tenantB->getKey(), 'workspace_id' => (int) $tenantB->workspace_id, - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'initiator_name' => 'TenantB', ]); diff --git a/tests/Feature/Monitoring/OperationsTenantScopeTest.php b/tests/Feature/Monitoring/OperationsTenantScopeTest.php index 0c030a5..89525bc 100644 --- a/tests/Feature/Monitoring/OperationsTenantScopeTest.php +++ b/tests/Feature/Monitoring/OperationsTenantScopeTest.php @@ -34,7 +34,7 @@ OperationRun::factory()->create([ 'tenant_id' => $tenantB->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', 'initiator_name' => 'TenantB', @@ -74,7 +74,7 @@ OperationRun::factory()->create([ 'tenant_id' => $tenantB->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', 'initiator_name' => 'TenantB', @@ -141,7 +141,7 @@ $runActiveB = OperationRun::factory()->create([ 'tenant_id' => $tenantB->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', 'initiator_name' => 'B-active', @@ -149,7 +149,7 @@ $runFailedB = OperationRun::factory()->create([ 'tenant_id' => $tenantB->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'completed', 'outcome' => 'failed', 'initiator_name' => 'B-failed', @@ -191,7 +191,7 @@ $runB = OperationRun::factory()->create([ 'tenant_id' => $tenantB->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', 'initiator_name' => 'TenantB', diff --git a/tests/Feature/MonitoringOperationsTest.php b/tests/Feature/MonitoringOperationsTest.php index c62f360..c6be8cf 100644 --- a/tests/Feature/MonitoringOperationsTest.php +++ b/tests/Feature/MonitoringOperationsTest.php @@ -91,7 +91,7 @@ OperationRun::factory()->create([ 'tenant_id' => (int) $tenantB->getKey(), 'workspace_id' => (int) $tenantB->workspace_id, - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'initiator_name' => 'TenantB', ]); diff --git a/tests/Feature/Notifications/OperationRunNotificationTest.php b/tests/Feature/Notifications/OperationRunNotificationTest.php index 2c8a3a8..07f05ad 100644 --- a/tests/Feature/Notifications/OperationRunNotificationTest.php +++ b/tests/Feature/Notifications/OperationRunNotificationTest.php @@ -101,7 +101,7 @@ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', 'context' => ['policy_types' => ['deviceConfiguration']], diff --git a/tests/Feature/Onboarding/OnboardingInlineConnectionEditTest.php b/tests/Feature/Onboarding/OnboardingInlineConnectionEditTest.php index bb7935c..e1a5a2c 100644 --- a/tests/Feature/Onboarding/OnboardingInlineConnectionEditTest.php +++ b/tests/Feature/Onboarding/OnboardingInlineConnectionEditTest.php @@ -173,7 +173,7 @@ 'provider_connection_id' => (int) $connection->getKey(), 'verification_operation_run_id' => (int) $run->getKey(), 'bootstrap_operation_runs' => [123, 456], - 'bootstrap_operation_types' => ['inventory.sync'], + 'bootstrap_operation_types' => ['inventory_sync'], ], 'started_by_user_id' => (int) $user->getKey(), 'updated_by_user_id' => (int) $user->getKey(), diff --git a/tests/Feature/OperationRunServiceTest.php b/tests/Feature/OperationRunServiceTest.php index e5a5f84..7a69368 100644 --- a/tests/Feature/OperationRunServiceTest.php +++ b/tests/Feature/OperationRunServiceTest.php @@ -134,7 +134,7 @@ $runA = $service->ensureRunWithIdentityStrict( tenant: $tenant, - type: 'backup_schedule.scheduled', + type: 'backup_schedule_run', identityInputs: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'], context: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'], ); @@ -143,7 +143,7 @@ $runB = $service->ensureRunWithIdentityStrict( tenant: $tenant, - type: 'backup_schedule.scheduled', + type: 'backup_schedule_run', identityInputs: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'], context: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'], ); @@ -185,7 +185,7 @@ try { $run = $service->ensureRunWithIdentityStrict( tenant: $tenant, - type: 'backup_schedule.scheduled', + type: 'backup_schedule_run', identityInputs: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'], context: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'], ); @@ -195,7 +195,7 @@ } expect($run)->toBeInstanceOf(OperationRun::class); - expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->where('type', 'backup_schedule.scheduled')->count()) + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->where('type', 'backup_schedule_run')->count()) ->toBe(1); }); diff --git a/tests/Feature/Operations/LegacyRunRedirectTest.php b/tests/Feature/Operations/LegacyRunRedirectTest.php index ff8a6af..434af03 100644 --- a/tests/Feature/Operations/LegacyRunRedirectTest.php +++ b/tests/Feature/Operations/LegacyRunRedirectTest.php @@ -1,11 +1,5 @@ create([ - 'tenant_id' => $tenant->getKey(), - 'type' => 'inventory.sync', - 'status' => 'queued', - 'outcome' => 'pending', - ]); + $legacyUrls = [ + "/admin/t/{$tenant->external_id}/inventory-sync-runs", + "/admin/t/{$tenant->external_id}/inventory-sync-runs/1", + "/admin/t/{$tenant->external_id}/entra-group-sync-runs", + "/admin/t/{$tenant->external_id}/entra-group-sync-runs/1", + ]; - $legacyRun = InventorySyncRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'operation_run_id' => (int) $opRun->getKey(), - ]); - - $this->actingAs($user) - ->get(InventorySyncRunResource::getUrl('view', ['record' => $legacyRun], tenant: $tenant)) - ->assertRedirect(OperationRunLinks::tenantlessView($opRun->getKey())); -}); - -it('does not redirect legacy inventory sync run view when not mapped', function (): void { - [$user, $tenant] = createUserWithTenant(role: 'owner'); - - $legacyRun = InventorySyncRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'operation_run_id' => null, - ]); - - $this->actingAs($user) - ->get(InventorySyncRunResource::getUrl('view', ['record' => $legacyRun], tenant: $tenant)) - ->assertOk(); -}); - -it('redirects legacy directory group sync run view to canonical OperationRun when mapped', function (): void { - [$user, $tenant] = createUserWithTenant(role: 'owner'); - - $opRun = OperationRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'type' => 'directory_groups.sync', - 'status' => 'queued', - 'outcome' => 'pending', - ]); - - $legacyRun = EntraGroupSyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => 'groups-v1:all', - 'slot_key' => null, - 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, - 'operation_run_id' => (int) $opRun->getKey(), - ]); - - $this->actingAs($user) - ->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $legacyRun], tenant: $tenant)) - ->assertRedirect(OperationRunLinks::tenantlessView($opRun->getKey())); -}); - -it('does not redirect legacy directory group sync run view when not mapped', function (): void { - [$user, $tenant] = createUserWithTenant(role: 'owner'); - - $legacyRun = EntraGroupSyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => 'groups-v1:all', - 'slot_key' => null, - 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, - 'operation_run_id' => null, - ]); - - $this->actingAs($user) - ->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $legacyRun], tenant: $tenant)) - ->assertOk(); + foreach ($legacyUrls as $url) { + $this->actingAs($user) + ->get($url) + ->assertNotFound(); + } }); diff --git a/tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php b/tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php new file mode 100644 index 0000000..85aa7c0 --- /dev/null +++ b/tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php @@ -0,0 +1,28 @@ +external_id}/inventory-sync-runs", + "/admin/t/{$tenant->external_id}/inventory-sync-runs/123", + "/admin/t/{$tenant->external_id}/entra-group-sync-runs", + "/admin/t/{$tenant->external_id}/entra-group-sync-runs/123", + "/admin/t/{$tenant->external_id}/backup-schedules/1/runs/123", + ]; + + foreach ($legacyUrls as $url) { + $this->actingAs($user) + ->get($url) + ->assertNotFound(); + } +}); diff --git a/tests/Feature/Operations/TenantlessOperationRunViewerTest.php b/tests/Feature/Operations/TenantlessOperationRunViewerTest.php index 95274f1..84b1a40 100644 --- a/tests/Feature/Operations/TenantlessOperationRunViewerTest.php +++ b/tests/Feature/Operations/TenantlessOperationRunViewerTest.php @@ -89,7 +89,7 @@ $run = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $workspace->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, ]); diff --git a/tests/Feature/OpsUx/ActiveRunsTest.php b/tests/Feature/OpsUx/ActiveRunsTest.php index ba61df8..85ce2b2 100644 --- a/tests/Feature/OpsUx/ActiveRunsTest.php +++ b/tests/Feature/OpsUx/ActiveRunsTest.php @@ -11,7 +11,7 @@ OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'completed', 'outcome' => 'succeeded', 'initiator_name' => 'System', @@ -25,7 +25,7 @@ OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', 'initiator_name' => 'System', @@ -39,7 +39,7 @@ OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'running', 'outcome' => 'pending', 'initiator_name' => 'System', @@ -54,7 +54,7 @@ OperationRun::factory()->create([ 'tenant_id' => $tenantB->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'running', 'outcome' => 'pending', 'initiator_name' => 'System', diff --git a/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php b/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php index be49d12..08f8c20 100644 --- a/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php +++ b/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php @@ -26,7 +26,7 @@ OperationRun::factory()->create([ 'tenant_id' => $tenantB->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'running', 'outcome' => 'pending', 'initiator_name' => 'TenantB', diff --git a/tests/Feature/OpsUx/NotificationViewRunLinkTest.php b/tests/Feature/OpsUx/NotificationViewRunLinkTest.php index 20ae505..f520178 100644 --- a/tests/Feature/OpsUx/NotificationViewRunLinkTest.php +++ b/tests/Feature/OpsUx/NotificationViewRunLinkTest.php @@ -18,7 +18,7 @@ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', 'context' => ['scope' => 'all'], diff --git a/tests/Feature/OpsUx/OperationCatalogCoverageTest.php b/tests/Feature/OpsUx/OperationCatalogCoverageTest.php index 6e67ed6..6da2a4b 100644 --- a/tests/Feature/OpsUx/OperationCatalogCoverageTest.php +++ b/tests/Feature/OpsUx/OperationCatalogCoverageTest.php @@ -26,14 +26,14 @@ $contents = File::get($path); // Capture common patterns where operation type strings are produced in code. - // Example: ensureRun(type: 'inventory.sync', ...) + // Example: ensureRun(type: 'inventory_sync', ...) if (preg_match_all("/(?:\btype\s*:\s*|\btype\b\s*=>\s*)'([a-z0-9_]+(?:\.[a-z0-9_]+)+)'/i", $contents, $matches)) { foreach ($matches[1] as $type) { $discoveredTypes[] = $type; } } - // Example: if ($run->type === 'inventory.sync') + // Example: if ($run->type === 'inventory_sync') if (preg_match_all("/\btype\s*(?:===|!==|==|!=)\s*'([a-z0-9_]+(?:\.[a-z0-9_]+)+)'/i", $contents, $matches)) { foreach ($matches[1] as $type) { $discoveredTypes[] = $type; diff --git a/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php b/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php index 555b1d1..bb62eb0 100644 --- a/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php +++ b/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php @@ -16,7 +16,7 @@ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', 'context' => ['scope' => 'all'], diff --git a/tests/Feature/OpsUx/TerminalNotificationFailureMessageTest.php b/tests/Feature/OpsUx/TerminalNotificationFailureMessageTest.php index 9089a47..bd58707 100644 --- a/tests/Feature/OpsUx/TerminalNotificationFailureMessageTest.php +++ b/tests/Feature/OpsUx/TerminalNotificationFailureMessageTest.php @@ -19,7 +19,7 @@ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'running', 'outcome' => 'pending', 'context' => ['scope' => 'all'], diff --git a/tests/Feature/OpsUx/TerminalNotificationIdempotencyTest.php b/tests/Feature/OpsUx/TerminalNotificationIdempotencyTest.php index 6f80a5f..1861eda 100644 --- a/tests/Feature/OpsUx/TerminalNotificationIdempotencyTest.php +++ b/tests/Feature/OpsUx/TerminalNotificationIdempotencyTest.php @@ -16,7 +16,7 @@ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'running', 'outcome' => 'pending', 'context' => ['scope' => 'all'], diff --git a/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php b/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php index 471f6c0..3af094a 100644 --- a/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php +++ b/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php @@ -46,7 +46,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'inventory.sync') + ->where('type', 'inventory_sync') ->latest('id') ->first(); @@ -62,7 +62,7 @@ expect(OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'inventory.sync') + ->where('type', 'inventory_sync') ->count())->toBe(1); Queue::assertPushed(ProviderInventorySyncJob::class, 1); @@ -150,7 +150,7 @@ $inventoryRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'inventory.sync') + ->where('type', 'inventory_sync') ->latest('id') ->first(); diff --git a/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php b/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php index 8337673..a8921ed 100644 --- a/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php +++ b/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php @@ -2,7 +2,6 @@ use App\Filament\Pages\DriftLanding; use App\Jobs\GenerateDriftFindingsJob; -use App\Models\InventorySyncRun; use Filament\Facades\Filament; use Illuminate\Support\Facades\Bus; use Livewire\Livewire; @@ -13,12 +12,12 @@ [$user, $tenant] = createUserWithTenant(role: 'readonly'); - InventorySyncRun::factory()->create([ + createInventorySyncOperationRun($tenant, [ 'tenant_id' => $tenant->getKey(), 'finished_at' => now()->subDays(2), ]); - InventorySyncRun::factory()->create([ + createInventorySyncOperationRun($tenant, [ 'tenant_id' => $tenant->getKey(), 'finished_at' => now()->subDay(), ]); @@ -39,12 +38,12 @@ [$user, $tenant] = createUserWithTenant(role: 'owner'); - InventorySyncRun::factory()->create([ + createInventorySyncOperationRun($tenant, [ 'tenant_id' => $tenant->getKey(), 'finished_at' => now()->subDays(2), ]); - $latestRun = InventorySyncRun::factory()->create([ + $latestRun = createInventorySyncOperationRun($tenant, [ 'tenant_id' => $tenant->getKey(), 'finished_at' => now()->subDay(), ]); diff --git a/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php b/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php deleted file mode 100644 index e66cb96..0000000 --- a/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php +++ /dev/null @@ -1,59 +0,0 @@ -actingAs($user); - $tenant->makeCurrent(); - Filament::setTenant($tenant, true); - - $component = Livewire::test(ListEntraGroupSyncRuns::class); - - $user->tenants()->detach($tenant->getKey()); - app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); - - expect($component->instance()->getAction([['name' => 'sync_groups']]))->toBeNull(); - - Queue::assertNothingPushed(); - }); - - it('does not expose a sync action for readonly members', function () { - [$user, $tenant] = createUserWithTenant(role: 'readonly'); - - $this->actingAs($user); - $tenant->makeCurrent(); - Filament::setTenant($tenant, true); - - $component = Livewire::test(ListEntraGroupSyncRuns::class); - expect($component->instance()->getAction([['name' => 'sync_groups']]))->toBeNull(); - - Queue::assertNothingPushed(); - }); - - it('does not expose a sync action for owner members', function () { - [$user, $tenant] = createUserWithTenant(role: 'owner'); - - $this->actingAs($user); - $tenant->makeCurrent(); - Filament::setTenant($tenant, true); - - $component = Livewire::test(ListEntraGroupSyncRuns::class); - expect($component->instance()->getAction([['name' => 'sync_groups']]))->toBeNull(); - - Queue::assertNothingPushed(); - }); -}); diff --git a/tests/Feature/RunAuthorizationTenantIsolationTest.php b/tests/Feature/RunAuthorizationTenantIsolationTest.php index 6129b80..8b5a9e2 100644 --- a/tests/Feature/RunAuthorizationTenantIsolationTest.php +++ b/tests/Feature/RunAuthorizationTenantIsolationTest.php @@ -36,7 +36,7 @@ OperationRun::factory()->create([ 'tenant_id' => (int) $tenantB->getKey(), 'workspace_id' => (int) $tenantB->workspace_id, - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', ]); @@ -71,7 +71,7 @@ $runB = OperationRun::factory()->create([ 'tenant_id' => (int) $tenantB->getKey(), 'workspace_id' => (int) $workspaceB->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', ]); @@ -88,7 +88,7 @@ $run = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, - 'type' => 'drift.generate', + 'type' => 'drift_generate_findings', 'status' => 'queued', 'outcome' => 'pending', ]); diff --git a/tests/Pest.php b/tests/Pest.php index 9d288f6..bf8a6e6 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,7 @@ $attributes + */ +function createInventorySyncOperationRun(Tenant $tenant, array $attributes = []): \App\Models\OperationRun +{ + $context = is_array($attributes['context'] ?? null) ? $attributes['context'] : []; + + if (array_key_exists('selection_hash', $attributes)) { + if (is_string($attributes['selection_hash']) && $attributes['selection_hash'] !== '') { + $context['selection_hash'] = $attributes['selection_hash']; + } + + unset($attributes['selection_hash']); + } + + if (array_key_exists('selection_payload', $attributes)) { + if (is_array($attributes['selection_payload'])) { + $context = array_merge($context, $attributes['selection_payload']); + } + + unset($attributes['selection_payload']); + } + + if (! isset($context['selection_hash']) || ! is_string($context['selection_hash']) || $context['selection_hash'] === '') { + $context['selection_hash'] = hash('sha256', 'inventory-sync-selection-default'); + } + + if (! isset($context['policy_types']) || ! is_array($context['policy_types'])) { + $context['policy_types'] = ['deviceConfiguration']; + } + + if (! isset($context['categories']) || ! is_array($context['categories'])) { + $context['categories'] = []; + } + + if (! array_key_exists('include_foundations', $context)) { + $context['include_foundations'] = false; + } + + if (! array_key_exists('include_dependencies', $context)) { + $context['include_dependencies'] = false; + } + + $finishedAt = $attributes['finished_at'] ?? null; + unset($attributes['finished_at']); + + $providedStatus = (string) ($attributes['status'] ?? 'success'); + $normalizedStatus = match ($providedStatus) { + 'pending', 'queued' => 'queued', + 'running' => 'running', + 'completed' => 'completed', + default => 'completed', + }; + + $normalizedOutcome = match ($providedStatus) { + 'success' => 'succeeded', + 'partial' => 'partially_succeeded', + 'skipped' => 'blocked', + 'failed' => 'failed', + 'pending', 'queued', 'running' => 'pending', + default => $normalizedStatus === 'completed' ? 'succeeded' : 'pending', + }; + + $attributes['type'] = (string) ($attributes['type'] ?? 'inventory_sync'); + $attributes['workspace_id'] = (int) ($attributes['workspace_id'] ?? $tenant->workspace_id); + $attributes['status'] = in_array($providedStatus, ['queued', 'running', 'completed'], true) + ? $providedStatus + : $normalizedStatus; + $attributes['outcome'] = (string) ($attributes['outcome'] ?? $normalizedOutcome); + $attributes['context'] = array_merge($context, is_array($attributes['context'] ?? null) ? $attributes['context'] : []); + + if ($finishedAt !== null && ! array_key_exists('completed_at', $attributes)) { + $attributes['completed_at'] = $finishedAt; + } + + return \App\Models\OperationRun::factory() + ->for($tenant) + ->create($attributes); +} + /** * @return array{0: User, 1: Tenant} */ diff --git a/tests/Support/LegacyModels/InventorySyncRun.php b/tests/Support/LegacyModels/InventorySyncRun.php new file mode 100644 index 0000000..6c57c7f --- /dev/null +++ b/tests/Support/LegacyModels/InventorySyncRun.php @@ -0,0 +1,157 @@ +state([ + 'type' => 'inventory_sync', + 'status' => self::STATUS_SUCCESS, + 'outcome' => 'succeeded', + 'context' => [ + 'selection_hash' => hash('sha256', 'inventory-sync-selection-default'), + 'policy_types' => ['deviceConfiguration'], + 'categories' => [], + 'include_foundations' => false, + 'include_dependencies' => false, + ], + 'started_at' => now()->subMinute(), + 'completed_at' => now(), + ]); + } + + protected static function booted(): void + { + static::addGlobalScope('inventory_sync_type', function (Builder $builder): void { + $builder->where('type', 'inventory_sync'); + }); + + static::saving(function (self $run): void { + if (! filled($run->type)) { + $run->type = 'inventory_sync'; + } + + $legacyStatus = (string) $run->status; + $normalizedStatus = match ($legacyStatus) { + self::STATUS_PENDING => 'queued', + self::STATUS_RUNNING => 'running', + self::STATUS_SUCCESS, + self::STATUS_PARTIAL, + self::STATUS_FAILED, + self::STATUS_SKIPPED => 'completed', + default => $legacyStatus, + }; + + if ($normalizedStatus !== '') { + $run->status = $normalizedStatus; + } + + $context = is_array($run->context) ? $run->context : []; + + if (! array_key_exists('selection_hash', $context) || ! is_string($context['selection_hash']) || $context['selection_hash'] === '') { + $context['selection_hash'] = hash('sha256', (string) $run->getKey().':selection'); + } + + $run->context = $context; + + $run->outcome = match ($legacyStatus) { + self::STATUS_SUCCESS => 'succeeded', + self::STATUS_PARTIAL => 'partially_succeeded', + self::STATUS_SKIPPED => 'blocked', + self::STATUS_RUNNING, self::STATUS_PENDING => 'pending', + default => 'failed', + }; + + if (in_array($legacyStatus, [self::STATUS_SUCCESS, self::STATUS_PARTIAL, self::STATUS_FAILED, self::STATUS_SKIPPED], true) + && $run->completed_at === null + ) { + $run->completed_at = now(); + } + }); + } + + public function getSelectionHashAttribute(): ?string + { + $context = is_array($this->context) ? $this->context : []; + + return isset($context['selection_hash']) && is_string($context['selection_hash']) + ? $context['selection_hash'] + : null; + } + + public function setSelectionHashAttribute(?string $value): void + { + $context = is_array($this->context) ? $this->context : []; + $context['selection_hash'] = $value; + + $this->context = $context; + } + + /** + * @return array + */ + public function getSelectionPayloadAttribute(): array + { + $context = is_array($this->context) ? $this->context : []; + + return Arr::only($context, [ + 'policy_types', + 'categories', + 'include_foundations', + 'include_dependencies', + ]); + } + + /** + * @param array|null $value + */ + public function setSelectionPayloadAttribute(?array $value): void + { + $context = is_array($this->context) ? $this->context : []; + + if (is_array($value)) { + $context = array_merge($context, Arr::only($value, [ + 'policy_types', + 'categories', + 'include_foundations', + 'include_dependencies', + ])); + } + + $this->context = $context; + } + + public function getFinishedAtAttribute(): mixed + { + return $this->completed_at; + } + + public function setFinishedAtAttribute(mixed $value): void + { + $this->completed_at = $value; + } +} diff --git a/tests/Unit/Badges/RunStatusBadgesTest.php b/tests/Unit/Badges/RunStatusBadgesTest.php index 57eff1a..eee3375 100644 --- a/tests/Unit/Badges/RunStatusBadgesTest.php +++ b/tests/Unit/Badges/RunStatusBadgesTest.php @@ -5,54 +5,38 @@ use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeDomain; -it('maps inventory sync run status values to canonical badge semantics', function (): void { - $pending = BadgeCatalog::spec(BadgeDomain::InventorySyncRunStatus, 'pending'); +it('maps operation run status values to canonical badge semantics', function (): void { + $queued = BadgeCatalog::spec(BadgeDomain::OperationRunStatus, 'queued'); + expect($queued->label)->toBe('Queued'); + expect($queued->color)->toBe('warning'); + + $running = BadgeCatalog::spec(BadgeDomain::OperationRunStatus, 'running'); + expect($running->label)->toBe('Running'); + expect($running->color)->toBe('info'); + + $completed = BadgeCatalog::spec(BadgeDomain::OperationRunStatus, 'completed'); + expect($completed->label)->toBe('Completed'); + expect($completed->color)->toBe('gray'); +}); + +it('maps operation run outcome values to canonical badge semantics', function (): void { + $pending = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'pending'); expect($pending->label)->toBe('Pending'); expect($pending->color)->toBe('gray'); - $running = BadgeCatalog::spec(BadgeDomain::InventorySyncRunStatus, 'running'); - expect($running->label)->toBe('Running'); - expect($running->color)->toBe('info'); + $succeeded = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'succeeded'); + expect($succeeded->label)->toBe('Succeeded'); + expect($succeeded->color)->toBe('success'); - $success = BadgeCatalog::spec(BadgeDomain::InventorySyncRunStatus, 'success'); - expect($success->label)->toBe('Success'); - expect($success->color)->toBe('success'); - - $partial = BadgeCatalog::spec(BadgeDomain::InventorySyncRunStatus, 'partial'); - expect($partial->label)->toBe('Partial'); + $partial = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'partially_succeeded'); + expect($partial->label)->toBe('Partially succeeded'); expect($partial->color)->toBe('warning'); - $failed = BadgeCatalog::spec(BadgeDomain::InventorySyncRunStatus, 'failed'); + $blocked = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'blocked'); + expect($blocked->label)->toBe('Blocked'); + expect($blocked->color)->toBe('warning'); + + $failed = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'failed'); expect($failed->label)->toBe('Failed'); expect($failed->color)->toBe('danger'); - - $skipped = BadgeCatalog::spec(BadgeDomain::InventorySyncRunStatus, 'skipped'); - expect($skipped->label)->toBe('Skipped'); - expect($skipped->color)->toBe('gray'); -}); - -it('maps backup schedule run status values to canonical badge semantics', function (): void { - $running = BadgeCatalog::spec(BadgeDomain::BackupScheduleRunStatus, 'running'); - expect($running->label)->toBe('Running'); - expect($running->color)->toBe('info'); - - $success = BadgeCatalog::spec(BadgeDomain::BackupScheduleRunStatus, 'success'); - expect($success->label)->toBe('Success'); - expect($success->color)->toBe('success'); - - $partial = BadgeCatalog::spec(BadgeDomain::BackupScheduleRunStatus, 'partial'); - expect($partial->label)->toBe('Partial'); - expect($partial->color)->toBe('warning'); - - $failed = BadgeCatalog::spec(BadgeDomain::BackupScheduleRunStatus, 'failed'); - expect($failed->label)->toBe('Failed'); - expect($failed->color)->toBe('danger'); - - $canceled = BadgeCatalog::spec(BadgeDomain::BackupScheduleRunStatus, 'canceled'); - expect($canceled->label)->toBe('Canceled'); - expect($canceled->color)->toBe('gray'); - - $skipped = BadgeCatalog::spec(BadgeDomain::BackupScheduleRunStatus, 'skipped'); - expect($skipped->label)->toBe('Skipped'); - expect($skipped->color)->toBe('gray'); }); diff --git a/tests/Unit/Providers/ProviderOperationStartGateTest.php b/tests/Unit/Providers/ProviderOperationStartGateTest.php index cc8f391..82ba683 100644 --- a/tests/Unit/Providers/ProviderOperationStartGateTest.php +++ b/tests/Unit/Providers/ProviderOperationStartGateTest.php @@ -107,7 +107,7 @@ $blocking = OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'type' => 'inventory.sync', + 'type' => 'inventory_sync', 'status' => 'queued', 'context' => [ 'provider_connection_id' => (int) $connection->getKey(),