## 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
155 lines
4.7 KiB
PHP
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);
|
|
});
|