TenantAtlas/apps/platform/tests/Feature/Operations/Spec365OperationRunOperatorActionsTest.php
ahmido 6ac0913ff8 feat: implement operations UI operator actions regression gate (#436)
Implemented operations UI operator actions regression gate.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #436
2026-06-08 01:21:14 +00:00

245 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\AuditLog;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\ManagedEnvironmentMembership;
use App\Models\OperationRun;
use App\Models\User;
use App\Services\EnvironmentReviews\EnvironmentReviewFingerprint;
use App\Services\Operations\OperationRunOperatorActionService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationRunActionEligibility;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
it('reconciles a stale review-compose run through the confirmed Filament operator action and audits the mutation in Spec365', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
$run = spec365StaleReviewComposeRun($tenant, $user, $fingerprint, (int) $snapshot->getKey());
$review = spec365ReadyReviewTruth($tenant, $user, $snapshot, $fingerprint);
Filament::setTenant(null, true);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertActionVisible('reconcileOperationRun')
->callAction('reconcileOperationRun')
->assertStatus(200);
$run->refresh();
expect($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
->and($run->reconciliationAdapter())->toBe('environment_review_compose')
->and($run->reconciledRelatedReviewId())->toBe((int) $review->getKey());
$postDecision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user);
expect(data_get($postDecision, 'primary_action.key'))->toBe('view_review')
->and($postDecision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.already_reconciled'));
$audit = AuditLog::query()
->where('action', 'operation.reconciled_by_operator')
->where('operation_run_id', (int) $run->getKey())
->first();
expect($audit)->not->toBeNull();
$metadata = is_array($audit?->metadata) ? $audit->metadata : [];
$encodedMetadata = json_encode($metadata, JSON_THROW_ON_ERROR);
expect($metadata)->toMatchArray([
'operator_action' => 'reconcile',
'operation_run_id' => (int) $run->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
'operation_type' => 'environment.review.compose',
'previous_status' => OperationRunStatus::Queued->value,
'previous_outcome' => OperationRunOutcome::Pending->value,
'resulting_status' => OperationRunStatus::Completed->value,
'resulting_outcome' => OperationRunOutcome::Succeeded->value,
'mutation_scope' => 'tenantpilot_operation_metadata_only',
])->and($encodedMetadata)->not->toContain('access_token', 'client_secret', 'refresh_token');
});
it('denies direct reconcile attempts for review viewers without manage capability and records a denied audit in Spec365', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
$run = spec365StaleReviewComposeRun($tenant, $user, $fingerprint, (int) $snapshot->getKey());
spec365ReadyReviewTruth($tenant, $user, $snapshot, $fingerprint);
Filament::setTenant(null, true);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertDontSee(__('localization.operations.actions.reconcile'));
try {
app(OperationRunOperatorActionService::class)->reconcile($run, $user);
$this->fail('Readonly users should not be able to reconcile review-compose operation runs.');
} catch (HttpException $exception) {
expect($exception->getStatusCode())->toBe(403);
}
$run->refresh();
expect($run->status)->toBe(OperationRunStatus::Queued->value)
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value)
->and($run->isLifecycleReconciled())->toBeFalse()
->and(AuditLog::query()
->where('action', 'operation.reconcile_denied')
->where('operation_run_id', (int) $run->getKey())
->exists())->toBeTrue();
});
it('denies unsupported reconcile attempts without mutating run state in Spec365', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->forTenant($tenant)->create([
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'unknown.operation',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(10),
]);
$decision = app(OperationRunActionEligibility::class)->forRun($run, $user);
expect(data_get($decision, 'primary_action.key'))->toBe('view_details')
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.unsupported_reconcile'));
try {
app(OperationRunOperatorActionService::class)->reconcile($run, $user);
$this->fail('Unsupported operation runs should not reconcile.');
} catch (HttpException $exception) {
expect($exception->getStatusCode())->toBe(403);
}
$run->refresh();
expect($run->status)->toBe(OperationRunStatus::Queued->value)
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value)
->and($run->isLifecycleReconciled())->toBeFalse();
});
it('returns not found for direct reconcile attempts outside workspace scope in Spec365', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
[$outsider] = createUserWithTenant(role: 'owner');
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
$run = spec365StaleReviewComposeRun($tenant, $owner, $fingerprint, (int) $snapshot->getKey());
try {
app(OperationRunOperatorActionService::class)->reconcile($run, $outsider);
$this->fail('Cross-workspace users should not be able to reconcile operation runs.');
} catch (HttpException $exception) {
expect($exception->getStatusCode())->toBe(404);
}
$run->refresh();
expect($run->status)->toBe(OperationRunStatus::Queued->value)
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value);
});
it('returns not found for direct reconcile attempts outside managed environment scope in Spec365', function (): void {
[$user, $allowedTenant] = createUserWithTenant(role: 'owner');
$deniedTenant = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $allowedTenant->workspace_id,
]);
expect(ManagedEnvironmentMembership::query()
->where('user_id', (int) $user->getKey())
->where('managed_environment_id', (int) $allowedTenant->getKey())
->exists())->toBeTrue();
$snapshot = seedEnvironmentReviewEvidence($deniedTenant, operationRunCount: 0);
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($deniedTenant, $snapshot);
$run = spec365StaleReviewComposeRun($deniedTenant, $user, $fingerprint, (int) $snapshot->getKey());
spec365ReadyReviewTruth($deniedTenant, $user, $snapshot, $fingerprint);
$decision = app(OperationRunActionEligibility::class)->forRun($run, $user);
expect($decision['primary_action'])->toBeNull()
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.scope_unavailable'));
try {
app(OperationRunOperatorActionService::class)->reconcile($run, $user);
$this->fail('Cross-environment users should not be able to reconcile operation runs.');
} catch (HttpException $exception) {
expect($exception->getStatusCode())->toBe(404);
}
$run->refresh();
expect($run->status)->toBe(OperationRunStatus::Queued->value)
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value);
});
function spec365StaleReviewComposeRun(ManagedEnvironment $tenant, User $user, string $fingerprint, int $snapshotId): OperationRun
{
return OperationRun::factory()->forTenant($tenant)->create([
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'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' => $snapshotId,
'review_fingerprint' => $fingerprint,
],
]);
}
function spec365ReadyReviewTruth(ManagedEnvironment $tenant, User $user, EvidenceSnapshot $snapshot, string $fingerprint): 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' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'operation_run_id' => (int) $publishedRun->getKey(),
'fingerprint' => $fingerprint,
'summary' => [
'finding_count' => 4,
'report_count' => 2,
'operation_count' => 1,
],
]);
}