From 7b01ef034bc6d69369bdcad15b0751fdb58ca5d4 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 11 Jan 2026 16:55:13 +0100 Subject: [PATCH] test: stabilize restore and run authorization --- tests/Feature/ExecuteRestoreRunJobTest.php | 63 ++++++++++- .../ConditionalAccessPreviewOnlyTest.php | 9 +- .../EnrollmentRestrictionsPreviewOnlyTest.php | 18 ++-- .../GroupPolicyConfigurationRestoreTest.php | 6 +- .../Filament/ODataTypeMismatchTest.php | 5 +- .../PolicyCaptureSnapshotOptionsTest.php | 16 +++ .../PolicyVersionRestoreToIntuneTest.php | 9 +- .../Feature/Filament/RestoreExecutionTest.php | 26 +++-- ...gsCatalogRestoreApplySettingsPatchTest.php | 15 +-- .../Filament/SettingsCatalogRestoreTest.php | 28 +++-- .../WindowsUpdateProfilesRestoreTest.php | 12 ++- .../Filament/WindowsUpdateRingRestoreTest.php | 4 +- .../PolicyCaptureSnapshotIdempotencyTest.php | 46 ++++++++ .../PolicyCaptureSnapshotQueuedTest.php | 48 +++++++++ .../RestoreAssignmentApplicationTest.php | 12 ++- tests/Feature/RestoreAuditLoggingTest.php | 69 ++++++++++++ .../Feature/RestoreGraphErrorMetadataTest.php | 2 +- tests/Feature/RestoreGroupMappingTest.php | 8 +- .../Feature/RestorePreviewDiffWizardTest.php | 4 + tests/Feature/RestoreRiskChecksWizardTest.php | 4 + tests/Feature/RestoreRunIdempotencyTest.php | 102 ++++++++++++++++++ .../RestoreUnknownPolicyTypeSafetyTest.php | 2 +- .../RunAuthorizationTenantIsolationTest.php | 58 ++++++++++ tests/Feature/RunStartAuthorizationTest.php | 34 ++++++ tests/Unit/RunIdempotencyTest.php | 57 ++++++++++ 25 files changed, 604 insertions(+), 53 deletions(-) create mode 100644 tests/Feature/PolicyCaptureSnapshotIdempotencyTest.php create mode 100644 tests/Feature/PolicyCaptureSnapshotQueuedTest.php create mode 100644 tests/Feature/RestoreAuditLoggingTest.php create mode 100644 tests/Feature/RestoreRunIdempotencyTest.php create mode 100644 tests/Feature/RunAuthorizationTenantIsolationTest.php create mode 100644 tests/Feature/RunStartAuthorizationTest.php create mode 100644 tests/Unit/RunIdempotencyTest.php diff --git a/tests/Feature/ExecuteRestoreRunJobTest.php b/tests/Feature/ExecuteRestoreRunJobTest.php index 9fb258a..3f2bc73 100644 --- a/tests/Feature/ExecuteRestoreRunJobTest.php +++ b/tests/Feature/ExecuteRestoreRunJobTest.php @@ -1,9 +1,12 @@ id, 'actor@example.com', 'Actor'); - $job->handle($restoreService, app(AuditLogger::class)); + $job->handle($restoreService, app(AuditLogger::class), app(BulkOperationService::class)); $restoreRun->refresh(); expect($restoreRun->started_at)->not->toBeNull(); expect($restoreRun->status)->toBe(RestoreRunStatus::Completed->value); }); + +test('execute restore run job persists per-item outcomes keyed by backup_item_id', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-results', + 'name' => 'Tenant Results', + 'metadata' => [], + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-results', + 'policy_type' => 'unknownPreviewOnlyType', + 'display_name' => 'Preview-only policy', + 'platform' => 'windows', + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => ['id' => $policy->external_id], + 'metadata' => [ + 'displayName' => 'Backup Policy', + ], + ]); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => 'actor@example.com', + 'is_dry_run' => false, + 'status' => RestoreRunStatus::Queued->value, + 'requested_items' => [$backupItem->id], + 'preview' => [], + 'results' => null, + 'metadata' => [], + ]); + + $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); + $job->handle(app(RestoreService::class), app(AuditLogger::class), app(BulkOperationService::class)); + + $restoreRun->refresh(); + + expect($restoreRun->completed_at)->not->toBeNull(); + expect($restoreRun->results)->toBeArray(); + expect($restoreRun->results['items'][(string) $backupItem->id]['backup_item_id'] ?? null)->toBe($backupItem->id); + expect($restoreRun->results['items'][(string) $backupItem->id]['status'] ?? null)->toBe('skipped'); +}); diff --git a/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php b/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php index e86f144..1294a72 100644 --- a/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php +++ b/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php @@ -103,9 +103,12 @@ public function request(string $method, string $path, array $options = []): Grap actorName: 'Tester', ); - expect($run->results)->toHaveCount(1); - expect($run->results[0]['status'])->toBe('skipped'); - expect($run->results[0]['reason'])->toBe('preview_only'); + expect($run->results['items'] ?? [])->toHaveCount(1); + + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('skipped'); + expect($result['reason'] ?? null)->toBe('preview_only'); expect($client->applyCalls)->toBe(0); }); diff --git a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php index 4bf6b6c..64703da 100644 --- a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php +++ b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php @@ -103,9 +103,12 @@ public function request(string $method, string $path, array $options = []): Grap actorName: 'Tester', ); - expect($run->results)->toHaveCount(1); - expect($run->results[0]['status'])->toBe('skipped'); - expect($run->results[0]['reason'])->toBe('preview_only'); + expect($run->results['items'] ?? [])->toHaveCount(1); + + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('skipped'); + expect($result['reason'] ?? null)->toBe('preview_only'); expect($client->applyCalls)->toBe(0); }); @@ -203,9 +206,12 @@ public function request(string $method, string $path, array $options = []): Grap actorName: 'Tester', ); - expect($run->results)->toHaveCount(1); - expect($run->results[0]['status'])->toBe('skipped'); - expect($run->results[0]['reason'])->toBe('preview_only'); + expect($run->results['items'] ?? [])->toHaveCount(1); + + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('skipped'); + expect($result['reason'] ?? null)->toBe('preview_only'); expect($client->applyCalls)->toBe(0); }); diff --git a/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php b/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php index 6d12db2..c46565d 100644 --- a/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php +++ b/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php @@ -164,8 +164,10 @@ public function request(string $method, string $path, array $options = []): Grap )->refresh(); expect($run->status)->toBe('completed'); - expect($run->results[0]['status'])->toBe('applied'); - expect($run->results[0]['definition_value_summary']['success'])->toBe(1); + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('applied'); + expect($result['definition_value_summary']['success'] ?? null)->toBe(1); expect($client->applyPolicyCalls)->toHaveCount(1); expect($client->applyPolicyCalls[0]['policy_type'])->toBe('groupPolicyConfiguration'); diff --git a/tests/Feature/Filament/ODataTypeMismatchTest.php b/tests/Feature/Filament/ODataTypeMismatchTest.php index 1d1d8d5..78dd683 100644 --- a/tests/Feature/Filament/ODataTypeMismatchTest.php +++ b/tests/Feature/Filament/ODataTypeMismatchTest.php @@ -120,5 +120,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ); expect($run->status)->toBe('failed'); - expect($run->results[0]['reason'])->toContain('mismatch'); + + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->toBeArray(); + expect($result['reason'] ?? null)->toContain('mismatch'); }); diff --git a/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php b/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php index 6b7227c..f7cd45f 100644 --- a/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php +++ b/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php @@ -1,6 +1,7 @@ create(); $tenant->makeCurrent(); $policy = Policy::factory()->for($tenant)->create([ @@ -58,6 +62,18 @@ 'include_scope_tags' => true, ]); + $job = null; + + Queue::assertPushed(CapturePolicySnapshotJob::class, function (CapturePolicySnapshotJob $queuedJob) use (&$job): bool { + $job = $queuedJob; + + return true; + }); + + expect($job)->not->toBeNull(); + + app()->call([$job, 'handle']); + $version = $policy->versions()->first(); expect($version)->not->toBeNull(); diff --git a/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php b/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php index d32d790..875f1dd 100644 --- a/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php +++ b/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php @@ -141,9 +141,14 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon actorName: $user->name, )->refresh(); + $backupItemId = is_array($run->requested_items) ? ($run->requested_items[0] ?? null) : null; + expect($backupItemId)->not->toBeNull(); + $result = $run->results['items'][(int) $backupItemId] ?? null; + expect($run->status)->toBe('completed'); - expect($run->results[0]['status'])->toBe('applied'); - expect($run->results[0]['definition_value_summary']['success'])->toBe(5); + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('applied'); + expect($result['definition_value_summary']['success'] ?? null)->toBe(5); $definitionValueCreateCalls = collect($client->requestCalls) ->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/definitionValues')) diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index 2fd856c..af6bf77 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -113,7 +113,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ); expect($run->status)->toBe('completed'); - expect($run->results[0]['status'])->toBe('applied'); + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('applied'); $this->assertDatabaseHas('audit_logs', [ 'action' => 'restore.executed', @@ -201,8 +203,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ); expect($run->status)->toBe('completed'); - expect($run->results)->toHaveCount(1); - expect($run->results[0]['decision'])->toBe('created'); + expect($run->results['foundations'] ?? [])->toHaveCount(1); + expect(($run->results['foundations'][0]['decision'] ?? null))->toBe('created'); $this->assertDatabaseHas('audit_logs', [ 'action' => 'restore.foundation.created', @@ -301,8 +303,10 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ); expect($run->status)->toBe('partial'); - expect($run->results[0]['status'])->toBe('partial'); - expect($run->results[0]['compliance_action_summary']['skipped'] ?? null)->toBe(1); + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('partial'); + expect($result['compliance_action_summary']['skipped'] ?? null)->toBe(1); $this->assertDatabaseHas('audit_logs', [ 'action' => 'restore.compliance.actions.mapped', @@ -517,8 +521,10 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon expect($graphClient->createCalls)->toBe(1); expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Autopilot Profile'); expect($run->status)->toBe('completed'); - expect($run->results[0]['status'])->toBe('applied'); - expect($run->results[0]['created_policy_id'])->toBe('autopilot-created'); + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('applied'); + expect($result['created_policy_id'] ?? null)->toBe('autopilot-created'); }); test('restore execution creates missing policy using contracts', function () { @@ -619,6 +625,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon expect($graphClient->createCalls)->toBe(1); expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Compliance Policy'); expect($run->status)->toBe('completed'); - expect($run->results[0]['status'])->toBe('applied'); - expect($run->results[0]['created_policy_id'])->toBe('compliance-created'); + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('applied'); + expect($result['created_policy_id'] ?? null)->toBe('compliance-created'); }); diff --git a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php index 68e7dcd..c94859f 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php @@ -162,23 +162,26 @@ public function request(string $method, string $path, array $options = []): Grap actorName: $user->name, )->refresh(); + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($run->status)->toBe('partial'); - expect($run->results[0]['status'])->toBe('manual_required'); - expect($run->results[0]['settings_apply']['manual_required'])->toBe(1); - expect($run->results[0]['settings_apply']['failed'])->toBe(0); - expect($run->results[0]['settings_apply']['issues'][0]['graph_request_id'])->toBe('req-setting-404'); + expect($result['status'] ?? null)->toBe('manual_required'); + expect($result['settings_apply']['manual_required'] ?? null)->toBe(1); + expect($result['settings_apply']['failed'] ?? null)->toBe(0); + expect($result['settings_apply']['issues'][0]['graph_request_id'] ?? null)->toBe('req-setting-404'); expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('settings'); expect($client->requestCalls[0]['method'])->toBe('POST'); expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-3/settings'); $results = $run->results; - $results[0]['assignment_summary'] = [ + $results['items'][$backupItem->id]['assignment_summary'] = [ 'success' => 0, 'failed' => 1, 'skipped' => 0, ]; - $results[0]['assignment_outcomes'] = [[ + $results['items'][$backupItem->id]['assignment_outcomes'] = [[ 'status' => 'failed', 'group_id' => 'group-1', 'mapped_group_id' => 'group-2', diff --git a/tests/Feature/Filament/SettingsCatalogRestoreTest.php b/tests/Feature/Filament/SettingsCatalogRestoreTest.php index 5b37350..d0e3bc3 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreTest.php @@ -177,13 +177,16 @@ public function request(string $method, string $path, array $options = []): Grap actorName: $user->name, )->refresh(); + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($run->status)->toBe('partial'); - expect($run->results[0]['status'])->toBe('manual_required'); - expect($run->results[0]['settings_apply']['manual_required'])->toBe(1); - expect($run->results[0]['settings_apply']['failed'])->toBe(0); - expect($run->results[0]['settings_apply']['issues'][0]['graph_error_message'])->toContain('settings are read-only'); - expect($run->results[0]['settings_apply']['issues'][0]['graph_request_id'])->toBe('req-123'); - expect($run->results[0]['settings_apply']['issues'][0]['graph_client_request_id'])->toBe('client-abc'); + expect($result['status'] ?? null)->toBe('manual_required'); + expect($result['settings_apply']['manual_required'] ?? null)->toBe(1); + expect($result['settings_apply']['failed'] ?? null)->toBe(0); + expect($result['settings_apply']['issues'][0]['graph_error_message'] ?? null)->toContain('settings are read-only'); + expect($result['settings_apply']['issues'][0]['graph_request_id'] ?? null)->toBe('req-123'); + expect($result['settings_apply']['issues'][0]['graph_client_request_id'] ?? null)->toBe('client-abc'); expect($client->applyPolicyCalls)->toHaveCount(1); expect($client->applyPolicyCalls[0]['payload'])->toHaveKey('name'); @@ -527,12 +530,15 @@ public function request(string $method, string $path, array $options = []): Grap actorName: $user->name, )->refresh(); + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($run->status)->toBe('partial'); - expect($run->results[0]['status'])->toBe('partial'); - expect($run->results[0]['created_policy_id'])->toBe('new-policy-123'); - expect($run->results[0]['created_policy_mode'])->toBe('metadata_only'); - expect($run->results[0]['settings_apply']['created_policy_id'])->toBe('new-policy-123'); - expect($run->results[0]['settings_apply']['created_policy_mode'])->toBe('metadata_only'); + expect($result['status'] ?? null)->toBe('partial'); + expect($result['created_policy_id'] ?? null)->toBe('new-policy-123'); + expect($result['created_policy_mode'] ?? null)->toBe('metadata_only'); + expect($result['settings_apply']['created_policy_id'] ?? null)->toBe('new-policy-123'); + expect($result['settings_apply']['created_policy_mode'] ?? null)->toBe('metadata_only'); expect($client->requestCalls)->toHaveCount(3); expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-5/settings'); diff --git a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php index 4ef31ec..058c034 100644 --- a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php +++ b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php @@ -117,7 +117,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ); expect($run->status)->toBe('completed'); - expect($run->results[0]['status'])->toBe('applied'); + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('applied'); expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); @@ -194,7 +196,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ); expect($run->status)->toBe('completed'); - expect($run->results[0]['status'])->toBe('applied'); + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('applied'); expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); @@ -277,7 +281,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ); expect($run->status)->toBe('completed'); - expect($run->results[0]['status'])->toBe('applied'); + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('applied'); expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); diff --git a/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php index 441dc24..a69832f 100644 --- a/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php +++ b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php @@ -124,7 +124,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ); expect($run->status)->toBe('completed'); - expect($run->results[0]['status'])->toBe('applied'); + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + expect($result['status'] ?? null)->toBe('applied'); $this->assertDatabaseHas('audit_logs', [ 'action' => 'restore.executed', diff --git a/tests/Feature/PolicyCaptureSnapshotIdempotencyTest.php b/tests/Feature/PolicyCaptureSnapshotIdempotencyTest.php new file mode 100644 index 0000000..2cfb790 --- /dev/null +++ b/tests/Feature/PolicyCaptureSnapshotIdempotencyTest.php @@ -0,0 +1,46 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->for($tenant)->create(); + + Livewire::test(ViewPolicy::class, ['record' => $policy->getRouteKey()]) + ->callAction('capture_snapshot', data: [ + 'include_assignments' => true, + 'include_scope_tags' => true, + ]); + + Livewire::test(ViewPolicy::class, ['record' => $policy->getRouteKey()]) + ->callAction('capture_snapshot', data: [ + 'include_assignments' => true, + 'include_scope_tags' => true, + ]); + + $key = RunIdempotency::buildKey($tenant->getKey(), 'policy.capture_snapshot', $policy->getKey()); + + expect(BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('idempotency_key', $key) + ->count())->toBe(1); + + Queue::assertPushed(CapturePolicySnapshotJob::class, 1); +}); diff --git a/tests/Feature/PolicyCaptureSnapshotQueuedTest.php b/tests/Feature/PolicyCaptureSnapshotQueuedTest.php new file mode 100644 index 0000000..6b48906 --- /dev/null +++ b/tests/Feature/PolicyCaptureSnapshotQueuedTest.php @@ -0,0 +1,48 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->for($tenant)->create(); + + $this->mock(VersionService::class, function (MockInterface $mock) { + $mock->shouldReceive('captureFromGraph')->never(); + }); + + Livewire::test(ViewPolicy::class, ['record' => $policy->getRouteKey()]) + ->callAction('capture_snapshot', data: [ + 'include_assignments' => true, + 'include_scope_tags' => true, + ]); + + Queue::assertPushed(CapturePolicySnapshotJob::class); + + $run = BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('resource', 'policies') + ->where('action', 'capture_snapshot') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run->item_ids)->toBe([(string) $policy->getKey()]); +}); diff --git a/tests/Feature/RestoreAssignmentApplicationTest.php b/tests/Feature/RestoreAssignmentApplicationTest.php index 2d803b0..501db21 100644 --- a/tests/Feature/RestoreAssignmentApplicationTest.php +++ b/tests/Feature/RestoreAssignmentApplicationTest.php @@ -142,7 +142,10 @@ public function request(string $method, string $path, array $options = []): Grap ], ); - $summary = $run->results[0]['assignment_summary'] ?? null; + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + + $summary = $result['assignment_summary'] ?? null; expect($summary)->not->toBeNull(); expect($summary['success'])->toBe(2); @@ -242,12 +245,15 @@ public function request(string $method, string $path, array $options = []): Grap ], ); - $summary = $run->results[0]['assignment_summary'] ?? null; + $result = $run->results['items'][$backupItem->id] ?? null; + expect($result)->not->toBeNull(); + + $summary = $result['assignment_summary'] ?? null; expect($summary)->not->toBeNull(); expect($summary['success'])->toBe(0); expect($summary['failed'])->toBe(2); - expect($run->results[0]['status'])->toBe('partial'); + expect($result['status'] ?? null)->toBe('partial'); }); test('restore maps assignment filter identifiers', function () { diff --git a/tests/Feature/RestoreAuditLoggingTest.php b/tests/Feature/RestoreAuditLoggingTest.php new file mode 100644 index 0000000..5a7ac53 --- /dev/null +++ b/tests/Feature/RestoreAuditLoggingTest.php @@ -0,0 +1,69 @@ + 'tenant-audit', + 'name' => 'Tenant Audit', + 'metadata' => [], + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => 'actor@example.com', + 'is_dry_run' => false, + 'status' => RestoreRunStatus::Queued->value, + 'requested_items' => null, + 'preview' => [], + 'results' => null, + 'metadata' => [], + ]); + + $restoreService = $this->mock(RestoreService::class, function (MockInterface $mock) use ($restoreRun) { + $mock->shouldReceive('executeForRun') + ->once() + ->andReturnUsing(function () use ($restoreRun): RestoreRun { + $restoreRun->update([ + 'status' => RestoreRunStatus::Completed->value, + 'completed_at' => now(), + ]); + + return $restoreRun->refresh(); + }); + }); + + $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); + $job->handle($restoreService, app(AuditLogger::class), app(BulkOperationService::class)); + + $audit = AuditLog::query() + ->where('tenant_id', $tenant->id) + ->where('action', 'restore.started') + ->where('metadata->restore_run_id', $restoreRun->id) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull(); + expect($audit->metadata['backup_set_id'] ?? null)->toBe($backupSet->id); + expect($audit->actor_email)->toBe('actor@example.com'); +}); diff --git a/tests/Feature/RestoreGraphErrorMetadataTest.php b/tests/Feature/RestoreGraphErrorMetadataTest.php index 0222ca2..62724de 100644 --- a/tests/Feature/RestoreGraphErrorMetadataTest.php +++ b/tests/Feature/RestoreGraphErrorMetadataTest.php @@ -95,7 +95,7 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->applyPolicyCalls)->toHaveCount(1); expect($run->status)->toBe('failed'); - $result = $run->results[0] ?? null; + $result = $run->results['items'][$backupItem->id] ?? null; expect($result)->toBeArray(); expect($result['graph_method'] ?? null)->toBe('PATCH'); expect($result['graph_path'] ?? null)->toBe('deviceManagement/endpointSecurityPolicy/esp-1'); diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index 1ddd818..a07e792 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -144,12 +144,14 @@ ]], ]); + $targetGroupId = fake()->uuid(); + $this->mock(GroupResolver::class, function (MockInterface $mock) { $mock->shouldReceive('resolveGroupIds') ->andReturnUsing(function (array $groupIds): array { return collect($groupIds) ->mapWithKeys(function (string $id) { - $resolved = $id === 'target-group-1'; + $resolved = $id === $targetGroupId; return [$id => [ 'id' => $id, @@ -178,7 +180,7 @@ 'scope_mode' => 'selected', 'backup_item_ids' => [$backupItem->id], 'group_mapping' => [ - 'source-group-1' => 'target-group-1', + 'source-group-1' => $targetGroupId, ], ]) ->goToNextWizardStep() @@ -192,7 +194,7 @@ expect($run)->not->toBeNull(); expect($run->group_mapping)->toBe([ - 'source-group-1' => 'target-group-1', + 'source-group-1' => $targetGroupId, ]); $this->assertDatabaseHas('audit_logs', [ diff --git a/tests/Feature/RestorePreviewDiffWizardTest.php b/tests/Feature/RestorePreviewDiffWizardTest.php index 90c0caa..ea1a937 100644 --- a/tests/Feature/RestorePreviewDiffWizardTest.php +++ b/tests/Feature/RestorePreviewDiffWizardTest.php @@ -100,6 +100,7 @@ ->goToNextWizardStep() ->goToNextWizardStep() ->goToNextWizardStep() + ->set('data.group_mapping.group-1', 'SKIP') ->callFormComponentAction('preview_diffs', 'run_restore_preview'); $summary = $component->get('data.preview_summary'); @@ -121,6 +122,9 @@ expect($first['scope_tags_changed'] ?? null)->toBeTrue(); expect($first['diff']['summary']['changed'] ?? null)->toBe(1); + $previewRanAt = $summary['generated_at'] ?? now()->toIso8601String(); + $component->set('data.preview_ran_at', $previewRanAt); + $component ->goToNextWizardStep() ->call('create') diff --git a/tests/Feature/RestoreRiskChecksWizardTest.php b/tests/Feature/RestoreRiskChecksWizardTest.php index 32a88f0..f545b5d 100644 --- a/tests/Feature/RestoreRiskChecksWizardTest.php +++ b/tests/Feature/RestoreRiskChecksWizardTest.php @@ -120,6 +120,10 @@ $component ->goToNextWizardStep() + ->set('data.group_mapping.source-group-1', 'SKIP') + ->set('data.check_summary', $summary) + ->set('data.check_results', $results) + ->set('data.checks_ran_at', $checksRanAt) ->callFormComponentAction('preview_diffs', 'run_restore_preview') ->goToNextWizardStep() ->call('create') diff --git a/tests/Feature/RestoreRunIdempotencyTest.php b/tests/Feature/RestoreRunIdempotencyTest.php new file mode 100644 index 0000000..b1d68eb --- /dev/null +++ b/tests/Feature/RestoreRunIdempotencyTest.php @@ -0,0 +1,102 @@ + 'tenant-idempotency', + 'name' => 'Tenant Idempotency', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'unknownPreviewOnlyType', + 'display_name' => 'Preview-only policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => ['id' => $policy->external_id], + 'metadata' => [ + 'displayName' => 'Backup Policy', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'executor@example.com', + 'name' => 'Executor', + ]); + + $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $data = [ + 'backup_set_id' => $backupSet->id, + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + 'group_mapping' => [], + 'is_dry_run' => false, + 'check_summary' => [ + 'blocking' => 0, + 'warning' => 0, + 'safe' => 1, + 'has_blockers' => false, + ], + 'check_results' => [], + 'checks_ran_at' => now()->toIso8601String(), + 'preview_ran_at' => now()->toIso8601String(), + 'acknowledged_impact' => true, + 'tenant_confirm' => 'Tenant Idempotency', + ]; + + $first = RestoreRunResource::createRestoreRun($data); + $second = RestoreRunResource::createRestoreRun($data); + + expect($first->id)->toBe($second->id); + expect(RestoreRun::count())->toBe(1); + + $run = RestoreRun::query()->first(); + + expect($run)->not->toBeNull(); + expect($run->status)->toBe(RestoreRunStatus::Queued->value); + + Bus::assertDispatchedTimes(ExecuteRestoreRunJob::class, 1); +}); diff --git a/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php b/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php index f66f7c6..43f6742 100644 --- a/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php +++ b/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php @@ -110,7 +110,7 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->applyPolicyCalls)->toHaveCount(0); - $result = $run->results[0] ?? null; + $result = $run->results['items'][$backupItem->id] ?? null; expect($result)->toBeArray(); expect($result['status'] ?? null)->toBe('skipped'); expect($result['restore_mode'] ?? null)->toBe('preview-only'); diff --git a/tests/Feature/RunAuthorizationTenantIsolationTest.php b/tests/Feature/RunAuthorizationTenantIsolationTest.php new file mode 100644 index 0000000..8553667 --- /dev/null +++ b/tests/Feature/RunAuthorizationTenantIsolationTest.php @@ -0,0 +1,58 @@ +create(); + $tenantB = Tenant::factory()->create(); + + BulkOperationRun::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'resource' => 'tenant_a', + 'action' => 'alpha', + ]); + + BulkOperationRun::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'resource' => 'tenant_b', + 'action' => 'beta', + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(BulkOperationRunResource::getUrl('index', tenant: $tenantA)) + ->assertOk() + ->assertSee('tenant_a') + ->assertDontSee('tenant_b'); +}); + +test('bulk operation run view is forbidden cross-tenant (403)', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + $runB = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'resource' => 'tenant_b', + 'action' => 'beta', + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(BulkOperationRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA)) + ->assertForbidden(); +}); diff --git a/tests/Feature/RunStartAuthorizationTest.php b/tests/Feature/RunStartAuthorizationTest.php new file mode 100644 index 0000000..ac67c6c --- /dev/null +++ b/tests/Feature/RunStartAuthorizationTest.php @@ -0,0 +1,34 @@ +create(); + + $this->actingAs($user); + Filament::setTenant($tenantA, true); + + $sync = app(InventorySyncService::class); + $allTypes = $sync->defaultSelectionPayload()['policy_types']; + + Livewire::test(InventoryLanding::class) + ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) + ->assertStatus(403); + + Queue::assertNothingPushed(); + + expect(InventorySyncRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); + expect(BulkOperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); +}); diff --git a/tests/Unit/RunIdempotencyTest.php b/tests/Unit/RunIdempotencyTest.php new file mode 100644 index 0000000..bd189e0 --- /dev/null +++ b/tests/Unit/RunIdempotencyTest.php @@ -0,0 +1,57 @@ + 2, 'a' => 1]); + $keyA2 = RunIdempotency::buildKey(1, 'policy.capture_snapshot', 'abc', ['a' => 1, 'b' => 2]); + $keyB = RunIdempotency::buildKey(1, 'policy.capture_snapshot', 'def', ['a' => 1, 'b' => 2]); + + expect($keyA1)->toBe($keyA2) + ->and($keyA1)->not->toBe($keyB) + ->and($keyA1)->toMatch('/^[a-f0-9]{64}$/'); +}); + +it('finds only active bulk operation runs by idempotency key', function () { + $pending = BulkOperationRun::factory()->create([ + 'idempotency_key' => RunIdempotency::buildKey(1, 'bulk.policy.capture_snapshot', 'abc'), + 'status' => 'pending', + ]); + + $completed = BulkOperationRun::factory()->create([ + 'tenant_id' => $pending->tenant_id, + 'user_id' => $pending->user_id, + 'idempotency_key' => $pending->idempotency_key, + 'status' => 'completed', + ]); + + expect(RunIdempotency::findActiveBulkOperationRun($pending->tenant_id, $pending->idempotency_key)) + ->not->toBeNull() + ->id->toBe($pending->id); + + expect(RunIdempotency::findActiveBulkOperationRun($pending->tenant_id, $completed->idempotency_key)) + ->id->toBe($pending->id); +}); + +it('finds only active restore runs by idempotency key', function () { + $active = RestoreRun::factory()->create([ + 'idempotency_key' => RunIdempotency::buildKey(1, 'restore.execute', 123), + 'status' => 'queued', + ]); + + RestoreRun::factory()->create([ + 'tenant_id' => $active->tenant_id, + 'backup_set_id' => $active->backup_set_id, + 'idempotency_key' => $active->idempotency_key, + 'status' => 'completed', + ]); + + expect(RunIdempotency::findActiveRestoreRun($active->tenant_id, $active->idempotency_key)) + ->not->toBeNull() + ->id->toBe($active->id); +});