test: stabilize restore and run authorization
This commit is contained in:
parent
e4a3a4d378
commit
7b01ef034b
@ -1,9 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Jobs\ExecuteRestoreRunJob;
|
use App\Jobs\ExecuteRestoreRunJob;
|
||||||
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
@ -59,10 +62,68 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor');
|
$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();
|
$restoreRun->refresh();
|
||||||
|
|
||||||
expect($restoreRun->started_at)->not->toBeNull();
|
expect($restoreRun->started_at)->not->toBeNull();
|
||||||
expect($restoreRun->status)->toBe(RestoreRunStatus::Completed->value);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@ -103,9 +103,12 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
actorName: 'Tester',
|
actorName: 'Tester',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect($run->results)->toHaveCount(1);
|
expect($run->results['items'] ?? [])->toHaveCount(1);
|
||||||
expect($run->results[0]['status'])->toBe('skipped');
|
|
||||||
expect($run->results[0]['reason'])->toBe('preview_only');
|
$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);
|
expect($client->applyCalls)->toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -103,9 +103,12 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
actorName: 'Tester',
|
actorName: 'Tester',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect($run->results)->toHaveCount(1);
|
expect($run->results['items'] ?? [])->toHaveCount(1);
|
||||||
expect($run->results[0]['status'])->toBe('skipped');
|
|
||||||
expect($run->results[0]['reason'])->toBe('preview_only');
|
$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);
|
expect($client->applyCalls)->toBe(0);
|
||||||
});
|
});
|
||||||
@ -203,9 +206,12 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
actorName: 'Tester',
|
actorName: 'Tester',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect($run->results)->toHaveCount(1);
|
expect($run->results['items'] ?? [])->toHaveCount(1);
|
||||||
expect($run->results[0]['status'])->toBe('skipped');
|
|
||||||
expect($run->results[0]['reason'])->toBe('preview_only');
|
$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);
|
expect($client->applyCalls)->toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -164,8 +164,10 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
)->refresh();
|
)->refresh();
|
||||||
|
|
||||||
expect($run->status)->toBe('completed');
|
expect($run->status)->toBe('completed');
|
||||||
expect($run->results[0]['status'])->toBe('applied');
|
$result = $run->results['items'][$backupItem->id] ?? null;
|
||||||
expect($run->results[0]['definition_value_summary']['success'])->toBe(1);
|
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)->toHaveCount(1);
|
||||||
expect($client->applyPolicyCalls[0]['policy_type'])->toBe('groupPolicyConfiguration');
|
expect($client->applyPolicyCalls[0]['policy_type'])->toBe('groupPolicyConfiguration');
|
||||||
|
|||||||
@ -120,5 +120,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect($run->status)->toBe('failed');
|
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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
|
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
|
||||||
|
use App\Jobs\CapturePolicySnapshotJob;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -9,12 +10,15 @@
|
|||||||
use App\Services\Intune\PolicySnapshotService;
|
use App\Services\Intune\PolicySnapshotService;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Mockery\MockInterface;
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
it('captures a policy snapshot with scope tags when requested', function () {
|
it('captures a policy snapshot with scope tags when requested', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
$policy = Policy::factory()->for($tenant)->create([
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
@ -58,6 +62,18 @@
|
|||||||
'include_scope_tags' => true,
|
'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();
|
$version = $policy->versions()->first();
|
||||||
|
|
||||||
expect($version)->not->toBeNull();
|
expect($version)->not->toBeNull();
|
||||||
|
|||||||
@ -141,9 +141,14 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
actorName: $user->name,
|
actorName: $user->name,
|
||||||
)->refresh();
|
)->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->status)->toBe('completed');
|
||||||
expect($run->results[0]['status'])->toBe('applied');
|
expect($result)->not->toBeNull();
|
||||||
expect($run->results[0]['definition_value_summary']['success'])->toBe(5);
|
expect($result['status'] ?? null)->toBe('applied');
|
||||||
|
expect($result['definition_value_summary']['success'] ?? null)->toBe(5);
|
||||||
|
|
||||||
$definitionValueCreateCalls = collect($client->requestCalls)
|
$definitionValueCreateCalls = collect($client->requestCalls)
|
||||||
->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/definitionValues'))
|
->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/definitionValues'))
|
||||||
|
|||||||
@ -113,7 +113,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect($run->status)->toBe('completed');
|
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', [
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
'action' => 'restore.executed',
|
'action' => 'restore.executed',
|
||||||
@ -201,8 +203,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect($run->status)->toBe('completed');
|
expect($run->status)->toBe('completed');
|
||||||
expect($run->results)->toHaveCount(1);
|
expect($run->results['foundations'] ?? [])->toHaveCount(1);
|
||||||
expect($run->results[0]['decision'])->toBe('created');
|
expect(($run->results['foundations'][0]['decision'] ?? null))->toBe('created');
|
||||||
|
|
||||||
$this->assertDatabaseHas('audit_logs', [
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
'action' => 'restore.foundation.created',
|
'action' => 'restore.foundation.created',
|
||||||
@ -301,8 +303,10 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect($run->status)->toBe('partial');
|
expect($run->status)->toBe('partial');
|
||||||
expect($run->results[0]['status'])->toBe('partial');
|
$result = $run->results['items'][$backupItem->id] ?? null;
|
||||||
expect($run->results[0]['compliance_action_summary']['skipped'] ?? null)->toBe(1);
|
expect($result)->not->toBeNull();
|
||||||
|
expect($result['status'] ?? null)->toBe('partial');
|
||||||
|
expect($result['compliance_action_summary']['skipped'] ?? null)->toBe(1);
|
||||||
|
|
||||||
$this->assertDatabaseHas('audit_logs', [
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
'action' => 'restore.compliance.actions.mapped',
|
'action' => 'restore.compliance.actions.mapped',
|
||||||
@ -517,8 +521,10 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
expect($graphClient->createCalls)->toBe(1);
|
expect($graphClient->createCalls)->toBe(1);
|
||||||
expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Autopilot Profile');
|
expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Autopilot Profile');
|
||||||
expect($run->status)->toBe('completed');
|
expect($run->status)->toBe('completed');
|
||||||
expect($run->results[0]['status'])->toBe('applied');
|
$result = $run->results['items'][$backupItem->id] ?? null;
|
||||||
expect($run->results[0]['created_policy_id'])->toBe('autopilot-created');
|
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 () {
|
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->createCalls)->toBe(1);
|
||||||
expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Compliance Policy');
|
expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Compliance Policy');
|
||||||
expect($run->status)->toBe('completed');
|
expect($run->status)->toBe('completed');
|
||||||
expect($run->results[0]['status'])->toBe('applied');
|
$result = $run->results['items'][$backupItem->id] ?? null;
|
||||||
expect($run->results[0]['created_policy_id'])->toBe('compliance-created');
|
expect($result)->not->toBeNull();
|
||||||
|
expect($result['status'] ?? null)->toBe('applied');
|
||||||
|
expect($result['created_policy_id'] ?? null)->toBe('compliance-created');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -162,23 +162,26 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
actorName: $user->name,
|
actorName: $user->name,
|
||||||
)->refresh();
|
)->refresh();
|
||||||
|
|
||||||
|
$result = $run->results['items'][$backupItem->id] ?? null;
|
||||||
|
expect($result)->not->toBeNull();
|
||||||
|
|
||||||
expect($run->status)->toBe('partial');
|
expect($run->status)->toBe('partial');
|
||||||
expect($run->results[0]['status'])->toBe('manual_required');
|
expect($result['status'] ?? null)->toBe('manual_required');
|
||||||
expect($run->results[0]['settings_apply']['manual_required'])->toBe(1);
|
expect($result['settings_apply']['manual_required'] ?? null)->toBe(1);
|
||||||
expect($run->results[0]['settings_apply']['failed'])->toBe(0);
|
expect($result['settings_apply']['failed'] ?? null)->toBe(0);
|
||||||
expect($run->results[0]['settings_apply']['issues'][0]['graph_request_id'])->toBe('req-setting-404');
|
expect($result['settings_apply']['issues'][0]['graph_request_id'] ?? null)->toBe('req-setting-404');
|
||||||
|
|
||||||
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('settings');
|
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('settings');
|
||||||
expect($client->requestCalls[0]['method'])->toBe('POST');
|
expect($client->requestCalls[0]['method'])->toBe('POST');
|
||||||
expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-3/settings');
|
expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-3/settings');
|
||||||
|
|
||||||
$results = $run->results;
|
$results = $run->results;
|
||||||
$results[0]['assignment_summary'] = [
|
$results['items'][$backupItem->id]['assignment_summary'] = [
|
||||||
'success' => 0,
|
'success' => 0,
|
||||||
'failed' => 1,
|
'failed' => 1,
|
||||||
'skipped' => 0,
|
'skipped' => 0,
|
||||||
];
|
];
|
||||||
$results[0]['assignment_outcomes'] = [[
|
$results['items'][$backupItem->id]['assignment_outcomes'] = [[
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
'group_id' => 'group-1',
|
'group_id' => 'group-1',
|
||||||
'mapped_group_id' => 'group-2',
|
'mapped_group_id' => 'group-2',
|
||||||
|
|||||||
@ -177,13 +177,16 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
actorName: $user->name,
|
actorName: $user->name,
|
||||||
)->refresh();
|
)->refresh();
|
||||||
|
|
||||||
|
$result = $run->results['items'][$backupItem->id] ?? null;
|
||||||
|
expect($result)->not->toBeNull();
|
||||||
|
|
||||||
expect($run->status)->toBe('partial');
|
expect($run->status)->toBe('partial');
|
||||||
expect($run->results[0]['status'])->toBe('manual_required');
|
expect($result['status'] ?? null)->toBe('manual_required');
|
||||||
expect($run->results[0]['settings_apply']['manual_required'])->toBe(1);
|
expect($result['settings_apply']['manual_required'] ?? null)->toBe(1);
|
||||||
expect($run->results[0]['settings_apply']['failed'])->toBe(0);
|
expect($result['settings_apply']['failed'] ?? null)->toBe(0);
|
||||||
expect($run->results[0]['settings_apply']['issues'][0]['graph_error_message'])->toContain('settings are read-only');
|
expect($result['settings_apply']['issues'][0]['graph_error_message'] ?? null)->toContain('settings are read-only');
|
||||||
expect($run->results[0]['settings_apply']['issues'][0]['graph_request_id'])->toBe('req-123');
|
expect($result['settings_apply']['issues'][0]['graph_request_id'] ?? null)->toBe('req-123');
|
||||||
expect($run->results[0]['settings_apply']['issues'][0]['graph_client_request_id'])->toBe('client-abc');
|
expect($result['settings_apply']['issues'][0]['graph_client_request_id'] ?? null)->toBe('client-abc');
|
||||||
|
|
||||||
expect($client->applyPolicyCalls)->toHaveCount(1);
|
expect($client->applyPolicyCalls)->toHaveCount(1);
|
||||||
expect($client->applyPolicyCalls[0]['payload'])->toHaveKey('name');
|
expect($client->applyPolicyCalls[0]['payload'])->toHaveKey('name');
|
||||||
@ -527,12 +530,15 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
actorName: $user->name,
|
actorName: $user->name,
|
||||||
)->refresh();
|
)->refresh();
|
||||||
|
|
||||||
|
$result = $run->results['items'][$backupItem->id] ?? null;
|
||||||
|
expect($result)->not->toBeNull();
|
||||||
|
|
||||||
expect($run->status)->toBe('partial');
|
expect($run->status)->toBe('partial');
|
||||||
expect($run->results[0]['status'])->toBe('partial');
|
expect($result['status'] ?? null)->toBe('partial');
|
||||||
expect($run->results[0]['created_policy_id'])->toBe('new-policy-123');
|
expect($result['created_policy_id'] ?? null)->toBe('new-policy-123');
|
||||||
expect($run->results[0]['created_policy_mode'])->toBe('metadata_only');
|
expect($result['created_policy_mode'] ?? null)->toBe('metadata_only');
|
||||||
expect($run->results[0]['settings_apply']['created_policy_id'])->toBe('new-policy-123');
|
expect($result['settings_apply']['created_policy_id'] ?? null)->toBe('new-policy-123');
|
||||||
expect($run->results[0]['settings_apply']['created_policy_mode'])->toBe('metadata_only');
|
expect($result['settings_apply']['created_policy_mode'] ?? null)->toBe('metadata_only');
|
||||||
|
|
||||||
expect($client->requestCalls)->toHaveCount(3);
|
expect($client->requestCalls)->toHaveCount(3);
|
||||||
expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-5/settings');
|
expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-5/settings');
|
||||||
|
|||||||
@ -117,7 +117,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect($run->status)->toBe('completed');
|
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);
|
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->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);
|
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->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);
|
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
|
||||||
|
|
||||||
|
|||||||
@ -124,7 +124,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect($run->status)->toBe('completed');
|
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', [
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
'action' => 'restore.executed',
|
'action' => 'restore.executed',
|
||||||
|
|||||||
46
tests/Feature/PolicyCaptureSnapshotIdempotencyTest.php
Normal file
46
tests/Feature/PolicyCaptureSnapshotIdempotencyTest.php
Normal 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);
|
||||||
|
});
|
||||||
48
tests/Feature/PolicyCaptureSnapshotQueuedTest.php
Normal file
48
tests/Feature/PolicyCaptureSnapshotQueuedTest.php
Normal 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()]);
|
||||||
|
});
|
||||||
@ -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)->not->toBeNull();
|
||||||
expect($summary['success'])->toBe(2);
|
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)->not->toBeNull();
|
||||||
expect($summary['success'])->toBe(0);
|
expect($summary['success'])->toBe(0);
|
||||||
expect($summary['failed'])->toBe(2);
|
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 () {
|
test('restore maps assignment filter identifiers', function () {
|
||||||
|
|||||||
69
tests/Feature/RestoreAuditLoggingTest.php
Normal file
69
tests/Feature/RestoreAuditLoggingTest.php
Normal 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');
|
||||||
|
});
|
||||||
@ -95,7 +95,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
expect($client->applyPolicyCalls)->toHaveCount(1);
|
expect($client->applyPolicyCalls)->toHaveCount(1);
|
||||||
expect($run->status)->toBe('failed');
|
expect($run->status)->toBe('failed');
|
||||||
|
|
||||||
$result = $run->results[0] ?? null;
|
$result = $run->results['items'][$backupItem->id] ?? null;
|
||||||
expect($result)->toBeArray();
|
expect($result)->toBeArray();
|
||||||
expect($result['graph_method'] ?? null)->toBe('PATCH');
|
expect($result['graph_method'] ?? null)->toBe('PATCH');
|
||||||
expect($result['graph_path'] ?? null)->toBe('deviceManagement/endpointSecurityPolicy/esp-1');
|
expect($result['graph_path'] ?? null)->toBe('deviceManagement/endpointSecurityPolicy/esp-1');
|
||||||
|
|||||||
@ -144,12 +144,14 @@
|
|||||||
]],
|
]],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$targetGroupId = fake()->uuid();
|
||||||
|
|
||||||
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||||
$mock->shouldReceive('resolveGroupIds')
|
$mock->shouldReceive('resolveGroupIds')
|
||||||
->andReturnUsing(function (array $groupIds): array {
|
->andReturnUsing(function (array $groupIds): array {
|
||||||
return collect($groupIds)
|
return collect($groupIds)
|
||||||
->mapWithKeys(function (string $id) {
|
->mapWithKeys(function (string $id) {
|
||||||
$resolved = $id === 'target-group-1';
|
$resolved = $id === $targetGroupId;
|
||||||
|
|
||||||
return [$id => [
|
return [$id => [
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
@ -178,7 +180,7 @@
|
|||||||
'scope_mode' => 'selected',
|
'scope_mode' => 'selected',
|
||||||
'backup_item_ids' => [$backupItem->id],
|
'backup_item_ids' => [$backupItem->id],
|
||||||
'group_mapping' => [
|
'group_mapping' => [
|
||||||
'source-group-1' => 'target-group-1',
|
'source-group-1' => $targetGroupId,
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
@ -192,7 +194,7 @@
|
|||||||
|
|
||||||
expect($run)->not->toBeNull();
|
expect($run)->not->toBeNull();
|
||||||
expect($run->group_mapping)->toBe([
|
expect($run->group_mapping)->toBe([
|
||||||
'source-group-1' => 'target-group-1',
|
'source-group-1' => $targetGroupId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('audit_logs', [
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
|||||||
@ -100,6 +100,7 @@
|
|||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
|
->set('data.group_mapping.group-1', 'SKIP')
|
||||||
->callFormComponentAction('preview_diffs', 'run_restore_preview');
|
->callFormComponentAction('preview_diffs', 'run_restore_preview');
|
||||||
|
|
||||||
$summary = $component->get('data.preview_summary');
|
$summary = $component->get('data.preview_summary');
|
||||||
@ -121,6 +122,9 @@
|
|||||||
expect($first['scope_tags_changed'] ?? null)->toBeTrue();
|
expect($first['scope_tags_changed'] ?? null)->toBeTrue();
|
||||||
expect($first['diff']['summary']['changed'] ?? null)->toBe(1);
|
expect($first['diff']['summary']['changed'] ?? null)->toBe(1);
|
||||||
|
|
||||||
|
$previewRanAt = $summary['generated_at'] ?? now()->toIso8601String();
|
||||||
|
$component->set('data.preview_ran_at', $previewRanAt);
|
||||||
|
|
||||||
$component
|
$component
|
||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
->call('create')
|
->call('create')
|
||||||
|
|||||||
@ -120,6 +120,10 @@
|
|||||||
|
|
||||||
$component
|
$component
|
||||||
->goToNextWizardStep()
|
->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')
|
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||||
->goToNextWizardStep()
|
->goToNextWizardStep()
|
||||||
->call('create')
|
->call('create')
|
||||||
|
|||||||
102
tests/Feature/RestoreRunIdempotencyTest.php
Normal file
102
tests/Feature/RestoreRunIdempotencyTest.php
Normal 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);
|
||||||
|
});
|
||||||
@ -110,7 +110,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
|
|
||||||
expect($client->applyPolicyCalls)->toHaveCount(0);
|
expect($client->applyPolicyCalls)->toHaveCount(0);
|
||||||
|
|
||||||
$result = $run->results[0] ?? null;
|
$result = $run->results['items'][$backupItem->id] ?? null;
|
||||||
expect($result)->toBeArray();
|
expect($result)->toBeArray();
|
||||||
expect($result['status'] ?? null)->toBe('skipped');
|
expect($result['status'] ?? null)->toBe('skipped');
|
||||||
expect($result['restore_mode'] ?? null)->toBe('preview-only');
|
expect($result['restore_mode'] ?? null)->toBe('preview-only');
|
||||||
|
|||||||
58
tests/Feature/RunAuthorizationTenantIsolationTest.php
Normal file
58
tests/Feature/RunAuthorizationTenantIsolationTest.php
Normal 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();
|
||||||
|
});
|
||||||
34
tests/Feature/RunStartAuthorizationTest.php
Normal file
34
tests/Feature/RunStartAuthorizationTest.php
Normal 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();
|
||||||
|
});
|
||||||
57
tests/Unit/RunIdempotencyTest.php
Normal file
57
tests/Unit/RunIdempotencyTest.php
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user