Implemented operations UI operator actions regression gate. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #436
269 lines
12 KiB
PHP
269 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\User;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewFingerprint;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\Operations\OperationRunActionEligibility;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
it('keeps fresh active operation runs on detail-only guidance and disables unsafe follow-up actions in Spec365', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'user_id' => (int) $user->getKey(),
|
|
'type' => 'environment.review.compose',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subMinute(),
|
|
]);
|
|
|
|
$decision = spec365OperationRunDecision($run, $user);
|
|
|
|
expect(data_get($decision, 'primary_action.key'))->toBe('view_details')
|
|
->and($decision['freshness_state'])->toBe('fresh_active')
|
|
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.lifecycle_fresh'))
|
|
->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.retry_deferred'));
|
|
});
|
|
|
|
it('fails closed for stale supported review-compose runs without canonical proof in Spec365', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
|
|
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'user_id' => (int) $user->getKey(),
|
|
'type' => 'environment.review.compose',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subMinutes(10),
|
|
'context' => [
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'review_fingerprint' => $fingerprint,
|
|
],
|
|
]);
|
|
|
|
$decision = spec365OperationRunDecision($run, $user);
|
|
|
|
expect(data_get($decision, 'primary_action.key'))->toBe('view_details')
|
|
->and(spec365OperationRunActionKeys($decision['secondary_actions']))->not->toContain('reconcile')
|
|
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.insufficient_proof'))
|
|
->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.retry_deferred'));
|
|
});
|
|
|
|
it('offers a confirmed reconcile action for stale supported review-compose runs when canonical proof exists in Spec365', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
|
|
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'user_id' => (int) $user->getKey(),
|
|
'type' => 'environment.review.compose',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subMinutes(10),
|
|
'context' => [
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'review_fingerprint' => $fingerprint,
|
|
],
|
|
]);
|
|
|
|
spec365UnitReadyReviewTruth($tenant, $user, $fingerprint, (int) $snapshot->getKey());
|
|
|
|
$decision = spec365OperationRunDecision($run, $user);
|
|
|
|
expect(data_get($decision, 'primary_action.key'))->toBe('reconcile')
|
|
->and(data_get($decision, 'primary_action.requires_confirmation'))->toBeTrue()
|
|
->and($decision['mutation_scope'])->toBe(__('localization.operations.actions.mutation_scope_reconcile'))
|
|
->and($decision['attention_reason'])->toBe(__('localization.operations.actions.attention.reconcile_available'))
|
|
->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.retry_deferred'));
|
|
});
|
|
|
|
it('offers reconcile for stale running supported review-compose runs in Spec365', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
|
|
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'user_id' => (int) $user->getKey(),
|
|
'type' => 'environment.review.compose',
|
|
'status' => OperationRunStatus::Running->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'started_at' => now()->subMinutes(20),
|
|
'created_at' => now()->subMinutes(20),
|
|
'context' => [
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'review_fingerprint' => $fingerprint,
|
|
],
|
|
]);
|
|
|
|
spec365UnitReadyReviewTruth($tenant, $user, $fingerprint, (int) $snapshot->getKey());
|
|
|
|
$decision = spec365OperationRunDecision($run, $user);
|
|
|
|
expect(data_get($decision, 'primary_action.key'))->toBe('reconcile')
|
|
->and($decision['freshness_state'])->toBe('likely_stale');
|
|
});
|
|
|
|
it('fails closed for unsupported operation types in Spec365', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'user_id' => (int) $user->getKey(),
|
|
'type' => 'unknown.operation',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subMinutes(10),
|
|
]);
|
|
|
|
$decision = spec365OperationRunDecision($run, $user);
|
|
|
|
expect($decision['high_risk'])->toBeTrue()
|
|
->and(data_get($decision, 'primary_action.key'))->toBe('view_details')
|
|
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.unsupported_reconcile'))
|
|
->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.high_risk_retry'));
|
|
});
|
|
|
|
it('fails closed for review viewers who lack the execution capability in Spec365', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'user_id' => (int) $user->getKey(),
|
|
'type' => 'environment.review.compose',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subMinutes(10),
|
|
]);
|
|
|
|
$decision = spec365OperationRunDecision($run, $user);
|
|
|
|
expect(data_get($decision, 'primary_action.key'))->toBe('view_details')
|
|
->and(spec365OperationRunActionKeys($decision['secondary_actions']))->not->toContain('reconcile')
|
|
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.missing_capability'));
|
|
});
|
|
|
|
it('returns no enabled actions outside the actor workspace scope in Spec365', function (): void {
|
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
$outsider = User::factory()->create();
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'user_id' => (int) $owner->getKey(),
|
|
'type' => 'environment.review.compose',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subMinutes(10),
|
|
]);
|
|
|
|
$decision = spec365OperationRunDecision($run, $outsider);
|
|
|
|
expect($decision['primary_action'])->toBeNull()
|
|
->and($decision['disabled_reasons']['view_details'])->toBe(__('localization.operations.actions.disabled.scope_unavailable'))
|
|
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.scope_unavailable'))
|
|
->and($decision['attention_reason'])->toBe(__('localization.operations.actions.attention.scope_unavailable'));
|
|
});
|
|
|
|
it('keeps high-risk restore runs off primary mutation actions and forbids success-forcing controls in Spec365', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
|
'user_id' => (int) $user->getKey(),
|
|
'type' => 'restore.execute',
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'created_at' => now()->subMinutes(10),
|
|
]);
|
|
|
|
$decision = spec365OperationRunDecision($run, $user);
|
|
$enabledKeys = array_merge(
|
|
[data_get($decision, 'primary_action.key')],
|
|
spec365OperationRunActionKeys($decision['secondary_actions']),
|
|
);
|
|
|
|
expect($decision['high_risk'])->toBeTrue()
|
|
->and(data_get($decision, 'primary_action.key'))->toBe('view_restore_details')
|
|
->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.high_risk_retry'))
|
|
->and($enabledKeys)->not->toContain(
|
|
'retry_restore',
|
|
'restore_reexecute',
|
|
'force_complete',
|
|
'mark_succeeded',
|
|
'delete_run',
|
|
'purge_run',
|
|
);
|
|
|
|
foreach ([
|
|
'retry_restore',
|
|
'restore_reexecute',
|
|
'force_complete',
|
|
'mark_succeeded',
|
|
'delete_run',
|
|
'purge_run',
|
|
] as $action) {
|
|
expect($decision['disabled_reasons'])->not->toHaveKey($action)
|
|
->and(spec365OperationRunActionKeys($decision['disabled_actions']))->not->toContain($action);
|
|
}
|
|
});
|
|
|
|
function spec365UnitReadyReviewTruth(
|
|
ManagedEnvironment $tenant,
|
|
User $user,
|
|
string $fingerprint,
|
|
int $snapshotId,
|
|
): EnvironmentReview {
|
|
$publishedRun = OperationRun::factory()->forTenant($tenant)->create([
|
|
'user_id' => (int) $user->getKey(),
|
|
'initiator_name' => $user->name,
|
|
'type' => 'environment.review.compose',
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'completed_at' => now()->subMinutes(5),
|
|
'context' => [
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'review_fingerprint' => $fingerprint,
|
|
],
|
|
]);
|
|
|
|
return EnvironmentReview::factory()->ready()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'evidence_snapshot_id' => $snapshotId,
|
|
'initiated_by_user_id' => (int) $user->getKey(),
|
|
'operation_run_id' => (int) $publishedRun->getKey(),
|
|
'fingerprint' => $fingerprint,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
function spec365OperationRunDecision(OperationRun $run, ?User $user): array
|
|
{
|
|
return app(OperationRunActionEligibility::class)->forRun($run, $user);
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $actions
|
|
* @return list<string>
|
|
*/
|
|
function spec365OperationRunActionKeys(array $actions): array
|
|
{
|
|
return array_values(array_filter(array_map(
|
|
static fn (array $action): ?string => is_string($action['key'] ?? null) ? (string) $action['key'] : null,
|
|
$actions,
|
|
)));
|
|
}
|