From b870c0c8d4907d534491c1f432b8bc57b353cf85 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 11 Feb 2026 01:03:00 +0100 Subject: [PATCH] feat(spec-086): retire legacy runs into operation runs --- ...TenantpilotDispatchDirectoryGroupsSync.php | 48 +- .../ManagedTenantOnboardingWizard.php | 8 +- .../Resources/BackupScheduleResource.php | 357 +++---------- ...upScheduleOperationRunsRelationManager.php | 82 +++ .../Pages/ListEntraGroups.php | 44 +- .../Pages/ListEntraGroupSyncRuns.php | 77 --- .../Pages/ViewEntraGroupSyncRun.php | 13 + .../Resources/InventoryItemResource.php | 14 +- .../Pages/ListInventoryItems.php | 53 +- .../Pages/ViewInventorySyncRun.php | 13 + .../Resources/ProviderConnectionResource.php | 62 ++- .../Pages/EditProviderConnection.php | 47 +- app/Filament/Resources/RestoreRunResource.php | 57 +- app/Filament/Resources/TenantResource.php | 380 +++++-------- app/Jobs/EntraGroupSyncJob.php | 197 ++++--- app/Jobs/ExecuteRestoreRunJob.php | 20 +- app/Jobs/ProviderConnectionHealthCheckJob.php | 26 +- app/Jobs/RunBackupScheduleJob.php | 503 +++++++++++++++++- app/Jobs/RunInventorySyncJob.php | 222 ++++---- app/Jobs/SyncRoleDefinitionsJob.php | 123 +++++ .../SyncRestoreRunToOperationRun.php | 31 +- app/Models/BackupSchedule.php | 11 + app/Models/EntraRoleDefinition.php | 24 + app/Models/InventoryItem.php | 5 + app/Models/RestoreRun.php | 5 + .../RunStatusChangedNotification.php | 2 +- app/Policies/OperationRunPolicy.php | 37 ++ app/Policies/ProviderConnectionPolicy.php | 19 +- .../BackupScheduleDispatcher.php | 47 +- .../Directory/EntraGroupSyncService.php | 44 +- .../Directory/RoleDefinitionsSyncService.php | 257 +++++++++ app/Services/Graph/GraphContractRegistry.php | 12 + .../Inventory/InventoryMissingService.php | 24 +- .../Inventory/InventorySyncService.php | 240 ++------- app/Services/OperationRunService.php | 58 ++ .../Providers/ProviderOperationStartGate.php | 20 + app/Support/OperationCatalog.php | 2 + app/Support/OperationRunType.php | 2 + .../OperationRunCapabilityResolver.php | 30 ++ .../Providers/ProviderNextStepsRegistry.php | 4 + .../StaleQueuedVerificationReportFactory.php | 90 ++++ config/graph_contracts.php | 5 + .../factories/EntraRoleDefinitionFactory.php | 31 ++ ...ckup_schedule_scheduled_operation_runs.php | 48 ++ ...on_run_id_to_inventory_sync_runs_table.php | 34 ++ ..._run_id_to_entra_group_sync_runs_table.php | 34 ++ ...n_run_id_to_backup_schedule_runs_table.php | 34 ++ ...ration_run_id_to_inventory_items_table.php | 34 ++ ...operation_run_id_to_restore_runs_table.php | 33 ++ ...38_create_entra_role_definitions_table.php | 37 ++ .../tasks.md | 86 +-- .../DispatchIdempotencyTest.php | 49 +- .../RunBackupScheduleJobTest.php | 62 +-- .../RunNowRetryActionsTest.php | 265 ++++++--- .../NoLiveGraphOnRenderTest.php | 2 +- .../ScheduledSyncDispatchTest.php | 26 +- .../StartSyncFromGroupsPageTest.php | 20 +- .../Feature/DirectoryGroups/StartSyncTest.php | 11 +- .../SyncJobUpsertsGroupsTest.php | 34 +- .../SyncRetentionPurgeTest.php | 21 +- .../TenantGroupSelectorsDbOnlyTest.php | 48 ++ tests/Feature/ExecuteRestoreRunJobTest.php | 57 +- .../EntraGroupSyncRunResourceTest.php | 74 +-- ...enantRoleDefinitionsSelectorDbOnlyTest.php | 48 ++ .../Inventory/InventorySyncButtonTest.php | 98 ++-- .../Inventory/InventorySyncServiceTest.php | 166 ++++-- .../Inventory/RunInventorySyncJobTest.php | 68 +-- .../ManagedTenantOnboardingWizardTest.php | 68 +++ .../Monitoring/MonitoringOperationsTest.php | 50 ++ tests/Feature/OperationRunServiceTest.php | 72 +++ .../Operations/LegacyRunRedirectTest.php | 88 +++ .../TenantlessOperationRunViewerTest.php | 38 ++ .../RestoreExecuteOperationRunSyncTest.php | 2 + .../RestoreExecutionOperationRunSyncTest.php | 20 +- .../ProviderConnectionHealthCheckJobTest.php | 98 ++++ .../EntraGroupSyncRunsUiEnforcementTest.php | 27 +- tests/Feature/RestoreAuditLoggingTest.php | 16 +- tests/Feature/RestoreRunWizardExecuteTest.php | 18 +- tests/Feature/RunStartAuthorizationTest.php | 104 ++++ .../TenantRBAC/RoleDefinitionsSyncNowTest.php | 38 ++ ...ProviderConnectionTenantResolutionTest.php | 43 ++ ...ionResourceLivewireTenantInferenceTest.php | 36 ++ ...erConnectionPolicyTenantResolutionTest.php | 36 ++ .../ProviderNextStepsRegistryTest.php | 60 +++ tests/Unit/TenantResourceConsentUrlTest.php | 32 ++ 85 files changed, 3943 insertions(+), 1638 deletions(-) create mode 100644 app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php create mode 100644 app/Jobs/SyncRoleDefinitionsJob.php create mode 100644 app/Models/EntraRoleDefinition.php create mode 100644 app/Services/Directory/RoleDefinitionsSyncService.php create mode 100644 app/Support/Operations/OperationRunCapabilityResolver.php create mode 100644 app/Support/Verification/StaleQueuedVerificationReportFactory.php create mode 100644 database/factories/EntraRoleDefinitionFactory.php create mode 100644 database/migrations/2026_02_10_004939_add_unique_index_for_backup_schedule_scheduled_operation_runs.php create mode 100644 database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php create mode 100644 database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php create mode 100644 database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php create mode 100644 database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php create mode 100644 database/migrations/2026_02_10_115908_add_operation_run_id_to_restore_runs_table.php create mode 100644 database/migrations/2026_02_10_133238_create_entra_role_definitions_table.php create mode 100644 tests/Feature/DirectoryGroups/TenantGroupSelectorsDbOnlyTest.php create mode 100644 tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php create mode 100644 tests/Feature/Monitoring/MonitoringOperationsTest.php create mode 100644 tests/Feature/Operations/LegacyRunRedirectTest.php create mode 100644 tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php create mode 100644 tests/Unit/Filament/EditProviderConnectionTenantResolutionTest.php create mode 100644 tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php create mode 100644 tests/Unit/Policies/ProviderConnectionPolicyTenantResolutionTest.php create mode 100644 tests/Unit/Providers/ProviderNextStepsRegistryTest.php diff --git a/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php b/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php index 8c684cc..54f88ce 100644 --- a/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php +++ b/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php @@ -2,10 +2,10 @@ namespace App\Console\Commands; +use App\Services\OperationRunService; use App\Models\Tenant; use Carbon\CarbonImmutable; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; class TenantpilotDispatchDirectoryGroupsSync extends Command { @@ -46,27 +46,37 @@ public function handle(): int $skipped = 0; foreach ($tenants as $tenant) { - $inserted = DB::table('entra_group_sync_runs')->insertOrIgnore([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => $selectionKey, - 'slot_key' => $slotKey, - 'status' => 'pending', - 'initiator_user_id' => null, - 'created_at' => $now, - 'updated_at' => $now, - ]); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRunWithIdentityStrict( + tenant: $tenant, + type: 'directory_groups.sync', + identityInputs: [ + 'selection_key' => $selectionKey, + 'slot_key' => $slotKey, + ], + context: [ + 'selection_key' => $selectionKey, + 'slot_key' => $slotKey, + 'trigger' => 'scheduled', + ], + initiator: null, + ); - if ($inserted === 1) { - $created++; - - dispatch(new \App\Jobs\EntraGroupSyncJob( - tenantId: $tenant->getKey(), - selectionKey: $selectionKey, - slotKey: $slotKey, - )); - } else { + if (! $opRun->wasRecentlyCreated) { $skipped++; + continue; } + + $created++; + + dispatch(new \App\Jobs\EntraGroupSyncJob( + tenantId: $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: $slotKey, + runId: null, + operationRun: $opRun, + )); } $this->info(sprintf( diff --git a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index 69b9f43..a9ba892 100644 --- a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -347,7 +347,7 @@ public function content(Schema $schema): Schema SchemaActions::make([ Action::make('wizardStartVerification') ->label('Start verification') - ->visible(fn (): bool => $this->managedTenant instanceof Tenant && $this->verificationStatus() !== 'in_progress') + ->visible(fn (): bool => $this->managedTenant instanceof Tenant) ->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)) ->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START) ? null @@ -629,6 +629,10 @@ private function verificationStatus(): string return 'in_progress'; } + if ($run->outcome === OperationRunOutcome::Blocked->value) { + return 'blocked'; + } + if ($run->outcome === OperationRunOutcome::Succeeded->value) { return 'ready'; } @@ -658,7 +662,7 @@ private function verificationStatus(): string continue; } - if (in_array($reasonCode, ['provider_auth_failed', 'permission_denied'], true)) { + if (in_array($reasonCode, ['provider_auth_failed', 'permission_denied', 'provider_consent_missing'], true)) { return 'blocked'; } } diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index cd05f7e..a126449 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -4,10 +4,10 @@ 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\BackupScheduleRun; use App\Models\Tenant; use App\Models\User; use App\Rules\SupportedPolicyTypesRule; @@ -384,104 +384,38 @@ public static function table(Table $table): Table /** @var OperationRunService $operationRunService */ $operationRunService = app(OperationRunService::class); - $operationRun = $operationRunService->ensureRun( + $nonce = (string) Str::uuid(); + $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, type: 'backup_schedule.run_now', - inputs: [ + identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), + 'nonce' => $nonce, ], - initiator: $userModel - ); - - if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Run already queued') - ->body('This schedule already has a queued or running backup.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - return; - } - - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - $run = null; - - for ($i = 0; $i < 5; $i++) { - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'user_id' => $userId, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - break; - } catch (UniqueConstraintViolationException) { - $scheduledFor = $scheduledFor->addMinute(); - } - } - - if (! $run instanceof BackupScheduleRun) { - Notification::make() - ->title('Run already queued') - ->body('Please wait a moment and try again.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: 'failed', - summaryCounts: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - failures: [ - [ - 'code' => 'SCHEDULE_CONFLICT', - 'message' => 'Unable to queue a unique backup schedule run.', - ], - ], - ); - - return; - } - - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ + context: [ 'backup_schedule_id' => (int) $record->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); + 'trigger' => 'run_now', + ], + initiator: $userModel, + ); app(AuditLogger::class)->log( tenant: $tenant, action: 'backup_schedule.run_dispatched_manual', - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $operationRun->getKey(), status: 'success', context: [ 'metadata' => [ 'backup_schedule_id' => $record->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'operation_run_id' => $operationRun->getKey(), 'trigger' => 'run_now', ], ], ); - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); + $operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey())); }); OpsUxBrowserEvents::dispatchRunEnqueued($livewire); @@ -519,104 +453,38 @@ public static function table(Table $table): Table /** @var OperationRunService $operationRunService */ $operationRunService = app(OperationRunService::class); - $operationRun = $operationRunService->ensureRun( + $nonce = (string) Str::uuid(); + $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, type: 'backup_schedule.retry', - inputs: [ + identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), + 'nonce' => $nonce, ], - initiator: $userModel - ); - - if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Retry already queued') - ->body('This schedule already has a queued or running retry.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - return; - } - - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - $run = null; - - for ($i = 0; $i < 5; $i++) { - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'user_id' => $userId, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - break; - } catch (UniqueConstraintViolationException) { - $scheduledFor = $scheduledFor->addMinute(); - } - } - - if (! $run instanceof BackupScheduleRun) { - Notification::make() - ->title('Retry already queued') - ->body('Please wait a moment and try again.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: 'failed', - summaryCounts: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - failures: [ - [ - 'code' => 'SCHEDULE_CONFLICT', - 'message' => 'Unable to queue a unique backup schedule retry run.', - ], - ], - ); - - return; - } - - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ + context: [ 'backup_schedule_id' => (int) $record->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); + 'trigger' => 'retry', + ], + initiator: $userModel, + ); app(AuditLogger::class)->log( tenant: $tenant, action: 'backup_schedule.run_dispatched_manual', - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $operationRun->getKey(), status: 'success', context: [ 'metadata' => [ 'backup_schedule_id' => $record->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'operation_run_id' => $operationRun->getKey(), 'trigger' => 'retry', ], ], ); - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); + $operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey())); }); OpsUxBrowserEvents::dispatchRunEnqueued($livewire); @@ -640,6 +508,7 @@ public static function table(Table $table): Table DeleteAction::make() ) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) + ->destructive() ->apply(), ])->icon('heroicon-o-ellipsis-vertical'), ]) @@ -674,96 +543,52 @@ public static function table(Table $table): Table $bulkRun = null; - $createdRunIds = []; + $createdOperationRunIds = []; /** @var BackupSchedule $record */ foreach ($records as $record) { - $operationRun = $operationRunService->ensureRun( + $nonce = (string) Str::uuid(); + $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, type: 'backup_schedule.run_now', - inputs: [ + identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), + 'nonce' => $nonce, ], - initiator: $user + context: [ + 'backup_schedule_id' => (int) $record->getKey(), + 'trigger' => 'bulk_run_now', + ], + initiator: $user, ); - if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - continue; - } - - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - $run = null; - - for ($i = 0; $i < 5; $i++) { - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'user_id' => $userId, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - break; - } catch (UniqueConstraintViolationException) { - $scheduledFor = $scheduledFor->addMinute(); - } - } - - if (! $run instanceof BackupScheduleRun) { - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: 'failed', - summaryCounts: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - failures: [ - [ - 'code' => 'SCHEDULE_CONFLICT', - 'message' => 'Unable to queue a unique backup schedule run.', - ], - ], - ); - - continue; - } - - $createdRunIds[] = (int) $run->id; - - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ - 'backup_schedule_id' => (int) $record->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); + $createdOperationRunIds[] = (int) $operationRun->getKey(); app(AuditLogger::class)->log( tenant: $tenant, action: 'backup_schedule.run_dispatched_manual', - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $operationRun->getKey(), status: 'success', context: [ 'metadata' => [ 'backup_schedule_id' => $record->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'operation_run_id' => $operationRun->getKey(), 'trigger' => 'bulk_run_now', ], ], ); - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); + $operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey())); }, emitQueuedNotification: false); } $notification = Notification::make() ->title('Runs dispatched') - ->body(sprintf('Queued %d run(s).', count($createdRunIds))); + ->body(sprintf('Queued %d run(s).', count($createdOperationRunIds))); - if (count($createdRunIds) === 0) { + if (count($createdOperationRunIds) === 0) { $notification->warning(); } else { $notification->success(); @@ -779,7 +604,7 @@ public static function table(Table $table): Table $notification->send(); - if (count($createdRunIds) > 0) { + if (count($createdOperationRunIds) > 0) { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); } }) @@ -815,96 +640,52 @@ public static function table(Table $table): Table $bulkRun = null; - $createdRunIds = []; + $createdOperationRunIds = []; /** @var BackupSchedule $record */ foreach ($records as $record) { - $operationRun = $operationRunService->ensureRun( + $nonce = (string) Str::uuid(); + $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, type: 'backup_schedule.retry', - inputs: [ + identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), + 'nonce' => $nonce, ], - initiator: $user + context: [ + 'backup_schedule_id' => (int) $record->getKey(), + 'trigger' => 'bulk_retry', + ], + initiator: $user, ); - if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - continue; - } - - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - $run = null; - - for ($i = 0; $i < 5; $i++) { - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'user_id' => $userId, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - break; - } catch (UniqueConstraintViolationException) { - $scheduledFor = $scheduledFor->addMinute(); - } - } - - if (! $run instanceof BackupScheduleRun) { - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: 'failed', - summaryCounts: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - failures: [ - [ - 'code' => 'SCHEDULE_CONFLICT', - 'message' => 'Unable to queue a unique backup schedule retry run.', - ], - ], - ); - - continue; - } - - $createdRunIds[] = (int) $run->id; - - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ - 'backup_schedule_id' => (int) $record->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); + $createdOperationRunIds[] = (int) $operationRun->getKey(); app(AuditLogger::class)->log( tenant: $tenant, action: 'backup_schedule.run_dispatched_manual', - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $operationRun->getKey(), status: 'success', context: [ 'metadata' => [ 'backup_schedule_id' => $record->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'operation_run_id' => $operationRun->getKey(), 'trigger' => 'bulk_retry', ], ], ); - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); + $operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey())); }, emitQueuedNotification: false); } $notification = Notification::make() ->title('Retries dispatched') - ->body(sprintf('Queued %d run(s).', count($createdRunIds))); + ->body(sprintf('Queued %d run(s).', count($createdOperationRunIds))); - if (count($createdRunIds) === 0) { + if (count($createdOperationRunIds) === 0) { $notification->warning(); } else { $notification->success(); @@ -920,7 +701,7 @@ public static function table(Table $table): Table $notification->send(); - if (count($createdRunIds) > 0) { + if (count($createdOperationRunIds) > 0) { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); } }) @@ -931,6 +712,7 @@ public static function table(Table $table): Table DeleteBulkAction::make('bulk_delete') ) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) + ->destructive() ->apply(), ]), ]); @@ -949,6 +731,7 @@ public static function getEloquentQuery(): Builder public static function getRelations(): array { return [ + BackupScheduleOperationRunsRelationManager::class, BackupScheduleRunsRelationManager::class, ]; } diff --git a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php new file mode 100644 index 0000000..c0baa2f --- /dev/null +++ b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php @@ -0,0 +1,82 @@ +modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())) + ->defaultSort('created_at', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('created_at') + ->label('Enqueued') + ->dateTime(), + + Tables\Columns\TextColumn::make('type') + ->label('Type') + ->formatStateUsing(fn (string $state): string => OperationCatalog::label($state)), + + Tables\Columns\TextColumn::make('status') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) + ->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), + + Tables\Columns\TextColumn::make('outcome') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) + ->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) + ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), + + Tables\Columns\TextColumn::make('counts') + ->label('Counts') + ->getStateUsing(function (OperationRun $record): string { + $counts = is_array($record->summary_counts) ? $record->summary_counts : []; + + $total = (int) ($counts['total'] ?? 0); + $succeeded = (int) ($counts['succeeded'] ?? 0); + $failed = (int) ($counts['failed'] ?? 0); + + if ($total === 0 && $succeeded === 0 && $failed === 0) { + return '—'; + } + + return sprintf('%d/%d (%d failed)', $succeeded, $total, $failed); + }), + ]) + ->filters([]) + ->headerActions([]) + ->actions([ + Actions\Action::make('view') + ->label('View') + ->icon('heroicon-o-eye') + ->url(function (OperationRun $record): string { + $tenant = Tenant::currentOrFail(); + + return OperationRunLinks::view($record, $tenant); + }) + ->openUrlInNewTab(true), + ]) + ->bulkActions([]); + } +} diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php index 998064e..f1c68ee 100644 --- a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php +++ b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php @@ -5,7 +5,6 @@ use App\Filament\Resources\EntraGroupResource; use App\Filament\Resources\EntraGroupSyncRunResource; use App\Jobs\EntraGroupSyncJob; -use App\Models\EntraGroupSyncRun; use App\Models\Tenant; use App\Models\User; use App\Services\Directory\EntraGroupSelection; @@ -47,11 +46,15 @@ protected function getHeaderActions(): array // --- Phase 3: Canonical Operation Run Start --- /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( + $opRun = $opService->ensureRunWithIdentity( tenant: $tenant, type: 'directory_groups.sync', - inputs: ['selection_key' => $selectionKey], - initiator: $user + identityInputs: ['selection_key' => $selectionKey], + context: [ + 'selection_key' => $selectionKey, + 'trigger' => 'manual', + ], + initiator: $user, ); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { @@ -70,42 +73,11 @@ protected function getHeaderActions(): array } // ---------------------------------------------- - $existing = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', $selectionKey) - ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) - ->orderByDesc('id') - ->first(); - - if ($existing instanceof EntraGroupSyncRun) { - Notification::make() - ->title('Group sync already active') - ->body('This operation is already queued or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->sendToDatabase($user) - ->send(); - - return; - } - - $run = EntraGroupSyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => $selectionKey, - 'slot_key' => null, - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - dispatch(new EntraGroupSyncJob( tenantId: (int) $tenant->getKey(), selectionKey: $selectionKey, slotKey: null, - runId: (int) $run->getKey(), + runId: null, operationRun: $opRun )); diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php index ff0fecb..563d411 100644 --- a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php +++ b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php @@ -3,86 +3,9 @@ namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages; use App\Filament\Resources\EntraGroupSyncRunResource; -use App\Jobs\EntraGroupSyncJob; -use App\Models\EntraGroupSyncRun; -use App\Models\Tenant; -use App\Models\User; -use App\Notifications\RunStatusChangedNotification; -use App\Services\Directory\EntraGroupSelection; -use App\Support\Auth\Capabilities; -use App\Support\Rbac\UiEnforcement; -use App\Support\Rbac\UiTooltips; -use Filament\Actions\Action; use Filament\Resources\Pages\ListRecords; class ListEntraGroupSyncRuns extends ListRecords { protected static string $resource = EntraGroupSyncRunResource::class; - - protected function getHeaderActions(): array - { - return [ - UiEnforcement::forAction( - Action::make('sync_groups') - ->label('Sync Groups') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->action(function (): void { - $user = auth()->user(); - $tenant = Tenant::current(); - - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return; - } - - $selectionKey = EntraGroupSelection::allGroupsV1(); - - $existing = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', $selectionKey) - ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) - ->orderByDesc('id') - ->first(); - - if ($existing instanceof EntraGroupSyncRun) { - $normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued'; - - $user->notify(new RunStatusChangedNotification([ - 'tenant_id' => (int) $tenant->getKey(), - 'run_type' => 'directory_groups', - 'run_id' => (int) $existing->getKey(), - 'status' => $normalizedStatus, - ])); - - return; - } - - $run = EntraGroupSyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => $selectionKey, - 'slot_key' => null, - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - - dispatch(new EntraGroupSyncJob( - tenantId: (int) $tenant->getKey(), - selectionKey: $selectionKey, - slotKey: null, - runId: (int) $run->getKey(), - )); - - $user->notify(new RunStatusChangedNotification([ - 'tenant_id' => (int) $tenant->getKey(), - 'run_type' => 'directory_groups', - 'run_id' => (int) $run->getKey(), - 'status' => 'queued', - ])); - }) - ) - ->requireCapability(Capabilities::TENANT_SYNC) - ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) - ->apply(), - ]; - } } diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php index d48840f..9785d84 100644 --- a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php +++ b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php @@ -3,9 +3,22 @@ namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages; use App\Filament\Resources\EntraGroupSyncRunResource; +use App\Models\EntraGroupSyncRun; +use App\Support\OperationRunLinks; use Filament\Resources\Pages\ViewRecord; class ViewEntraGroupSyncRun extends ViewRecord { protected static string $resource = EntraGroupSyncRunResource::class; + + public function mount(int|string $record): void + { + parent::mount($record); + + $legacyRun = $this->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/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 5eb93f1..a159bbe 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -125,8 +125,20 @@ public static function infolist(Schema $schema): Schema ->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)), TextEntry::make('external_id')->label('External ID'), TextEntry::make('last_seen_at')->label('Last seen')->dateTime(), + TextEntry::make('last_seen_operation_run_id') + ->label('Last inventory sync') + ->visible(fn (InventoryItem $record): bool => filled($record->last_seen_operation_run_id)) + ->url(function (InventoryItem $record): ?string { + if (! $record->last_seen_operation_run_id) { + return null; + } + + return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]); + }) + ->openUrlInNewTab(), TextEntry::make('last_seen_run_id') - ->label('Last sync run') + ->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; diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index e15908a..536b706 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -5,7 +5,6 @@ use App\Filament\Resources\InventoryItemResource; use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Jobs\RunInventorySyncJob; -use App\Models\InventorySyncRun; use App\Models\Tenant; use App\Models\User; use App\Services\Intune\AuditLogger; @@ -152,11 +151,16 @@ protected function getHeaderActions(): array /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( + $opRun = $opService->ensureRunWithIdentity( tenant: $tenant, type: 'inventory.sync', - inputs: $computed['selection'], - initiator: $user + identityInputs: [ + 'selection_hash' => $computed['selection_hash'], + ], + context: array_merge($computed['selection'], [ + 'selection_hash' => $computed['selection_hash'], + ]), + initiator: $user, ); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { @@ -176,57 +180,26 @@ protected function getHeaderActions(): array return; } - // Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now) - $existing = InventorySyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_hash', $computed['selection_hash']) - ->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING]) - ->first(); - - // If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe. - if ($existing instanceof InventorySyncRun) { - Notification::make() - ->title('Inventory sync already active') - ->body('A matching inventory sync run is already pending or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - - $run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']); - - $policyTypes = $computed['selection']['policy_types'] ?? []; - if (! is_array($policyTypes)) { - $policyTypes = []; - } - $auditLogger->log( tenant: $tenant, action: 'inventory.sync.dispatched', context: [ 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, + 'operation_run_id' => (int) $opRun->getKey(), + 'selection_hash' => $computed['selection_hash'], ], ], actorId: $user->id, actorEmail: $user->email, actorName: $user->name, - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $opRun->getKey(), ); - $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void { + $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $opRun): void { RunInventorySyncJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), - inventorySyncRunId: (int) $run->id, operationRun: $opRun ); }); diff --git a/app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php b/app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php index 3dbb0a9..98e5b2b 100644 --- a/app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php +++ b/app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php @@ -3,9 +3,22 @@ namespace App\Filament\Resources\InventorySyncRunResource\Pages; use App\Filament\Resources\InventorySyncRunResource; +use App\Models\InventorySyncRun; +use App\Support\OperationRunLinks; use Filament\Resources\Pages\ViewRecord; class ViewInventorySyncRun extends ViewRecord { protected static string $resource = InventorySyncRunResource::class; + + public function mount(int|string $record): void + { + parent::mount($record); + + $legacyRun = $this->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 14d62f4..1b43099 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -19,6 +19,7 @@ use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use App\Support\Rbac\UiEnforcement; +use App\Support\Providers\ProviderReasonCodes; use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions; @@ -84,9 +85,61 @@ protected static function resolveScopedTenant(): ?Tenant ->first(); } + $externalId = static::resolveTenantExternalIdFromLivewireRequest(); + + if (is_string($externalId) && $externalId !== '') { + return Tenant::query() + ->where('external_id', $externalId) + ->first(); + } + return Tenant::current(); } + private static function resolveTenantExternalIdFromLivewireRequest(): ?string + { + if (! request()->headers->has('x-livewire') && ! request()->headers->has('x-livewire-navigate')) { + return null; + } + + try { + $url = \Livewire\Livewire::originalUrl(); + + if (is_string($url) && $url !== '') { + $externalId = static::extractTenantExternalIdFromUrl($url); + + if (is_string($externalId) && $externalId !== '') { + return $externalId; + } + } + } catch (\Throwable) { + // Ignore and fall back to referer. + } + + $referer = request()->headers->get('referer'); + + if (! is_string($referer) || $referer === '') { + return null; + } + + return static::extractTenantExternalIdFromUrl($referer); + } + + private static function extractTenantExternalIdFromUrl(string $url): ?string + { + $path = parse_url($url, PHP_URL_PATH); + + if (! is_string($path) || $path === '') { + $path = $url; + } + + if (preg_match('~/(?:admin)/(?:tenants|t)/([0-9a-fA-F-]{36})(?:/|$)~', $path, $matches) !== 1) { + return null; + } + + return (string) $matches[1]; + } + public static function form(Schema $schema): Schema { return $schema @@ -589,15 +642,18 @@ public static function table(Table $table): Table } $hadCredentials = $record->credential()->exists(); - $status = $hadCredentials ? 'connected' : 'needs_consent'; $previousStatus = (string) $record->status; + $status = $hadCredentials ? 'connected' : 'error'; + $errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing; + $errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.'; + $record->update([ 'status' => $status, 'health_status' => 'unknown', 'last_health_check_at' => null, - 'last_error_reason_code' => null, - 'last_error_message' => null, + 'last_error_reason_code' => $errorReasonCode, + 'last_error_message' => $errorMessage, ]); $user = auth()->user(); diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index 8b70473..ebea9ad 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -28,10 +28,29 @@ class EditProviderConnection extends EditRecord { protected static string $resource = ProviderConnectionResource::class; + public ?string $scopedTenantExternalId = null; + protected bool $shouldMakeDefault = false; protected bool $defaultWasChanged = false; + public function mount($record): void + { + parent::mount($record); + + $tenant = request()->route('tenant'); + + if ($tenant instanceof Tenant) { + $this->scopedTenantExternalId = (string) $tenant->external_id; + + return; + } + + if (is_string($tenant) && $tenant !== '') { + $this->scopedTenantExternalId = $tenant; + } + } + protected function mutateFormDataBeforeSave(array $data): array { $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); @@ -42,9 +61,16 @@ protected function mutateFormDataBeforeSave(array $data): array protected function afterSave(): void { - $tenant = $this->currentTenant(); $record = $this->getRecord(); + $tenant = $record instanceof ProviderConnection + ? ($record->tenant ?? $this->currentTenant()) + : $this->currentTenant(); + + if (! $tenant instanceof Tenant) { + return; + } + $changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at'])); if ($this->shouldMakeDefault && ! $record->is_default) { @@ -602,15 +628,18 @@ protected function getHeaderActions(): array } $hadCredentials = $record->credential()->exists(); - $status = $hadCredentials ? 'connected' : 'needs_consent'; $previousStatus = (string) $record->status; + $status = $hadCredentials ? 'connected' : 'error'; + $errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing; + $errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.'; + $record->update([ 'status' => $status, 'health_status' => 'unknown', 'last_health_check_at' => null, - 'last_error_reason_code' => null, - 'last_error_message' => null, + 'last_error_reason_code' => $errorReasonCode, + 'last_error_message' => $errorMessage, ]); $user = auth()->user(); @@ -744,7 +773,9 @@ protected function getFormActions(): array protected function handleRecordUpdate(Model $record, array $data): Model { - $tenant = $this->currentTenant(); + $tenant = $record instanceof ProviderConnection + ? ($record->tenant ?? $this->currentTenant()) + : $this->currentTenant(); $user = auth()->user(); @@ -767,6 +798,12 @@ protected function handleRecordUpdate(Model $record, array $data): Model private function currentTenant(): ?Tenant { + if (is_string($this->scopedTenantExternalId) && $this->scopedTenantExternalId !== '') { + return Tenant::query() + ->where('external_id', $this->scopedTenantExternalId) + ->first(); + } + $tenant = request()->route('tenant'); if ($tenant instanceof Tenant) { diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index f0ee904..d0f4e32 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -876,7 +876,27 @@ public static function table(Table $table): Table status: 'success', ); - ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + $initiator = auth()->user(); + $initiator = $initiator instanceof \App\Models\User ? $initiator : null; + + $opRun = $runs->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => (int) $newRun->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'is_dry_run' => (bool) ($newRun->is_dry_run ?? false), + ], + initiator: $initiator, + ); + + if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) { + $newRun->update(['operation_run_id' => $opRun->getKey()]); + } + + ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun); $auditLogger->log( tenant: $tenant, @@ -896,6 +916,11 @@ public static function table(Table $table): Table OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OperationUxPresenter::queuedToast('restore.execute') + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) ->send(); return; @@ -1727,7 +1752,35 @@ public static function createRestoreRun(array $data): RestoreRun status: 'success', ); - ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + $initiator = auth()->user(); + $initiator = $initiator instanceof \App\Models\User ? $initiator : null; + + $opRun = $runs->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => (int) $restoreRun->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false), + ], + initiator: $initiator, + ); + + if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) { + $restoreRun->update(['operation_run_id' => $opRun->getKey()]); + } + + ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName, $opRun); + + OperationUxPresenter::queuedToast('restore.execute') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); return $restoreRun->refresh(); } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index fe16ec1..6bad2e4 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -7,13 +7,17 @@ use App\Http\Controllers\RbacDelegatedAuthController; use App\Jobs\BulkTenantSyncJob; use App\Jobs\SyncPoliciesJob; +use App\Jobs\SyncRoleDefinitionsJob; +use App\Models\EntraGroup; +use App\Models\EntraRoleDefinition; +use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; use App\Models\WorkspaceMembership; use App\Services\Auth\CapabilityResolver; use App\Services\Auth\RoleCapabilityMap; use App\Services\Directory\EntraGroupLabelResolver; -use App\Services\Graph\GraphClientInterface; +use App\Services\Directory\RoleDefinitionsSyncService; use App\Services\Intune\AuditLogger; use App\Services\Intune\RbacOnboardingService; use App\Services\OperationRunService; @@ -949,18 +953,14 @@ public static function rbacAction(): Actions\Action ->optionsLimit(20) ->searchDebounce(400) ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::roleSearchOptions($record, $search)) - ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::formatRoleLabel( - static::resolveRoleName($record, $value), - $value ?? '' - )) - ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null) + ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::roleLabelFromCache($record, $value)) ->helperText(fn (?Tenant $record) => static::roleSearchHelper($record)) - ->hintAction(fn (?Tenant $record) => static::loginToSearchRolesAction($record)) + ->hintAction(fn (?Tenant $record) => static::syncRoleDefinitionsAction()) ->hint('Wizard grants Intune RBAC roles only. "Intune Administrator" is an Entra directory role and is not assigned here.') ->noSearchResultsMessage('No Intune RBAC roleDefinitions found (tenant may restrict RBAC or missing permission).') ->loadingMessage('Loading roles...') ->afterStateUpdated(function (Set $set, ?string $state, ?Tenant $record) { - $set('role_display_name', static::resolveRoleName($record, $state)); + $set('role_display_name', static::roleNameFromCache($record, $state)); }), Forms\Components\Hidden::make('role_display_name') ->dehydrated(), @@ -982,11 +982,8 @@ public static function rbacAction(): Actions\Action ->placeholder('Search security groups') ->visible(fn (Get $get) => $get('scope') === 'scope_group') ->required(fn (Get $get) => $get('scope') === 'scope_group') - ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null) - ->helperText(fn (?Tenant $record) => static::groupSearchHelper($record)) - ->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record)) ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search)) - ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value)) + ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::groupLabelFromCache($record, $value)) ->noSearchResultsMessage('No security groups found') ->loadingMessage('Searching groups...'), Forms\Components\Select::make('group_mode') @@ -1011,11 +1008,8 @@ public static function rbacAction(): Actions\Action ->placeholder('Search security groups') ->visible(fn (Get $get) => $get('group_mode') === 'existing') ->required(fn (Get $get) => $get('group_mode') === 'existing') - ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null) - ->helperText(fn (?Tenant $record) => static::groupSearchHelper($record)) - ->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record)) ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search)) - ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value)) + ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::groupLabelFromCache($record, $value)) ->noSearchResultsMessage('No security groups found') ->loadingMessage('Searching groups...'), ]) @@ -1133,6 +1127,10 @@ public static function adminConsentUrl(Tenant $tenant): ?string { $tenantId = $tenant->graphTenantId(); $clientId = $tenant->app_client_id; + + if (! is_string($clientId) || trim($clientId) === '') { + $clientId = static::resolveProviderClientIdForConsent($tenant); + } $redirectUri = route('admin.consent.callback'); $state = sprintf('tenantpilot|%s', $tenant->id); @@ -1162,6 +1160,36 @@ public static function adminConsentUrl(Tenant $tenant): ?string return sprintf('https://login.microsoftonline.com/%s/v2.0/adminconsent?%s', $tenantId, $query); } + private static function resolveProviderClientIdForConsent(Tenant $tenant): ?string + { + $connection = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('provider', 'microsoft') + ->orderByDesc('is_default') + ->orderBy('id') + ->first(); + + if (! $connection instanceof ProviderConnection) { + return null; + } + + $payload = $connection->credential?->payload; + + if (! is_array($payload)) { + return null; + } + + $clientId = $payload['client_id'] ?? null; + + if (! is_string($clientId)) { + return null; + } + + $clientId = trim($clientId); + + return $clientId !== '' ? $clientId : null; + } + public static function entraUrl(Tenant $tenant): ?string { if ($tenant->app_client_id) { @@ -1195,23 +1223,20 @@ private static function delegatedToken(?Tenant $tenant): ?string private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Action { - if (! $tenant) { - return null; - } - - return Actions\Action::make('login_to_load_roles') - ->label('Login to load roles') - ->url(route('admin.rbac.start', [ - 'tenant' => $tenant->graphTenantId(), - 'return' => route('filament.admin.resources.tenants.view', [ - 'record' => $tenant, - ]), - ])); + return null; } public static function roleSearchHelper(?Tenant $tenant): ?string { - return static::delegatedToken($tenant) ? null : 'Login to load roles'; + if (! $tenant) { + return null; + } + + $exists = EntraRoleDefinition::query() + ->where('tenant_id', $tenant->getKey()) + ->exists(); + + return $exists ? null : 'Role definitions not synced yet. Use “Sync now” to load.'; } /** @@ -1227,114 +1252,52 @@ public static function roleSearchOptions(?Tenant $tenant, string $search): array */ private static function searchRoleDefinitions(?Tenant $tenant, string $search): array { - if (! $tenant) { + if (! $tenant || mb_strlen($search) < 2) { return []; } - $token = static::delegatedToken($tenant); + $needle = mb_strtolower($search); - if (! $token) { - return []; - } - - if (Str::contains(Str::lower($search), 'intune administrator')) { - Notification::make() - ->title('Intune Administrator is a directory role') - ->body('Das ist eine Entra Directory Role, nicht Intune RBAC; wird vom Wizard nicht vergeben.') - ->warning() - ->persistent() - ->send(); - } - - $filter = mb_strlen($search) >= 2 - ? sprintf("startswith(displayName,'%s')", static::escapeOdataValue($search)) - : null; - - $query = [ - '$select' => 'id,displayName,isBuiltIn', - '$top' => 20, - ]; - - if ($filter) { - $query['$filter'] = $filter; - } - - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - 'deviceManagement/roleDefinitions', - [ - 'query' => $query, - ] + $tenant->graphOptions() + [ - 'access_token' => $token, - ] - ); - } catch (Throwable) { - static::notifyRoleLookupFailure(); - - return []; - } - - if ($response->failed()) { - static::notifyRoleLookupFailure(); - - return []; - } - - $roles = collect($response->data['value'] ?? []) - ->filter(fn (array $role) => filled($role['id'] ?? null)) - ->mapWithKeys(fn (array $role) => [ - $role['id'] => static::formatRoleLabel($role['displayName'] ?? null, $role['id']), + return EntraRoleDefinition::query() + ->where('tenant_id', $tenant->getKey()) + ->whereRaw('lower(display_name) like ?', [$needle.'%']) + ->orderBy('display_name') + ->limit(20) + ->get(['entra_id', 'display_name']) + ->mapWithKeys(fn (EntraRoleDefinition $role): array => [ + (string) $role->entra_id => static::formatRoleLabel((string) $role->display_name, (string) $role->entra_id), ]) ->all(); - - if (empty($roles)) { - static::logEmptyRoleDefinitions($tenant, $response->data['value'] ?? []); - } - - return $roles; } - private static function resolveRoleName(?Tenant $tenant, ?string $roleId): ?string + public static function roleLabelFromCache(?Tenant $tenant, ?string $roleId): ?string { if (! $tenant || blank($roleId)) { return $roleId; } - $token = static::delegatedToken($tenant); + $displayName = EntraRoleDefinition::query() + ->where('tenant_id', $tenant->getKey()) + ->where('entra_id', $roleId) + ->value('display_name'); - if (! $token) { + $displayName = is_string($displayName) && $displayName !== '' ? $displayName : $roleId; + + return static::formatRoleLabel($displayName, $roleId); + } + + private static function roleNameFromCache(?Tenant $tenant, ?string $roleId): ?string + { + if (! $tenant || blank($roleId)) { return $roleId; } - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - "deviceManagement/roleDefinitions/{$roleId}", - [ - 'query' => [ - '$select' => 'id,displayName', - ], - ] + $tenant->graphOptions() + [ - 'access_token' => $token, - ] - ); - } catch (Throwable) { - static::notifyRoleLookupFailure(); + $displayName = EntraRoleDefinition::query() + ->where('tenant_id', $tenant->getKey()) + ->where('entra_id', $roleId) + ->value('display_name'); - return $roleId; - } - - if ($response->failed()) { - static::notifyRoleLookupFailure(); - - return $roleId; - } - - $displayName = $response->data['displayName'] ?? null; - $id = $response->data['id'] ?? $roleId; - - return $displayName ?: $id; + return is_string($displayName) && $displayName !== '' ? $displayName : $roleId; } private static function formatRoleLabel(?string $displayName, string $id): string @@ -1344,67 +1307,14 @@ private static function formatRoleLabel(?string $displayName, string $id): strin return trim(($displayName ?: 'RBAC role').$suffix); } - private static function escapeOdataValue(string $value): string - { - return str_replace("'", "''", $value); - } - - private static function notifyRoleLookupFailure(): void - { - Notification::make() - ->title('Role lookup failed') - ->body('Delegated session may have expired. Login again to load Intune RBAC roles.') - ->danger() - ->send(); - } - - private static function logEmptyRoleDefinitions(Tenant $tenant, array $roles): void - { - $names = collect($roles)->pluck('displayName')->filter()->take(5)->values()->all(); - - Log::warning('rbac.role_definitions.empty', [ - 'tenant_id' => $tenant->id, - 'count' => count($roles), - 'sample' => $names, - ]); - - try { - app(AuditLogger::class)->log( - tenant: $tenant, - action: 'rbac.roles.empty', - resourceType: 'tenant', - resourceId: (string) $tenant->id, - status: 'warning', - context: ['metadata' => ['count' => count($roles), 'sample' => $names]], - ); - } catch (Throwable) { - Log::notice('rbac.role_definitions.audit_failed', ['tenant_id' => $tenant->id]); - } - } - private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Action { - if (! $tenant) { - return null; - } - - return Actions\Action::make('login_to_search_groups') - ->label('Login to search groups') - ->url(route('admin.rbac.start', [ - 'tenant' => $tenant->graphTenantId(), - 'return' => route('filament.admin.resources.tenants.view', [ - 'record' => $tenant, - ]), - ])); + return null; } public static function groupSearchHelper(?Tenant $tenant): ?string { - if (! $tenant) { - return null; - } - - return static::delegatedToken($tenant) ? null : 'Login to search groups'; + return null; } /** @@ -1416,78 +1326,82 @@ public static function groupSearchOptions(?Tenant $tenant, string $search): arra return []; } - $token = static::delegatedToken($tenant); + $needle = mb_strtolower($search); - if (! $token) { - return []; - } - - $filter = sprintf( - "securityEnabled eq true and startswith(displayName,'%s')", - static::escapeOdataValue($search) - ); - - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - 'groups', - [ - 'query' => [ - '$select' => 'id,displayName', - '$top' => 20, - '$filter' => $filter, - ], - ] + $tenant->graphOptions() + [ - 'access_token' => $token, - ] - ); - } catch (Throwable) { - return []; - } - - if ($response->failed()) { - return []; - } - - return collect($response->data['value'] ?? []) - ->filter(fn (array $group) => filled($group['id'] ?? null)) - ->mapWithKeys(fn (array $group) => [ - (string) $group['id'] => EntraGroupLabelResolver::formatLabel($group['displayName'] ?? null, (string) $group['id']), + return EntraGroup::query() + ->where('tenant_id', $tenant->getKey()) + ->whereRaw('lower(display_name) like ?', [$needle.'%']) + ->orderBy('display_name') + ->limit(20) + ->get(['entra_id', 'display_name']) + ->mapWithKeys(fn (EntraGroup $group): array => [ + (string) $group->entra_id => EntraGroupLabelResolver::formatLabel((string) $group->display_name, (string) $group->entra_id), ]) ->all(); } - private static function resolveGroupLabel(?Tenant $tenant, ?string $groupId): ?string + public static function groupLabelFromCache(?Tenant $tenant, ?string $groupId): ?string { if (! $tenant || blank($groupId)) { return $groupId; } - $token = static::delegatedToken($tenant); + $resolver = app(EntraGroupLabelResolver::class); - if (! $token) { - return $groupId; - } + return $resolver->resolveOne($tenant, $groupId); + } - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - 'groups/'.$groupId, - [] + $tenant->graphOptions() + [ - 'access_token' => $token, - ] - ); - } catch (Throwable) { - return $groupId; - } + public static function syncRoleDefinitionsAction(): Actions\Action + { + return Actions\Action::make('sync_role_definitions') + ->label('Sync now') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(fn (?Tenant $record): bool => $record instanceof Tenant && $record->isActive()) + ->action(function (Tenant $record, RoleDefinitionsSyncService $syncService): void { + $user = auth()->user(); - if ($response->failed()) { - return $groupId; - } + if (! $user instanceof User) { + abort(403); + } - return EntraGroupLabelResolver::formatLabel( - $response->data['displayName'] ?? null, - $response->data['id'] ?? $groupId - ); + if (! $user->canAccessTenant($record)) { + abort(404); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) { + abort(403); + } + + $opRun = $syncService->startManualSync($record, $user); + + $runUrl = OperationRunLinks::tenantlessView($opRun); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Role definitions sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url($runUrl), + ]) + ->send(); + + return; + } + + OperationUxPresenter::queuedToast('directory_role_definitions.sync') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url($runUrl), + ]) + ->send(); + }); } } diff --git a/app/Jobs/EntraGroupSyncJob.php b/app/Jobs/EntraGroupSyncJob.php index e5353c4..2466301 100644 --- a/app/Jobs/EntraGroupSyncJob.php +++ b/app/Jobs/EntraGroupSyncJob.php @@ -40,38 +40,59 @@ public function middleware(): array public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLogger): void { + if (! $this->operationRun) { + $this->fail(new RuntimeException('OperationRun context is required for EntraGroupSyncJob.')); + + return; + } + $tenant = Tenant::query()->find($this->tenantId); if (! $tenant instanceof Tenant) { throw new RuntimeException('Tenant not found.'); } - $run = $this->resolveRun($tenant); + $legacyRun = $this->resolveLegacyRun($tenant); - if ($run->status !== EntraGroupSyncRun::STATUS_PENDING) { - // Already ran? - return; + 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(), + ); } - $run->update([ - 'status' => EntraGroupSyncRun::STATUS_RUNNING, - 'started_at' => CarbonImmutable::now('UTC'), - ]); + $result = $syncService->sync($tenant, $this->selectionKey); - $auditLogger->log( - tenant: $tenant, - action: 'directory_groups.sync.started', - context: [ - 'selection_key' => $run->selection_key, - 'run_id' => $run->getKey(), - 'slot_key' => $run->slot_key, - ], - actorId: $run->initiator_user_id, - status: 'success', - resourceType: 'entra_group_sync_run', - resourceId: (string) $run->getKey(), - ); - - $result = $syncService->sync($tenant, $run); $terminalStatus = EntraGroupSyncRun::STATUS_SUCCEEDED; @@ -81,43 +102,80 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog $terminalStatus = EntraGroupSyncRun::STATUS_PARTIAL; } - $run->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'), - ]); + 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'), + ]); + } - // Update OperationRun with stats - if ($this->operationRun) { - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); - $opOutcome = match ($terminalStatus) { - EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded', - EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded', - EntraGroupSyncRun::STATUS_FAILED => 'failed', - default => 'failed' - }; + $opOutcome = match ($terminalStatus) { + EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded', + EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded', + EntraGroupSyncRun::STATUS_FAILED => 'failed', + default => 'failed', + }; - $opService->updateRun( - $this->operationRun, - 'completed', - $opOutcome, - [ - 'fetched' => $result['items_observed_count'], - 'upserted' => $result['items_upserted_count'], - 'errors' => $result['error_count'], + $failures = []; + if (is_string($result['error_code']) && $result['error_code'] !== '') { + $failures[] = [ + 'code' => $result['error_code'], + 'message' => is_string($result['error_summary']) ? $result['error_summary'] : 'Directory groups sync failed.', + ]; + } + + $opService->updateRun( + $this->operationRun, + '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'], + ], + $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, ], - $result['error_summary'] ? [['code' => $result['error_code'] ?? 'ERR', 'message' => json_encode($result['error_summary'])]] : [] + actorId: $legacyRun->initiator_user_id, + status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success', + resourceType: 'entra_group_sync_run', + resourceId: (string) $legacyRun->getKey(), ); + + return; } $auditLogger->log( @@ -128,23 +186,22 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog ? 'directory_groups.sync.partial' : 'directory_groups.sync.failed'), context: [ - 'selection_key' => $run->selection_key, - 'run_id' => $run->getKey(), - 'slot_key' => $run->slot_key, - 'pages_fetched' => $run->pages_fetched, - 'items_observed_count' => $run->items_observed_count, - 'items_upserted_count' => $run->items_upserted_count, - 'error_code' => $run->error_code, - 'error_category' => $run->error_category, + 'selection_key' => $this->selectionKey, + 'slot_key' => $this->slotKey, + 'pages_fetched' => $result['pages_fetched'], + 'items_observed_count' => $result['items_observed_count'], + 'items_upserted_count' => $result['items_upserted_count'], + 'error_code' => $result['error_code'], + 'error_category' => $result['error_category'], ], - actorId: $run->initiator_user_id, + actorId: $this->operationRun->user_id, status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success', - resourceType: 'entra_group_sync_run', - resourceId: (string) $run->getKey(), + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), ); } - private function resolveRun(Tenant $tenant): EntraGroupSyncRun + private function resolveLegacyRun(Tenant $tenant): ?EntraGroupSyncRun { if ($this->runId !== null) { $run = EntraGroupSyncRun::query() @@ -156,7 +213,7 @@ private function resolveRun(Tenant $tenant): EntraGroupSyncRun return $run; } - throw new RuntimeException('EntraGroupSyncRun not found.'); + return null; } if ($this->slotKey !== null) { @@ -170,9 +227,9 @@ private function resolveRun(Tenant $tenant): EntraGroupSyncRun return $run; } - throw new RuntimeException('EntraGroupSyncRun not found for slot.'); + return null; } - throw new RuntimeException('Job missing runId/slotKey.'); + return null; } } diff --git a/app/Jobs/ExecuteRestoreRunJob.php b/app/Jobs/ExecuteRestoreRunJob.php index 044a537..013e3aa 100644 --- a/app/Jobs/ExecuteRestoreRunJob.php +++ b/app/Jobs/ExecuteRestoreRunJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Listeners\SyncRestoreRunToOperationRun; +use App\Models\OperationRun; use App\Models\RestoreRun; use App\Models\User; use App\Notifications\RunStatusChangedNotification; @@ -22,20 +23,37 @@ class ExecuteRestoreRunJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + public function __construct( public int $restoreRunId, public ?string $actorEmail = null, public ?string $actorName = null, - ) {} + ?OperationRun $operationRun = null, + ) { + $this->operationRun = $operationRun; + } public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void { + if (! $this->operationRun) { + $this->fail(new \RuntimeException('OperationRun context is required for ExecuteRestoreRunJob.')); + + return; + } + $restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId); if (! $restoreRun) { return; } + if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $this->operationRun->getKey()) { + RestoreRun::withoutEvents(function () use ($restoreRun): void { + $restoreRun->forceFill(['operation_run_id' => $this->operationRun?->getKey()])->save(); + }); + } + if ($restoreRun->status !== RestoreRunStatus::Queued->value) { return; } diff --git a/app/Jobs/ProviderConnectionHealthCheckJob.php b/app/Jobs/ProviderConnectionHealthCheckJob.php index 3c53475..0b6d740 100644 --- a/app/Jobs/ProviderConnectionHealthCheckJob.php +++ b/app/Jobs/ProviderConnectionHealthCheckJob.php @@ -16,6 +16,7 @@ use App\Support\Audit\AuditActionId; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderNextStepsRegistry; use App\Support\Verification\TenantPermissionCheckClusters; use App\Support\Verification\VerificationReportWriter; @@ -230,13 +231,36 @@ public function handle( return; } + $reasonCode = is_string($result->reasonCode) && $result->reasonCode !== '' + ? $result->reasonCode + : 'unknown_error'; + + $nextSteps = app(ProviderNextStepsRegistry::class)->forReason( + $tenant, + $reasonCode, + $connection, + ); + + if ($reasonCode === ProviderReasonCodes::ProviderConsentMissing) { + $run = $runs->finalizeBlockedRun( + $this->operationRun, + reasonCode: $reasonCode, + nextSteps: $nextSteps, + message: $result->message ?? 'Admin consent is required before verification can proceed.', + ); + + $this->logVerificationCompletion($tenant, $user, $run, $report); + + return; + } + $run = $runs->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Failed->value, failures: [[ 'code' => 'provider.connection.check.failed', - 'reason_code' => $result->reasonCode ?? 'unknown_error', + 'reason_code' => $reasonCode, 'message' => $result->message ?? 'Health check failed.', ]], ); diff --git a/app/Jobs/RunBackupScheduleJob.php b/app/Jobs/RunBackupScheduleJob.php index 26dfc0d..26964b0 100644 --- a/app/Jobs/RunBackupScheduleJob.php +++ b/app/Jobs/RunBackupScheduleJob.php @@ -26,6 +26,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; class RunBackupScheduleJob implements ShouldQueue { @@ -38,6 +39,7 @@ class RunBackupScheduleJob implements ShouldQueue public function __construct( public int $backupScheduleRunId, ?OperationRun $operationRun = null, + public ?int $backupScheduleId = null, ) { $this->operationRun = $operationRun; } @@ -55,6 +57,26 @@ public function handle( AuditLogger $auditLogger, RunErrorMapper $errorMapper, ): void { + if (! $this->operationRun) { + $this->fail(new \RuntimeException('OperationRun context is required for RunBackupScheduleJob.')); + + return; + } + + if ($this->backupScheduleId !== null) { + $this->handleFromScheduleId( + backupScheduleId: $this->backupScheduleId, + policySyncService: $policySyncService, + backupService: $backupService, + policyTypeResolver: $policyTypeResolver, + scheduleTimeService: $scheduleTimeService, + auditLogger: $auditLogger, + errorMapper: $errorMapper, + ); + + return; + } + $run = BackupScheduleRun::query() ->with(['schedule', 'tenant', 'user']) ->find($this->backupScheduleRunId); @@ -74,10 +96,6 @@ public function handle( $tenant = $run->tenant; - if ($tenant instanceof Tenant) { - $this->resolveOperationRunFromContext($tenant, $run); - } - if ($this->operationRun) { $this->operationRun->update([ 'context' => array_merge($this->operationRun->context ?? [], [ @@ -347,6 +365,464 @@ public function handle( } } + 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); + + if (! $schedule instanceof BackupSchedule) { + $this->markOperationRunFailed( + run: $this->operationRun, + summaryCounts: [], + reasonCode: 'schedule_not_found', + reason: 'Schedule not found.', + ); + + return; + } + + $tenant = $schedule->tenant; + + if (! $tenant instanceof Tenant) { + $this->markOperationRunFailed( + run: $this->operationRun, + summaryCounts: [], + reasonCode: 'tenant_not_found', + reason: 'Tenant not found.', + ); + + return; + } + + $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'); + } + + $lock = Cache::lock("backup_schedule:{$schedule->id}", 900); + + if (! $lock->get()) { + $nowUtc = CarbonImmutable::now('UTC'); + + $this->finishSchedule( + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + scheduleTimeService: $scheduleTimeService, + nowUtc: $nowUtc, + ); + + $operationRunService->updateRun( + $this->operationRun, + status: 'completed', + outcome: 'failed', + summaryCounts: [ + 'total' => 0, + 'processed' => 0, + 'failed' => 1, + ], + failures: [ + [ + 'code' => 'concurrent_run', + 'message' => 'Another run is already in progress for this schedule.', + ], + ], + ); + + $this->notifyScheduleRunFinished( + tenant: $tenant, + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + errorMessage: 'Another run is already in progress for this schedule.', + ); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_skipped', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'operation_run_id' => $this->operationRun->getKey(), + 'reason' => 'concurrent_run', + ], + ], + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + status: 'partial' + ); + + return; + } + + try { + $nowUtc = CarbonImmutable::now('UTC'); + + $this->notifyScheduleRunStarted(tenant: $tenant, schedule: $schedule); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_started', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'operation_run_id' => $this->operationRun->getKey(), + ], + ], + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + status: 'success' + ); + + $runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? [])); + $validTypes = $runtime['valid']; + $unknownTypes = $runtime['unknown']; + + if (empty($validTypes)) { + $this->finishSchedule( + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + scheduleTimeService: $scheduleTimeService, + nowUtc: $nowUtc, + ); + + $operationRunService->updateRun( + $this->operationRun, + status: 'completed', + outcome: 'failed', + summaryCounts: [ + 'total' => 0, + 'processed' => 0, + 'failed' => 1, + ], + failures: [ + [ + 'code' => 'unknown_policy_type', + 'message' => 'All configured policy types are unknown.', + ], + ], + ); + + $this->notifyScheduleRunFinished( + tenant: $tenant, + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + errorMessage: 'All configured policy types are unknown.', + ); + + 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; + + if (! empty($unknownTypes)) { + $status = BackupScheduleRun::STATUS_PARTIAL; + $errorCode = 'UNKNOWN_POLICY_TYPE'; + $errorMessage = 'Some configured policy types are unknown and were skipped.'; + } + + $policiesTotal = count($policyIds); + $policiesBackedUp = (int) ($backupSet->item_count ?? 0); + $failedCount = max(0, $policiesTotal - $policiesBackedUp); + + $summaryCounts = [ + 'total' => $policiesTotal, + 'processed' => $policiesTotal, + 'succeeded' => $policiesBackedUp, + 'failed' => $failedCount, + 'skipped' => 0, + 'created' => 1, + 'updated' => $policiesBackedUp, + 'items' => $policiesTotal, + ]; + + $failures = []; + + if (is_string($errorMessage) && $errorMessage !== '') { + $failures[] = [ + 'code' => strtolower((string) ($errorCode ?: 'backup_schedule_error')), + 'message' => $errorMessage, + ]; + } + + if (is_array($syncFailures)) { + foreach ($syncFailures as $failure) { + if (! is_array($failure)) { + continue; + } + + $policyType = (string) ($failure['policy_type'] ?? 'unknown'); + $httpStatus = 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 = $httpStatus !== null + ? "{$policyType}: Graph returned {$httpStatus}" + : "{$policyType}: Graph request failed"; + + if (is_string($firstErrorMessage) && $firstErrorMessage !== '') { + $message .= ' - '.trim($firstErrorMessage); + } + + $failures[] = [ + 'code' => $httpStatus !== null ? 'graph_http_'.(string) $httpStatus : 'graph_error', + 'message' => $message, + ]; + } + } + + $this->operationRun->update([ + 'context' => array_merge($this->operationRun->context ?? [], [ + 'backup_schedule_id' => (int) $schedule->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + ]), + ]); + + $outcome = match ($status) { + BackupScheduleRun::STATUS_SUCCESS => 'succeeded', + BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded', + default => 'failed', + }; + + $operationRunService->updateRun( + $this->operationRun, + status: 'completed', + outcome: $outcome, + summaryCounts: $summaryCounts, + failures: $failures, + ); + + $this->finishSchedule( + schedule: $schedule, + status: $status, + scheduleTimeService: $scheduleTimeService, + nowUtc: $nowUtc, + ); + + $this->notifyScheduleRunFinished( + tenant: $tenant, + schedule: $schedule, + status: $status, + errorMessage: $errorMessage, + ); + + if (in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) { + Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id)); + } + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_finished', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'operation_run_id' => $this->operationRun->getKey(), + 'status' => $status, + 'error_code' => $errorCode, + ], + ], + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + 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']) { + $operationRunService->updateRun($this->operationRun, 'running', 'pending'); + + $this->release($mapped['delay']); + + return; + } + + $nowUtc = CarbonImmutable::now('UTC'); + + $this->finishSchedule( + schedule: $schedule, + status: BackupScheduleRun::STATUS_FAILED, + scheduleTimeService: $scheduleTimeService, + nowUtc: $nowUtc, + ); + + $operationRunService->updateRun( + $this->operationRun, + status: 'completed', + outcome: 'failed', + summaryCounts: [ + 'total' => 0, + 'processed' => 0, + 'failed' => 1, + ], + failures: [ + [ + 'code' => strtolower((string) $mapped['error_code']), + 'message' => (string) $mapped['error_message'], + ], + ], + ); + + $this->notifyScheduleRunFinished( + tenant: $tenant, + schedule: $schedule, + status: BackupScheduleRun::STATUS_FAILED, + errorMessage: (string) $mapped['error_message'], + ); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_failed', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'operation_run_id' => $this->operationRun->getKey(), + 'error_code' => $mapped['error_code'], + ], + ], + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + status: 'failed' + ); + } finally { + optional($lock)->release(); + } + } + + private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedule): void + { + $userId = $this->operationRun?->user_id; + + if (! $userId) { + return; + } + + $user = \App\Models\User::query()->find($userId); + + if (! $user) { + return; + } + + Notification::make() + ->title('Backup started') + ->body(sprintf('Schedule "%s" has started.', $schedule->name)) + ->info() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ]) + ->sendToDatabase($user); + } + + private function notifyScheduleRunFinished( + Tenant $tenant, + BackupSchedule $schedule, + string $status, + ?string $errorMessage, + ): void { + $userId = $this->operationRun?->user_id; + + if (! $userId) { + return; + } + + $user = \App\Models\User::query()->find($userId); + + if (! $user) { + return; + } + + $title = match ($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, $status)); + + if (is_string($errorMessage) && $errorMessage !== '') { + $notification->body($notification->getBody()."\n".$errorMessage); + } + + match ($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(OperationRunLinks::view($this->operationRun, $tenant)), + ]) + ->sendToDatabase($user); + } + + private function finishSchedule( + BackupSchedule $schedule, + string $status, + ScheduleTimeService $scheduleTimeService, + CarbonImmutable $nowUtc, + ): void { + $schedule->forceFill([ + 'last_run_at' => $nowUtc, + 'last_run_status' => $status, + 'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc), + ])->saveQuietly(); + } + private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedule): void { $user = $run->user; @@ -527,25 +1003,6 @@ private function markOperationRunFailed( ); } - private function resolveOperationRunFromContext(Tenant $tenant, BackupScheduleRun $run): void - { - if ($this->operationRun) { - return; - } - - $operationRun = OperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry']) - ->whereIn('status', ['queued', 'running']) - ->where('context->backup_schedule_run_id', (int) $run->getKey()) - ->latest('id') - ->first(); - - if ($operationRun instanceof OperationRun) { - $this->operationRun = $operationRun; - } - } - private function finishRun( BackupScheduleRun $run, BackupSchedule $schedule, diff --git a/app/Jobs/RunInventorySyncJob.php b/app/Jobs/RunInventorySyncJob.php index 2e8bcb7..574e890 100644 --- a/app/Jobs/RunInventorySyncJob.php +++ b/app/Jobs/RunInventorySyncJob.php @@ -3,7 +3,6 @@ namespace App\Jobs; use App\Jobs\Middleware\TrackOperationRun; -use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; @@ -31,7 +30,6 @@ class RunInventorySyncJob implements ShouldQueue public function __construct( public int $tenantId, public int $userId, - public int $inventorySyncRunId, ?OperationRun $operationRun = null ) { $this->operationRun = $operationRun; @@ -52,6 +50,12 @@ public function middleware(): array */ public function handle(InventorySyncService $inventorySyncService, AuditLogger $auditLogger, OperationRunService $operationRunService): void { + if (! $this->operationRun) { + $this->fail(new RuntimeException('OperationRun context is required for RunInventorySyncJob.')); + + return; + } + $tenant = Tenant::query()->find($this->tenantId); if (! $tenant instanceof Tenant) { throw new RuntimeException('Tenant not found.'); @@ -62,15 +66,9 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $ throw new RuntimeException('User not found.'); } - $run = InventorySyncRun::query()->find($this->inventorySyncRunId); - if (! $run instanceof InventorySyncRun) { - throw new RuntimeException('InventorySyncRun not found.'); - } - - $policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : []; - if (! is_array($policyTypes)) { - $policyTypes = []; - } + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $policyTypes = $context['policy_types'] ?? []; + $policyTypes = is_array($policyTypes) ? array_values(array_filter(array_map('strval', $policyTypes))) : []; $processedPolicyTypes = []; $successCount = 0; @@ -81,9 +79,11 @@ 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. - $run = $inventorySyncService->executePendingRun( - $run, + + $result = $inventorySyncService->executeSelection( + $this->operationRun, $tenant, + $context, function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$successCount, &$failedCount): void { $processedPolicyTypes[] = $policyType; @@ -97,134 +97,90 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe }, ); - if ($run->status === InventorySyncRun::STATUS_SUCCESS) { - if ($this->operationRun) { - $operationRunService->updateRun( - $this->operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Succeeded->value, - summaryCounts: [ - 'total' => count($policyTypes), - 'processed' => count($policyTypes), - 'succeeded' => count($policyTypes), - 'failed' => 0, - // Reuse allowed keys for inventory item stats. - 'items' => (int) $run->items_observed_count, - 'updated' => (int) $run->items_upserted_count, - ], - ); - } + $status = (string) ($result['status'] ?? 'failed'); + $errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : []; + $reason = (string) ($errorCodes[0] ?? $status); + + $itemsObserved = (int) ($result['items_observed_count'] ?? 0); + $itemsUpserted = (int) ($result['items_upserted_count'] ?? 0); + $errorsCount = (int) ($result['errors_count'] ?? 0); + + if ($status === 'success') { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: [ + 'total' => count($policyTypes), + 'processed' => count($policyTypes), + 'succeeded' => count($policyTypes), + 'failed' => 0, + 'items' => $itemsObserved, + 'updated' => $itemsUpserted, + ], + ); $auditLogger->log( tenant: $tenant, action: 'inventory.sync.completed', context: [ 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, - 'observed' => $run->items_observed_count, - 'upserted' => $run->items_upserted_count, + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'observed' => $itemsObserved, + 'upserted' => $itemsUpserted, ], ], actorId: $user->id, actorEmail: $user->email, actorName: $user->name, - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), ); return; } - if ($run->status === InventorySyncRun::STATUS_PARTIAL) { - if ($this->operationRun) { - $operationRunService->updateRun( - $this->operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::PartiallySucceeded->value, - summaryCounts: [ - 'total' => count($policyTypes), - 'processed' => count($policyTypes), - 'succeeded' => max(0, count($policyTypes) - (int) $run->errors_count), - 'failed' => (int) $run->errors_count, - 'items' => (int) $run->items_observed_count, - 'updated' => (int) $run->items_upserted_count, - ], - failures: [ - ['code' => 'inventory.partial', 'message' => "Errors: {$run->errors_count}"], - ], - ); - } + if ($status === 'partial') { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::PartiallySucceeded->value, + summaryCounts: [ + 'total' => count($policyTypes), + 'processed' => count($policyTypes), + 'succeeded' => max(0, count($policyTypes) - $errorsCount), + 'failed' => $errorsCount, + 'items' => $itemsObserved, + 'updated' => $itemsUpserted, + ], + failures: [ + ['code' => 'inventory.partial', 'message' => "Errors: {$errorsCount}"], + ], + ); $auditLogger->log( tenant: $tenant, action: 'inventory.sync.partial', context: [ 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, - 'observed' => $run->items_observed_count, - 'upserted' => $run->items_upserted_count, - 'errors' => $run->errors_count, + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'observed' => $itemsObserved, + 'upserted' => $itemsUpserted, + 'errors' => $errorsCount, ], ], actorId: $user->id, actorEmail: $user->email, actorName: $user->name, status: 'failure', - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), ); return; } - if ($run->status === InventorySyncRun::STATUS_SKIPPED) { - $reason = (string) (($run->error_codes ?? [])[0] ?? 'skipped'); - - if ($this->operationRun) { - $operationRunService->updateRun( - $this->operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Failed->value, - summaryCounts: [ - 'total' => count($policyTypes), - 'processed' => count($policyTypes), - 'succeeded' => 0, - 'failed' => 0, - 'skipped' => count($policyTypes), - ], - failures: [ - ['code' => 'inventory.skipped', 'message' => $reason], - ], - ); - } - - $auditLogger->log( - tenant: $tenant, - action: 'inventory.sync.skipped', - context: [ - 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, - 'reason' => $reason, - ], - ], - actorId: $user->id, - actorEmail: $user->email, - actorName: $user->name, - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, - ); - - return; - } - - $reason = (string) (($run->error_codes ?? [])[0] ?? 'failed'); - - $missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes))); - - if ($this->operationRun) { + if ($status === 'skipped') { $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, @@ -232,22 +188,57 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe summaryCounts: [ 'total' => count($policyTypes), 'processed' => count($policyTypes), - 'succeeded' => $successCount, - 'failed' => max($failedCount, count($missingPolicyTypes)), + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => count($policyTypes), ], failures: [ - ['code' => 'inventory.failed', 'message' => $reason], + ['code' => 'inventory.skipped', 'message' => $reason], ], ); + + $auditLogger->log( + tenant: $tenant, + action: 'inventory.sync.skipped', + context: [ + 'metadata' => [ + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'reason' => $reason, + ], + ], + actorId: $user->id, + actorEmail: $user->email, + actorName: $user->name, + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + ); + + return; } + $missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes))); + + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + summaryCounts: [ + 'total' => count($policyTypes), + 'processed' => count($policyTypes), + 'succeeded' => $successCount, + 'failed' => max($failedCount, count($missingPolicyTypes)), + ], + failures: [ + ['code' => 'inventory.failed', 'message' => $reason], + ], + ); + $auditLogger->log( tenant: $tenant, action: 'inventory.sync.failed', context: [ 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, + 'operation_run_id' => (int) $this->operationRun->getKey(), 'reason' => $reason, ], ], @@ -255,9 +246,8 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe actorEmail: $user->email, actorName: $user->name, status: 'failure', - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), ); - } } diff --git a/app/Jobs/SyncRoleDefinitionsJob.php b/app/Jobs/SyncRoleDefinitionsJob.php new file mode 100644 index 0000000..34d881b --- /dev/null +++ b/app/Jobs/SyncRoleDefinitionsJob.php @@ -0,0 +1,123 @@ +operationRun = $operationRun; + } + + public function middleware(): array + { + return [new TrackOperationRun]; + } + + /** + * Execute the job. + */ + public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $auditLogger): void + { + if (! $this->operationRun) { + $this->fail(new RuntimeException('OperationRun context is required for SyncRoleDefinitionsJob.')); + + return; + } + + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $auditLogger->log( + tenant: $tenant, + action: 'directory_role_definitions.sync.started', + context: [ + 'tenant_id' => (int) $tenant->getKey(), + ], + actorId: $this->operationRun->user_id, + status: 'success', + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + ); + + $result = $syncService->sync($tenant); + + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + + $outcome = 'succeeded'; + + if ($result['error_code'] !== null) { + $outcome = 'failed'; + } elseif ($result['safety_stop_triggered'] === true) { + $outcome = 'partially_succeeded'; + } + + $failures = []; + if (is_string($result['error_code']) && $result['error_code'] !== '') { + $failures[] = [ + 'code' => $result['error_code'], + 'message' => is_string($result['error_summary']) ? $result['error_summary'] : 'Role definitions sync failed.', + ]; + } + + $opService->updateRun( + $this->operationRun, + 'completed', + $outcome, + [ + 'total' => $result['items_observed_count'], + 'processed' => $result['items_observed_count'], + 'updated' => $result['items_upserted_count'], + 'failed' => $result['error_count'], + ], + $failures, + ); + + $auditLogger->log( + tenant: $tenant, + action: $outcome === 'succeeded' + ? 'directory_role_definitions.sync.succeeded' + : ($outcome === 'partially_succeeded' + ? 'directory_role_definitions.sync.partial' + : 'directory_role_definitions.sync.failed'), + context: [ + 'pages_fetched' => $result['pages_fetched'], + 'items_observed_count' => $result['items_observed_count'], + 'items_upserted_count' => $result['items_upserted_count'], + 'error_code' => $result['error_code'], + 'error_category' => $result['error_category'], + 'finished_at' => CarbonImmutable::now('UTC')->toIso8601String(), + ], + actorId: $this->operationRun->user_id, + status: $outcome === 'failed' ? 'failed' : 'success', + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + ); + } +} diff --git a/app/Listeners/SyncRestoreRunToOperationRun.php b/app/Listeners/SyncRestoreRunToOperationRun.php index 5ddd78e..a0c08cf 100644 --- a/app/Listeners/SyncRestoreRunToOperationRun.php +++ b/app/Listeners/SyncRestoreRunToOperationRun.php @@ -2,6 +2,7 @@ namespace App\Listeners; +use App\Models\OperationRun; use App\Models\RestoreRun; use App\Services\OperationRunService; use App\Support\RestoreRunStatus; @@ -42,12 +43,30 @@ public function handle(RestoreRun $restoreRun): void 'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false), ]; - $opRun = $this->service->ensureRun( - tenant: $restoreRun->tenant, - type: 'restore.execute', - inputs: $inputs, - initiator: null - ); + $opRun = null; + + if ($restoreRun->operation_run_id) { + $opRun = OperationRun::query()->whereKey($restoreRun->operation_run_id)->first(); + + if ($opRun?->type !== 'restore.execute') { + $opRun = null; + } + } + + if (! $opRun) { + $opRun = $this->service->ensureRun( + tenant: $restoreRun->tenant, + type: 'restore.execute', + inputs: $inputs, + initiator: null + ); + } + + if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) { + RestoreRun::withoutEvents(function () use ($restoreRun, $opRun): void { + $restoreRun->forceFill(['operation_run_id' => $opRun->getKey()])->save(); + }); + } [$opStatus, $opOutcome, $failures] = $this->mapStatus($status); diff --git a/app/Models/BackupSchedule.php b/app/Models/BackupSchedule.php index 66e4e21..81172bf 100644 --- a/app/Models/BackupSchedule.php +++ b/app/Models/BackupSchedule.php @@ -31,4 +31,15 @@ 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', + ]) + ->where('context->backup_schedule_id', (int) $this->getKey()); + } } diff --git a/app/Models/EntraRoleDefinition.php b/app/Models/EntraRoleDefinition.php new file mode 100644 index 0000000..85e52dc --- /dev/null +++ b/app/Models/EntraRoleDefinition.php @@ -0,0 +1,24 @@ + 'boolean', + 'last_seen_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php index 73b75a8..44035b6 100644 --- a/app/Models/InventoryItem.php +++ b/app/Models/InventoryItem.php @@ -27,4 +27,9 @@ public function lastSeenRun(): BelongsTo { return $this->belongsTo(InventorySyncRun::class, 'last_seen_run_id'); } + + public function lastSeenOperationRun(): BelongsTo + { + return $this->belongsTo(OperationRun::class, 'last_seen_operation_run_id'); + } } diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index bf741c9..a340de5 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -38,6 +38,11 @@ public function backupSet(): BelongsTo return $this->belongsTo(BackupSet::class)->withTrashed(); } + public function operationRun(): BelongsTo + { + return $this->belongsTo(OperationRun::class); + } + public function scopeDeletable(Builder $query): Builder { return $query->whereIn('status', array_map( diff --git a/app/Notifications/RunStatusChangedNotification.php b/app/Notifications/RunStatusChangedNotification.php index 696d8d0..6805b59 100644 --- a/app/Notifications/RunStatusChangedNotification.php +++ b/app/Notifications/RunStatusChangedNotification.php @@ -68,7 +68,7 @@ public function toDatabase(object $notifiable): array $url = match ($runType) { 'bulk_operation' => OperationRunLinks::view($runId, $tenant), 'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), - 'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), + 'directory_groups' => OperationRunLinks::view($runId, $tenant), default => null, }; diff --git a/app/Policies/OperationRunPolicy.php b/app/Policies/OperationRunPolicy.php index ba6d3ee..1140de3 100644 --- a/app/Policies/OperationRunPolicy.php +++ b/app/Policies/OperationRunPolicy.php @@ -3,11 +3,15 @@ namespace App\Policies; use App\Models\OperationRun; +use App\Models\Tenant; use App\Models\User; +use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\Operations\OperationRunCapabilityResolver; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\Response; +use Illuminate\Support\Facades\Gate; class OperationRunPolicy { @@ -56,6 +60,39 @@ public function view(User $user, OperationRun $run): Response|bool } } + $requiredCapability = app(OperationRunCapabilityResolver::class) + ->requiredCapabilityForType((string) $run->type); + + if (! is_string($requiredCapability) || $requiredCapability === '') { + return true; + } + + if (str_starts_with($requiredCapability, 'workspace')) { + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + if (! Gate::forUser($user)->allows($requiredCapability, $workspace)) { + return Response::deny(); + } + + return true; + } + + if ($tenantId > 0) { + $tenant = Tenant::query()->whereKey($tenantId)->first(); + + if (! $tenant instanceof Tenant) { + return Response::denyAsNotFound(); + } + + if (! Gate::forUser($user)->allows($requiredCapability, $tenant)) { + return Response::deny(); + } + } + return true; } } diff --git a/app/Policies/ProviderConnectionPolicy.php b/app/Policies/ProviderConnectionPolicy.php index 03300cd..1d0a52f 100644 --- a/app/Policies/ProviderConnectionPolicy.php +++ b/app/Policies/ProviderConnectionPolicy.php @@ -36,7 +36,7 @@ public function view(User $user, ProviderConnection $connection): Response|bool return Response::denyAsNotFound(); } - $tenant = $this->currentTenant(); + $tenant = $this->tenantForConnection($connection) ?? $this->currentTenant(); if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) { return Response::denyAsNotFound(); @@ -78,7 +78,7 @@ public function update(User $user, ProviderConnection $connection): Response|boo return Response::denyAsNotFound(); } - $tenant = $this->currentTenant(); + $tenant = $this->tenantForConnection($connection) ?? $this->currentTenant(); if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) { return Response::denyAsNotFound(); @@ -106,7 +106,7 @@ public function delete(User $user, ProviderConnection $connection): Response|boo return Response::denyAsNotFound(); } - $tenant = $this->currentTenant(); + $tenant = $this->tenantForConnection($connection) ?? $this->currentTenant(); if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) { return Response::denyAsNotFound(); @@ -152,4 +152,17 @@ private function currentTenant(): ?Tenant return Tenant::current(); } + + private function tenantForConnection(ProviderConnection $connection): ?Tenant + { + if ($connection->relationLoaded('tenant') && $connection->tenant instanceof Tenant) { + return $connection->tenant; + } + + if (is_int($connection->tenant_id) || is_numeric($connection->tenant_id)) { + return Tenant::query()->whereKey((int) $connection->tenant_id)->first(); + } + + return null; + } } diff --git a/app/Services/BackupScheduling/BackupScheduleDispatcher.php b/app/Services/BackupScheduling/BackupScheduleDispatcher.php index be63a73..ebaad3f 100644 --- a/app/Services/BackupScheduling/BackupScheduleDispatcher.php +++ b/app/Services/BackupScheduling/BackupScheduleDispatcher.php @@ -4,11 +4,11 @@ use App\Jobs\RunBackupScheduleJob; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; use App\Models\Tenant; use App\Services\Intune\AuditLogger; +use App\Services\OperationRunService; +use App\Support\OperationRunType; use Carbon\CarbonImmutable; -use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Log; @@ -17,6 +17,7 @@ class BackupScheduleDispatcher public function __construct( private readonly ScheduleTimeService $scheduleTimeService, private readonly AuditLogger $auditLogger, + private readonly OperationRunService $operationRunService, ) {} /** @@ -62,23 +63,29 @@ public function dispatchDue(?array $tenantIdentifiers = null): array continue; } - $run = null; + $scheduledFor = $slot->startOfMinute(); - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $schedule->id, - 'tenant_id' => $schedule->tenant_id, - 'scheduled_for' => $slot->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - } catch (UniqueConstraintViolationException) { - // Idempotency: unique (backup_schedule_id, scheduled_for) + $operationRun = $this->operationRunService->ensureRunWithIdentityStrict( + tenant: $schedule->tenant, + type: OperationRunType::BackupScheduleScheduled->value, + identityInputs: [ + 'backup_schedule_id' => (int) $schedule->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + ], + context: [ + 'backup_schedule_id' => (int) $schedule->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'scheduled', + ], + ); + + if (! $operationRun->wasRecentlyCreated) { $skippedRuns++; - Log::debug('Backup schedule run already dispatched for slot.', [ + Log::debug('Backup schedule operation already dispatched for slot.', [ 'schedule_id' => $schedule->id, - 'slot' => $slot->toDateTimeString(), + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'operation_run_id' => $operationRun->getKey(), ]); $schedule->forceFill([ @@ -96,12 +103,12 @@ public function dispatchDue(?array $tenantIdentifiers = null): array context: [ 'metadata' => [ 'backup_schedule_id' => $schedule->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $slot->toDateTimeString(), + 'operation_run_id' => $operationRun->getKey(), + 'scheduled_for' => $scheduledFor->toDateTimeString(), ], ], - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $operationRun->getKey(), status: 'success' ); @@ -109,7 +116,7 @@ public function dispatchDue(?array $tenantIdentifiers = null): array 'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc), ])->saveQuietly(); - Bus::dispatch(new RunBackupScheduleJob($run->id)); + Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $schedule->id)); } return [ diff --git a/app/Services/Directory/EntraGroupSyncService.php b/app/Services/Directory/EntraGroupSyncService.php index e3a437e..79f0353 100644 --- a/app/Services/Directory/EntraGroupSyncService.php +++ b/app/Services/Directory/EntraGroupSyncService.php @@ -3,9 +3,11 @@ namespace App\Services\Directory; use App\Models\EntraGroup; -use App\Models\EntraGroupSyncRun; +use App\Jobs\EntraGroupSyncJob; +use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; +use App\Services\OperationRunService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphResponse; @@ -18,36 +20,36 @@ public function __construct( private readonly GraphContractRegistry $contracts, ) {} - public function startManualSync(Tenant $tenant, User $user): EntraGroupSyncRun + public function startManualSync(Tenant $tenant, User $user): OperationRun { $selectionKey = EntraGroupSelection::allGroupsV1(); - $existing = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', $selectionKey) - ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) - ->orderByDesc('id') - ->first(); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'directory_groups.sync', + identityInputs: ['selection_key' => $selectionKey], + context: [ + 'selection_key' => $selectionKey, + 'trigger' => 'manual', + ], + initiator: $user, + ); - if ($existing instanceof EntraGroupSyncRun) { - return $existing; + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + return $opRun; } - $run = EntraGroupSyncRun::create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => $selectionKey, - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - - dispatch(new \App\Jobs\EntraGroupSyncJob( + dispatch(new EntraGroupSyncJob( tenantId: (int) $tenant->getKey(), selectionKey: $selectionKey, slotKey: null, - runId: (int) $run->getKey(), + runId: null, + operationRun: $opRun, )); - return $run; + return $opRun; } /** @@ -63,7 +65,7 @@ public function startManualSync(Tenant $tenant, User $user): EntraGroupSyncRun * error_summary:?string * } */ - public function sync(Tenant $tenant, EntraGroupSyncRun $run): array + public function sync(Tenant $tenant, string $selectionKey): array { $nowUtc = CarbonImmutable::now('UTC'); diff --git a/app/Services/Directory/RoleDefinitionsSyncService.php b/app/Services/Directory/RoleDefinitionsSyncService.php new file mode 100644 index 0000000..ddb77b4 --- /dev/null +++ b/app/Services/Directory/RoleDefinitionsSyncService.php @@ -0,0 +1,257 @@ +ensureRunWithIdentity( + tenant: $tenant, + type: 'directory_role_definitions.sync', + identityInputs: ['selection_key' => $selectionKey], + context: [ + 'selection_key' => $selectionKey, + 'trigger' => 'manual', + ], + initiator: $user, + ); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + return $opRun; + } + + dispatch(new SyncRoleDefinitionsJob( + tenantId: (int) $tenant->getKey(), + operationRun: $opRun, + )); + + return $opRun; + } + + /** + * @return array{ + * pages_fetched:int, + * items_observed_count:int, + * items_upserted_count:int, + * error_count:int, + * safety_stop_triggered:bool, + * safety_stop_reason:?string, + * error_code:?string, + * error_category:?string, + * error_summary:?string + * } + */ + public function sync(Tenant $tenant): array + { + $nowUtc = CarbonImmutable::now('UTC'); + + $policyType = $this->contracts->directoryRoleDefinitionsPolicyType(); + $path = $this->contracts->directoryRoleDefinitionsListPath(); + + $contract = $this->contracts->get($policyType); + $query = []; + + if (isset($contract['allowed_select']) && is_array($contract['allowed_select']) && $contract['allowed_select'] !== []) { + $query['$select'] = $contract['allowed_select']; + } + + $pageSize = (int) config('directory_role_definitions.page_size', 200); + if ($pageSize > 0) { + $query['$top'] = $pageSize; + } + + $sanitized = $this->contracts->sanitizeQuery($policyType, $query); + $query = $sanitized['query']; + + $maxPages = (int) config('directory_role_definitions.safety_stop.max_pages', 50); + $maxRuntimeSeconds = (int) config('directory_role_definitions.safety_stop.max_runtime_seconds', 120); + $deadline = $nowUtc->addSeconds(max(1, $maxRuntimeSeconds)); + + $pagesFetched = 0; + $observed = 0; + $upserted = 0; + + $safetyStopTriggered = false; + $safetyStopReason = null; + + $errorCode = null; + $errorCategory = null; + $errorSummary = null; + $errorCount = 0; + + $options = $tenant->graphOptions(); + $useQuery = $query; + $nextPath = $path; + + while ($nextPath) { + if (CarbonImmutable::now('UTC')->greaterThan($deadline)) { + $safetyStopTriggered = true; + $safetyStopReason = 'runtime_exceeded'; + break; + } + + if ($pagesFetched >= $maxPages) { + $safetyStopTriggered = true; + $safetyStopReason = 'max_pages_exceeded'; + break; + } + + $response = $this->requestWithRetry('GET', $nextPath, $options + ['query' => $useQuery]); + + if ($response->failed()) { + [$errorCode, $errorCategory, $errorSummary] = $this->categorizeError($response); + $errorCount = 1; + break; + } + + $pagesFetched++; + + $data = $response->data; + $pageItems = $data['value'] ?? (is_array($data) ? $data : []); + + if (is_array($pageItems)) { + foreach ($pageItems as $item) { + if (! is_array($item)) { + continue; + } + + $entraId = $item['id'] ?? null; + if (! is_string($entraId) || $entraId === '') { + continue; + } + + $displayName = $item['displayName'] ?? null; + $isBuiltIn = (bool) ($item['isBuiltIn'] ?? false); + + $values = [ + 'display_name' => is_string($displayName) ? $displayName : $entraId, + 'is_built_in' => $isBuiltIn, + 'last_seen_at' => $nowUtc, + ]; + + EntraRoleDefinition::query()->updateOrCreate([ + 'tenant_id' => $tenant->getKey(), + 'entra_id' => $entraId, + ], $values); + + $observed++; + $upserted++; + } + } + + $nextLink = is_array($data) ? ($data['@odata.nextLink'] ?? null) : null; + + if (! is_string($nextLink) || $nextLink === '') { + break; + } + + $nextPath = $this->stripGraphBaseUrl($nextLink); + $useQuery = []; + } + + $retentionDays = (int) config('directory_role_definitions.retention_days', 90); + if ($retentionDays > 0) { + $cutoff = $nowUtc->subDays($retentionDays); + + EntraRoleDefinition::query() + ->where('tenant_id', $tenant->getKey()) + ->whereNotNull('last_seen_at') + ->where('last_seen_at', '<', $cutoff) + ->delete(); + } + + return [ + 'pages_fetched' => $pagesFetched, + 'items_observed_count' => $observed, + 'items_upserted_count' => $upserted, + 'error_count' => $errorCount, + 'safety_stop_triggered' => $safetyStopTriggered, + 'safety_stop_reason' => $safetyStopReason, + 'error_code' => $errorCode, + 'error_category' => $errorCategory, + 'error_summary' => $errorSummary, + ]; + } + + private function requestWithRetry(string $method, string $path, array $options): GraphResponse + { + $maxRetries = (int) config('directory_role_definitions.safety_stop.max_retries', 6); + $maxRetries = max(0, $maxRetries); + + for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { + $response = $this->graph->request($method, $path, $options); + + if ($response->successful()) { + return $response; + } + + $status = (int) ($response->status ?? 0); + + if (! in_array($status, [429, 503], true) || $attempt >= $maxRetries) { + return $response; + } + + $baseDelaySeconds = min(30, 1 << $attempt); + $jitterMillis = random_int(0, 250); + usleep(($baseDelaySeconds * 1000 + $jitterMillis) * 1000); + } + + return new GraphResponse(success: false, data: [], status: 500, errors: [['message' => 'Retry loop exceeded']]); + } + + /** + * @return array{0:string,1:string,2:string} + */ + private function categorizeError(GraphResponse $response): array + { + $status = (int) ($response->status ?? 0); + + if (in_array($status, [401, 403], true)) { + return ['permission_denied', 'permission', 'Graph permission denied for role definitions listing.']; + } + + if ($status === 429) { + return ['throttled', 'throttling', 'Graph throttled the role definitions listing request.']; + } + + if (in_array($status, [500, 502, 503, 504], true)) { + return ['graph_unavailable', 'transient', 'Graph returned a transient server error.']; + } + + return ['graph_request_failed', 'unknown', 'Graph request failed.']; + } + + private function stripGraphBaseUrl(string $nextLink): string + { + $base = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/') + .'/'.trim((string) config('graph.version', 'v1.0'), '/'); + + if (str_starts_with($nextLink, $base)) { + return ltrim((string) substr($nextLink, strlen($base)), '/'); + } + + return ltrim($nextLink, '/'); + } +} diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 3afc766..59b25ea 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -37,6 +37,18 @@ public function directoryGroupsListPath(): string return '/'.ltrim($resource, '/'); } + public function directoryRoleDefinitionsPolicyType(): string + { + return 'directoryRoleDefinitions'; + } + + public function directoryRoleDefinitionsListPath(): string + { + $resource = $this->resourcePath($this->directoryRoleDefinitionsPolicyType()) ?? 'deviceManagement/roleDefinitions'; + + return '/'.ltrim($resource, '/'); + } + /** * @return array */ diff --git a/app/Services/Inventory/InventoryMissingService.php b/app/Services/Inventory/InventoryMissingService.php index b1ff4e6..a14bc7d 100644 --- a/app/Services/Inventory/InventoryMissingService.php +++ b/app/Services/Inventory/InventoryMissingService.php @@ -3,7 +3,7 @@ namespace App\Services\Inventory; use App\Models\InventoryItem; -use App\Models\InventorySyncRun; +use App\Models\OperationRun; use App\Models\Tenant; use App\Services\BackupScheduling\PolicyTypeResolver; use Illuminate\Database\Eloquent\Collection; @@ -17,7 +17,7 @@ public function __construct( /** * @param array $selectionPayload - * @return array{latestRun: InventorySyncRun|null, missing: Collection, lowConfidence: bool} + * @return array{latestRun: OperationRun|null, missing: Collection, lowConfidence: bool} */ public function missingForSelection(Tenant $tenant, array $selectionPayload): array { @@ -25,16 +25,12 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar $normalized['policy_types'] = $this->policyTypeResolver->filterRuntime($normalized['policy_types']); $selectionHash = $this->selectionHasher->hash($normalized); - $latestRun = InventorySyncRun::query() + $latestRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('selection_hash', $selectionHash) - ->whereIn('status', [ - InventorySyncRun::STATUS_SUCCESS, - InventorySyncRun::STATUS_PARTIAL, - InventorySyncRun::STATUS_FAILED, - InventorySyncRun::STATUS_SKIPPED, - ]) - ->orderByDesc('finished_at') + ->where('type', 'inventory.sync') + ->where('status', 'completed') + ->where('context->selection_hash', $selectionHash) + ->orderByDesc('completed_at') ->orderByDesc('id') ->first(); @@ -51,11 +47,11 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar ->whereIn('policy_type', $normalized['policy_types']) ->where(function ($query) use ($latestRun): void { $query - ->whereNull('last_seen_run_id') - ->orWhere('last_seen_run_id', '!=', $latestRun->getKey()); + ->whereNull('last_seen_operation_run_id') + ->orWhere('last_seen_operation_run_id', '!=', $latestRun->getKey()); }); - $lowConfidence = $latestRun->status !== InventorySyncRun::STATUS_SUCCESS || (bool) ($latestRun->had_errors ?? false); + $lowConfidence = $latestRun->outcome !== 'succeeded'; return [ 'latestRun' => $latestRun, diff --git a/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php index 85066f5..d98d670 100644 --- a/app/Services/Inventory/InventorySyncService.php +++ b/app/Services/Inventory/InventorySyncService.php @@ -3,16 +3,14 @@ namespace App\Services\Inventory; use App\Models\InventoryItem; -use App\Models\InventorySyncRun; +use App\Models\OperationRun; use App\Models\ProviderConnection; use App\Models\Tenant; -use App\Models\User; use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\Graph\GraphResponse; use App\Services\Providers\ProviderConnectionResolver; use App\Services\Providers\ProviderGateway; use App\Support\Providers\ProviderReasonCodes; -use Carbon\CarbonImmutable; use Illuminate\Contracts\Cache\Lock; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -31,40 +29,30 @@ public function __construct( ) {} /** - * Runs an inventory sync inline (no queue), enforcing locks/concurrency and creating an observable run record. + * Runs an inventory sync (inline), enforcing locks/concurrency. + * + * This method MUST NOT create or update InventorySyncRun rows; OperationRun is canonical. * * @param array $selectionPayload + * @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed + * @return array{status: string, had_errors: bool, error_codes: list, error_context: array|null, items_observed_count: int, items_upserted_count: int, errors_count: int} */ - public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun + public function executeSelection(OperationRun $operationRun, Tenant $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed = null): array { $computed = $this->normalizeAndHashSelection($selectionPayload); $normalizedSelection = $computed['selection']; $selectionHash = $computed['selection_hash']; - $now = CarbonImmutable::now('UTC'); - $globalSlot = $this->concurrencyLimiter->acquireGlobalSlot(); if (! $globalSlot instanceof Lock) { - return $this->createSkippedRun( - tenant: $tenant, - selectionHash: $selectionHash, - selectionPayload: $normalizedSelection, - now: $now, - errorCode: 'concurrency_limit_global', - ); + return $this->skippedResult('concurrency_limit_global'); } $tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id); if (! $tenantSlot instanceof Lock) { $globalSlot->release(); - return $this->createSkippedRun( - tenant: $tenant, - selectionHash: $selectionHash, - selectionPayload: $normalizedSelection, - now: $now, - errorCode: 'concurrency_limit_tenant', - ); + return $this->skippedResult('concurrency_limit_tenant'); } $selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900); @@ -72,33 +60,11 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR $tenantSlot->release(); $globalSlot->release(); - return $this->createSkippedRun( - tenant: $tenant, - selectionHash: $selectionHash, - selectionPayload: $normalizedSelection, - now: $now, - errorCode: 'lock_contended', - ); + return $this->skippedResult('lock_contended'); } - $run = InventorySyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'user_id' => null, - 'selection_hash' => $selectionHash, - 'selection_payload' => $normalizedSelection, - 'status' => InventorySyncRun::STATUS_RUNNING, - 'had_errors' => false, - 'error_codes' => [], - 'error_context' => null, - 'started_at' => $now, - 'finished_at' => null, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]); - try { - return $this->executeRun($run, $tenant, $normalizedSelection); + return $this->executeSelectionUnderLock($operationRun, $tenant, $normalizedSelection, $onPolicyTypeProcessed); } finally { $selectionLock->release(); $tenantSlot->release(); @@ -135,110 +101,12 @@ public function normalizeAndHashSelection(array $selectionPayload): array ]; } - /** - * Creates a pending run record attributed to the initiating user so the run remains observable even if queue workers are down. - * - * @param array $selectionPayload - */ - public function createPendingRunForUser(Tenant $tenant, User $user, array $selectionPayload): InventorySyncRun - { - $computed = $this->normalizeAndHashSelection($selectionPayload); - - return InventorySyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'user_id' => $user->getKey(), - 'selection_hash' => $computed['selection_hash'], - 'selection_payload' => $computed['selection'], - 'status' => InventorySyncRun::STATUS_PENDING, - 'had_errors' => false, - 'error_codes' => [], - 'error_context' => null, - 'started_at' => null, - 'finished_at' => null, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]); - } - - /** - * Executes an existing pending run under locks/concurrency, updating that run to running/skipped/terminal. - */ - /** - * @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed - */ - public function executePendingRun(InventorySyncRun $run, Tenant $tenant, ?callable $onPolicyTypeProcessed = null): InventorySyncRun - { - $computed = $this->normalizeAndHashSelection($run->selection_payload ?? []); - $normalizedSelection = $computed['selection']; - $selectionHash = $computed['selection_hash']; - - $now = CarbonImmutable::now('UTC'); - - $run->update([ - 'tenant_id' => $tenant->getKey(), - 'selection_hash' => $selectionHash, - 'selection_payload' => $normalizedSelection, - 'status' => InventorySyncRun::STATUS_RUNNING, - 'had_errors' => false, - 'error_codes' => [], - 'error_context' => null, - 'started_at' => $now, - 'finished_at' => null, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]); - - $globalSlot = $this->concurrencyLimiter->acquireGlobalSlot(); - if (! $globalSlot instanceof Lock) { - return $this->markExistingRunSkipped( - run: $run, - now: $now, - errorCode: 'concurrency_limit_global', - ); - } - - $tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id); - if (! $tenantSlot instanceof Lock) { - $globalSlot->release(); - - return $this->markExistingRunSkipped( - run: $run, - now: $now, - errorCode: 'concurrency_limit_tenant', - ); - } - - $selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900); - if (! $selectionLock->get()) { - $tenantSlot->release(); - $globalSlot->release(); - - return $this->markExistingRunSkipped( - run: $run, - now: $now, - errorCode: 'lock_contended', - ); - } - - try { - return $this->executeRun($run, $tenant, $normalizedSelection, $onPolicyTypeProcessed); - } finally { - $selectionLock->release(); - $tenantSlot->release(); - $globalSlot->release(); - } - } - - /** - * @param array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool} $normalizedSelection - */ /** * @param array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool} $normalizedSelection * @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed + * @return array{status: string, had_errors: bool, error_codes: list, error_context: array|null, items_observed_count: int, items_upserted_count: int, errors_count: int} */ - private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): InventorySyncRun + private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): array { $observed = 0; $upserted = 0; @@ -349,7 +217,7 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal 'platform' => $typeConfig['platform'] ?? null, 'meta_jsonb' => $meta, 'last_seen_at' => now(), - 'last_seen_run_id' => $run->getKey(), + 'last_seen_operation_run_id' => (int) $operationRun->getKey(), ] ); @@ -368,10 +236,8 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null); } - $status = $hadErrors ? InventorySyncRun::STATUS_PARTIAL : InventorySyncRun::STATUS_SUCCESS; - - $run->update([ - 'status' => $status, + return [ + 'status' => $hadErrors ? 'partial' : 'success', 'had_errors' => $hadErrors, 'error_codes' => array_values(array_unique($errorCodes)), 'error_context' => [ @@ -380,29 +246,39 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal 'items_observed_count' => $observed, 'items_upserted_count' => $upserted, 'errors_count' => $errors, - 'finished_at' => CarbonImmutable::now('UTC'), - ]); - - return $run->refresh(); + ]; } catch (Throwable $throwable) { $errorContext = $this->safeErrorContext($throwable); $errorContext['warnings'] = array_values($warnings); - $run->update([ - 'status' => InventorySyncRun::STATUS_FAILED, + return [ + 'status' => 'failed', 'had_errors' => true, 'error_codes' => ['unexpected_exception'], 'error_context' => $errorContext, 'items_observed_count' => $observed, 'items_upserted_count' => $upserted, 'errors_count' => $errors + 1, - 'finished_at' => CarbonImmutable::now('UTC'), - ]); - - return $run->refresh(); + ]; } } + /** + * @return array{status: string, had_errors: bool, error_codes: list, error_context: array|null, items_observed_count: int, items_upserted_count: int, errors_count: int} + */ + private function skippedResult(string $errorCode): array + { + return [ + 'status' => 'skipped', + 'had_errors' => true, + 'error_codes' => [$errorCode], + 'error_context' => null, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'errors_count' => 0, + ]; + } + private function shouldSkipPolicyForSelectedType(string $selectedPolicyType, array $policyData): bool { $configurationPolicyTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy']; @@ -556,50 +432,6 @@ private function selectionLockKey(Tenant $tenant, string $selectionHash): string return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash); } - /** - * @param array $selectionPayload - */ - private function createSkippedRun( - Tenant $tenant, - string $selectionHash, - array $selectionPayload, - CarbonImmutable $now, - string $errorCode, - ): InventorySyncRun { - return InventorySyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'user_id' => null, - 'selection_hash' => $selectionHash, - 'selection_payload' => $selectionPayload, - 'status' => InventorySyncRun::STATUS_SKIPPED, - 'had_errors' => true, - 'error_codes' => [$errorCode], - 'error_context' => null, - 'started_at' => $now, - 'finished_at' => $now, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]); - } - - private function markExistingRunSkipped(InventorySyncRun $run, CarbonImmutable $now, string $errorCode): InventorySyncRun - { - $run->update([ - 'status' => InventorySyncRun::STATUS_SKIPPED, - 'had_errors' => true, - 'error_codes' => [$errorCode], - 'error_context' => null, - 'started_at' => $run->started_at ?? $now, - 'finished_at' => $now, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]); - - return $run->refresh(); - } - private function mapGraphFailureToErrorCode(GraphResponse $response): string { $status = (int) ($response->status ?? 0); diff --git a/app/Services/OperationRunService.php b/app/Services/OperationRunService.php index e369742..e7596d3 100644 --- a/app/Services/OperationRunService.php +++ b/app/Services/OperationRunService.php @@ -184,6 +184,64 @@ public function ensureRunWithIdentity( } } + public function ensureRunWithIdentityStrict( + Tenant $tenant, + string $type, + array $identityInputs, + array $context, + ?User $initiator = null, + ): OperationRun { + $workspaceId = (int) ($tenant->workspace_id ?? 0); + + if ($workspaceId <= 0) { + throw new InvalidArgumentException('Tenant must belong to a workspace to start an operation run.'); + } + + $hash = $this->calculateHash($tenant->id, $type, $identityInputs); + + $existing = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('workspace_id', $workspaceId) + ->where('type', $type) + ->where('run_identity_hash', $hash) + ->first(); + + if ($existing instanceof OperationRun) { + return $existing; + } + + try { + return OperationRun::create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenant->id, + 'user_id' => $initiator?->id, + 'initiator_name' => $initiator?->name ?? 'System', + 'type' => $type, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'run_identity_hash' => $hash, + 'context' => $context, + ]); + } catch (QueryException $e) { + if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) { + throw $e; + } + + $existing = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('workspace_id', $workspaceId) + ->where('type', $type) + ->where('run_identity_hash', $hash) + ->first(); + + if ($existing instanceof OperationRun) { + return $existing; + } + + throw $e; + } + } + /** * Standardized enqueue helper for bulk operations. * diff --git a/app/Services/Providers/ProviderOperationStartGate.php b/app/Services/Providers/ProviderOperationStartGate.php index a4fc1f8..c5721a8 100644 --- a/app/Services/Providers/ProviderOperationStartGate.php +++ b/app/Services/Providers/ProviderOperationStartGate.php @@ -10,6 +10,7 @@ use App\Support\Providers\ProviderNextStepsRegistry; use App\Support\Providers\ProviderReasonCodes; use App\Support\Verification\BlockedVerificationReportFactory; +use App\Support\Verification\StaleQueuedVerificationReportFactory; use App\Support\Verification\VerificationReportWriter; use Illuminate\Support\Facades\DB; use InvalidArgumentException; @@ -73,8 +74,27 @@ public function start( ->active() ->where('context->provider_connection_id', (int) $lockedConnection->getKey()) ->orderByDesc('id') + ->lockForUpdate() ->first(); + if ($activeRun instanceof OperationRun) { + if ($this->runs->isStaleQueuedRun($activeRun)) { + $this->runs->failStaleQueuedRun($activeRun); + + if ($activeRun->type === 'provider.connection.check') { + VerificationReportWriter::write( + run: $activeRun, + checks: StaleQueuedVerificationReportFactory::checks($activeRun), + identity: StaleQueuedVerificationReportFactory::identity($activeRun), + ); + + $activeRun->refresh(); + } + + $activeRun = null; + } + } + if ($activeRun instanceof OperationRun) { if ($activeRun->type === $operationType) { return ProviderOperationStartResult::deduped($activeRun); diff --git a/app/Support/OperationCatalog.php b/app/Support/OperationCatalog.php index ce8f38e..40af26f 100644 --- a/app/Support/OperationCatalog.php +++ b/app/Support/OperationCatalog.php @@ -30,7 +30,9 @@ public static function labels(): array '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', 'restore.execute' => 'Restore execution', + 'directory_role_definitions.sync' => 'Role definitions sync', 'restore_run.delete' => 'Delete restore runs', 'restore_run.restore' => 'Restore restore runs', 'restore_run.force_delete' => 'Force delete restore runs', diff --git a/app/Support/OperationRunType.php b/app/Support/OperationRunType.php index c2f2467..1268ce5 100644 --- a/app/Support/OperationRunType.php +++ b/app/Support/OperationRunType.php @@ -13,6 +13,8 @@ enum OperationRunType: string case BackupSetRemovePolicies = 'backup_set.remove_policies'; case BackupScheduleRunNow = 'backup_schedule.run_now'; case BackupScheduleRetry = 'backup_schedule.retry'; + case BackupScheduleScheduled = 'backup_schedule.scheduled'; + case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync'; case RestoreExecute = 'restore.execute'; public static function values(): array diff --git a/app/Support/Operations/OperationRunCapabilityResolver.php b/app/Support/Operations/OperationRunCapabilityResolver.php new file mode 100644 index 0000000..2722737 --- /dev/null +++ b/app/Support/Operations/OperationRunCapabilityResolver.php @@ -0,0 +1,30 @@ + 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, + 'restore.execute' => Capabilities::TENANT_MANAGE, + 'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE, + + 'provider.connection.check' => Capabilities::PROVIDER_RUN, + + // Keep legacy / unknown types viewable by membership+entitlement only. + default => null, + }; + } +} diff --git a/app/Support/Providers/ProviderNextStepsRegistry.php b/app/Support/Providers/ProviderNextStepsRegistry.php index a5cebd9..90c27ec 100644 --- a/app/Support/Providers/ProviderNextStepsRegistry.php +++ b/app/Support/Providers/ProviderNextStepsRegistry.php @@ -27,6 +27,10 @@ public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnectio ProviderReasonCodes::ProviderCredentialInvalid, ProviderReasonCodes::ProviderAuthFailed, ProviderReasonCodes::ProviderConsentMissing => [ + [ + 'label' => 'Grant admin consent', + 'url' => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant), + ], [ 'label' => $connection instanceof ProviderConnection ? 'Update Credentials' : 'Manage Provider Connections', 'url' => $connection instanceof ProviderConnection diff --git a/app/Support/Verification/StaleQueuedVerificationReportFactory.php b/app/Support/Verification/StaleQueuedVerificationReportFactory.php new file mode 100644 index 0000000..93fde61 --- /dev/null +++ b/app/Support/Verification/StaleQueuedVerificationReportFactory.php @@ -0,0 +1,90 @@ +> + */ + public static function checks(OperationRun $run): array + { + $context = is_array($run->context ?? null) ? $run->context : []; + + return [[ + 'key' => 'provider.connection.check', + 'title' => 'Provider connection check', + 'status' => 'fail', + 'severity' => 'critical', + 'blocking' => true, + 'reason_code' => 'unknown_error', + 'message' => 'Run was queued but never started. A queue worker may not be running.', + 'evidence' => self::evidence($run, $context), + 'next_steps' => [], + ]]; + } + + /** + * @return array + */ + public static function identity(OperationRun $run): array + { + $context = is_array($run->context ?? null) ? $run->context : []; + + $identity = []; + + $providerConnectionId = $context['provider_connection_id'] ?? null; + if (is_numeric($providerConnectionId)) { + $identity['provider_connection_id'] = (int) $providerConnectionId; + } + + $targetScope = $context['target_scope'] ?? []; + $targetScope = is_array($targetScope) ? $targetScope : []; + + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + $identity['entra_tenant_id'] = trim($entraTenantId); + } + + return $identity; + } + + /** + * @param array $context + * @return array + */ + private static function evidence(OperationRun $run, array $context): array + { + $evidence = []; + + $providerConnectionId = $context['provider_connection_id'] ?? null; + if (is_numeric($providerConnectionId)) { + $evidence[] = [ + 'kind' => 'provider_connection_id', + 'value' => (int) $providerConnectionId, + ]; + } + + $targetScope = $context['target_scope'] ?? []; + $targetScope = is_array($targetScope) ? $targetScope : []; + + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + $evidence[] = [ + 'kind' => 'entra_tenant_id', + 'value' => trim($entraTenantId), + ]; + } + + $evidence[] = [ + 'kind' => 'operation_run_id', + 'value' => (int) $run->getKey(), + ]; + + return $evidence; + } +} diff --git a/config/graph_contracts.php b/config/graph_contracts.php index f698b72..2544b04 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -28,6 +28,11 @@ 'allowed_select' => ['id', 'displayName', 'groupTypes', 'securityEnabled', 'mailEnabled'], 'allowed_expand' => [], ], + 'directoryRoleDefinitions' => [ + 'resource' => 'deviceManagement/roleDefinitions', + 'allowed_select' => ['id', 'displayName', 'isBuiltIn'], + 'allowed_expand' => [], + ], 'managedDevices' => [ 'resource' => 'deviceManagement/managedDevices', 'allowed_select' => ['id', 'complianceState'], diff --git a/database/factories/EntraRoleDefinitionFactory.php b/database/factories/EntraRoleDefinitionFactory.php new file mode 100644 index 0000000..9117c6f --- /dev/null +++ b/database/factories/EntraRoleDefinitionFactory.php @@ -0,0 +1,31 @@ + + */ +class EntraRoleDefinitionFactory extends Factory +{ + protected $model = EntraRoleDefinition::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'entra_id' => fake()->uuid(), + 'display_name' => fake()->jobTitle(), + 'is_built_in' => false, + 'last_seen_at' => now('UTC'), + ]; + } +} 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 new file mode 100644 index 0000000..99ca489 --- /dev/null +++ b/database/migrations/2026_02_10_004939_add_unique_index_for_backup_schedule_scheduled_operation_runs.php @@ -0,0 +1,48 @@ +getDriverName(); + + if (! in_array($driver, ['pgsql', 'sqlite'], true)) { + return; + } + + 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' + SQL); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('operation_runs')) { + return; + } + + $driver = Schema::getConnection()->getDriverName(); + + if (! in_array($driver, ['pgsql', 'sqlite'], true)) { + return; + } + + DB::statement('DROP INDEX IF EXISTS operation_runs_backup_schedule_scheduled_unique'); + } +}; diff --git a/database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php b/database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php new file mode 100644 index 0000000..eba504a --- /dev/null +++ b/database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php @@ -0,0 +1,34 @@ +foreignId('operation_run_id') + ->nullable() + ->constrained('operation_runs') + ->nullOnDelete() + ->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_sync_runs', function (Blueprint $table) { + $table->dropForeign(['operation_run_id']); + $table->dropColumn('operation_run_id'); + }); + } +}; diff --git a/database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php b/database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php new file mode 100644 index 0000000..0959b62 --- /dev/null +++ b/database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php @@ -0,0 +1,34 @@ +foreignId('operation_run_id') + ->nullable() + ->constrained('operation_runs') + ->nullOnDelete() + ->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entra_group_sync_runs', function (Blueprint $table) { + $table->dropForeign(['operation_run_id']); + $table->dropColumn('operation_run_id'); + }); + } +}; diff --git a/database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php b/database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php new file mode 100644 index 0000000..217490b --- /dev/null +++ b/database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php @@ -0,0 +1,34 @@ +foreignId('operation_run_id') + ->nullable() + ->constrained('operation_runs') + ->nullOnDelete() + ->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('backup_schedule_runs', function (Blueprint $table) { + $table->dropForeign(['operation_run_id']); + $table->dropColumn('operation_run_id'); + }); + } +}; diff --git a/database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php b/database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php new file mode 100644 index 0000000..c916c4d --- /dev/null +++ b/database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php @@ -0,0 +1,34 @@ +foreignId('last_seen_operation_run_id') + ->nullable() + ->after('last_seen_run_id') + ->constrained('operation_runs') + ->nullOnDelete(); + + $table->index('last_seen_operation_run_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_items', function (Blueprint $table) { + $table->dropConstrainedForeignId('last_seen_operation_run_id'); + }); + } +}; diff --git a/database/migrations/2026_02_10_115908_add_operation_run_id_to_restore_runs_table.php b/database/migrations/2026_02_10_115908_add_operation_run_id_to_restore_runs_table.php new file mode 100644 index 0000000..271e784 --- /dev/null +++ b/database/migrations/2026_02_10_115908_add_operation_run_id_to_restore_runs_table.php @@ -0,0 +1,33 @@ +foreignId('operation_run_id') + ->nullable() + ->constrained('operation_runs') + ->nullOnDelete() + ->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('restore_runs', function (Blueprint $table) { + $table->dropConstrainedForeignId('operation_run_id'); + }); + } +}; diff --git a/database/migrations/2026_02_10_133238_create_entra_role_definitions_table.php b/database/migrations/2026_02_10_133238_create_entra_role_definitions_table.php new file mode 100644 index 0000000..7b87e2f --- /dev/null +++ b/database/migrations/2026_02_10_133238_create_entra_role_definitions_table.php @@ -0,0 +1,37 @@ +id(); + + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + + $table->uuid('entra_id'); + $table->string('display_name'); + $table->boolean('is_built_in')->default(false); + + $table->timestampTz('last_seen_at')->nullable(); + + $table->timestamps(); + + $table->unique(['tenant_id', 'entra_id']); + $table->index(['tenant_id', 'display_name']); + $table->index(['tenant_id', 'last_seen_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entra_role_definitions'); + } +}; diff --git a/specs/086-retire-legacy-runs-into-operation-runs/tasks.md b/specs/086-retire-legacy-runs-into-operation-runs/tasks.md index 0a86968..d5843e2 100644 --- a/specs/086-retire-legacy-runs-into-operation-runs/tasks.md +++ b/specs/086-retire-legacy-runs-into-operation-runs/tasks.md @@ -12,8 +12,8 @@ # Tasks: Retire Legacy Runs Into Operation Runs (086) ## Phase 1: Setup (Shared Infrastructure) -- [ ] T001 Confirm baseline green test subset via `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/Monitoring/OperationsDbOnlyTest.php`, and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` -- [ ] T002 Confirm Filament v5 + Livewire v4 constraints are respected for any touched pages/resources in `app/Filament/**` + - [x] T001 Confirm baseline green test subset via `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/Monitoring/OperationsDbOnlyTest.php`, and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` +- [x] T002 Confirm Filament v5 + Livewire v4 constraints are respected for any touched pages/resources in `app/Filament/**` --- @@ -21,11 +21,11 @@ ## Phase 2: Foundational (Blocking Prerequisites) **Purpose**: Shared primitives required by all stories. -- [ ] T003 Add centralized “run type → required capability” resolver in `app/Support/Operations/OperationRunCapabilityResolver.php` -- [ ] T004 Update `app/Policies/OperationRunPolicy.php` to enforce clarified 404/403 semantics (non-member 404; member missing capability 403) using T003 -- [ ] T005 [P] Add/extend operation type registry for new types in `app/Support/OperationRunType.php` -- [ ] T006 [P] Add/extend operation labels/catalog entries in `app/Support/OperationCatalog.php` -- [ ] T007 Add tests covering view authorization semantics in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` (404 vs 403 + capability-gated view) +- [x] T003 Add centralized “run type → required capability” resolver in `app/Support/Operations/OperationRunCapabilityResolver.php` +- [x] T004 Update `app/Policies/OperationRunPolicy.php` to enforce clarified 404/403 semantics (non-member 404; member missing capability 403) using T003 +- [x] T005 [P] Add/extend operation type registry for new types in `app/Support/OperationRunType.php` +- [x] T006 [P] Add/extend operation labels/catalog entries in `app/Support/OperationCatalog.php` +- [x] T007 Add tests covering view authorization semantics in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` (404 vs 403 + capability-gated view) **Checkpoint**: Canonical viewer authorization matches spec; new run types exist in registries. @@ -39,24 +39,24 @@ ## Phase 3: User Story 1 — Start an operation with an immediate canonical run ### Tests (US1) -- [ ] T008 [P] [US1] Add/extend tests for OperationRun dispatch-time creation in `tests/Feature/OperationRunServiceTest.php` -- [ ] T009 [P] [US1] Add/extend tests for start-surface authorization (403 prevents run creation) in `tests/Feature/RunStartAuthorizationTest.php` +- [x] T008 [P] [US1] Add/extend tests for OperationRun dispatch-time creation in `tests/Feature/OperationRunServiceTest.php` +- [X] T009 [P] [US1] Add/extend tests for start-surface authorization (403 prevents run creation) in `tests/Feature/RunStartAuthorizationTest.php` ### Implementation (US1) -- [ ] T010 [US1] Ensure inventory sync start surface creates OperationRun before dispatch and uses canonical link in `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php` -- [ ] T011 [US1] Ensure directory groups sync start surface creates OperationRun before dispatch and uses canonical link in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` -- [ ] T012 [US1] Ensure backup schedule manual run-now creates OperationRun before dispatch with unique-per-click identity (nonce) in `app/Filament/Resources/BackupScheduleResource.php` -- [ ] T013 [US1] Ensure backup schedule retry creates OperationRun before dispatch with unique-per-click identity (nonce) in `app/Filament/Resources/BackupScheduleResource.php` -- [ ] T014 [US1] Ensure scheduled backup dispatcher creates OperationRun before dispatch with strict identity by (schedule_id, scheduled_for) and type `backup_schedule.scheduled` in `app/Services/BackupScheduling/BackupScheduleDispatcher.php` -- [ ] T014a [US1] Enforce strict scheduled backup idempotency (at most one canonical run ever per schedule_id + intended fire-time), using an explicit DB constraint and/or lock strategy aligned with `OperationRunService` identities -- [ ] T015 [US1] Enforce “no job fallback-create” by validating required OperationRun context is present; fail fast if missing in `app/Jobs/RunInventorySyncJob.php`, `app/Jobs/EntraGroupSyncJob.php`, and `app/Jobs/RunBackupScheduleJob.php` +- [X] T010 [US1] Ensure inventory sync start surface creates OperationRun before dispatch and uses canonical link in `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php` +- [X] T011 [US1] Ensure directory groups sync start surface creates OperationRun before dispatch and uses canonical link in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` +- [X] T012 [US1] Ensure backup schedule manual run-now creates OperationRun before dispatch with unique-per-click identity (nonce) in `app/Filament/Resources/BackupScheduleResource.php` +- [X] T013 [US1] Ensure backup schedule retry creates OperationRun before dispatch with unique-per-click identity (nonce) in `app/Filament/Resources/BackupScheduleResource.php` +- [x] T014 [US1] Ensure scheduled backup dispatcher creates OperationRun before dispatch with strict identity by (schedule_id, scheduled_for) and type `backup_schedule.scheduled` in `app/Services/BackupScheduling/BackupScheduleDispatcher.php` +- [x] T014a [US1] Enforce strict scheduled backup idempotency (at most one canonical run ever per schedule_id + intended fire-time), using an explicit DB constraint and/or lock strategy aligned with `OperationRunService` identities +- [x] T015 [US1] Enforce “no job fallback-create” by validating required OperationRun context is present; fail fast if missing in `app/Jobs/RunInventorySyncJob.php`, `app/Jobs/EntraGroupSyncJob.php`, and `app/Jobs/RunBackupScheduleJob.php` ### Restore (US1) -- [ ] T015a [P] [US1] Add/extend tests that starting a restore execution creates an OperationRun at dispatch time (target existing restore execution tests under `tests/Feature/RestoreRunWizardExecuteTest.php` and/or `tests/Feature/ExecuteRestoreRunJobTest.php`) -- [ ] T015b [US1] Ensure the restore execution start surface creates OperationRun before dispatch and surfaces the stable canonical “View run” link (adjust the Filament restore execution action/page used in the wizard flow) -- [ ] T015c [US2] Ensure restore domain records link to canonical OperationRuns for observability (align with FR-014; no legacy fallback-create) +- [X] T015a [P] [US1] Add/extend tests that starting a restore execution creates an OperationRun at dispatch time (target existing restore execution tests under `tests/Feature/RestoreRunWizardExecuteTest.php` and/or `tests/Feature/ExecuteRestoreRunJobTest.php`) +- [X] T015b [US1] Ensure the restore execution start surface creates OperationRun before dispatch and surfaces the stable canonical “View run” link (adjust the Filament restore execution action/page used in the wizard flow) +- [x] T015c [US2] Ensure restore domain records link to canonical OperationRuns for observability (align with FR-014; no legacy fallback-create) **Checkpoint**: Starting operations always yields a stable `/admin/operations/{run}` link immediately. @@ -70,20 +70,20 @@ ## Phase 4: User Story 2 — Monitor executions from a single canonical viewer ( ### Tests (US2) -- [ ] T016 [P] [US2] Add tests asserting Monitoring pages render DB-only (no Graph calls) in `tests/Feature/Monitoring/MonitoringOperationsTest.php` -- [ ] T017 [P] [US2] Add tests for legacy-to-canonical redirect when mapping exists and no redirect when mapping absent in `tests/Feature/Operations/` (new file: `tests/Feature/Operations/LegacyRunRedirectTest.php`) +- [X] T016 [P] [US2] Add tests asserting Monitoring pages render DB-only (no Graph calls) in `tests/Feature/Monitoring/MonitoringOperationsTest.php` +- [X] T017 [P] [US2] Add tests for legacy-to-canonical redirect when mapping exists and no redirect when mapping absent in `tests/Feature/Operations/` (new file: `tests/Feature/Operations/LegacyRunRedirectTest.php`) ### Implementation (US2) -- [ ] T018 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `inventory_sync_runs` (new migration in `database/migrations/**_add_operation_run_id_to_inventory_sync_runs_table.php`) -- [ ] T019 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `entra_group_sync_runs` (new migration in `database/migrations/**_add_operation_run_id_to_entra_group_sync_runs_table.php`) -- [ ] T020 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `backup_schedule_runs` (new migration in `database/migrations/**_add_operation_run_id_to_backup_schedule_runs_table.php`) -- [ ] T021 [US2] Stop writing NEW legacy run rows for inventory sync and use `operation_runs` only for execution tracking (adjust service + callers in `app/Services/Inventory/InventorySyncService.php` and any start surfaces) -- [ ] T022 [US2] Stop writing NEW legacy run rows for directory group sync and use `operation_runs` only for execution tracking (adjust service + callers in `app/Services/Directory/EntraGroupSyncService.php` and any start surfaces) -- [ ] T023 [US2] Stop writing NEW legacy run rows for backup schedule executions and use `operation_runs` only for execution tracking; keep legacy table strictly read-only history for existing rows (adjust dispatcher and UI surfaces in `app/Services/BackupScheduling/BackupScheduleDispatcher.php` and `app/Filament/Resources/BackupScheduleResource.php`) -- [ ] T023a [US2] Update Backup Schedule UI to show new executions from `operation_runs` (query by type + context like schedule_id) and link to canonical viewer; legacy runs list remains history-only -- [ ] T024 [US2] Implement deterministic redirect on legacy “view” pages when `operation_run_id` exists in `app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php` and `app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php` -- [ ] T025 [US2] Ensure legacy run history pages remain strictly read-only (remove/disable start/retry actions) in `app/Filament/Resources/InventorySyncRunResource.php`, `app/Filament/Resources/EntraGroupSyncRunResource.php`, and `app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php` +- [X] T018 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `inventory_sync_runs` (new migration in `database/migrations/**_add_operation_run_id_to_inventory_sync_runs_table.php`) +- [X] T019 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `entra_group_sync_runs` (new migration in `database/migrations/**_add_operation_run_id_to_entra_group_sync_runs_table.php`) +- [X] T020 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `backup_schedule_runs` (new migration in `database/migrations/**_add_operation_run_id_to_backup_schedule_runs_table.php`) +- [x] T021 [US2] Stop writing NEW legacy run rows for inventory sync and use `operation_runs` only for execution tracking (adjust service + callers in `app/Services/Inventory/InventorySyncService.php` and any start surfaces) +- [x] T022 [US2] Stop writing NEW legacy run rows for directory group sync and use `operation_runs` only for execution tracking (adjust service + callers in `app/Services/Directory/EntraGroupSyncService.php` and any start surfaces) +- [x] T023 [US2] Stop writing NEW legacy run rows for backup schedule executions and use `operation_runs` only for execution tracking; keep legacy table strictly read-only history for existing rows (adjust dispatcher and UI surfaces in `app/Services/BackupScheduling/BackupScheduleDispatcher.php` and `app/Filament/Resources/BackupScheduleResource.php`) +- [x] T023a [US2] Update Backup Schedule UI to show new executions from `operation_runs` (query by type + context like schedule_id) and link to canonical viewer; legacy runs list remains history-only +- [X] T024 [US2] Implement deterministic redirect on legacy “view” pages when `operation_run_id` exists in `app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php` and `app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php` +- [x] T025 [US2] Ensure legacy run history pages remain strictly read-only (remove/disable start/retry actions) in `app/Filament/Resources/InventorySyncRunResource.php`, `app/Filament/Resources/EntraGroupSyncRunResource.php`, and `app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php` **Checkpoint**: Canonical viewer is the only execution-tracker UI; legacy is view-only and redirects only when mapped. @@ -97,18 +97,18 @@ ## Phase 5: User Story 3 — Use cached directory data in forms without blocking ### Tests (US3) -- [ ] T026 [P] [US3] Add tests that TenantResource role definition selectors render/search DB-only (no Graph calls) in `tests/Feature/Filament/` (new file: `tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php`) -- [ ] T027 [P] [US3] Add tests that “Sync now” creates an OperationRun and returns a canonical view link in `tests/Feature/DirectoryGroups/` or `tests/Feature/TenantRBAC/` (choose closest existing folder) -- [ ] T027a [P] [US3] Add tests that directory group selectors render/search DB-only (no Graph calls) and use cached DB tables (new file under `tests/Feature/DirectoryGroups/` or `tests/Feature/Filament/`) +- [x] T026 [P] [US3] Add tests that TenantResource role definition selectors render/search DB-only (no Graph calls) in `tests/Feature/Filament/` (new file: `tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php`) +- [x] T027 [P] [US3] Add tests that “Sync now” creates an OperationRun and returns a canonical view link in `tests/Feature/DirectoryGroups/` or `tests/Feature/TenantRBAC/` (choose closest existing folder) +- [x] T027a [P] [US3] Add tests that directory group selectors render/search DB-only (no Graph calls) and use cached DB tables (new file under `tests/Feature/DirectoryGroups/` or `tests/Feature/Filament/`) ### Implementation (US3) -- [ ] T028 [US3] Create cached role definitions table + model + factory (new migration in `database/migrations/**_create_entra_role_definitions_table.php`, model in `app/Models/EntraRoleDefinition.php`, factory in `database/factories/EntraRoleDefinitionFactory.php`) -- [ ] T029 [US3] Add “role definitions sync” operation type `directory_role_definitions.sync` to `app/Support/OperationRunType.php` and label in `app/Support/OperationCatalog.php` (if not already completed in T005/T006) -- [ ] T030 [US3] Implement role definitions sync service + job that updates the cache and records progress/failures on the OperationRun (service in `app/Services/Directory/RoleDefinitionsSyncService.php`, job in `app/Jobs/SyncRoleDefinitionsJob.php`) -- [ ] T030a [US3] Register/verify Graph contract entries required for role definitions sync in `config/graph_contracts.php` and ensure the sync uses `GraphClientInterface` only (no ad-hoc endpoints) -- [ ] T031 [US3] Update `app/Filament/Resources/TenantResource.php` roleDefinitions search/label callbacks to query cached DB tables only (remove Graph calls from callbacks) -- [ ] T032 [US3] Add a non-destructive “Sync now” Filament action that dispatches `directory_role_definitions.sync` and provides a canonical “View run” link (in `app/Filament/Resources/TenantResource.php`) +- [x] T028 [US3] Create cached role definitions table + model + factory (new migration in `database/migrations/**_create_entra_role_definitions_table.php`, model in `app/Models/EntraRoleDefinition.php`, factory in `database/factories/EntraRoleDefinitionFactory.php`) +- [x] T029 [US3] Add “role definitions sync” operation type `directory_role_definitions.sync` to `app/Support/OperationRunType.php` and label in `app/Support/OperationCatalog.php` (if not already completed in T005/T006) +- [x] T030 [US3] Implement role definitions sync service + job that updates the cache and records progress/failures on the OperationRun (service in `app/Services/Directory/RoleDefinitionsSyncService.php`, job in `app/Jobs/SyncRoleDefinitionsJob.php`) +- [x] T030a [US3] Register/verify Graph contract entries required for role definitions sync in `config/graph_contracts.php` and ensure the sync uses `GraphClientInterface` only (no ad-hoc endpoints) +- [x] T031 [US3] Update `app/Filament/Resources/TenantResource.php` roleDefinitions search/label callbacks to query cached DB tables only (remove Graph calls from callbacks) +- [x] T032 [US3] Add a non-destructive “Sync now” Filament action that dispatches `directory_role_definitions.sync` and provides a canonical “View run” link (in `app/Filament/Resources/TenantResource.php`) **Checkpoint**: Tenant configuration selectors are DB-only; cache sync is async and observable via canonical run. @@ -116,9 +116,11 @@ ### Implementation (US3) ## Phase 6: Polish & Cross-Cutting Concerns -- [ ] T033 Ensure new/modified destructive-like actions (if any) use `Action::make(...)->action(...)->requiresConfirmation()` and are authorized server-side (audit existing touched Filament actions under `app/Filament/**`) -- [ ] T034 Run Pint on changed files via `vendor/bin/sail bin pint --dirty` -- [ ] T035 Run targeted test subset per quickstart: `vendor/bin/sail artisan test --compact --filter=OperationRun` and the new/changed test files +- [x] T033 Ensure new/modified destructive-like actions (if any) use `Action::make(...)->action(...)->requiresConfirmation()` and are authorized server-side (audit existing touched Filament actions under `app/Filament/**`) +- [x] T034 Run Pint on changed files via `vendor/bin/sail bin pint --dirty` +- [x] T035 Run targeted test subset per quickstart: `vendor/bin/sail artisan test --compact --filter=OperationRun` and the new/changed test files +- [x] T036 Allow re-running onboarding verification while status is `in_progress` (prevents dead-end when a prior run is stuck and the current connection would immediately block with next steps) in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [x] T037 Auto-fail stale queued provider operation runs to allow rerun (prevents permanent dedupe when a worker isn’t running) in `app/Services/Providers/ProviderOperationStartGate.php` --- diff --git a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php index 8df6cd6..c2bb534 100644 --- a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php +++ b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php @@ -2,8 +2,9 @@ use App\Jobs\RunBackupScheduleJob; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; +use App\Models\OperationRun; use App\Services\BackupScheduling\BackupScheduleDispatcher; +use App\Services\OperationRunService; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Bus; @@ -34,12 +35,24 @@ $dispatcher->dispatchDue([$tenant->external_id]); $dispatcher->dispatchDue([$tenant->external_id]); - expect(BackupScheduleRun::query()->count())->toBe(1); + expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'backup_schedule.scheduled') + ->count())->toBe(1); Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1); + + Bus::assertDispatched(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($tenant): bool { + return $job->backupScheduleId !== null + && $job->backupScheduleRunId === 0 + && $job->operationRun?->tenant_id === $tenant->getKey() + && $job->operationRun?->type === 'backup_schedule.scheduled'; + }); }); -it('treats a unique constraint collision as already-dispatched and advances next_run_at', function () { +it('treats an existing canonical run as already-dispatched and advances next_run_at', function () { CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -59,22 +72,36 @@ 'next_run_at' => null, ]); - BackupScheduleRun::query()->create([ - 'backup_schedule_id' => $schedule->id, - 'tenant_id' => $tenant->id, - 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + + $operationRunService->ensureRunWithIdentityStrict( + tenant: $tenant, + type: 'backup_schedule.scheduled', + identityInputs: [ + 'backup_schedule_id' => (int) $schedule->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), + ], + context: [ + 'backup_schedule_id' => (int) $schedule->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), + 'trigger' => 'scheduled', + ], + ); Bus::fake(); $dispatcher = app(BackupScheduleDispatcher::class); $dispatcher->dispatchDue([$tenant->external_id]); - expect(BackupScheduleRun::query()->count())->toBe(1); + 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') + ->count())->toBe(1); + $schedule->refresh(); expect($schedule->next_run_at)->not->toBeNull(); expect($schedule->next_run_at->toDateTimeString())->toBe('2026-01-06 10:00:00'); diff --git a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php index f0b43de..cfbdbd3 100644 --- a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php +++ b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php @@ -9,6 +9,7 @@ use App\Services\OperationRunService; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Cache; +use Illuminate\Contracts\Queue\Job; it('creates a backup set and marks the run successful', function () { CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); @@ -160,7 +161,7 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ]); }); -it('updates the operation run based on the backup schedule run id when not passed into the job', function () { +it('fails fast when operation run context is not passed into the job', function () { CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -187,50 +188,13 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, 'status' => BackupScheduleRun::STATUS_RUNNING, ]); - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - $operationRun = $operationRunService->ensureRun( - tenant: $tenant, - type: 'backup_schedule.run_now', - inputs: ['backup_schedule_id' => (int) $schedule->id], - initiator: $user, - ); + $queueJob = \Mockery::mock(Job::class); + $queueJob->shouldReceive('fail')->once(); - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); + $job = new RunBackupScheduleJob($run->id); + $job->setJob($queueJob); - app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService - { - public function __construct() {} - - public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array - { - return ['synced' => [], 'failures' => []]; - } - }); - - $backupSet = BackupSet::factory()->create([ - 'tenant_id' => $tenant->id, - 'status' => 'completed', - 'item_count' => 0, - ]); - - app()->bind(BackupService::class, fn () => new class($backupSet) extends BackupService - { - public function __construct(private readonly BackupSet $backupSet) {} - - public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet - { - return $this->backupSet; - } - }); - - Cache::flush(); - - (new RunBackupScheduleJob($run->id))->handle( + $job->handle( app(PolicySyncService::class), app(BackupService::class), app(\App\Services\BackupScheduling\PolicyTypeResolver::class), @@ -239,14 +203,6 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, app(\App\Services\BackupScheduling\RunErrorMapper::class), ); - $operationRun->refresh(); - expect($operationRun->status)->toBe('completed'); - expect($operationRun->outcome)->toBe('succeeded'); - expect($operationRun->context)->toMatchArray([ - 'backup_schedule_run_id' => (int) $run->id, - 'backup_set_id' => (int) $backupSet->id, - ]); - expect($operationRun->summary_counts)->toMatchArray([ - 'created' => 1, - ]); + $run->refresh(); + expect($run->status)->toBe(BackupScheduleRun::STATUS_RUNNING); }); diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php index d5c8d5c..1f18b97 100644 --- a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php +++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -3,11 +3,11 @@ use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules; use App\Jobs\RunBackupScheduleJob; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; use App\Models\OperationRun; use App\Models\User; use App\Notifications\OperationRunQueued; use App\Services\Graph\GraphClientInterface; +use App\Services\OperationRunService; use App\Support\OperationRunLinks; use Carbon\CarbonImmutable; use Filament\Facades\Filament; @@ -49,12 +49,8 @@ Livewire::test(ListBackupSchedules::class) ->callTableAction('runNow', $schedule); - expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) - ->toBe(1); - - $run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first(); - expect($run)->not->toBeNull(); - expect($run->user_id)->toBe($user->id); + expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(0); $operationRun = OperationRun::query() ->where('tenant_id', $tenant->id) @@ -62,13 +58,15 @@ ->first(); expect($operationRun)->not->toBeNull(); + expect($operationRun->user_id)->toBe($user->id); expect($operationRun->context)->toMatchArray([ 'backup_schedule_id' => (int) $schedule->id, - 'backup_schedule_run_id' => (int) $run->id, + 'trigger' => 'run_now', ]); - Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool { - return $job->backupScheduleRunId === (int) $run->id + Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($schedule, $operationRun): bool { + return $job->backupScheduleRunId === 0 + && $job->backupScheduleId === (int) $schedule->getKey() && $job->operationRun instanceof OperationRun && $job->operationRun->is($operationRun); }); @@ -88,6 +86,49 @@ ->toBe(OperationRunLinks::view($operationRun, $tenant)); }); +test('run now is unique per click (no dedupe)', function () { + Queue::fake([RunBackupScheduleJob::class]); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $schedule = BackupSchedule::query()->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, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableAction('runNow', $schedule); + + 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') + ->pluck('id') + ->all(); + + expect($runs)->toHaveCount(2); + expect($runs[0])->not->toBe($runs[1]); + + Queue::assertPushed(RunBackupScheduleJob::class, 2); + $this->assertDatabaseCount('notifications', 2); +}); + test('operator can retry and it persists a database notification', function () { Queue::fake([RunBackupScheduleJob::class]); @@ -112,12 +153,8 @@ Livewire::test(ListBackupSchedules::class) ->callTableAction('retry', $schedule); - expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) - ->toBe(1); - - $run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first(); - expect($run)->not->toBeNull(); - expect($run->user_id)->toBe($user->id); + expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(0); $operationRun = OperationRun::query() ->where('tenant_id', $tenant->id) @@ -125,13 +162,15 @@ ->first(); expect($operationRun)->not->toBeNull(); + expect($operationRun->user_id)->toBe($user->id); expect($operationRun->context)->toMatchArray([ 'backup_schedule_id' => (int) $schedule->id, - 'backup_schedule_run_id' => (int) $run->id, + 'trigger' => 'retry', ]); - Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool { - return $job->backupScheduleRunId === (int) $run->id + Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($schedule, $operationRun): bool { + return $job->backupScheduleRunId === 0 + && $job->backupScheduleId === (int) $schedule->getKey() && $job->operationRun instanceof OperationRun && $job->operationRun->is($operationRun); }); @@ -150,6 +189,49 @@ ->toBe(OperationRunLinks::view($operationRun, $tenant)); }); +test('retry is unique per click (no dedupe)', function () { + Queue::fake([RunBackupScheduleJob::class]); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $schedule = BackupSchedule::query()->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, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableAction('retry', $schedule); + + 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') + ->pluck('id') + ->all(); + + expect($runs)->toHaveCount(2); + expect($runs[0])->not->toBe($runs[1]); + + Queue::assertPushed(RunBackupScheduleJob::class, 2); + $this->assertDatabaseCount('notifications', 2); +}); + test('readonly cannot dispatch run now or retry', function () { [$user, $tenant] = createUserWithTenant(role: 'readonly'); @@ -183,7 +265,7 @@ // Action should be hidden/blocked for readonly users. } - expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) ->toBe(0); expect(OperationRun::query() @@ -230,11 +312,8 @@ Livewire::test(ListBackupSchedules::class) ->callTableBulkAction('bulk_run_now', collect([$scheduleA, $scheduleB])); - expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) - ->toBe(2); - - expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id); - expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id); + expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) + ->toBe(0); expect(OperationRun::query() ->where('tenant_id', $tenant->id) @@ -242,6 +321,15 @@ ->count()) ->toBe(2); + expect(OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_schedule.run_now') + ->pluck('user_id') + ->unique() + ->values() + ->all()) + ->toBe([$user->id]); + Queue::assertPushed(RunBackupScheduleJob::class, 2); $this->assertDatabaseCount('notifications', 1); $this->assertDatabaseHas('notifications', [ @@ -293,11 +381,8 @@ Livewire::test(ListBackupSchedules::class) ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); - expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) - ->toBe(2); - - expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id); - expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id); + expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) + ->toBe(0); expect(OperationRun::query() ->where('tenant_id', $tenant->id) @@ -305,6 +390,15 @@ ->count()) ->toBe(2); + expect(OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_schedule.retry') + ->pluck('user_id') + ->unique() + ->values() + ->all()) + ->toBe([$user->id]); + Queue::assertPushed(RunBackupScheduleJob::class, 2); $this->assertDatabaseCount('notifications', 1); $this->assertDatabaseHas('notifications', [ @@ -319,66 +413,75 @@ ->toBe(OperationRunLinks::index($tenant)); }); -test('operator can bulk retry even if a run already exists for this minute', function () { +test('operator can bulk retry even if a previous canonical run exists', function () { Queue::fake([RunBackupScheduleJob::class]); - [$user, $tenant] = createUserWithTenant(role: 'operator'); + $frozenNow = CarbonImmutable::parse('2026-02-10 01:04:06', 'UTC'); + CarbonImmutable::setTestNow($frozenNow); - $scheduleA = BackupSchedule::query()->create([ - 'tenant_id' => $tenant->id, - 'name' => 'Nightly A', - '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, - ]); + try { + [$user, $tenant] = createUserWithTenant(role: 'operator'); - $scheduleB = BackupSchedule::query()->create([ - 'tenant_id' => $tenant->id, - 'name' => 'Nightly B', - 'is_enabled' => true, - 'timezone' => 'UTC', - 'frequency' => 'daily', - 'time_of_day' => '02:00:00', - 'days_of_week' => null, - 'policy_types' => ['deviceConfiguration'], - 'include_foundations' => true, - 'retention_keep_last' => 30, - ]); + $scheduleA = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly A', + '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, + ]); - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - BackupScheduleRun::query()->create([ - 'backup_schedule_id' => $scheduleA->id, - 'tenant_id' => $tenant->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); + $scheduleB = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly B', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); - $this->actingAs($user); - Filament::setTenant($tenant, true); + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + $existing = $operationRunService->ensureRunWithIdentity( + tenant: $tenant, + type: 'backup_schedule.retry', + identityInputs: [ + 'backup_schedule_id' => (int) $scheduleA->getKey(), + 'nonce' => 'existing', + ], + context: [ + 'backup_schedule_id' => (int) $scheduleA->getKey(), + 'trigger' => 'retry', + ], + initiator: $user, + ); + $operationRunService->updateRun($existing, status: 'completed', outcome: 'succeeded'); - Livewire::test(ListBackupSchedules::class) - ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); + $this->actingAs($user); + Filament::setTenant($tenant, true); - expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->count()) - ->toBe(2); + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); - $newRunA = BackupScheduleRun::query() - ->where('backup_schedule_id', $scheduleA->id) - ->orderByDesc('id') - ->first(); + expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) + ->toBe(0); - expect($newRunA)->not->toBeNull(); - expect($newRunA->scheduled_for->setTimezone('UTC')->toDateTimeString()) - ->toBe($scheduledFor->addMinute()->toDateTimeString()); + expect(OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_schedule.retry') + ->count()) + ->toBe(3); - expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->count()) - ->toBe(1); - - Queue::assertPushed(RunBackupScheduleJob::class, 2); + Queue::assertPushed(RunBackupScheduleJob::class, 2); + } finally { + CarbonImmutable::setTestNow(); + } }); diff --git a/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php b/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php index 82141b2..80994fc 100644 --- a/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php +++ b/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php @@ -48,7 +48,7 @@ $this->actingAs($this->user); - $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + $response = $this->get(route('filament.tenant.resources.policy-versions.view', array_merge( filamentTenantRouteParams($this->tenant), ['record' => $version], ))); diff --git a/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php b/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php index 7517372..150c6e4 100644 --- a/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php +++ b/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php @@ -1,7 +1,7 @@ where('tenant_id', $tenant->getKey()) + ->count(); + Artisan::call('tenantpilot:directory-groups:dispatch', [ '--tenant' => [$tenant->tenant_id], ]); $slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z'; - $run = EntraGroupSyncRun::query() + $legacyCountAfter = \App\Models\EntraGroupSyncRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', 'groups-v1:all') - ->where('slot_key', $slotKey) + ->count(); + + expect($legacyCountAfter)->toBe($legacyCountBefore); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'directory_groups.sync') + ->where('context->slot_key', $slotKey) ->first(); - expect($run)->not->toBeNull() - ->and($run->initiator_user_id)->toBeNull(); + expect($opRun)->not->toBeNull(); + expect($opRun?->user_id)->toBeNull(); - Queue::assertPushed(EntraGroupSyncJob::class); + Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($opRun): bool { + return (int) ($job->operationRun?->getKey() ?? 0) === (int) $opRun->getKey(); + }); CarbonImmutable::setTestNow(); }); diff --git a/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php b/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php index aff8996..fd14735 100644 --- a/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php +++ b/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php @@ -2,7 +2,6 @@ use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups; use App\Jobs\EntraGroupSyncJob; -use App\Models\EntraGroupSyncRun; use App\Models\OperationRun; use App\Services\Graph\GraphClientInterface; use Filament\Facades\Filament; @@ -27,17 +26,18 @@ $tenant->makeCurrent(); Filament::setTenant($tenant, true); + $legacyCountBefore = \App\Models\EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->count(); + Livewire::test(ListEntraGroups::class) ->callAction('sync_groups'); - $run = EntraGroupSyncRun::query() + $legacyCountAfter = \App\Models\EntraGroupSyncRun::query() ->where('tenant_id', $tenant->getKey()) - ->latest('id') - ->first(); + ->count(); - expect($run)->not->toBeNull(); - expect($run?->status)->toBe(EntraGroupSyncRun::STATUS_PENDING); - expect($run?->selection_key)->toBe('groups-v1:all'); + expect($legacyCountAfter)->toBe($legacyCountBefore); $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) @@ -47,10 +47,12 @@ expect($opRun)->not->toBeNull(); expect($opRun?->status)->toBe('queued'); + expect($opRun?->context['selection_key'] ?? null)->toBe('groups-v1:all'); - Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $run, $opRun): bool { + Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $opRun): bool { return $job->tenantId === (int) $tenant->getKey() - && $job->runId === (int) $run?->getKey() + && $job->selectionKey === 'groups-v1:all' + && $job->runId === null && $job->operationRun instanceof OperationRun && (int) $job->operationRun->getKey() === (int) $opRun?->getKey(); }); diff --git a/tests/Feature/DirectoryGroups/StartSyncTest.php b/tests/Feature/DirectoryGroups/StartSyncTest.php index 982ea0a..ca20948 100644 --- a/tests/Feature/DirectoryGroups/StartSyncTest.php +++ b/tests/Feature/DirectoryGroups/StartSyncTest.php @@ -1,7 +1,7 @@ startManualSync($tenant, $user); - expect($run)->toBeInstanceOf(EntraGroupSyncRun::class) + expect($run)->toBeInstanceOf(OperationRun::class) ->and($run->tenant_id)->toBe($tenant->getKey()) - ->and($run->initiator_user_id)->toBe($user->getKey()) - ->and($run->selection_key)->toBe('groups-v1:all') - ->and($run->status)->toBe(EntraGroupSyncRun::STATUS_PENDING); + ->and($run->user_id)->toBe($user->getKey()) + ->and($run->type)->toBe('directory_groups.sync') + ->and($run->status)->toBe('queued') + ->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all'); Queue::assertPushed(EntraGroupSyncJob::class); }); diff --git a/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php b/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php index df3f1d8..589c644 100644 --- a/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php +++ b/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php @@ -2,23 +2,16 @@ use App\Jobs\EntraGroupSyncJob; use App\Models\EntraGroup; -use App\Models\EntraGroupSyncRun; use App\Services\Directory\EntraGroupSyncService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\AuditLogger; +use App\Services\OperationRunService; it('sync job upserts groups and updates run counters', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); - $run = EntraGroupSyncRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => 'groups-v1:all', - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - EntraGroup::factory()->create([ 'tenant_id' => $tenant->getKey(), 'entra_id' => '11111111-1111-1111-1111-111111111111', @@ -57,23 +50,32 @@ app()->instance(GraphClientInterface::class, $mock); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'directory_groups.sync', + inputs: ['selection_key' => 'groups-v1:all'], + initiator: $user, + ); + $job = new EntraGroupSyncJob( tenantId: (int) $tenant->getKey(), selectionKey: 'groups-v1:all', slotKey: null, - runId: (int) $run->getKey(), + runId: null, + operationRun: $opRun, ); $job->handle(app(EntraGroupSyncService::class), app(AuditLogger::class)); - $run->refresh(); + $opRun->refresh(); - expect($run->status)->toBe(EntraGroupSyncRun::STATUS_SUCCEEDED) - ->and($run->pages_fetched)->toBe(2) - ->and($run->items_observed_count)->toBe(2) - ->and($run->items_upserted_count)->toBe(2) - ->and($run->error_count)->toBe(0) - ->and($run->finished_at)->not->toBeNull(); + expect($opRun->status)->toBe('completed') + ->and($opRun->outcome)->toBe('succeeded') + ->and($opRun->summary_counts['processed'] ?? null)->toBe(2) + ->and($opRun->summary_counts['updated'] ?? null)->toBe(2) + ->and($opRun->summary_counts['failed'] ?? null)->toBe(0); expect(EntraGroup::query()->where('tenant_id', $tenant->getKey())->count())->toBe(2); diff --git a/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php b/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php index da21325..d1b243b 100644 --- a/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php +++ b/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php @@ -2,11 +2,11 @@ use App\Jobs\EntraGroupSyncJob; use App\Models\EntraGroup; -use App\Models\EntraGroupSyncRun; use App\Services\Directory\EntraGroupSyncService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\AuditLogger; +use App\Services\OperationRunService; use Illuminate\Support\Facades\Config; it('purges cached groups older than the retention window', function () { @@ -24,24 +24,27 @@ 'last_seen_at' => now('UTC')->subDays(10), ]); - $run = EntraGroupSyncRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => 'groups-v1:all', - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - $mock = \Mockery::mock(GraphClientInterface::class); $mock->shouldReceive('request') ->once() ->andReturn(new GraphResponse(success: true, data: ['value' => []], status: 200)); app()->instance(GraphClientInterface::class, $mock); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'directory_groups.sync', + inputs: ['selection_key' => 'groups-v1:all'], + initiator: $user, + ); + $job = new EntraGroupSyncJob( tenantId: (int) $tenant->getKey(), selectionKey: 'groups-v1:all', slotKey: null, - runId: (int) $run->getKey(), + runId: null, + operationRun: $opRun, ); $job->handle(app(EntraGroupSyncService::class), app(AuditLogger::class)); diff --git a/tests/Feature/DirectoryGroups/TenantGroupSelectorsDbOnlyTest.php b/tests/Feature/DirectoryGroups/TenantGroupSelectorsDbOnlyTest.php new file mode 100644 index 0000000..3369d3b --- /dev/null +++ b/tests/Feature/DirectoryGroups/TenantGroupSelectorsDbOnlyTest.php @@ -0,0 +1,48 @@ +create(); + + EntraGroup::factory() + ->for($tenant) + ->create([ + 'entra_id' => '33333333-3333-3333-3333-333333333333', + 'display_name' => 'TenantPilot Operators', + ]); + + $options = assertNoOutboundHttp(fn () => TenantResource::groupSearchOptions($tenant, 'Ten')); + + expect($options)->toMatchArray([ + '33333333-3333-3333-3333-333333333333' => 'TenantPilot Operators (…33333333)', + ]); +}); + +it('resolves a directory group label from cached data without Graph calls', function (): void { + bindFailHardGraphClient(); + + /** @var Tenant $tenant */ + $tenant = Tenant::factory()->create(); + + EntraGroup::factory() + ->for($tenant) + ->create([ + 'entra_id' => '44444444-4444-4444-4444-444444444444', + 'display_name' => 'TenantPilot Admins', + ]); + + $label = assertNoOutboundHttp(fn () => TenantResource::groupLabelFromCache($tenant, '44444444-4444-4444-4444-444444444444')); + + expect($label)->toBe('TenantPilot Admins (…44444444)'); +}); diff --git a/tests/Feature/ExecuteRestoreRunJobTest.php b/tests/Feature/ExecuteRestoreRunJobTest.php index c6dc634..b6b2392 100644 --- a/tests/Feature/ExecuteRestoreRunJobTest.php +++ b/tests/Feature/ExecuteRestoreRunJobTest.php @@ -8,6 +8,7 @@ use App\Models\Tenant; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreService; +use App\Services\OperationRunService; use App\Support\RestoreRunStatus; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery\MockInterface; @@ -15,7 +16,7 @@ uses(RefreshDatabase::class); test('execute restore run job moves queued to running and calls the executor', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-1', 'name' => 'Tenant One', 'metadata' => [], @@ -60,7 +61,18 @@ }); }); - $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); + $operationRun = app(OperationRunService::class)->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'is_dry_run' => false, + ], + initiator: null, + ); + + $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun); $job->handle($restoreService, app(AuditLogger::class)); $restoreRun->refresh(); @@ -70,7 +82,7 @@ }); test('execute restore run job persists per-item outcomes keyed by backup_item_id', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-results', 'name' => 'Tenant Results', 'metadata' => [], @@ -116,8 +128,43 @@ 'metadata' => [], ]); - $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); - $job->handle(app(RestoreService::class), app(AuditLogger::class)); + $restoreService = $this->mock(RestoreService::class, function (MockInterface $mock) use ($backupItem): void { + $mock->shouldReceive('executeForRun') + ->once() + ->andReturnUsing(function (...$args) use ($backupItem): RestoreRun { + /** @var RestoreRun $run */ + $run = $args[0]; + + $run->update([ + 'status' => RestoreRunStatus::Completed->value, + 'completed_at' => now(), + 'results' => [ + 'items' => [ + (string) $backupItem->id => [ + 'backup_item_id' => $backupItem->id, + 'status' => 'skipped', + ], + ], + ], + ]); + + return $run->refresh(); + }); + }); + + $operationRun = app(OperationRunService::class)->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'is_dry_run' => false, + ], + initiator: null, + ); + + $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun); + $job->handle($restoreService, app(AuditLogger::class)); $restoreRun->refresh(); diff --git a/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php index 341f1c1..6c13c71 100644 --- a/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php +++ b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php @@ -2,18 +2,14 @@ use App\Filament\Resources\EntraGroupSyncRunResource; use App\Filament\Resources\EntraGroupSyncRunResource\Pages\ListEntraGroupSyncRuns; -use App\Jobs\EntraGroupSyncJob; use App\Models\EntraGroupSyncRun; use App\Models\Tenant; use App\Models\User; -use App\Notifications\RunStatusChangedNotification; -use App\Support\Auth\UiTooltips; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; -use Symfony\Component\HttpKernel\Exception\HttpException; uses(RefreshDatabase::class); @@ -69,7 +65,7 @@ ->assertForbidden(); }); -test('sync groups action enqueues job and writes database notification', function () { +test('legacy sync runs list is read-only (no sync action)', function () { Queue::fake(); [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -79,76 +75,10 @@ $tenant->makeCurrent(); Filament::setTenant($tenant, true); - Livewire::test(ListEntraGroupSyncRuns::class) - ->callAction('sync_groups'); - - Queue::assertPushed(EntraGroupSyncJob::class); - - $run = EntraGroupSyncRun::query()->where('tenant_id', $tenant->getKey())->latest('id')->first(); - expect($run)->not->toBeNull(); - - $this->assertDatabaseHas('notifications', [ - 'notifiable_id' => $user->getKey(), - 'notifiable_type' => $user->getMorphClass(), - 'type' => RunStatusChangedNotification::class, - ]); - - $notification = $user->notifications()->latest('id')->first(); - expect($notification)->not->toBeNull(); - expect($notification->data['actions'][0]['url'] ?? null) - ->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant)); -}); - -test('sync groups action is forbidden for readonly members when disabled check is bypassed', function () { - Queue::fake(); - - [$user, $tenant] = createUserWithTenant(role: 'readonly'); - - $this->actingAs($user); - - $tenant->makeCurrent(); - Filament::setTenant($tenant, true); - $component = Livewire::test(ListEntraGroupSyncRuns::class)->instance(); $action = $component->getAction([['name' => 'sync_groups']]); - expect($action)->not->toBeNull(); - $thrown = null; - - try { - $action->callBefore(); - $action->call(); - } catch (HttpException $exception) { - $thrown = $exception; - } - - expect($thrown)->not->toBeNull(); - expect($thrown?->getStatusCode())->toBe(403); - - Queue::assertNothingPushed(); - - $runCount = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->count(); - - expect($runCount)->toBe(0); -}); - -test('sync groups action is disabled for readonly users with standard tooltip', function () { - Queue::fake(); - - [$user, $tenant] = createUserWithTenant(role: 'readonly'); - - $this->actingAs($user); - - $tenant->makeCurrent(); - Filament::setTenant($tenant, true); - - Livewire::actingAs($user) - ->test(ListEntraGroupSyncRuns::class) - ->assertActionVisible('sync_groups') - ->assertActionDisabled('sync_groups') - ->assertActionExists('sync_groups', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission()); + expect($action)->toBeNull(); Queue::assertNothingPushed(); }); diff --git a/tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php b/tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php new file mode 100644 index 0000000..0bec2e2 --- /dev/null +++ b/tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php @@ -0,0 +1,48 @@ +create(); + + EntraRoleDefinition::factory() + ->for($tenant) + ->create([ + 'entra_id' => '11111111-1111-1111-1111-111111111111', + 'display_name' => 'Policy and Profile Manager', + ]); + + $options = assertNoOutboundHttp(fn () => TenantResource::roleSearchOptions($tenant, 'Pol')); + + expect($options)->toMatchArray([ + '11111111-1111-1111-1111-111111111111' => 'Policy and Profile Manager (11111111)', + ]); +}); + +it('resolves a role definition label from cached data without Graph calls', function (): void { + bindFailHardGraphClient(); + + /** @var Tenant $tenant */ + $tenant = Tenant::factory()->create(); + + EntraRoleDefinition::factory() + ->for($tenant) + ->create([ + 'entra_id' => '22222222-2222-2222-2222-222222222222', + 'display_name' => 'Read Only Operator', + ]); + + $label = assertNoOutboundHttp(fn () => TenantResource::roleLabelFromCache($tenant, '22222222-2222-2222-2222-222222222222')); + + expect($label)->toBe('Read Only Operator (22222222)'); +}); diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php index 841c9ab..03cc3ed 100644 --- a/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -7,6 +7,7 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Services\Inventory\InventorySyncService; +use App\Services\OperationRunService; use App\Support\OpsUx\OpsUxBrowserEvents; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; @@ -30,10 +31,7 @@ Queue::assertPushed(RunInventorySyncJob::class); - $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); - expect($run)->not->toBeNull(); - expect($run->user_id)->toBe($user->id); - expect($run->status)->toBe(InventorySyncRun::STATUS_PENDING); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) @@ -43,6 +41,10 @@ ->first(); expect($opRun)->not->toBeNull(); + expect($opRun->status)->toBe('queued'); + + $context = is_array($opRun->context) ? $opRun->context : []; + expect($context['selection_hash'] ?? null)->not->toBeNull(); }); it('dispatches inventory sync for selected policy types', function () { @@ -67,9 +69,17 @@ Queue::assertPushed(RunInventorySyncJob::class); - $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); - expect($run)->not->toBeNull(); - expect($run->selection_payload['policy_types'] ?? [])->toEqualCanonicalizing($selectedTypes); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'inventory.sync') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + $context = is_array($opRun->context) ? $opRun->context : []; + expect($context['policy_types'] ?? [])->toEqualCanonicalizing($selectedTypes); }); it('persists include dependencies toggle into the run selection payload', function () { @@ -92,9 +102,17 @@ ]) ->assertHasNoActionErrors(); - $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); - expect($run)->not->toBeNull(); - expect((bool) ($run->selection_payload['include_dependencies'] ?? true))->toBeFalse(); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'inventory.sync') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + $context = is_array($opRun->context) ? $opRun->context : []; + expect((bool) ($context['include_dependencies'] ?? true))->toBeFalse(); }); it('defaults include foundations toggle to true and persists it into the run selection payload', function () { @@ -117,9 +135,17 @@ ->callMountedAction() ->assertHasNoActionErrors(); - $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); - expect($run)->not->toBeNull(); - expect((bool) ($run->selection_payload['include_foundations'] ?? false))->toBeTrue(); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'inventory.sync') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + $context = is_array($opRun->context) ? $opRun->context : []; + expect((bool) ($context['include_foundations'] ?? false))->toBeTrue(); }); it('persists include foundations toggle into the run selection payload', function () { @@ -142,9 +168,17 @@ ]) ->assertHasNoActionErrors(); - $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); - expect($run)->not->toBeNull(); - expect((bool) ($run->selection_payload['include_foundations'] ?? true))->toBeFalse(); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'inventory.sync') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + $context = is_array($opRun->context) ? $opRun->context : []; + expect((bool) ($context['include_foundations'] ?? true))->toBeFalse(); }); it('rejects cross-tenant initiation attempts (403) with no side effects', function () { @@ -180,27 +214,29 @@ $selectionPayload = $sync->defaultSelectionPayload(); $computed = $sync->normalizeAndHashSelection($selectionPayload); - InventorySyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'user_id' => $user->getKey(), - 'selection_hash' => $computed['selection_hash'], - 'selection_payload' => $computed['selection'], - 'status' => InventorySyncRun::STATUS_RUNNING, - 'had_errors' => false, - 'error_codes' => [], - 'error_context' => null, + $opService = app(OperationRunService::class); + $existing = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'inventory.sync', + identityInputs: [ + 'selection_hash' => $computed['selection_hash'], + ], + context: array_merge($computed['selection'], [ + 'selection_hash' => $computed['selection_hash'], + ]), + initiator: $user, + ); + + $existing->forceFill([ + 'status' => 'running', 'started_at' => now(), - 'finished_at' => null, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]); + ])->save(); Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]); Queue::assertNothingPushed(); - expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->count())->toBe(1); + expect(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); }); diff --git a/tests/Feature/Inventory/InventorySyncServiceTest.php b/tests/Feature/Inventory/InventorySyncServiceTest.php index 2f89312..4eb621b 100644 --- a/tests/Feature/Inventory/InventorySyncServiceTest.php +++ b/tests/Feature/Inventory/InventorySyncServiceTest.php @@ -5,12 +5,17 @@ use App\Models\BackupScheduleRun; use App\Models\BackupSet; use App\Models\PolicyVersion; +use App\Models\ProviderConnection; +use App\Models\ProviderCredential; use App\Models\Tenant; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Inventory\InventoryMetaSanitizer; use App\Services\Inventory\InventoryMissingService; use App\Services\Inventory\InventorySyncService; +use App\Services\OperationRunService; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; @@ -66,6 +71,84 @@ public function request(string $method, string $path, array $options = []): Grap }; } +/** + * Executes an inventory sync against a canonical OperationRun. + * + * @param array $selection + * @return array{opRun: \App\Models\OperationRun, result: array, selection: array, selection_hash: string} + */ +function executeInventorySyncNow(Tenant $tenant, array $selection): array +{ + $service = app(InventorySyncService::class); + $opService = app(OperationRunService::class); + + $defaultConnection = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('provider', 'microsoft') + ->where('is_default', true) + ->first(); + + if (! $defaultConnection instanceof ProviderConnection) { + $defaultConnection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenant->tenant_id, + 'is_default' => true, + 'status' => 'ok', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $defaultConnection->getKey(), + 'type' => 'client_secret', + 'payload' => [ + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + ], + ]); + } + + $computed = $service->normalizeAndHashSelection($selection); + $context = array_merge($computed['selection'], [ + 'selection_hash' => $computed['selection_hash'], + ]); + + $opRun = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'inventory.sync', + identityInputs: [ + 'selection_hash' => $computed['selection_hash'], + ], + context: $context, + initiator: null, + ); + + $result = $service->executeSelection($opRun, $tenant, $context); + $status = (string) ($result['status'] ?? 'failed'); + + $outcome = match ($status) { + 'success' => OperationRunOutcome::Succeeded->value, + 'partial' => OperationRunOutcome::PartiallySucceeded->value, + default => OperationRunOutcome::Failed->value, + }; + + $opService->updateRun( + $opRun, + status: OperationRunStatus::Completed->value, + outcome: $outcome, + summaryCounts: [ + 'total' => count($computed['selection']['policy_types'] ?? []), + ], + ); + + return [ + 'opRun' => $opRun->refresh(), + 'result' => $result, + 'selection' => $computed['selection'], + 'selection_hash' => $computed['selection_hash'], + ]; +} + test('inventory sync upserts and updates last_seen fields without duplicates', function () { $tenant = Tenant::factory()->create(); @@ -75,8 +158,6 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - $service = app(InventorySyncService::class); - $selection = [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], @@ -84,21 +165,21 @@ public function request(string $method, string $path, array $options = []): Grap 'include_dependencies' => false, ]; - $runA = $service->syncNow($tenant, $selection); - expect($runA->status)->toBe('success'); + $runA = executeInventorySyncNow($tenant, $selection); + expect($runA['result']['status'] ?? null)->toBe('success'); $item = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->first(); expect($item)->not->toBeNull(); expect($item->external_id)->toBe('cfg-1'); - expect($item->last_seen_run_id)->toBe($runA->id); + expect($item->last_seen_operation_run_id)->toBe($runA['opRun']->id); - $runB = $service->syncNow($tenant, $selection); + $runB = executeInventorySyncNow($tenant, $selection); $items = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->get(); expect($items)->toHaveCount(1); $items->first()->refresh(); - expect($items->first()->last_seen_run_id)->toBe($runB->id); + expect($items->first()->last_seen_operation_run_id)->toBe($runB['opRun']->id); }); test('inventory sync includes foundation types when include_foundations is true', function () { @@ -117,16 +198,14 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - $service = app(InventorySyncService::class); - - $run = $service->syncNow($tenant, [ + $run = executeInventorySyncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => true, 'include_dependencies' => false, ]); - expect($run->status)->toBe('success'); + expect($run['result']['status'] ?? null)->toBe('success'); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) @@ -159,16 +238,14 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - $service = app(InventorySyncService::class); - - $run = $service->syncNow($tenant, [ + $run = executeInventorySyncNow($tenant, [ 'policy_types' => ['roleScopeTag'], 'categories' => [], 'include_foundations' => false, 'include_dependencies' => false, ]); - expect($run->status)->toBe('success'); + expect($run['result']['status'] ?? null)->toBe('success'); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) @@ -192,16 +269,14 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - $service = app(InventorySyncService::class); - - $run = $service->syncNow($tenant, [ + $run = executeInventorySyncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => true, 'include_dependencies' => false, ]); - expect($run->status)->toBe('success'); + expect($run['result']['status'] ?? null)->toBe('success'); $foundationItem = \App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) @@ -247,29 +322,27 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - $service = app(InventorySyncService::class); - - $runA = $service->syncNow($tenantA, [ + $runA = executeInventorySyncNow($tenantA, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => true, 'include_dependencies' => false, ]); - expect($runA->status)->toBe('success'); - expect($runA->items_observed_count)->toBe(4); - expect($runA->items_upserted_count)->toBe(4); + expect($runA['result']['status'] ?? null)->toBe('success'); + expect((int) ($runA['result']['items_observed_count'] ?? 0))->toBe(4); + expect((int) ($runA['result']['items_upserted_count'] ?? 0))->toBe(4); - $runB = $service->syncNow($tenantB, [ + $runB = executeInventorySyncNow($tenantB, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => false, 'include_dependencies' => false, ]); - expect($runB->status)->toBe('success'); - expect($runB->items_observed_count)->toBe(1); - expect($runB->items_upserted_count)->toBe(1); + expect($runB['result']['status'] ?? null)->toBe('success'); + expect((int) ($runB['result']['items_observed_count'] ?? 0))->toBe(1); + expect((int) ($runB['result']['items_upserted_count'] ?? 0))->toBe(1); }); test('configuration policy inventory filtering: settings catalog is not stored as security baseline', function () { @@ -306,7 +379,7 @@ public function request(string $method, string $path, array $options = []): Grap 'include_dependencies' => false, ]; - app(InventorySyncService::class)->syncNow($tenant, $selection); + executeInventorySyncNow($tenant, $selection); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) @@ -375,13 +448,13 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - app(InventorySyncService::class)->syncNow($tenant, $selection); + executeInventorySyncNow($tenant, $selection); app()->instance(GraphClientInterface::class, fakeGraphClient([ 'deviceConfiguration' => [], ])); - app(InventorySyncService::class)->syncNow($tenant, $selection); + executeInventorySyncNow($tenant, $selection); $missingService = app(InventoryMissingService::class); $result = $missingService->missingForSelection($tenant, $selection); @@ -393,7 +466,7 @@ public function request(string $method, string $path, array $options = []): Grap 'deviceConfiguration' => [], ], failedTypes: ['deviceConfiguration'])); - app(InventorySyncService::class)->syncNow($tenant, $selection); + executeInventorySyncNow($tenant, $selection); $result2 = $missingService->missingForSelection($tenant, $selection); expect($result2['missing'])->toHaveCount(1); @@ -426,10 +499,8 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - $service = app(InventorySyncService::class); - $service->syncNow($tenant, $selectionX); - - $service->syncNow($tenant, $selectionY); + executeInventorySyncNow($tenant, $selectionX); + executeInventorySyncNow($tenant, $selectionY); $missingService = app(InventoryMissingService::class); $resultX = $missingService->missingForSelection($tenant, $selectionX); @@ -444,8 +515,6 @@ public function request(string $method, string $path, array $options = []): Grap 'deviceConfiguration' => [], ])); - $service = app(InventorySyncService::class); - $selection = [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], @@ -457,10 +526,11 @@ public function request(string $method, string $path, array $options = []): Grap $lock = Cache::lock("inventory_sync:tenant:{$tenant->id}:selection:{$hash}", 900); expect($lock->get())->toBeTrue(); - $run = $service->syncNow($tenant, $selection); + $run = executeInventorySyncNow($tenant, $selection); - expect($run->status)->toBe('skipped'); - expect($run->error_codes)->toContain('lock_contended'); + expect($run['result']['status'] ?? null)->toBe('skipped'); + $codes = is_array($run['result']['error_codes'] ?? null) ? $run['result']['error_codes'] : []; + expect($codes)->toContain('lock_contended'); $lock->release(); }); @@ -480,9 +550,7 @@ public function request(string $method, string $path, array $options = []): Grap 'deviceConfiguration' => [], ])); - $service = app(InventorySyncService::class); - - $service->syncNow($tenant, [ + executeInventorySyncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], 'include_foundations' => false, @@ -503,18 +571,16 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, fakeGraphClient(throwable: $throwable)); - $service = app(InventorySyncService::class); - - $run = $service->syncNow($tenant, [ + $run = executeInventorySyncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], 'include_foundations' => false, 'include_dependencies' => false, ]); - expect($run->status)->toBe('failed'); + expect($run['result']['status'] ?? null)->toBe('failed'); - $context = is_array($run->error_context) ? $run->error_context : []; + $context = is_array($run['result']['error_context'] ?? null) ? $run['result']['error_context'] : []; $message = (string) ($context['message'] ?? ''); expect($message)->not->toContain('abc.def.ghi'); diff --git a/tests/Feature/Inventory/RunInventorySyncJobTest.php b/tests/Feature/Inventory/RunInventorySyncJobTest.php index d014d24..a117992 100644 --- a/tests/Feature/Inventory/RunInventorySyncJobTest.php +++ b/tests/Feature/Inventory/RunInventorySyncJobTest.php @@ -1,10 +1,8 @@ mock(GraphClientInterface::class, function (MockInterface $mock) { - $mock->shouldReceive('listPolicies') - ->atLeast() - ->once() - ->andReturn(new GraphResponse(true, [], 200)); + $mock->shouldReceive('listPolicies')->never(); }); $sync = app(InventorySyncService::class); $selectionPayload = $sync->defaultSelectionPayload(); $computed = $sync->normalizeAndHashSelection($selectionPayload); $policyTypes = $computed['selection']['policy_types']; - $run = $sync->createPendingRunForUser($tenant, $user, $computed['selection']); + + $mockSync = \Mockery::mock(InventorySyncService::class); + $mockSync + ->shouldReceive('executeSelection') + ->once() + ->andReturn([ + 'status' => 'success', + 'had_errors' => false, + 'error_codes' => [], + 'error_context' => [], + 'errors_count' => 0, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'skipped_policy_types' => [], + 'processed_policy_types' => $computed['selection']['policy_types'], + 'failed_policy_types' => [], + 'selection_hash' => $computed['selection_hash'], + ]); /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); @@ -38,20 +50,13 @@ $job = new RunInventorySyncJob( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), - inventorySyncRunId: (int) $run->getKey(), operationRun: $opRun, ); - $job->handle($sync, app(AuditLogger::class), $opService); + $job->handle($mockSync, app(AuditLogger::class), $opService); - $run->refresh(); $opRun->refresh(); - expect($run->user_id)->toBe($user->id); - expect($run->status)->toBe(InventorySyncRun::STATUS_SUCCESS); - expect($run->started_at)->not->toBeNull(); - expect($run->finished_at)->not->toBeNull(); - expect($opRun->status)->toBe('completed'); expect($opRun->outcome)->toBe('succeeded'); @@ -75,13 +80,10 @@ $sync = app(InventorySyncService::class); $selectionPayload = $sync->defaultSelectionPayload(); - $run = $sync->createPendingRunForUser($tenant, $user, $selectionPayload); $computed = $sync->normalizeAndHashSelection($selectionPayload); $policyTypes = $computed['selection']['policy_types']; - $run->update(['selection_payload' => $computed['selection']]); - /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( @@ -93,34 +95,32 @@ $mockSync = \Mockery::mock(InventorySyncService::class); $mockSync - ->shouldReceive('executePendingRun') + ->shouldReceive('executeSelection') ->once() - ->andReturnUsing(function (InventorySyncRun $inventorySyncRun) { - $inventorySyncRun->forceFill([ - 'status' => InventorySyncRun::STATUS_SKIPPED, - 'error_codes' => ['locked'], - 'selection_payload' => $inventorySyncRun->selection_payload ?? [], - 'started_at' => now(), - 'finished_at' => now(), - ])->save(); - - return $inventorySyncRun; - }); + ->andReturn([ + 'status' => 'skipped', + 'had_errors' => true, + 'error_codes' => ['locked'], + 'error_context' => [], + 'errors_count' => 0, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'skipped_policy_types' => $computed['selection']['policy_types'], + 'processed_policy_types' => [], + 'failed_policy_types' => [], + 'selection_hash' => $computed['selection_hash'], + ]); $job = new RunInventorySyncJob( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), - inventorySyncRunId: (int) $run->getKey(), operationRun: $opRun, ); $job->handle($mockSync, app(AuditLogger::class), $opService); - $run->refresh(); $opRun->refresh(); - expect($run->status)->toBe(InventorySyncRun::STATUS_SKIPPED); - expect($opRun->status)->toBe('completed'); expect($opRun->outcome)->toBe('failed'); diff --git a/tests/Feature/ManagedTenantOnboardingWizardTest.php b/tests/Feature/ManagedTenantOnboardingWizardTest.php index 2d595c5..b5ee7f7 100644 --- a/tests/Feature/ManagedTenantOnboardingWizardTest.php +++ b/tests/Feature/ManagedTenantOnboardingWizardTest.php @@ -746,6 +746,74 @@ Bus::assertNotDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class); }); +it('fails a stale queued verification run and allows starting a new verification run', function (): void { + Bus::fake(); + + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = '99999999-9999-9999-9999-999999999999'; + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenantGuid, + 'is_default' => true, + ]); + + $staleRun = OperationRun::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'provider.connection.check', + 'status' => 'queued', + 'outcome' => 'pending', + 'run_identity_hash' => sha1('stale-queued-verify-'.(string) $connection->getKey()), + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + 'created_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + ]); + + $component->set('selectedProviderConnectionId', (int) $connection->getKey()); + $component->call('startVerification'); + + $staleRun->refresh(); + expect($staleRun->status)->toBe('completed'); + expect($staleRun->outcome)->toBe('failed'); + expect($staleRun->context)->toBeArray(); + expect($staleRun->context['verification_report'] ?? null)->toBeArray(); + + $report = $staleRun->context['verification_report'] ?? null; + expect($report['checks'] ?? null)->toBeArray(); + expect($report['checks'][0]['message'] ?? null)->toBe('Run was queued but never started. A queue worker may not be running.'); + + $newRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->firstOrFail(); + + expect((int) $newRun->getKey())->not->toBe((int) $staleRun->getKey()); + + Bus::assertDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class); +}); + it('registers the onboarding capability in the canonical registry', function (): void { expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeTrue(); }); diff --git a/tests/Feature/Monitoring/MonitoringOperationsTest.php b/tests/Feature/Monitoring/MonitoringOperationsTest.php new file mode 100644 index 0000000..4162fd8 --- /dev/null +++ b/tests/Feature/Monitoring/MonitoringOperationsTest.php @@ -0,0 +1,50 @@ +create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'System', + ]); + + $this->actingAs($user); + + Bus::fake(); + + Filament::setTenant(null, true); + + assertNoOutboundHttp(function () use ($tenant, $run) { + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/operations') + ->assertOk(); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertOk(); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/alerts') + ->assertOk(); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/audit-log') + ->assertOk(); + }); + + Bus::assertNothingDispatched(); +}); diff --git a/tests/Feature/OperationRunServiceTest.php b/tests/Feature/OperationRunServiceTest.php index 8cc8b05..e5a5f84 100644 --- a/tests/Feature/OperationRunServiceTest.php +++ b/tests/Feature/OperationRunServiceTest.php @@ -127,6 +127,78 @@ expect(OperationRun::query()->count())->toBe(2); }); +it('reuses the same run even after completion when using strict identity', function () { + $tenant = Tenant::factory()->create(); + + $service = new OperationRunService; + + $runA = $service->ensureRunWithIdentityStrict( + tenant: $tenant, + type: 'backup_schedule.scheduled', + 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'], + ); + + $runA->update(['status' => 'completed', 'outcome' => 'succeeded']); + + $runB = $service->ensureRunWithIdentityStrict( + tenant: $tenant, + type: 'backup_schedule.scheduled', + 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'], + ); + + expect($runA->getKey())->toBe($runB->getKey()); + expect(OperationRun::query()->count())->toBe(1); +}); + +it('handles strict unique-index race collisions by returning the existing run', function () { + $tenant = Tenant::factory()->create(); + $service = new OperationRunService; + + $fired = false; + + $dispatcher = OperationRun::getEventDispatcher(); + + OperationRun::creating(function (OperationRun $model) use (&$fired, $tenant): void { + if ($fired) { + return; + } + + $fired = true; + + OperationRun::withoutEvents(function () use ($model, $tenant): void { + OperationRun::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => $model->tenant_id, + 'user_id' => $model->user_id, + 'initiator_name' => $model->initiator_name, + 'type' => $model->type, + 'status' => $model->status, + 'outcome' => $model->outcome, + 'run_identity_hash' => $model->run_identity_hash, + 'context' => $model->context, + ]); + }); + }); + + try { + $run = $service->ensureRunWithIdentityStrict( + tenant: $tenant, + type: 'backup_schedule.scheduled', + 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'], + ); + } finally { + OperationRun::flushEventListeners(); + OperationRun::setEventDispatcher($dispatcher); + } + + expect($run)->toBeInstanceOf(OperationRun::class); + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->where('type', 'backup_schedule.scheduled')->count()) + ->toBe(1); +}); + it('updates run lifecycle fields and summaries', function () { $tenant = Tenant::factory()->create(); diff --git a/tests/Feature/Operations/LegacyRunRedirectTest.php b/tests/Feature/Operations/LegacyRunRedirectTest.php new file mode 100644 index 0000000..ff8a6af --- /dev/null +++ b/tests/Feature/Operations/LegacyRunRedirectTest.php @@ -0,0 +1,88 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $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(); +}); diff --git a/tests/Feature/Operations/TenantlessOperationRunViewerTest.php b/tests/Feature/Operations/TenantlessOperationRunViewerTest.php index 6ad7063..ea62b35 100644 --- a/tests/Feature/Operations/TenantlessOperationRunViewerTest.php +++ b/tests/Feature/Operations/TenantlessOperationRunViewerTest.php @@ -3,11 +3,13 @@ declare(strict_types=1); use App\Models\OperationRun; +use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\TenantRole; use App\Support\Workspaces\WorkspaceContext; it('allows viewing an operation run without a selected workspace when the user is a member of the run workspace', function (): void { @@ -56,6 +58,42 @@ ->assertNotFound(); }); +it('returns 403 for members missing the required capability for the operation type', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $tenant->users()->attach((int) $user->getKey(), [ + 'role' => TenantRole::Readonly->value, + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + session()->forget(WorkspaceContext::SESSION_KEY); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'type' => 'inventory.sync', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + $this->actingAs($user) + ->get("/admin/operations/{$run->getKey()}") + ->assertForbidden(); +}); + it('renders stored target scope and failure details for a completed run', function (): void { $workspace = Workspace::factory()->create(); $user = User::factory()->create(); diff --git a/tests/Feature/OpsUx/RestoreExecuteOperationRunSyncTest.php b/tests/Feature/OpsUx/RestoreExecuteOperationRunSyncTest.php index 795a1ca..7e400ce 100644 --- a/tests/Feature/OpsUx/RestoreExecuteOperationRunSyncTest.php +++ b/tests/Feature/OpsUx/RestoreExecuteOperationRunSyncTest.php @@ -48,6 +48,8 @@ expect($opRun?->status)->toBe('completed'); expect($opRun?->outcome)->toBe('succeeded'); + expect((int) ($restoreRun->refresh()->operation_run_id ?? 0))->toBe((int) ($opRun?->getKey() ?? 0)); + expect($opRun?->summary_counts['total'] ?? null)->toBe(10); expect($opRun?->summary_counts['succeeded'] ?? null)->toBe(8); expect($opRun?->summary_counts['failed'] ?? null)->toBe(1); diff --git a/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php b/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php index 06ea62e..44f87aa 100644 --- a/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php +++ b/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php @@ -7,6 +7,7 @@ use App\Models\RestoreRun; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreService; +use App\Services\OperationRunService; it('syncs restore execution into OperationRun even if restore status updates bypass model events', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -24,12 +25,17 @@ 'completed_at' => null, ]); - // Observer should create the adapter OperationRun row on create. - $operationRun = OperationRun::query() - ->where('tenant_id', $tenant->id) - ->where('type', 'restore.execute') - ->where('context->restore_run_id', $restoreRun->id) - ->first(); + // Canonical OperationRun must exist at dispatch time and be passed into the job. + $operationRun = app(OperationRunService::class)->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false), + ], + initiator: $user, + ); expect($operationRun)->not->toBeNull(); expect($operationRun?->status)->toBe('queued'); @@ -48,7 +54,7 @@ }); }); - $job = new ExecuteRestoreRunJob($restoreRun->id); + $job = new ExecuteRestoreRunJob($restoreRun->id, null, null, $operationRun); $job->handle( app(RestoreService::class), app(AuditLogger::class), diff --git a/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php b/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php index ac274e9..899835c 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php @@ -7,6 +7,8 @@ use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\OperationRunService; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; it('updates connection health and marks the run succeeded on success', function (): void { app()->instance(GraphClientInterface::class, new class implements GraphClientInterface @@ -109,6 +111,102 @@ public function request(string $method, string $path, array $options = []): Grap ]); }); +it('finalizes the verification run as blocked when admin consent is missing', function (): void { + app()->instance(GraphClientInterface::class, new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getOrganization(array $options = []): GraphResponse + { + throw new RuntimeException('provider_consent_missing'); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + }); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + 'status' => 'connected', + 'health_status' => 'ok', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->getKey(), + 'payload' => [ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Running->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'provider' => 'microsoft', + 'module' => 'health_check', + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $connection->entra_tenant_id, + ], + ], + ]); + + $job = new ProviderConnectionHealthCheckJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + operationRun: $run, + ); + + $job->handle(app(\App\Services\Providers\MicrosoftProviderHealthCheck::class), app(OperationRunService::class)); + + $run->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Completed->value); + expect($run->outcome)->toBe(OperationRunOutcome::Blocked->value); + + $context = is_array($run->context ?? null) ? $run->context : []; + expect($context['reason_code'] ?? null)->toBe('provider_consent_missing'); + + $nextSteps = $context['next_steps'] ?? null; + expect($nextSteps)->toBeArray(); + expect($nextSteps)->not->toBeEmpty(); + + $first = $nextSteps[0] ?? null; + expect($first)->toBeArray(); + expect($first['label'] ?? null)->toBe('Grant admin consent'); + expect($first['url'] ?? null)->toBeString()->not->toBeEmpty(); +}); + it('uses provider connection credentials when refreshing observed permissions', function (): void { $graph = new class implements GraphClientInterface { diff --git a/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php b/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php index 94d7c7d..e66cb96 100644 --- a/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php +++ b/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php @@ -1,7 +1,6 @@ makeCurrent(); Filament::setTenant($tenant, true); - $component = Livewire::test(ListEntraGroupSyncRuns::class) - ->assertActionVisible('sync_groups'); + $component = Livewire::test(ListEntraGroupSyncRuns::class); $user->tenants()->detach($tenant->getKey()); app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); - $component->assertActionHidden('sync_groups'); + expect($component->instance()->getAction([['name' => 'sync_groups']]))->toBeNull(); Queue::assertNothingPushed(); }); - it('shows sync action as visible but disabled for readonly members', function () { + 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); - Livewire::test(ListEntraGroupSyncRuns::class) - ->assertActionVisible('sync_groups') - ->assertActionDisabled('sync_groups'); + $component = Livewire::test(ListEntraGroupSyncRuns::class); + expect($component->instance()->getAction([['name' => 'sync_groups']]))->toBeNull(); Queue::assertNothingPushed(); }); - it('allows owner members to execute sync action (dispatches job)', function () { + 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); - Livewire::test(ListEntraGroupSyncRuns::class) - ->assertActionVisible('sync_groups') - ->assertActionEnabled('sync_groups') - ->mountAction('sync_groups') - ->callMountedAction() - ->assertHasNoActionErrors(); + $component = Livewire::test(ListEntraGroupSyncRuns::class); + expect($component->instance()->getAction([['name' => 'sync_groups']]))->toBeNull(); - Queue::assertPushed(EntraGroupSyncJob::class); + Queue::assertNothingPushed(); }); }); diff --git a/tests/Feature/RestoreAuditLoggingTest.php b/tests/Feature/RestoreAuditLoggingTest.php index f87c535..bb000d8 100644 --- a/tests/Feature/RestoreAuditLoggingTest.php +++ b/tests/Feature/RestoreAuditLoggingTest.php @@ -5,6 +5,7 @@ use App\Models\BackupSet; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Services\OperationRunService; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreService; use App\Support\RestoreRunStatus; @@ -14,7 +15,7 @@ uses(RefreshDatabase::class); test('live restore execution emits an auditable event linked to the run', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-audit', 'name' => 'Tenant Audit', 'metadata' => [], @@ -52,7 +53,18 @@ }); }); - $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); + $operationRun = app(OperationRunService::class)->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'is_dry_run' => false, + ], + initiator: null, + ); + + $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun); $job->handle($restoreService, app(AuditLogger::class)); $audit = AuditLog::query() diff --git a/tests/Feature/RestoreRunWizardExecuteTest.php b/tests/Feature/RestoreRunWizardExecuteTest.php index 8528c5c..88f8ed4 100644 --- a/tests/Feature/RestoreRunWizardExecuteTest.php +++ b/tests/Feature/RestoreRunWizardExecuteTest.php @@ -4,6 +4,7 @@ use App\Jobs\ExecuteRestoreRunJob; use App\Models\BackupItem; use App\Models\BackupSet; +use App\Models\OperationRun; use App\Models\Policy; use App\Models\RestoreRun; use App\Models\Tenant; @@ -172,5 +173,20 @@ expect($run->metadata['confirmed_by'] ?? null)->toBe('executor@example.com'); expect($run->metadata['confirmed_at'] ?? null)->toBeString(); - Bus::assertDispatched(ExecuteRestoreRunJob::class); + $operationRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'restore.execute') + ->latest('id') + ->first(); + + expect($operationRun)->not->toBeNull(); + expect($operationRun?->status)->toBe('queued'); + expect((int) ($operationRun?->context['restore_run_id'] ?? 0))->toBe((int) $run->getKey()); + expect((int) ($run->refresh()->operation_run_id ?? 0))->toBe((int) ($operationRun?->getKey() ?? 0)); + + Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($run, $operationRun): bool { + return $job->restoreRunId === (int) $run->getKey() + && $job->operationRun instanceof OperationRun + && $job->operationRun->getKey() === $operationRun?->getKey(); + }); }); diff --git a/tests/Feature/RunStartAuthorizationTest.php b/tests/Feature/RunStartAuthorizationTest.php index ceb8d0b..8e56c88 100644 --- a/tests/Feature/RunStartAuthorizationTest.php +++ b/tests/Feature/RunStartAuthorizationTest.php @@ -1,7 +1,14 @@ where('tenant_id', $tenantB->id)->exists())->toBeFalse(); expect(OperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); }); + +it('prevents run creation when a readonly member tries to start inventory sync', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $sync = app(InventorySyncService::class); + $allTypes = $sync->defaultSelectionPayload()['policy_types']; + + Livewire::test(ListInventoryItems::class) + ->assertActionVisible('run_inventory_sync') + ->assertActionDisabled('run_inventory_sync') + ->callAction('run_inventory_sync', data: ['tenant_id' => $tenant->getKey(), 'policy_types' => $allTypes]); + + Queue::assertNothingPushed(); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); +}); + +it('prevents run creation when a readonly member tries to start directory group sync', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListEntraGroups::class) + ->assertActionVisible('sync_groups') + ->assertActionDisabled('sync_groups') + ->callAction('sync_groups'); + + Queue::assertNotPushed(EntraGroupSyncJob::class); + expect(EntraGroupSyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); +}); + +it('prevents run creation when a readonly member tries to run a backup schedule now', function () { + Queue::fake([RunBackupScheduleJob::class]); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $schedule = BackupSchedule::query()->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, + ]); + + Livewire::test(ListBackupSchedules::class) + ->assertTableActionVisible('runNow', $schedule) + ->assertTableActionDisabled('runNow', $schedule) + ->callTableAction('runNow', $schedule); + + Queue::assertNothingPushed(); + expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->exists())->toBeFalse(); + expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); +}); + +it('prevents run creation when a readonly member tries to retry a backup schedule', function () { + Queue::fake([RunBackupScheduleJob::class]); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $schedule = BackupSchedule::query()->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, + ]); + + Livewire::test(ListBackupSchedules::class) + ->assertTableActionVisible('retry', $schedule) + ->assertTableActionDisabled('retry', $schedule) + ->callTableAction('retry', $schedule); + + Queue::assertNothingPushed(); + expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->exists())->toBeFalse(); + expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); +}); diff --git a/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php b/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php new file mode 100644 index 0000000..5972dda --- /dev/null +++ b/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php @@ -0,0 +1,38 @@ +create([ + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret', + 'status' => 'active', + ]); + + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + $service = app(RoleDefinitionsSyncService::class); + + $run = $service->startManualSync($tenant, $user); + + expect($run->type)->toBe('directory_role_definitions.sync'); + + $url = OperationRunLinks::tenantlessView($run); + expect($url)->toContain('/admin/operations/'); + + Bus::assertDispatched( + App\Jobs\SyncRoleDefinitionsJob::class, + fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey() && $job->operationRun?->is($run) + ); +}); diff --git a/tests/Unit/Filament/EditProviderConnectionTenantResolutionTest.php b/tests/Unit/Filament/EditProviderConnectionTenantResolutionTest.php new file mode 100644 index 0000000..07b1115 --- /dev/null +++ b/tests/Unit/Filament/EditProviderConnectionTenantResolutionTest.php @@ -0,0 +1,43 @@ +create(); + $tenantB = Tenant::factory()->create(); + + $tenantB->makeCurrent(); + + $page = app(EditProviderConnection::class); + $page->scopedTenantExternalId = (string) $tenantA->external_id; + + $method = new ReflectionMethod($page, 'currentTenant'); + $method->setAccessible(true); + + $resolvedTenant = $method->invoke($page); + + expect($resolvedTenant)->toBeInstanceOf(Tenant::class); + expect($resolvedTenant->is($tenantA))->toBeTrue(); +}); + +test('EditProviderConnection falls back to Tenant::current when no scoped tenant is set', function (): void { + $tenantA = Tenant::factory()->create(); + + $tenantA->makeCurrent(); + + $page = app(EditProviderConnection::class); + + $method = new ReflectionMethod($page, 'currentTenant'); + $method->setAccessible(true); + + $resolvedTenant = $method->invoke($page); + + expect($resolvedTenant)->toBeInstanceOf(Tenant::class); + expect($resolvedTenant->is($tenantA))->toBeTrue(); +}); diff --git a/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php b/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php new file mode 100644 index 0000000..f253f46 --- /dev/null +++ b/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php @@ -0,0 +1,36 @@ +create([ + 'external_id' => 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf', + ]); + + $request = Request::create('/livewire/update', 'POST'); + $request->headers->set('x-livewire', '1'); + $request->headers->set('referer', "http://localhost/admin/tenants/{$tenant->external_id}/provider-connections/1/edit"); + app()->instance('request', $request); + + expect(Tenant::query()->where('external_id', $tenant->external_id)->exists())->toBeTrue(); + + $method = new ReflectionMethod(ProviderConnectionResource::class, 'resolveScopedTenant'); + $method->setAccessible(true); + $resolvedTenant = $method->invoke(null); + + expect($resolvedTenant)->toBeInstanceOf(Tenant::class); + expect($resolvedTenant->is($tenant))->toBeTrue(); + + $url = ProviderConnectionResource::getUrl('index'); + + expect($url)->toContain((string) $tenant->external_id); + expect($url)->toContain('/admin/tenants/'); + expect($url)->toContain('/provider-connections'); +}); diff --git a/tests/Unit/Policies/ProviderConnectionPolicyTenantResolutionTest.php b/tests/Unit/Policies/ProviderConnectionPolicyTenantResolutionTest.php new file mode 100644 index 0000000..2788d57 --- /dev/null +++ b/tests/Unit/Policies/ProviderConnectionPolicyTenantResolutionTest.php @@ -0,0 +1,36 @@ +create([ + 'workspace_id' => $tenantA->workspace_id, + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + + // Simulate a different "current" tenant (e.g. Livewire update without {tenant} route param). + $tenantB->makeCurrent(); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'tenant_id' => (int) $tenantA->getKey(), + 'provider' => 'microsoft', + ]); + + $policy = app(ProviderConnectionPolicy::class); + + $result = $policy->update($user, $connection); + + expect($result)->toBeTrue(); +}); diff --git a/tests/Unit/Providers/ProviderNextStepsRegistryTest.php b/tests/Unit/Providers/ProviderNextStepsRegistryTest.php new file mode 100644 index 0000000..434a347 --- /dev/null +++ b/tests/Unit/Providers/ProviderNextStepsRegistryTest.php @@ -0,0 +1,60 @@ + 'tenant-1', + 'name' => 'Contoso', + ]); + + $registry = app(ProviderNextStepsRegistry::class); + + $steps = $registry->forReason($tenant, ProviderReasonCodes::ProviderConsentMissing); + + expect($steps)->toBeArray() + ->and($steps)->not->toBeEmpty() + ->and($steps[0]['label'])->toBe('Grant admin consent') + ->and($steps[0]['url'])->toContain('learn.microsoft.com'); +}); + +it('links to the real admin consent endpoint when provider credentials exist', function () { + $tenant = Tenant::factory()->create([ + 'app_client_id' => null, + ]); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->graphTenantId(), + 'is_default' => true, + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'payload' => [ + 'client_id' => 'derived-client-id', + 'client_secret' => 'derived-client-secret', + ], + ]); + + $registry = app(ProviderNextStepsRegistry::class); + + $steps = $registry->forReason($tenant, ProviderReasonCodes::ProviderConsentMissing, $connection); + + expect($steps)->toBeArray() + ->and($steps)->not->toBeEmpty() + ->and($steps[0]['label'])->toBe('Grant admin consent') + ->and($steps[0]['url'])->toContain('login.microsoftonline.com') + ->and($steps[0]['url'])->toContain('adminconsent'); +}); diff --git a/tests/Unit/TenantResourceConsentUrlTest.php b/tests/Unit/TenantResourceConsentUrlTest.php index faa07dd..5add285 100644 --- a/tests/Unit/TenantResourceConsentUrlTest.php +++ b/tests/Unit/TenantResourceConsentUrlTest.php @@ -1,6 +1,8 @@ toContain(urlencode('https://graph.microsoft.com/.default')); } }); + +it('can derive admin consent url from provider connection credentials when tenant app_client_id is missing', function () { + $tenant = Tenant::factory()->create([ + 'app_client_id' => null, + ]); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->graphTenantId(), + 'is_default' => true, + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'payload' => [ + 'client_id' => 'derived-client-id', + 'client_secret' => 'derived-client-secret', + ], + ]); + + $url = TenantResource::adminConsentUrl($tenant); + + expect($url) + ->not->toBeNull() + ->and($url)->toContain('login.microsoftonline.com') + ->and($url)->toContain('adminconsent') + ->and($url)->toContain(urlencode('derived-client-id')); +});