test: stabilize restore and run authorization

This commit is contained in:
Ahmed Darrazi 2026-01-11 16:55:13 +01:00
parent e4a3a4d378
commit 7b01ef034b
25 changed files with 604 additions and 53 deletions

View File

@ -1,9 +1,12 @@
<?php
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Support\RestoreRunStatus;
@ -59,10 +62,68 @@
});
$job = new ExecuteRestoreRunJob($restoreRun->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');
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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');

View File

@ -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');
});

View File

@ -1,6 +1,7 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
use App\Jobs\CapturePolicySnapshotJob;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
@ -9,12 +10,15 @@
use App\Services\Intune\PolicySnapshotService;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
it('captures a policy snapshot with scope tags when requested', function () {
Queue::fake();
$tenant = Tenant::factory()->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();

View File

@ -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'))

View File

@ -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');
});

View File

@ -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',

View File

@ -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');

View File

@ -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);

View File

@ -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',

View File

@ -0,0 +1,46 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
use App\Jobs\CapturePolicySnapshotJob;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Support\RunIdempotency;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('reuses an active run on double click (idempotency)', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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);
});

View File

@ -0,0 +1,48 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
use App\Jobs\CapturePolicySnapshotJob;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Services\Intune\VersionService;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
it('queues a capture snapshot job (no inline Graph capture)', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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()]);
});

View File

@ -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 () {

View File

@ -0,0 +1,69 @@
<?php
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\AuditLog;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Support\RestoreRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
test('live restore execution emits an auditable event linked to the run', function () {
$tenant = Tenant::create([
'tenant_id' => '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');
});

View File

@ -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');

View File

@ -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', [

View File

@ -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')

View File

@ -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')

View File

@ -0,0 +1,102 @@
<?php
use App\Filament\Resources\RestoreRunResource;
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\RestoreRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
test('restore execution reuses active run for identical starts', function () {
Bus::fake();
$tenant = Tenant::create([
'tenant_id' => '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);
});

View File

@ -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');

View File

@ -0,0 +1,58 @@
<?php
use App\Filament\Resources\BulkOperationRunResource;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Models\User;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('bulk operation runs are listed for the active tenant', function () {
$tenantA = Tenant::factory()->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();
});

View File

@ -0,0 +1,34 @@
<?php
use App\Filament\Pages\InventoryLanding;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Services\Inventory\InventorySyncService;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
it('rejects cross-tenant run starts (403) with no run records created', function () {
Queue::fake();
[$user, $tenantA] = createUserWithTenant(role: 'owner');
$tenantB = Tenant::factory()->create();
$this->actingAs($user);
Filament::setTenant($tenantA, true);
$sync = app(InventorySyncService::class);
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
Livewire::test(InventoryLanding::class)
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
->assertStatus(403);
Queue::assertNothingPushed();
expect(InventorySyncRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse();
expect(BulkOperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse();
});

View File

@ -0,0 +1,57 @@
<?php
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use App\Support\RunIdempotency;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('builds a deterministic 64 char sha256 idempotency key', function () {
$keyA1 = RunIdempotency::buildKey(1, 'policy.capture_snapshot', 'abc', ['b' => 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);
});