TenantAtlas/apps/platform/tests/Unit/Support/Operations/Spec365OperationRunActionEligibilityTest.php
Ahmed Darrazi 2a856d2693
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m44s
feat: implement operations UI operator actions regression gate
2026-06-08 03:19:34 +02:00

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