TenantAtlas/tests/Feature/Operations/ExecuteRestoreRunExecutionReauthorizationTest.php
ahmido 5bcb4f6ab8 feat: harden queued execution legitimacy (#179)
## Summary
- add a canonical queued execution legitimacy contract for actor-bound and system-authority operation runs
- enforce legitimacy before queued jobs transition runs to running across provider, inventory, restore, bulk, sync, and scheduled backup flows
- surface blocked execution outcomes consistently in Monitoring, notifications, audit data, and the tenantless operation viewer
- add Spec 149 artifacts and focused Pest coverage for legitimacy decisions, middleware ordering, blocked presentation, retry behavior, and cross-family adoption

## Testing
- vendor/bin/sail artisan test --compact tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionMiddlewareOrderingTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Verification/ProviderExecutionReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/RunInventorySyncExecutionReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/ExecuteRestoreRunExecutionReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/SystemRunBlockedExecutionNotificationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/BulkOperationExecutionReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionRetryReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionContractMatrixTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionAuditTrailTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- vendor/bin/sail bin pint --dirty --format agent

## Manual validation
- validated queued provider execution blocking for tenant operability drift in the integrated browser on /admin/operations and /admin/operations/{run}
- validated 404 vs 403 route behavior for non-membership vs in-scope capability denial
- validated initiator-null blocked system-run behavior without creating a user terminal notification

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #179
2026-03-17 21:52:40 +00:00

155 lines
4.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunOutcome;
use App\Support\RestoreRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function runQueuedRestoreJobThroughMiddleware(object $job, Closure $terminal): mixed
{
$pipeline = array_reduce(
array_reverse($job->middleware()),
fn (Closure $next, object $middleware): Closure => fn (object $job): mixed => $middleware->handle($job, $next),
$terminal,
);
return $pipeline($job);
}
it('blocks restore execution when the tenant becomes non-operable before start', function (): void {
$tenant = \App\Models\Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$user = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$backupSet = BackupSet::factory()->for($tenant)->create([
'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' => [],
]);
$tenant->delete();
$tenant->refresh();
$operationRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
initiator: $user,
);
$tenant->forceFill([
'status' => 'active',
'deleted_at' => null,
])->save();
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun);
$terminalInvoked = false;
runQueuedRestoreJobThroughMiddleware(
$job,
function (ExecuteRestoreRunJob $job) use (&$terminalInvoked): mixed {
$terminalInvoked = true;
return $job;
},
);
$operationRun->refresh();
$restoreRun->refresh();
expect($terminalInvoked)->toBeFalse()
->and($operationRun->outcome)->toBe(OperationRunOutcome::Blocked->value)
->and($operationRun->context['reason_code'] ?? null)->toBe('tenant_not_operable')
->and($restoreRun->status)->toBe(RestoreRunStatus::Queued->value);
});
it('allows restore execution when legitimacy still holds', function (): void {
$tenant = \App\Models\Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$user = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$backupSet = BackupSet::factory()->for($tenant)->create([
'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' => [],
]);
$tenant->delete();
$tenant->refresh();
$operationRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
initiator: $user,
);
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun);
$terminalInvoked = false;
runQueuedRestoreJobThroughMiddleware(
$job,
function (ExecuteRestoreRunJob $job) use (&$terminalInvoked): mixed {
$terminalInvoked = true;
return $job;
},
);
$operationRun->refresh();
expect($terminalInvoked)->toBeTrue()
->and($operationRun->outcome)->toBe(OperationRunOutcome::Succeeded->value);
});