Implemented operations UI operator actions regression gate. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #436
245 lines
11 KiB
PHP
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,
|
|
],
|
|
]);
|
|
}
|