From a05aefec9a8757f956165c105a3a76ae78bcfebb Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 01:59:03 +0100 Subject: [PATCH] feat: bulk sync selected policies --- app/Filament/Resources/PolicyResource.php | 39 ++++++ app/Jobs/BulkPolicySyncJob.php | 147 ++++++++++++++++++++++ app/Services/Intune/PolicySyncService.php | 65 ++++++++++ specs/005-bulk-operations/tasks.md | 8 +- tests/Feature/BulkSyncPoliciesTest.php | 84 +++++++++++++ tests/Unit/BulkActionPermissionTest.php | 20 ++- 6 files changed, 353 insertions(+), 10 deletions(-) create mode 100644 app/Jobs/BulkPolicySyncJob.php create mode 100644 tests/Feature/BulkSyncPoliciesTest.php diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index cf762de..d552e6a 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -6,6 +6,7 @@ use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; use App\Jobs\BulkPolicyDeleteJob; use App\Jobs\BulkPolicyExportJob; +use App\Jobs\BulkPolicySyncJob; use App\Jobs\BulkPolicyUnignoreJob; use App\Models\Policy; use App\Models\Tenant; @@ -420,6 +421,44 @@ public static function table(Table $table): Table }) ->deselectRecordsAfterCompletion(), + BulkAction::make('bulk_sync') + ->label('Sync Policies') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; + $value = $visibilityFilterState['value'] ?? null; + + return $value === 'ignored'; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'sync', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk sync started') + ->body("Syncing {$count} policies in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkPolicySyncJob::dispatch($run->id); + } else { + BulkPolicySyncJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + BulkAction::make('bulk_export') ->label('Export to Backup') ->icon('heroicon-o-archive-box-arrow-down') diff --git a/app/Jobs/BulkPolicySyncJob.php b/app/Jobs/BulkPolicySyncJob.php new file mode 100644 index 0000000..559eaf5 --- /dev/null +++ b/app/Jobs/BulkPolicySyncJob.php @@ -0,0 +1,147 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + try { + $chunkSize = 10; + $itemCount = 0; + + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $policyId) { + $itemCount++; + + try { + $policy = Policy::query() + ->whereKey($policyId) + ->where('tenant_id', $run->tenant_id) + ->first(); + + if (! $policy) { + $service->recordFailure($run, (string) $policyId, 'Policy not found'); + + if ($run->failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if ($policy->ignored_at) { + $service->recordSkippedWithReason($run, (string) $policyId, 'Policy is ignored locally'); + + continue; + } + + $syncService->syncPolicy($run->tenant, $policy); + + $service->recordSuccess($run); + } catch (Throwable $e) { + $service->recordFailure($run, (string) $policyId, $e->getMessage()); + + if ($run->failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if ($run->user) { + $message = "Synced {$run->succeeded} policies"; + + if ($run->skipped > 0) { + $message .= " ({$run->skipped} skipped)"; + } + + if ($run->failed > 0) { + $message .= " ({$run->failed} failed)"; + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Sync Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } + } catch (Throwable $e) { + $service->fail($run, $e->getMessage()); + + $run->refresh(); + $run->load('user'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Failed') + ->body($e->getMessage()) + ->icon('heroicon-o-x-circle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + throw $e; + } + } +} diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index a920120..6ed859c 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -8,6 +8,7 @@ use App\Services\Graph\GraphErrorMapper; use App\Services\Graph\GraphLogger; use Illuminate\Support\Arr; +use RuntimeException; use Throwable; class PolicySyncService @@ -108,4 +109,68 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr return $synced; } + + /** + * Re-fetch a single policy from Graph and update local metadata. + */ + public function syncPolicy(Tenant $tenant, Policy $policy): void + { + if (! $tenant->isActive()) { + throw new RuntimeException('Tenant is archived or inactive.'); + } + + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + + $this->graphLogger->logRequest('get_policy', [ + 'tenant' => $tenantIdentifier, + 'policy_type' => $policy->policy_type, + 'policy_id' => $policy->external_id, + 'platform' => $policy->platform, + ]); + + try { + $response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + 'platform' => $policy->platform, + ]); + } catch (Throwable $throwable) { + throw GraphErrorMapper::fromThrowable($throwable, [ + 'policy_type' => $policy->policy_type, + 'policy_id' => $policy->external_id, + 'tenant_id' => $tenant->id, + 'tenant_identifier' => $tenantIdentifier, + ]); + } + + $this->graphLogger->logResponse('get_policy', $response, [ + 'tenant_id' => $tenant->id, + 'tenant' => $tenantIdentifier, + 'policy_type' => $policy->policy_type, + 'policy_id' => $policy->external_id, + ]); + + if ($response->failed()) { + $message = $response->errors[0]['message'] ?? $response->data['error']['message'] ?? 'Graph request failed.'; + + throw new RuntimeException($message); + } + + $payload = $response->data['payload'] ?? $response->data; + + if (! is_array($payload)) { + throw new RuntimeException('Invalid Graph response payload.'); + } + + $displayName = $payload['displayName'] ?? $payload['name'] ?? $policy->display_name; + $platform = $payload['platform'] ?? $policy->platform; + + $policy->forceFill([ + 'display_name' => $displayName, + 'platform' => $platform, + 'last_synced_at' => now(), + 'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']), + ])->save(); + } } diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index 81c2d00..7e6eaf7 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -106,13 +106,13 @@ ## Phase 4b: Requirement - Bulk Sync Policies (FR-005.19) ### Tests for Bulk Sync Policies -- [ ] T035a [P] Write feature test for bulk sync dispatch in tests/Feature/BulkSyncPoliciesTest.php -- [ ] T035b [P] Write permission test for bulk sync action in tests/Unit/BulkActionPermissionTest.php +- [x] T035a [P] Write feature test for bulk sync dispatch in tests/Feature/BulkSyncPoliciesTest.php +- [x] T035b [P] Write permission test for bulk sync action in tests/Unit/BulkActionPermissionTest.php ### Implementation for Bulk Sync Policies -- [ ] T035c Add bulk sync action to PolicyResource in app/Filament/Resources/PolicyResource.php -- [ ] T035d Dispatch SyncPoliciesJob for selected policies (choose per-ID or batched IDs) in app/Jobs/SyncPoliciesJob.php (or a new dedicated bulk sync job) +- [x] T035c Add bulk sync action to PolicyResource in app/Filament/Resources/PolicyResource.php +- [x] T035d Dispatch SyncPoliciesJob for selected policies (choose per-ID or batched IDs) in app/Jobs/SyncPoliciesJob.php (or a new dedicated bulk sync job) - [ ] T035e Manual QA: bulk sync 25 policies (verify queued processing + notifications) **Checkpoint**: Bulk sync action queues work and respects permissions diff --git a/tests/Feature/BulkSyncPoliciesTest.php b/tests/Feature/BulkSyncPoliciesTest.php new file mode 100644 index 0000000..ad50805 --- /dev/null +++ b/tests/Feature/BulkSyncPoliciesTest.php @@ -0,0 +1,84 @@ +create(); + $user = User::factory()->create(); + + $policies = Policy::factory() + ->count(3) + ->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10AndLater', + 'last_synced_at' => null, + ]); + + app()->bind(GraphClientInterface::class, fn () => 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, [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => "Synced {$policyId}", + 'platform' => $options['platform'] ?? null, + 'example' => 'value', + ], + ]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + Livewire::actingAs($user) + ->test(PolicyResource\Pages\ListPolicies::class) + ->callTableBulkAction('bulk_sync', $policies) + ->assertHasNoTableBulkActionErrors(); + + $policies->each(function (Policy $policy) { + $policy->refresh(); + + expect($policy->last_synced_at)->not->toBeNull(); + expect($policy->display_name)->toBe("Synced {$policy->external_id}"); + expect($policy->metadata)->toMatchArray([ + 'example' => 'value', + ]); + }); + + expect(AuditLog::where('action', 'bulk.policy.sync.completed')->exists())->toBeTrue(); +}); diff --git a/tests/Unit/BulkActionPermissionTest.php b/tests/Unit/BulkActionPermissionTest.php index 60d3c04..e6e8986 100644 --- a/tests/Unit/BulkActionPermissionTest.php +++ b/tests/Unit/BulkActionPermissionTest.php @@ -1,14 +1,22 @@ create(); + $user = User::factory()->create(); + $policies = Policy::factory()->count(2)->create(['tenant_id' => $tenant->id]); - expect(true)->toBeTrue(); + Livewire::actingAs($user) + ->test(PolicyResource\Pages\ListPolicies::class) + ->callTableBulkAction('bulk_sync', $policies) + ->assertHasNoTableBulkActionErrors(); });